Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,20 @@
"@ai-sdk/gateway": "^2.0.2",
"@iconify/vue": "^5.0.0",
"@nuxt/ui": "^4.1.0",
"@unovis/vue": "^1.6.1",
"ai": "^5.0.80",
"date-fns": "^4.1.0",
"defu": "^6.1.4",
"drizzle-orm": "^0.44.7",
"katex": "^0.16.25",
"mermaid": "^11.12.0",
"nitro": "npm:nitro-nightly@3.1.0-20251028-090722-437659e4",
"nitro": "npm:nitro-nightly@3.0.1-20251030-220619-920c05a8",
"ofetch": "^1.4.1",
"pg": "^8.16.3",
"shiki-stream": "^0.1.2",
"ufo": "^1.6.1",
"vue": "^3.5.22",
"vue-chrts": "^1.0.2",
"vue-renderer-markdown": "0.0.59",
"vue-router": "^4.6.3",
"vue-use-monaco": "^0.0.33",
Expand Down
809 changes: 745 additions & 64 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion server/routes/api/chats/[id].get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ export default defineEventHandler(async (event) => {
const chat = await useDrizzle().query.chats.findFirst({
where: (chat, { eq }) => and(eq(chat.id, id as string), eq(chat.userId, session.data.user?.id || session.id!)),
with: {
messages: true
messages: {
orderBy: (message, { asc }) => asc(message.createdAt)
}
}
})

Expand Down
45 changes: 41 additions & 4 deletions server/routes/api/chats/[id].post.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { convertToModelMessages, createUIMessageStream, createUIMessageStreamResponse, generateText, streamText } from 'ai'
import { convertToModelMessages, createUIMessageStream, createUIMessageStreamResponse, generateText, smoothStream, stepCountIs, streamText } from 'ai'
import { gateway } from '@ai-sdk/gateway'
import type { UIMessage } from 'ai'
import { z } from 'zod'
import { useUserSession } from '../../../utils/session'
import { useDrizzle, tables, eq, and } from '../../../utils/drizzle'
import { defineEventHandler, getValidatedRouterParams, readValidatedBody, HTTPError } from 'nitro/deps/h3'
import { weatherTool } from '../../../utils/tools/weather'
import { chartTool } from '../../../utils/tools/chart'

export default defineEventHandler(async (event) => {
const session = await useUserSession(event)
Expand Down Expand Up @@ -58,8 +60,41 @@ export default defineEventHandler(async (event) => {
execute: ({ writer }) => {
const result = streamText({
model: gateway(model),
system: 'You are a helpful assistant that can answer questions and help.',
messages: convertToModelMessages(messages)
system: `You are a knowledgeable and helpful AI assistant. ${session.data.user?.username ? `The user's name is ${session.data.user.username}.` : ''} Your goal is to provide clear, accurate, and well-structured responses.

**FORMATTING RULES (CRITICAL):**
- ABSOLUTELY NO MARKDOWN HEADINGS: Never use #, ##, ###, ####, #####, or ######
- NO underline-style headings with === or ---
- Use **bold text** for emphasis and section labels instead
- Examples:
* Instead of "## Usage", write "**Usage:**" or just "Here's how to use it:"
* Instead of "# Complete Guide", write "**Complete Guide**" or start directly with content
- Start all responses with content, never with a heading

**RESPONSE QUALITY:**
- Be concise yet comprehensive
- Use examples when helpful
- Break down complex topics into digestible parts
- Maintain a friendly, professional tone`,
messages: convertToModelMessages(messages),
providerOptions: {
openai: {
reasoningEffort: 'low',
reasoningSummary: 'detailed'
},
google: {
thinkingConfig: {
includeThoughts: true,
thinkingBudget: 2048
}
}
},
stopWhen: stepCountIs(5),
experimental_transform: smoothStream({ chunking: 'word' }),
tools: {
weather: weatherTool,
chart: chartTool
}
})

if (!chat.title) {
Expand All @@ -70,7 +105,9 @@ export default defineEventHandler(async (event) => {
})
}

writer.merge(result.toUIMessageStream())
writer.merge(result.toUIMessageStream({
sendReasoning: true
}))
},
onFinish: async ({ messages }) => {
await db.insert(tables.messages).values(messages.map(message => ({
Expand Down
34 changes: 34 additions & 0 deletions server/utils/tools/chart.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { tool } from 'ai'
import { z } from 'zod'
import type { UIToolInvocation } from 'ai'

export type ChartUIToolInvocation = UIToolInvocation<typeof chartTool>

export const chartTool = tool({
description: 'Create a line chart visualization with one or multiple data series. Use this tool to display time-series data, trends, or comparisons between different metrics over time.',
inputSchema: z.object({
title: z.string().optional().describe('Title of the chart'),
data: z.array(z.record(z.string(), z.union([z.string(), z.number()]))).min(1).describe('REQUIRED: Array of data points (minimum 1 point). Each object must contain the xKey property and all series keys'),
xKey: z.string().describe('The property name in data objects to use for x-axis values (e.g., "month", "date")'),
series: z.array(z.object({
key: z.string().describe('The property name in data objects for this series (must exist in all data points)'),
name: z.string().describe('Display name for this series in the legend'),
color: z.string().describe('Hex color code for this line (e.g., "#3b82f6" for blue, "#10b981" for green)')
})).min(1).describe('Array of series configurations (minimum 1 series). Each series represents one line on the chart'),
xLabel: z.string().optional().describe('Optional label for x-axis'),
yLabel: z.string().optional().describe('Optional label for y-axis')
}),
execute: async ({ title, data, xKey, series, xLabel, yLabel }) => {
// Create a delay to simulate the input-available state
await new Promise(resolve => setTimeout(resolve, 1500))

return {
title,
data,
xKey,
series,
xLabel,
yLabel
}
}
})
40 changes: 40 additions & 0 deletions server/utils/tools/weather.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { UIToolInvocation } from 'ai'
import { tool } from 'ai'
import { z } from 'zod'

export type WeatherUIToolInvocation = UIToolInvocation<typeof weatherTool>

const getWeatherData = (k: string) => ({
'sunny': { text: 'Sunny', icon: 'i-lucide-sun' },
'partly-cloudy': { text: 'Partly Cloudy', icon: 'i-lucide-cloud-sun' },
'cloudy': { text: 'Cloudy', icon: 'i-lucide-cloud' },
'rainy': { text: 'Rainy', icon: 'i-lucide-cloud-rain' },
'foggy': { text: 'Foggy', icon: 'i-lucide-cloud-fog' }
}[k] || { text: 'Sunny', icon: 'i-lucide-sun' })

export const weatherTool = tool({
description: 'Get weather info with 5-day forecast',
inputSchema: z.object({ location: z.string().describe('Location for weather') }),
execute: async ({ location }) => {
// Create a delay to simulate the input-available state
await new Promise(resolve => setTimeout(resolve, 1500))

const temp = Math.floor(Math.random() * 35) + 5
const conds = ['sunny', 'partly-cloudy', 'cloudy', 'rainy', 'foggy'] as const
return {
location,
temperature: Math.round(temp),
temperatureHigh: Math.round(temp + Math.random() * 5 + 2),
temperatureLow: Math.round(temp - Math.random() * 5 - 2),
condition: getWeatherData(conds[Math.floor(Math.random() * conds.length)]!),
humidity: Math.floor(Math.random() * 60) + 20,
windSpeed: Math.floor(Math.random() * 25) + 5,
dailyForecast: ['Today', 'Tomorrow', 'Thu', 'Fri', 'Sat'].map((day, i) => ({
day,
high: Math.round(temp + Math.random() * 8 - 2),
low: Math.round(temp - Math.random() * 8 - 3),
condition: getWeatherData(conds[(Math.floor(Math.random() * conds.length) + i) % conds.length]!)
}))
}
}
})
2 changes: 1 addition & 1 deletion src/components/ModelSelect.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const { model, models } = useModels()
const items = computed(() => models.map(model => ({
label: model,
value: model,
icon: `i-simple-icons${model.split('/')[0]}`
icon: `i-simple-icons:${model.split('/')[0]}`
})))
</script>

Expand Down
49 changes: 49 additions & 0 deletions src/components/Reasoning.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { ref, watch } from 'vue'

const { isStreaming = false } = defineProps<{
text: string
isStreaming?: boolean
}>()

const open = ref(false)

watch(() => isStreaming, () => {
open.value = isStreaming
}, { immediate: true })

function cleanMarkdown(text: string): string {
return text
.replace(/\*\*(.+?)\*\*/g, '$1') // Remove bold
.replace(/\*(.+?)\*/g, '$1') // Remove italic
.replace(/`(.+?)`/g, '$1') // Remove inline code
.replace(/^#+\s+/gm, '') // Remove headers
}
</script>

<template>
<UCollapsible
v-model:open="open"
class="flex flex-col gap-1 my-5"
>
<UButton
class="p-0 group"
color="neutral"
variant="link"
trailing-icon="i-lucide-chevron-down"
:ui="{
trailingIcon: text.length > 0 ? 'group-data-[state=open]:rotate-180 transition-transform duration-200' : 'hidden'
}"
:label="isStreaming ? 'Thinking...' : 'Thoughts'"
/>

<template #content>
<div
v-for="(value, index) in cleanMarkdown(text).split('\n').filter(Boolean)"
:key="index"
>
<span class="whitespace-pre-wrap text-sm text-muted font-normal">{{ value }}</span>
</div>
</template>
</UCollapsible>
</template>
Loading