Skip to content

Commit c5a725f

Browse files
feat(tool): add tool-chart and tool-weather (#71)
Co-authored-by: Benjamin Canac <[email protected]>
1 parent e636174 commit c5a725f

File tree

12 files changed

+1662
-27
lines changed

12 files changed

+1662
-27
lines changed

app/components/tool/Chart.vue

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
<script setup lang="ts">
2+
const props = defineProps<{
3+
invocation: ChartUIToolInvocation
4+
}>()
5+
6+
const color = computed(() => {
7+
return ({
8+
'output-available': 'bg-gradient-to-br from-sky-400 via-blue-500 to-indigo-600 dark:from-sky-500 dark:via-blue-600 dark:to-indigo-700',
9+
'output-error': 'bg-muted text-error'
10+
})[props.invocation.state as string] || 'bg-muted text-white'
11+
})
12+
13+
const icon = computed(() => {
14+
return ({
15+
'input-available': 'i-lucide-line-chart',
16+
'output-error': 'i-lucide-triangle-alert'
17+
})[props.invocation.state as string] || 'i-lucide-loader-circle'
18+
})
19+
20+
const message = computed(() => {
21+
return ({
22+
'input-available': 'Generating chart...',
23+
'output-error': 'Can\'t generate chart, please try again'
24+
})[props.invocation.state as string] || 'Loading chart data...'
25+
})
26+
27+
const xFormatter = (invocation: ChartUIToolInvocation) => {
28+
return (tick: number, _i?: number, _ticks?: number[]): string => {
29+
if (!invocation.output?.data[tick]) return ''
30+
return String(invocation.output.data[tick][invocation.output.xKey] ?? '')
31+
}
32+
}
33+
34+
const categories = (invocation: ChartUIToolInvocation): Record<string, BulletLegendItemInterface> => {
35+
if (!invocation.output?.series) return {}
36+
return invocation.output.series.reduce((acc: Record<string, BulletLegendItemInterface>, serie: { key: string, name: string, color: string }) => {
37+
acc[serie.key] = {
38+
name: serie.name,
39+
color: serie.color
40+
}
41+
return acc
42+
}, {} as Record<string, BulletLegendItemInterface>)
43+
}
44+
45+
const formatValue = (value: string | number | undefined): string => {
46+
if (value === undefined || value === null) return 'N/A'
47+
if (typeof value === 'string') return value
48+
49+
if (Number.isInteger(value)) {
50+
return value.toLocaleString()
51+
}
52+
return value.toLocaleString(undefined, {
53+
minimumFractionDigits: 0,
54+
maximumFractionDigits: 2
55+
})
56+
}
57+
</script>
58+
59+
<template>
60+
<div v-if="invocation.state === 'output-available'">
61+
<div v-if="invocation.output.title" class="flex items-center gap-2 mb-2">
62+
<UIcon name="i-lucide-line-chart" class="size-5 text-primary shrink-0" />
63+
<div class="min-w-0">
64+
<h3 class="text-lg font-semibold truncate">
65+
{{ invocation.output.title }}
66+
</h3>
67+
</div>
68+
</div>
69+
70+
<div class="relative overflow-hidden">
71+
<div class="dot-pattern h-full -top-5 left-0 right-0" />
72+
73+
<LineChart
74+
:height="300"
75+
:data="invocation.output.data"
76+
:categories="categories(invocation)"
77+
:x-formatter="xFormatter(invocation)"
78+
:x-label="invocation.output.xLabel"
79+
:y-label="invocation.output.yLabel"
80+
:y-grid-line="true"
81+
:curve-type="CurveType.MonotoneX"
82+
:legend-position="LegendPosition.Top"
83+
:hide-legend="false"
84+
:x-num-ticks="Math.min(6, invocation.output.data.length)"
85+
:y-num-ticks="5"
86+
:show-tooltip="true"
87+
>
88+
<template #tooltip="{ values }">
89+
<div class="bg-muted/50 rounded-sm px-2 py-1 shadow-lg backdrop-blur-sm max-w-xs ring ring-offset-2 ring-offset-(--ui-bg) ring-default border border-default">
90+
<div v-if="values && values[invocation.output.xKey]" class="text-sm font-semibold text-highlighted mb-2">
91+
{{ values[invocation.output.xKey] }}
92+
</div>
93+
<div class="space-y-1.5">
94+
<div
95+
v-for="serie in invocation.output.series"
96+
:key="serie.key"
97+
class="flex items-center justify-between gap-3"
98+
>
99+
<div class="flex items-center gap-2 min-w-0">
100+
<div
101+
class="size-2.5 rounded-full shrink-0"
102+
:style="{ backgroundColor: serie.color }"
103+
/>
104+
<span class="text-sm text-muted truncate">{{ serie.name }}</span>
105+
</div>
106+
<span class="text-sm font-semibold text-highlighted shrink-0">
107+
{{ formatValue(values?.[serie.key]) }}
108+
</span>
109+
</div>
110+
</div>
111+
</div>
112+
</template>
113+
</LineChart>
114+
</div>
115+
</div>
116+
117+
<div v-else class="rounded-xl px-5 py-4" :class="color">
118+
<div class="flex items-center justify-center h-44">
119+
<div class="text-center">
120+
<UIcon
121+
:name="icon"
122+
class="size-8 mx-auto mb-2"
123+
:class="[invocation.state === 'input-streaming' && 'animate-spin']"
124+
/>
125+
<div class="text-sm">
126+
{{ message }}
127+
</div>
128+
</div>
129+
</div>
130+
</div>
131+
</template>
132+
133+
<style>
134+
:root {
135+
--vis-tooltip-padding: 0 !important;
136+
--vis-tooltip-background-color: transparent !important;
137+
--vis-tooltip-border-color: transparent !important;
138+
139+
--vis-axis-grid-color: rgba(255, 255, 255, 0) !important;
140+
--vis-axis-tick-label-color: var(--ui-text-muted) !important;
141+
--vis-axis-label-color: var(--ui-text-toned) !important;
142+
--vis-legend-label-color: var(--ui-text-muted) !important;
143+
144+
--dot-pattern-color: #111827;
145+
}
146+
147+
.dark {
148+
--dot-pattern-color: #9ca3af;
149+
}
150+
151+
.dot-pattern {
152+
position: absolute;
153+
background-image: radial-gradient(var(--dot-pattern-color) 1px, transparent 1px);
154+
background-size: 7px 7px;
155+
background-position: -8.5px -8.5px;
156+
opacity: 20%;
157+
mask-image: radial-gradient(ellipse at center, rgba(0, 0, 0, 1), transparent 75%);
158+
}
159+
</style>

app/components/tool/Weather.vue

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
<script setup lang="ts">
2+
const props = defineProps<{
3+
invocation: WeatherUIToolInvocation
4+
}>()
5+
6+
const color = computed(() => {
7+
return ({
8+
'output-available': 'bg-gradient-to-br from-sky-400 via-blue-500 to-indigo-600 dark:from-sky-500 dark:via-blue-600 dark:to-indigo-700',
9+
'output-error': 'bg-muted text-error'
10+
})[props.invocation.state as string] || 'bg-muted text-white'
11+
})
12+
13+
const icon = computed(() => {
14+
return ({
15+
'input-available': 'i-lucide-cloud-sun',
16+
'output-error': 'i-lucide-triangle-alert'
17+
})[props.invocation.state as string] || 'i-lucide-loader-circle'
18+
})
19+
20+
const message = computed(() => {
21+
return ({
22+
'input-available': 'Loading weather data...',
23+
'output-error': 'Can\'t get weather data, please try again later'
24+
})[props.invocation.state as string] || 'Loading weather data...'
25+
})
26+
</script>
27+
28+
<template>
29+
<div class="rounded-xl px-5 py-4" :class="color">
30+
<template v-if="invocation.state === 'output-available'">
31+
<div class="flex items-start justify-between mb-3">
32+
<div class="flex items-baseline gap-1">
33+
<span class="text-4xl font-light">{{ invocation.output.temperature }}°</span>
34+
<span class="text-base text-white/80 mt-1">C</span>
35+
</div>
36+
<div class="text-right">
37+
<div class="text-base font-medium mb-1">
38+
{{ invocation.output.location }}
39+
</div>
40+
<div class="text-xs text-white/70">
41+
H:{{ invocation.output.temperatureHigh }}° L:{{ invocation.output.temperatureLow }}°
42+
</div>
43+
</div>
44+
</div>
45+
46+
<div class="flex items-center justify-between mb-4">
47+
<div class="flex items-center gap-2">
48+
<UIcon
49+
:name="invocation.output.condition.icon"
50+
class="size-6 text-white"
51+
/>
52+
<div class="text-sm font-medium">
53+
{{ invocation.output.condition.text }}
54+
</div>
55+
</div>
56+
57+
<div class="flex gap-3 text-xs">
58+
<div class="flex items-center gap-1">
59+
<UIcon name="i-lucide-droplets" class="size-3 text-blue-200" />
60+
<span>{{ invocation.output.humidity }}%</span>
61+
</div>
62+
<div class="flex items-center gap-1">
63+
<UIcon name="i-lucide-wind" class="size-3 text-blue-200" />
64+
<span>{{ invocation.output.windSpeed }} km/h</span>
65+
</div>
66+
</div>
67+
</div>
68+
69+
<div v-if="invocation.output.dailyForecast.length > 0" class="flex items-center justify-between">
70+
<div
71+
v-for="(forecast, index) in invocation.output.dailyForecast"
72+
:key="index"
73+
class="flex flex-col items-center gap-1.5"
74+
>
75+
<div class="text-xs text-white/70 font-medium">
76+
{{ forecast.day }}
77+
</div>
78+
79+
<UIcon
80+
:name="forecast.condition.icon"
81+
class="size-5 text-white"
82+
/>
83+
<div class="text-xs font-medium">
84+
<div>
85+
{{ forecast.high }}°
86+
</div>
87+
<div class="text-white/60">
88+
{{ forecast.low }}°
89+
</div>
90+
</div>
91+
</div>
92+
</div>
93+
94+
<div v-else class="flex items-center justify-center py-3">
95+
<div class="text-xs">
96+
No forecast available
97+
</div>
98+
</div>
99+
</template>
100+
101+
<div v-else class="flex items-center justify-center h-44">
102+
<div class="text-center">
103+
<UIcon
104+
:name="icon"
105+
class="size-8 mx-auto mb-2"
106+
:class="[invocation.state === 'input-streaming' && 'animate-spin']"
107+
/>
108+
<div class="text-sm">
109+
{{ message }}
110+
</div>
111+
</div>
112+
</div>
113+
</div>
114+
</template>

app/pages/chat/[id].vue

Lines changed: 29 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -89,31 +89,40 @@ onMounted(() => {
8989
<template #body>
9090
<UContainer class="flex-1 flex flex-col gap-4 sm:gap-6">
9191
<UChatMessages
92+
should-auto-scroll
9293
:messages="chat.messages"
9394
:status="chat.status"
9495
:assistant="chat.status !== 'streaming' ? { actions: [{ label: 'Copy', icon: copied ? 'i-lucide-copy-check' : 'i-lucide-copy', onClick: copy }] } : { actions: [] }"
9596
class="lg:pt-(--ui-header-height) pb-4 sm:pb-6"
9697
:spacing-offset="160"
9798
>
9899
<template #content="{ message }">
99-
<div class="space-y-4">
100-
<template v-for="(part, index) in message.parts" :key="`${part.type}-${index}-${message.id}`">
100+
<div class="space-y-5">
101+
<template v-for="(part, index) in message.parts" :key="`${message.id}-${part.type}-${index}${'state' in part ? `-${part.state}` : ''}`">
101102
<UButton
102-
v-if="part.type === 'reasoning' && part.state !== 'done'"
103-
label="Thinking..."
103+
v-if="part.type === 'reasoning'"
104+
:label="part.state === 'done' ? 'Done' : 'Thinking...'"
104105
variant="link"
105106
color="neutral"
106-
class="p-0"
107-
loading
107+
class="px-0"
108+
/>
109+
<MDCCached
110+
v-else-if="part.type === 'text'"
111+
:value="part.text"
112+
:cache-key="`${message.id}-${index}`"
113+
unwrap="p"
114+
:components="components"
115+
:parser-options="{ highlight: false }"
116+
/>
117+
<ToolWeather
118+
v-else-if="part.type === 'tool-weather'"
119+
:invocation="(part as WeatherUIToolInvocation)"
120+
/>
121+
<ToolChart
122+
v-else-if="part.type === 'tool-chart'"
123+
:invocation="(part as ChartUIToolInvocation)"
108124
/>
109125
</template>
110-
<MDCCached
111-
:value="getTextFromMessage(message)"
112-
:cache-key="message.id"
113-
unwrap="p"
114-
:components="components"
115-
:parser-options="{ highlight: false }"
116-
/>
117126
</div>
118127
</template>
119128
</UChatMessages>
@@ -125,15 +134,15 @@ onMounted(() => {
125134
class="sticky bottom-0 [view-transition-name:chat-prompt] rounded-b-none z-10"
126135
@submit="handleSubmit"
127136
>
128-
<UChatPromptSubmit
129-
:status="chat.status"
130-
color="neutral"
131-
@stop="chat.stop"
132-
@reload="chat.regenerate"
133-
/>
134-
135137
<template #footer>
136138
<ModelSelect v-model="model" />
139+
140+
<UChatPromptSubmit
141+
:status="chat.status"
142+
color="neutral"
143+
@stop="chat.stop"
144+
@reload="chat.regenerate"
145+
/>
137146
</template>
138147
</UChatPrompt>
139148
</UContainer>

app/pages/index.vue

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,14 @@ const quickChats = [
4040
{
4141
label: 'Tailwind CSS best practices',
4242
icon: 'i-logos-tailwindcss-icon'
43+
},
44+
{
45+
label: 'What is the weather in Bordeaux?',
46+
icon: 'i-lucide-sun'
47+
},
48+
{
49+
label: 'Show me a chart of sales data',
50+
icon: 'i-lucide-line-chart'
4351
}
4452
]
4553
</script>

nuxt.config.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ export default defineNuxtConfig({
44
'@nuxt/eslint',
55
'@nuxt/ui',
66
'@nuxtjs/mdc',
7-
'nuxt-auth-utils'
7+
'nuxt-auth-utils',
8+
'nuxt-charts'
89
],
910

1011
devtools: {
@@ -14,6 +15,9 @@ export default defineNuxtConfig({
1415
css: ['~/assets/css/main.css'],
1516

1617
mdc: {
18+
headings: {
19+
anchorLinks: false
20+
},
1721
highlight: {
1822
// noApiRoute: true
1923
shikiEngine: 'javascript'

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"drizzle-orm": "^0.44.6",
2626
"nuxt": "^4.1.3",
2727
"nuxt-auth-utils": "^0.5.25",
28+
"nuxt-charts": "0.2.4",
2829
"pg": "^8.16.3",
2930
"shiki-stream": "^0.1.2"
3031
},

0 commit comments

Comments
 (0)