Skip to content

Commit 811f918

Browse files
committed
feat: simple AI chat demo example
1 parent 5bb94f8 commit 811f918

15 files changed

Lines changed: 486 additions & 31 deletions

File tree

apps/backend-convex/convex/_generated/api.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010

1111
import type * as authInfo from "../authInfo.js";
1212
import type * as crons from "../crons.js";
13+
import type * as http_ai from "../http/ai.js";
14+
import type * as http from "../http.js";
1315
import type * as tasks from "../tasks.js";
1416

1517
import type {
@@ -29,6 +31,8 @@ import type {
2931
declare const fullApi: ApiFromModules<{
3032
authInfo: typeof authInfo;
3133
crons: typeof crons;
34+
"http/ai": typeof http_ai;
35+
http: typeof http;
3236
tasks: typeof tasks;
3337
}>;
3438
declare const fullApiWithMounts: typeof fullApi;

apps/backend-convex/convex/http.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { HonoWithConvex } from 'convex-helpers/server/hono'
2+
import type { ActionCtx } from './_generated/server'
3+
import { HttpRouterWithHono } from 'convex-helpers/server/hono'
4+
import { Hono } from 'hono'
5+
import { aiApp } from './http/ai'
6+
7+
const app: HonoWithConvex<ActionCtx> = new Hono()
8+
app.route('/api/ai', aiApp)
9+
10+
export default new HttpRouterWithHono(app)
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type { HonoWithConvex } from 'convex-helpers/server/hono'
2+
import type { ActionCtx } from '../_generated/server'
3+
import RateLimiter, { MINUTE } from '@convex-dev/rate-limiter'
4+
import { zValidator } from '@hono/zod-validator'
5+
import { sample } from '@namesmt/utils'
6+
import { createOpenRouter } from '@openrouter/ai-sdk-provider'
7+
import { streamText } from 'ai'
8+
import { ConvexError } from 'convex/values'
9+
import { Hono } from 'hono'
10+
import { cors } from 'hono/cors'
11+
import { z } from 'zod'
12+
import { components } from '../_generated/api'
13+
14+
const models = process.env.AI_MODELS_LIST?.split(',') ?? ['qwen/qwen3-32b:free']
15+
const apiKey = process.env.OPENROUTER_API_KEY
16+
if (!apiKey)
17+
throw new Error('Missing OPENROUTER_API_KEY')
18+
19+
const openrouter = createOpenRouter({
20+
apiKey,
21+
})
22+
23+
const aiMessagesSchema = z.array(
24+
z.object({
25+
role: z.enum(['user', 'assistant']),
26+
content: z.string(),
27+
}),
28+
)
29+
30+
const rateLimiter = new RateLimiter(components.rateLimiter, {
31+
aiChat: { kind: 'token bucket', rate: 10, period: MINUTE, capacity: 3 },
32+
})
33+
34+
export const aiApp: HonoWithConvex<ActionCtx> = new Hono()
35+
aiApp
36+
.use(cors())
37+
.post('/chat', zValidator('json', z.object({ messages: aiMessagesSchema })), async (c) => {
38+
const userIdentity = await c.env.auth.getUserIdentity()
39+
if (userIdentity === null)
40+
throw new ConvexError({ msg: 'Not authenticated' })
41+
42+
await rateLimiter.limit(c.env, 'aiChat', { key: userIdentity.subject, throws: true })
43+
44+
const { messages } = c.req.valid('json')
45+
46+
const result = streamText({
47+
model: openrouter(sample(models, 1)[0]),
48+
messages,
49+
})
50+
51+
return result.toDataStreamResponse()
52+
})

apps/backend-convex/package.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,14 @@
1717
"predev": "convex dev --once"
1818
},
1919
"dependencies": {
20-
"@convex-dev/rate-limiter": "^0.2.7"
20+
"@ai-sdk/openai": "^1.3.22",
21+
"@convex-dev/rate-limiter": "^0.2.7",
22+
"@hono/zod-validator": "^0.7.0",
23+
"@openrouter/ai-sdk-provider": "^0.7.1",
24+
"ai": "^4.3.16",
25+
"convex-helpers": "^0.1.92",
26+
"hono": "^4.7.11",
27+
"zod": "^3.25.62"
2128
},
2229
"devDependencies": {
2330
"@local/common": "workspace:*",

apps/frontend/app/components/ConvexIntegrationTest.vue

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,6 @@ const { data: tasks } = useConvexQuery(api.tasks.get)
1010
const { data: authInfo } = useConvexQuery(api.authInfo.get)
1111
const { mutate: mutateAddTask } = useConvexMutation(api.tasks.add)
1212
13-
const taskInputPlaceholders = [
14-
`What's on your mind?`,
15-
`What's the plan?`,
16-
`What's the goal?`,
17-
`What's the purpose?`,
18-
`What's the objective?`,
19-
`What's the mission?`,
20-
`What's the direction?`,
21-
`What's the vision?`,
22-
`What's the strategy?`,
23-
]
2413
const taskInputRef = ref('')
2514
async function addTask() {
2615
await mutateAddTask({ text: taskInputRef.value })
@@ -61,7 +50,7 @@ async function testConvexViaBackendTasksCTA() {
6150
<div class="max-w-md w-full flex flex-col gap-5">
6251
<VanishingInput
6352
v-model="taskInputRef"
64-
:placeholders="taskInputPlaceholders"
53+
:placeholders="useInputThoughtsPlaceholders().value"
6554
@submit="addTask()"
6655
/>
6756
</div>

apps/frontend/app/components/Logo.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,6 @@ const {
1010
</script>
1111

1212
<template>
13-
<LogoFull v-if="variant === 'full'" />
14-
<LogoSimple v-else />
13+
<LogoFull v-if="variant === 'full'" class="site-logo site-logo--full" />
14+
<LogoSimple v-else class="site-logo site-logo--simple" />
1515
</template>

apps/frontend/app/components/layouts/default/DefaultHeader.vue

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@
2626
>
2727
<span class="group-hover:text-primary">{{ $t('pages.test.title') }}</span>
2828
</NuxtLink>
29+
30+
<NuxtLink
31+
to="/chat" class="group flex items-center"
32+
active-class="text-accent-foreground font-semibold border-t-2px border-primary bg-gradient-to-b from-primary/20"
33+
>
34+
<span class="group-hover:text-primary">{{ $t('pages.chat.title') }}</span>
35+
</NuxtLink>
2936
</div>
3037

3138
<div class="col-span-3 flex">
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
export function useInputThoughtsPlaceholders() {
2+
const { $i18n } = useNuxtApp()
3+
4+
return computed(() => [
5+
$i18n.t('getInputThoughtsPlaceholders.0'),
6+
$i18n.t('getInputThoughtsPlaceholders.1'),
7+
$i18n.t('getInputThoughtsPlaceholders.2'),
8+
$i18n.t('getInputThoughtsPlaceholders.3'),
9+
$i18n.t('getInputThoughtsPlaceholders.4'),
10+
$i18n.t('getInputThoughtsPlaceholders.5'),
11+
$i18n.t('getInputThoughtsPlaceholders.6'),
12+
$i18n.t('getInputThoughtsPlaceholders.7'),
13+
$i18n.t('getInputThoughtsPlaceholders.8'),
14+
])
15+
}

apps/frontend/app/pages/chat.vue

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<script setup lang="ts">
2+
import { useChat } from '@ai-sdk/vue'
3+
import { Card, CardContent, CardHeader, CardTitle } from '~/lib/shadcn/components/ui/card'
4+
5+
const { $auth } = useNuxtApp()
6+
const { convexApiUrl } = useRuntimeConfig().public
7+
8+
const { messages, input, handleSubmit } = useChat({
9+
api: `${convexApiUrl}/api/ai/chat`,
10+
headers: {
11+
Authorization: `Bearer ${$auth.token}`,
12+
},
13+
})
14+
</script>
15+
16+
<template>
17+
<div class="w-full flex justify-center">
18+
<div v-if="!$auth.loggedIn" class="h-full flex items-center justify-center text-xl">
19+
{{ $t('pages.chat.loginPrompt') }}
20+
</div>
21+
22+
<div v-else class="max-w-6xl w-full flex flex-col p-4 space-y-4">
23+
<div class="flex-1 overflow-y-auto space-y-4">
24+
<div
25+
v-for="m, index in messages"
26+
:key="m.id ? m.id : index"
27+
class="flex"
28+
:class="m.role === 'user' ? 'justify-end' : 'justify-start'"
29+
>
30+
<Card class="max-w-md">
31+
<CardHeader>
32+
<CardTitle>{{ m.role === 'user' ? $t('pages.chat.userLabel') : $t('pages.chat.aiLabel') }}</CardTitle>
33+
</CardHeader>
34+
<CardContent>
35+
<div v-for="part, pIndex in m.parts" :key="pIndex">
36+
<div v-if="part.type === 'text'">
37+
<p class="whitespace-pre-wrap">
38+
{{ part.text }}
39+
</p>
40+
</div>
41+
</div>
42+
</CardContent>
43+
</Card>
44+
</div>
45+
</div>
46+
47+
<div class="pt-4">
48+
<form class="mx-auto max-w-lg w-full flex gap-2" @submit="handleSubmit">
49+
<VanishingInput
50+
v-model="input"
51+
:placeholders="useInputThoughtsPlaceholders().value"
52+
@submit="handleSubmit()"
53+
/>
54+
</form>
55+
</div>
56+
</div>
57+
</div>
58+
</template>

apps/frontend/nuxt.config.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,18 @@ import { getConvexEnvs } from 'backend-convex/_util'
44
import { config } from 'dotenv'
55
import optimizeExclude from 'vite-plugin-optimize-exclude'
66

7-
if (import.meta.env.NODE_ENV === 'development')
7+
if (import.meta.env.NODE_ENV === 'development') {
88
config({ path: ['.env.local.ignored', '.env.local'] })
9-
else
9+
import.meta.env.NUXT_PUBLIC_CONVEX_URL ||= (await getConvexEnvs()).CONVEX_URL || ''
10+
}
11+
else {
1012
config({ path: ['.env.prod.ignored', '.env.prod'] })
13+
}
1114

1215
const siteConfig = {
1316
url: import.meta.env.NUXT_PUBLIC_FRONTEND_URL,
1417
backend: import.meta.env.NUXT_PUBLIC_BACKEND_URL,
18+
convex: import.meta.env.NUXT_PUBLIC_CONVEX_URL,
1519
name: 'starter-monorepo',
1620
description: 'Monorepo with 🤖 AI initialize and localize | 🔥Hono + OpenAPI & RPC, Nuxt, Convex, SST Ion, Kinde Auth, Tanstack Query, Shadcn, UnoCSS, Spreadsheet I18n, Lingo.dev',
1721
}
@@ -25,8 +29,6 @@ function genFrontendLocale(code: string, languageISO: string, dir?: LocaleObject
2529
}
2630
}
2731

28-
const convexLocalEnvs = await getConvexEnvs()
29-
3032
// https://nuxt.com/docs/api/configuration/nuxt-config
3133
export default defineNuxtConfig({
3234
future: {
@@ -36,6 +38,7 @@ export default defineNuxtConfig({
3638
devtools: { enabled: true },
3739

3840
experimental: {
41+
viewTransition: true,
3942
watcher: 'parcel',
4043
componentIslands: true,
4144
},
@@ -57,10 +60,13 @@ export default defineNuxtConfig({
5760
public: {
5861
frontendUrl: siteConfig.url,
5962
backendUrl: siteConfig.backend,
63+
convexUrl: siteConfig.convex,
64+
convexApiUrl: siteConfig.convex.replace('.convex.cloud', '.convex.site'),
6065
},
6166
},
6267

6368
app: {
69+
viewTransition: false,
6470
head: {
6571
link: [
6672
{ rel: 'icon', href: '/favicon.svg', type: 'image/svg+xml' },
@@ -89,6 +95,9 @@ export default defineNuxtConfig({
8995
'clsx',
9096
'embla-carousel-vue',
9197
],
98+
include: [
99+
'secure-json-parse',
100+
],
92101
},
93102
},
94103

@@ -110,7 +119,7 @@ export default defineNuxtConfig({
110119
],
111120

112121
convex: {
113-
url: import.meta.env.NUXT_PUBLIC_CONVEX_URL || convexLocalEnvs.CONVEX_URL || '',
122+
url: siteConfig.convex,
114123
manualInit: true,
115124
},
116125

0 commit comments

Comments
 (0)