Skip to content
Closed
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
56297a6
feat: add tools
HugoRCD Sep 18, 2025
2855b10
up
HugoRCD Sep 18, 2025
2026537
up
HugoRCD Sep 18, 2025
5115808
Merge remote-tracking branch 'origin/main' into feat/tools
HugoRCD Sep 18, 2025
c60da38
image upload
HugoRCD Sep 18, 2025
a05f70e
test image edition
HugoRCD Sep 18, 2025
e8789b3
improve structure
HugoRCD Sep 18, 2025
4faf703
add motion-v and remove anchor links
HugoRCD Sep 27, 2025
4340be8
Merge remote-tracking branch 'origin/main' into feat/tools
HugoRCD Sep 27, 2025
8ce6b66
fix typecheck
HugoRCD Sep 27, 2025
80b5e15
use useDropZone from vueuse instead of a custom one
HugoRCD Sep 29, 2025
0c66829
Merge remote-tracking branch 'origin/main' into feat/tools
HugoRCD Sep 29, 2025
b914d3a
Merge remote-tracking branch 'origin/main' into feat/tools
HugoRCD Sep 30, 2025
53cd1f0
Merge branch 'main' into feat/tools
HugoRCD Oct 3, 2025
0c8e0a3
Merge remote-tracking branch 'origin/main' into feat/tools
HugoRCD Oct 3, 2025
f818902
Merge remote-tracking branch 'origin/main' into feat/tools
HugoRCD Oct 6, 2025
f2525ad
remove custom composable
HugoRCD Oct 7, 2025
6d3f1c5
improve file upload handling
HugoRCD Oct 7, 2025
9c99bf5
remove imageGeneration tool for the moment
HugoRCD Oct 8, 2025
617919a
small ui and streaming improvements
HugoRCD Oct 8, 2025
5597256
add chart tool
HugoRCD Oct 13, 2025
e71abe8
Merge remote-tracking branch 'origin/main' into feat/tools
HugoRCD Oct 13, 2025
f744f99
add reasoning component
HugoRCD Oct 13, 2025
a706d0d
improve reasoning, file-preview and drag-n-drop overlay components
HugoRCD Oct 13, 2025
2d78795
fix lint
HugoRCD Oct 13, 2025
d383fbd
fix loading
HugoRCD Oct 15, 2025
e8c6b00
clean up
HugoRCD Oct 15, 2025
4d86762
Merge remote-tracking branch 'origin/main' into feat/tools
HugoRCD Oct 15, 2025
01bf315
fix parts ordering and improve reasoning for GPT-5
HugoRCD Oct 15, 2025
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'
}
}
}
})
22 changes: 22 additions & 0 deletions app/components/DragDropOverlay.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<script setup lang="ts">
defineProps<{
show: boolean
}>()
</script>

<template>
<div
v-if="show"
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>
51 changes: 51 additions & 0 deletions app/components/FilePreview.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<script setup lang="ts">
const files = defineModel<File[]>({ required: true })

function createObjectUrl(file: File): string {
return URL.createObjectURL(file)
}

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(index: number) {
const fileToRemove = files.value[index]
if (fileToRemove) {
URL.revokeObjectURL(createObjectUrl(fileToRemove))
files.value.splice(index, 1)
}
}
</script>

<template>
<div v-if="files.length > 0" class="flex flex-wrap gap-2">
<div
v-for="(file, index) in files"
:key="index"
class="relative group"
>
<UAvatar
size="3xl"
:src="file.type.startsWith('image/') ? createObjectUrl(file) : undefined"
:icon="getFileIcon(file)"
class="border-2 border-default rounded-lg"
/>
<UButton
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(index)"
/>
</div>
</div>
</template>
38 changes: 38 additions & 0 deletions app/components/FileUploadButton.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<script setup lang="ts">
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>
<label :for="inputId" class="cursor-pointer">
<UButton
icon="i-lucide-paperclip"
variant="ghost"
color="neutral"
size="sm"
as="span"
/>
</label>
<input
:id="inputId"
type="file"
multiple
accept="image/*,.pdf"
class="hidden"
@change="handleFileSelect"
>
</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>
82 changes: 82 additions & 0 deletions app/composables/useChatFileUpload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
export function useChatFileUpload(files: Ref<File[]>) {
const isDragOver = ref(false)

function createObjectUrl(file: File): string {
return URL.createObjectURL(file)
}

function handleDragEnter(e: DragEvent) {
e.preventDefault()
e.stopPropagation()

if (e.dataTransfer?.types.includes('Files')) {
isDragOver.value = true
}
}

function handleDragOver(e: DragEvent) {
e.preventDefault()
e.stopPropagation()
isDragOver.value = true
}

function handleDragLeave(e: DragEvent) {
e.preventDefault()
e.stopPropagation()
isDragOver.value = false
}

function handleDrop(e: DragEvent) {
e.preventDefault()
e.stopPropagation()
isDragOver.value = false

const arrayFiles = Array.from(e.dataTransfer?.files || [])

if (arrayFiles.length > 0) {
files.value = [...files.value, ...arrayFiles]
}
}

function clearFiles() {
files.value.forEach((file) => {
URL.revokeObjectURL(createObjectUrl(file))
})
files.value = []
}

async function convertFilesToDataURLs(files: File[]) {
return Promise.all(
files.map(
file =>
new Promise<{
type: 'file'
mediaType: string
url: string
}>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
resolve({
type: 'file',
mediaType: file.type,
url: reader.result as string
})
}
reader.onerror = reject
reader.readAsDataURL(file)
})
)
)
}

return {
isDragOver: readonly(isDragOver),
createObjectUrl,
handleDragEnter,
handleDragOver,
handleDragLeave,
handleDrop,
clearFiles,
convertFilesToDataURLs
}
}
Loading