Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ export default defineAppConfig({
colors: {
primary: 'blue',
neutral: 'neutral'
},
prose: {
img: {
base: 'size-64 mt-2'
}
}
}
})
24 changes: 24 additions & 0 deletions app/components/DragDropOverlay.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<script setup lang="ts">
const { loggedIn } = useUserSession()

defineProps<{
show: boolean
}>()
</script>

<template>
<div
v-if="show && loggedIn"
class="absolute inset-0 m-4 z-50 bg-radial from-primary/10 from-10% to-primary/20 border-2 border-primary/30 rounded-lg flex items-center justify-center backdrop-blur-lg pointer-events-none"
>
<div class="text-center">
<UIcon name="i-lucide-upload" class="size-12 mb-4" />
<p class="text-lg font-medium">
Drop your files here
</p>
<p class="text-sm text-muted">
Supported formats: Images and PDFs
</p>
</div>
</div>
</template>
68 changes: 68 additions & 0 deletions app/components/FilePreview.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<script setup lang="ts">
const files = defineModel<FileWithStatus[]>({ required: true })

const emit = defineEmits<{
remove: [id: string]
}>()

function getFileIcon(file: File): string {
if (file.type.startsWith('image/')) {
return 'i-lucide-image'
}
if (file.type === 'application/pdf') {
return 'i-lucide-file-text'
}
return 'i-lucide-file'
}

function removeFile(id: string) {
emit('remove', id)
}
</script>

<template>
<div v-if="files.length > 0" class="flex flex-wrap gap-2">
<div
v-for="fileWithStatus in files"
:key="fileWithStatus.id"
class="relative group"
>
<UAvatar
size="3xl"
:src="fileWithStatus.file.type.startsWith('image/') ? fileWithStatus.previewUrl : undefined"
:icon="getFileIcon(fileWithStatus.file)"
class="border-2 border-default rounded-lg"
:class="{
'opacity-50': fileWithStatus.status === 'uploading',
'border-error': fileWithStatus.status === 'error'
}"
/>

<div
v-if="fileWithStatus.status === 'uploading'"
class="absolute inset-0 flex items-center justify-center bg-black/50 rounded-lg"
>
<UIcon name="i-lucide-loader-2" class="size-8 animate-spin text-white" />
</div>

<div
v-if="fileWithStatus.status === 'error'"
class="absolute inset-0 flex items-center justify-center bg-error/50 rounded-lg"
:title="fileWithStatus.error"
>
<UIcon name="i-lucide-alert-circle" class="size-8 text-white" />
</div>

<UButton
v-if="fileWithStatus.status !== 'uploading'"
icon="i-lucide-x"
size="xs"
square
color="neutral"
variant="solid"
class="absolute p-0 -top-1 -right-1 opacity-0 group-hover:opacity-100 transition-opacity rounded-full"
@click="removeFile(fileWithStatus.id)"
/>
</div>
</div>
</template>
49 changes: 49 additions & 0 deletions app/components/FileUploadButton.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<script setup lang="ts">
const { loggedIn } = useUserSession()

const emit = defineEmits<{
filesSelected: [files: File[]]
}>()

const inputId = useId()

function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement
const files = Array.from(input.files || [])

if (files.length > 0) {
emit('filesSelected', files)
}

input.value = ''
}
</script>

<template>
<UTooltip
:content="{
side: 'top'
}"
:text="!loggedIn ? 'You need to be logged in to upload files' : ''"
>
<label :for="inputId" :class="{ 'cursor-not-allowed opacity-50': !loggedIn }">
<UButton
icon="i-lucide-paperclip"
variant="ghost"
color="neutral"
size="sm"
as="span"
:disabled="!loggedIn"
/>
</label>
<input
:id="inputId"
type="file"
multiple
accept="image/*,application/pdf"
class="hidden"
:disabled="!loggedIn"
@change="handleFileSelect"
>
</UTooltip>
</template>
8 changes: 7 additions & 1 deletion app/components/ModelSelect.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,14 @@ const items = computed(() => models.map(model => ({
variant="ghost"
value-key="value"
class="hover:bg-default focus:bg-default data-[state=open]:bg-default"
:content="{
align: 'start',
side: 'bottom',
sideOffset: 4
}"
:ui="{
trailingIcon: 'group-data-[state=open]:rotate-180 transition-transform duration-200'
trailingIcon: 'group-data-[state=open]:rotate-180 transition-transform duration-200',
content: 'w-fit'
}"
/>
</template>
119 changes: 119 additions & 0 deletions app/components/tool/Weather.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<script setup lang="ts">
defineProps<{
invocation: WeatherUIToolInvocation
}>()
</script>

<template>
<div v-if="invocation.state === 'output-available'" class="w-[480px] bg-gradient-to-br from-blue-500 to-blue-700 rounded-xl px-5 py-4 text-highlighted shadow dark:shadow-lg">
<div class="flex items-start justify-between mb-3">
<div class="flex items-baseline gap-1">
<span class="text-4xl font-light">{{ invocation.output.temperature }}°</span>
<span class="text-base text-highlighted/80 mt-1">C</span>
</div>
<div class="text-right">
<div class="text-base font-medium mb-1">
{{ invocation.output.location }}
</div>
<div class="text-xs text-highlighted/70">
H:{{ invocation.output.temperatureHigh }}° L:{{ invocation.output.temperatureLow }}°
</div>
</div>
</div>

<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<UIcon
:name="invocation.output.condition.icon"
class="size-6 text-white"
/>
<div class="text-sm font-medium">
{{ invocation.output.condition.text }}
</div>
</div>

<div class="flex gap-3 text-xs">
<div class="flex items-center gap-1">
<UIcon name="i-lucide-droplets" class="size-3 text-blue-200" />
<span>{{ invocation.output.humidity }}%</span>
</div>
<div class="flex items-center gap-1">
<UIcon name="i-lucide-wind" class="size-3 text-blue-200" />
<span>{{ invocation.output.windSpeed }} km/h</span>
</div>
</div>
</div>

<div v-if="invocation.output.dailyForecast.length > 0" class="flex items-center justify-between">
<div
v-for="(forecast, index) in invocation.output.dailyForecast"
:key="index"
class="flex flex-col items-center gap-1.5"
>
<div class="text-xs text-highlighted/70 font-medium">
{{ forecast.day }}
</div>
<UIcon
:name="forecast.condition.icon"
class="size-5 text-white"
/>
<div class="text-xs font-medium">
<div>
{{ forecast.high }}°
</div>
<div class="text-highlighted/60">
{{ forecast.low }}°
</div>
</div>
</div>
</div>

<div v-else class="flex items-center justify-center py-3">
<div class="text-xs text-highlighted/70">
No forecast available
</div>
</div>
</div>

<div v-else-if="invocation.state === 'input-available'" class="w-[480px] bg-gradient-to-br from-gray-400 to-gray-600 rounded-xl px-5 py-4 text-highlighted shadow dark:shadow-lg">
<div class="flex items-center justify-center py-6">
<div class="text-center">
<UIcon
name="i-lucide-cloud-sun"
class="size-8 text-white mx-auto mb-2"
/>
<div class="text-sm">
Loading weather data...
</div>
</div>
</div>
</div>

<div v-else-if="invocation.state === 'output-error'" class="w-[480px] bg-gradient-to-br from-red-500 to-red-700 rounded-xl px-5 py-4 text-highlighted shadow dark:shadow-lg">
<div class="flex items-center justify-center py-6">
<div class="text-center">
<UIcon
name="i-lucide-alert-triangle"
class="size-8 text-white mx-auto mb-2"
/>
<div class="text-sm">
Can't get weather data, please try again later
</div>
</div>
</div>
</div>

<div v-else class="w-[480px] bg-gradient-to-br from-gray-400 to-gray-600 rounded-xl px-5 py-4 text-highlighted shadow dark:shadow-lg">
<div class="flex items-center justify-center py-6">
<div class="text-center">
<UIcon
name="i-lucide-loader-2"
class="size-8 text-white mx-auto mb-2 animate-spin"
/>
<div class="text-sm">
Getting weather...
</div>
</div>
</div>
</div>
</template>
87 changes: 87 additions & 0 deletions app/composables/useFileUploadWithStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
export function useFileUploadWithStatus(chatId: string) {
const files = ref<FileWithStatus[]>([])

const { loggedIn } = useUserSession()

async function uploadFiles(newFiles: File[]) {
if (!loggedIn.value) {
return
}

const filesWithStatus: FileWithStatus[] = newFiles.map(file => ({
file,
id: crypto.randomUUID(),
previewUrl: createObjectUrl(file),
status: 'uploading' as const
}))

files.value = [...files.value, ...filesWithStatus]

for (const fileWithStatus of filesWithStatus) {
try {
const response = await uploadFileToBlob(fileWithStatus.file, chatId)

const index = files.value.findIndex(f => f.id === fileWithStatus.id)
if (index !== -1) {
files.value[index] = {
...files.value[index]!,
status: 'uploaded',
uploadedUrl: response.url
}
}
} catch (error) {
const index = files.value.findIndex(f => f.id === fileWithStatus.id)
if (index !== -1) {
files.value[index] = {
...files.value[index]!,
status: 'error',
error: error instanceof Error ? error.message : 'Upload failed'
}
}
}
}
}

const { dropzoneRef, isDragging } = useFileUpload({
accept: 'image/*,application/pdf',
multiple: true,
onUpdate: uploadFiles
})

const isUploading = computed(() =>
files.value.some(f => f.status === 'uploading')
)

const uploadedFiles = computed(() =>
files.value
.filter(f => f.status === 'uploaded' && f.uploadedUrl)
.map(f => ({
type: 'file' as const,
mediaType: f.file.type,
url: f.uploadedUrl!
}))
)

function removeFile(id: string) {
const file = files.value.find(f => f.id === id)
if (file) {
URL.revokeObjectURL(file.previewUrl)
files.value = files.value.filter(f => f.id !== id)
}
}

function clear() {
clearFiles(files)
}

return {
dropzoneRef,
isDragging,
files,
isUploading,
uploadedFiles,
addFiles: uploadFiles,
removeFile,
clearFiles: clear
}
}
Loading