├── .editorconfig ├── .env.example ├── .github └── workflows │ ├── ci.yml │ └── nuxthub.yml ├── .gitignore ├── .npmrc ├── README.md ├── app ├── app.config.ts ├── app.vue ├── assets │ └── css │ │ └── main.css ├── components │ ├── DashboardNavbar.vue │ ├── Logo.vue │ ├── ModalConfirm.vue │ ├── ModelSelect.vue │ ├── UserMenu.vue │ └── prose │ │ └── PreStream.vue ├── composables │ ├── useChats.ts │ ├── useHighlighter.ts │ └── useLLM.ts ├── error.vue ├── layouts │ └── default.vue ├── middleware │ └── transitions.global.ts ├── pages │ ├── chat │ │ └── [id].vue │ └── index.vue └── types │ └── auth.d.ts ├── drizzle.config.ts ├── eslint.config.mjs ├── nuxt.config.ts ├── package.json ├── pnpm-lock.yaml ├── public └── favicon.ico ├── renovate.json ├── server ├── api │ ├── chats.get.ts │ ├── chats.post.ts │ └── chats │ │ ├── [id].delete.ts │ │ ├── [id].get.ts │ │ └── [id].post.ts ├── database │ ├── migrations │ │ ├── 0000_moaning_mastermind.sql │ │ └── meta │ │ │ ├── 0000_snapshot.json │ │ │ └── _journal.json │ └── schema.ts ├── routes │ └── auth │ │ └── github.get.ts └── utils │ └── drizzle.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_size = 2 6 | indent_style = space 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Production license for @nuxt/ui-pro, get one at https://ui.nuxt.com/pro/purchase 2 | NUXT_UI_PRO_LICENSE= 3 | # Password for nuxt-auth-utils (minimum 32 characters) 4 | NUXT_SESSION_PASSWORD= 5 | # Optional, Cloudflare AI Gateway ID 6 | NUXT_CLOUDFLARE_GATEWAY_ID= 7 | # GitHub OAuth client ID 8 | NUXT_OAUTH_GITHUB_CLIENT_ID= 9 | # GitHub OAuth client secret 10 | NUXT_OAUTH_GITHUB_CLIENT_SECRET= -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: push 4 | 5 | jobs: 6 | ci: 7 | runs-on: ${{ matrix.os }} 8 | 9 | strategy: 10 | matrix: 11 | os: [ubuntu-latest] 12 | node: [22] 13 | 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | 18 | - name: Install pnpm 19 | uses: pnpm/action-setup@v4 20 | 21 | - name: Install node 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: ${{ matrix.node }} 25 | cache: pnpm 26 | 27 | - name: Install dependencies 28 | run: pnpm install 29 | 30 | - name: Lint 31 | run: pnpm run lint 32 | 33 | - name: Typecheck 34 | run: pnpm run typecheck 35 | -------------------------------------------------------------------------------- /.github/workflows/nuxthub.yml: -------------------------------------------------------------------------------- 1 | name: nuxthub 2 | 3 | on: push 4 | 5 | jobs: 6 | deploy: 7 | runs-on: ubuntu-latest 8 | 9 | environment: 10 | name: ${{ github.ref == 'refs/heads/main' && 'production' || 'preview' }} 11 | url: ${{ steps.deploy.outputs.deployment-url }} 12 | 13 | permissions: 14 | contents: read 15 | id-token: write 16 | 17 | env: 18 | NUXT_UI_PRO_LICENSE: ${{ secrets.NUXT_UI_PRO_LICENSE }} 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | 23 | - name: Install pnpm 24 | uses: pnpm/action-setup@v4 25 | 26 | - name: Install Node.js 27 | uses: actions/setup-node@v4 28 | with: 29 | node-version: 22 30 | cache: 'pnpm' 31 | 32 | - name: Install dependencies 33 | run: pnpm install 34 | 35 | - name: Ensure NuxtHub module is installed 36 | run: pnpx nuxthub@latest ensure 37 | 38 | - name: Build application 39 | run: pnpm build 40 | 41 | - name: Deploy to NuxtHub 42 | uses: nuxt-hub/action@v1 43 | id: deploy 44 | with: 45 | project-key: chat-template-ooai 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Nuxt dev/build outputs 2 | .output 3 | .data 4 | .nuxt 5 | .nitro 6 | .cache 7 | dist 8 | 9 | # Node dependencies 10 | node_modules 11 | 12 | # Logs 13 | logs 14 | *.log 15 | 16 | # Misc 17 | .DS_Store 18 | .fleet 19 | .idea 20 | 21 | # Local env files 22 | .env 23 | .env.* 24 | !.env.example 25 | 26 | # VSC 27 | .history 28 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nuxt AI Chatbot Template 2 | 3 | [](https://ui.nuxt.com/pro) 4 | [](https://hub.nuxt.com/new?repo=nuxt-ui-pro/chat) 5 | 6 | Full-featured AI Chatbot Nuxt application with authentication, chat history, multiple pages, collapsible sidebar, keyboard shortcuts, light & dark mode, command palette and more. Built using [Nuxt UI Pro](https://ui.nuxt.com/pro) components and integrated with [Workers AI](https://ai.cloudflare.com) for a complete chat experience. 7 | 8 | - [Live demo](https://chat-template.nuxt.dev/) 9 | - [Documentation](https://ui.nuxt.com/getting-started/installation/pro/nuxt) 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | ## Features 20 | 21 | - ⚡️ **Streaming AI messages** powered by the [Vercel AI SDK ](https://sdk.vercel.ai) 22 | - 🤖 **Multiple model support** via [Workers AI](https://ai.cloudflare.com) with support for [AI Gateway](https://developers.cloudflare.com/ai-gateway/) 23 | - 🔐 **Authentication** via [nuxt-auth-utils](https://github.com/atinux/nuxt-auth-utils) 24 | - 💾 **Chat history persistence** using [NuxtHub database](https://hub.nuxt.com/docs/features/database) and [Drizzle ORM](https://orm.drizzle.team) 25 | - 🚀 **One-click deploy** to your Cloudflare account with NuxtHub: [deploy now](https://hub.nuxt.com/new?repo=nuxt-ui-pro/chat) 26 | 27 | ## Quick Start 28 | 29 | ```bash 30 | npx nuxi@latest init -t github:nuxt-ui-pro/chat 31 | ``` 32 | 33 | ## Setup 34 | 35 | Make sure to install the dependencies: 36 | 37 | ```bash 38 | pnpm install 39 | ``` 40 | 41 | Next, link a NuxtHub project (even if not deployed) to access AI models in development: 42 | 43 | ```bash 44 | npx nuxthub link 45 | ``` 46 | 47 | > [!TIP] 48 | > It works with free Cloudflare and NuxtHub accounts. 49 | 50 | To add authentication with GitHub, you need to [create a GitHub OAuth application](https://github.com/settings/applications/new) and then fill the credentials in your `.env`: 51 | 52 | ```env 53 | NUXT_OAUTH_GITHUB_CLIENT_ID= 54 | NUXT_OAUTH_GITHUB_CLIENT_SECRET= 55 | ``` 56 | 57 | ## Development 58 | 59 | Start the development server on `http://localhost:3000`: 60 | 61 | ```bash 62 | pnpm dev 63 | ``` 64 | 65 | ## Production 66 | 67 | Build the application for production: 68 | 69 | ```bash 70 | pnpm build 71 | ``` 72 | 73 | > [!IMPORTANT] 74 | > Make sure to add your [Nuxt UI Pro License](https://ui.nuxt.com/getting-started/license) in order to build for production 75 | 76 | Locally preview production build: 77 | 78 | ```bash 79 | pnpm preview 80 | ``` 81 | 82 | Deploy to your Cloudflare account with zero configuration: 83 | 84 | ```bash 85 | npx nuxthub deploy 86 | ``` 87 | 88 | > [!NOTE] 89 | > NuxtHub will automatically spawn a D1 database and apply the database migrations when deploying your project. 90 | 91 | Optionally, you can create a [Cloudflare AI Gateway](https://developers.cloudflare.com/ai-gateway/) to have usage analytics and the ability to cache response to reduce costs. Once created, you can add the `NUXT_CLOUDFLARE_GATEWAY_ID` environment variable with the named of your gateway. 92 | 93 | ## Renovate integration 94 | 95 | Install [Renovate GitHub app](https://github.com/apps/renovate/installations/select_target) on your repository and you are good to go. 96 | -------------------------------------------------------------------------------- /app/app.config.ts: -------------------------------------------------------------------------------- 1 | export default defineAppConfig({ 2 | ui: { 3 | colors: { 4 | primary: 'blue', 5 | neutral: 'neutral' 6 | } 7 | } 8 | }) 9 | -------------------------------------------------------------------------------- /app/app.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /app/assets/css/main.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss" theme(static); 2 | @import "@nuxt/ui-pro"; 3 | 4 | @theme static { 5 | --font-sans: 'Public Sans', sans-serif; 6 | 7 | --color-green-50: #EFFDF5; 8 | --color-green-100: #D9FBE8; 9 | --color-green-200: #B3F5D1; 10 | --color-green-300: #75EDAE; 11 | --color-green-400: #00DC82; 12 | --color-green-500: #00C16A; 13 | --color-green-600: #00A155; 14 | --color-green-700: #007F45; 15 | --color-green-800: #016538; 16 | --color-green-900: #0A5331; 17 | --color-green-950: #052E16; 18 | } 19 | 20 | :root { 21 | --ui-container: var(--container-3xl); 22 | } -------------------------------------------------------------------------------- /app/components/DashboardNavbar.vue: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /app/components/Logo.vue: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/components/ModalConfirm.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 20 | 21 | 22 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /app/components/ModelSelect.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 15 | 16 | -------------------------------------------------------------------------------- /app/components/UserMenu.vue: -------------------------------------------------------------------------------- 1 | 141 | 142 | 143 | 148 | 166 | 167 | 168 | 175 | 176 | 177 | 178 | -------------------------------------------------------------------------------- /app/components/prose/PreStream.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 35 | 36 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /app/composables/useChats.ts: -------------------------------------------------------------------------------- 1 | import { isToday, isYesterday, subMonths } from 'date-fns' 2 | 3 | interface Chat { 4 | id: string 5 | label: string 6 | icon: string 7 | createdAt: string 8 | } 9 | 10 | export function useChats(chats: Ref) { 11 | const groups = computed(() => { 12 | // Group chats by date 13 | const today: Chat[] = [] 14 | const yesterday: Chat[] = [] 15 | const lastWeek: Chat[] = [] 16 | const lastMonth: Chat[] = [] 17 | const older: Record = {} 18 | 19 | const oneWeekAgo = subMonths(new Date(), 0.25) // ~7 days ago 20 | const oneMonthAgo = subMonths(new Date(), 1) 21 | 22 | chats.value?.forEach((chat) => { 23 | const chatDate = new Date(chat.createdAt) 24 | 25 | if (isToday(chatDate)) { 26 | today.push(chat) 27 | } else if (isYesterday(chatDate)) { 28 | yesterday.push(chat) 29 | } else if (chatDate >= oneWeekAgo) { 30 | lastWeek.push(chat) 31 | } else if (chatDate >= oneMonthAgo) { 32 | lastMonth.push(chat) 33 | } else { 34 | // Format: "January 2023", "February 2023", etc. 35 | const monthYear = chatDate.toLocaleDateString('en-US', { 36 | month: 'long', 37 | year: 'numeric' 38 | }) 39 | 40 | if (!older[monthYear]) { 41 | older[monthYear] = [] 42 | } 43 | 44 | older[monthYear].push(chat) 45 | } 46 | }) 47 | 48 | // Sort older chats by month-year in descending order (newest first) 49 | const sortedMonthYears = Object.keys(older).sort((a, b) => { 50 | const dateA = new Date(a) 51 | const dateB = new Date(b) 52 | return dateB.getTime() - dateA.getTime() 53 | }) 54 | 55 | // Create formatted groups for navigation 56 | const formattedGroups = [] as Array<{ 57 | id: string 58 | label: string 59 | items: Array 60 | }> 61 | 62 | // Add groups that have chats 63 | if (today.length) { 64 | formattedGroups.push({ 65 | id: 'today', 66 | label: 'Today', 67 | items: today 68 | }) 69 | } 70 | 71 | if (yesterday.length) { 72 | formattedGroups.push({ 73 | id: 'yesterday', 74 | label: 'Yesterday', 75 | items: yesterday 76 | }) 77 | } 78 | 79 | if (lastWeek.length) { 80 | formattedGroups.push({ 81 | id: 'last-week', 82 | label: 'Last week', 83 | items: lastWeek 84 | }) 85 | } 86 | 87 | if (lastMonth.length) { 88 | formattedGroups.push({ 89 | id: 'last-month', 90 | label: 'Last month', 91 | items: lastMonth 92 | }) 93 | } 94 | 95 | // Add each month-year group 96 | sortedMonthYears.forEach((monthYear) => { 97 | if (older[monthYear]?.length) { 98 | formattedGroups.push({ 99 | id: monthYear, 100 | label: monthYear, 101 | items: older[monthYear] 102 | }) 103 | } 104 | }) 105 | 106 | return formattedGroups 107 | }) 108 | 109 | return { 110 | groups 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /app/composables/useHighlighter.ts: -------------------------------------------------------------------------------- 1 | import { createHighlighter, type HighlighterGeneric } from 'shiki' 2 | import { createJavaScriptRegexEngine } from 'shiki/engine-javascript.mjs' 3 | 4 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 5 | let highlighter: HighlighterGeneric | null = null 6 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 7 | let promise: Promise> | null = null 8 | 9 | export const useHighlighter = async () => { 10 | if (!promise) { 11 | promise = createHighlighter({ 12 | langs: ['vue', 'js', 'ts', 'css', 'html', 'json', 'yaml', 'markdown', 'bash'], 13 | themes: ['material-theme-palenight', 'material-theme-lighter'], 14 | engine: createJavaScriptRegexEngine() 15 | }) 16 | } 17 | if (!highlighter) { 18 | highlighter = await promise 19 | } 20 | 21 | return highlighter 22 | } 23 | -------------------------------------------------------------------------------- /app/composables/useLLM.ts: -------------------------------------------------------------------------------- 1 | export function useLLM() { 2 | const models = [ 3 | '@hf/thebloke/deepseek-coder-6.7b-base-awq', 4 | '@hf/thebloke/deepseek-coder-6.7b-instruct-awq', 5 | '@cf/deepseek-ai/deepseek-math-7b-base', 6 | '@cf/deepseek-ai/deepseek-math-7b-instruct', 7 | '@cf/thebloke/discolm-german-7b-v1-awq', 8 | '@cf/tiiuae/falcon-7b-instruct', 9 | '@cf/google/gemma-2b-it-lora', 10 | '@cf/google/gemma-7b-it-lora', 11 | '@hf/google/gemma-7b-it', 12 | '@hf/nousresearch/hermes-2-pro-mistral-7b', 13 | '@hf/thebloke/llama-2-13b-chat-awq', 14 | '@cf/meta/llama-2-7b-chat-fp16', 15 | '@cf/meta-llama/llama-2-7b-chat-hf-lora', 16 | '@cf/meta/llama-3-8b-instruct', 17 | '@cf/meta/llama-3-8b-instruct-awq', 18 | '@cf/meta/llama-3.1-8b-instruct', 19 | '@cf/meta/llama-3.1-8b-instruct-awq', 20 | '@cf/meta/llama-3.1-8b-instruct-fp8', 21 | '@hf/thebloke/llamaguard-7b-awq', 22 | '@cf/mistral/mistral-7b-instruct-v0.1', 23 | '@hf/thebloke/mistral-7b-instruct-v0.1-awq', 24 | '@cf/mistral/mistral-7b-instruct-v0.2-lora', 25 | '@hf/mistral/mistral-7b-instruct-v0.2', 26 | '@hf/thebloke/neural-chat-7b-v3-1-awq', 27 | '@cf/openchat/openchat-3.5-0106', 28 | '@hf/thebloke/openhermes-2.5-mistral-7b-awq', 29 | '@cf/microsoft/phi-2', 30 | '@cf/qwen/qwen1.5-0.5b-chat', 31 | '@cf/qwen/qwen1.5-1.8b-chat', 32 | '@cf/qwen/qwen1.5-14b-chat-awq', 33 | '@cf/qwen/qwen1.5-7b-chat-awq', 34 | '@cf/defog/sqlcoder-7b-2', 35 | '@hf/nexusflow/starling-lm-7b-beta', 36 | '@cf/tinyllama/tinyllama-1.1b-chat-v1.0', 37 | '@cf/fblgit/una-cybertron-7b-v2-bf16', 38 | '@hf/thebloke/zephyr-7b-beta-awq' 39 | ] 40 | const model = useCookie('llm-model', { default: () => '@cf/meta/llama-3.2-3b-instruct' }) 41 | 42 | return { 43 | models, 44 | model 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /app/error.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /app/layouts/default.vue: -------------------------------------------------------------------------------- 1 | 85 | 86 | 87 | 88 | 96 | 97 | 98 | 99 | Chat 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 131 | 132 | 133 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 158 | 159 | 160 | 161 | 172 | 173 | 174 | 175 | 176 | -------------------------------------------------------------------------------- /app/middleware/transitions.global.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtRouteMiddleware((to, from) => { 2 | if (import.meta.server) return 3 | 4 | if (to.params.id && from.params.id) { 5 | to.meta.viewTransition = false 6 | } 7 | }) 8 | -------------------------------------------------------------------------------- /app/pages/chat/[id].vue: -------------------------------------------------------------------------------- 1 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 84 | 85 | 92 | 93 | 94 | 95 | 102 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | -------------------------------------------------------------------------------- /app/pages/index.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | How can I help you today? 57 | 58 | 59 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 85 | 86 | 87 | 88 | 89 | 90 | -------------------------------------------------------------------------------- /app/types/auth.d.ts: -------------------------------------------------------------------------------- 1 | // auth.d.ts 2 | declare module '#auth-utils' { 3 | interface User { 4 | id: string 5 | name: string 6 | email: string 7 | avatar: string 8 | username: string 9 | provider: 'github' 10 | providerId: number 11 | } 12 | } 13 | 14 | export {} 15 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'drizzle-kit' 2 | 3 | export default defineConfig({ 4 | dialect: 'sqlite', 5 | schema: './server/database/schema.ts', 6 | out: './server/database/migrations' 7 | }) 8 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import withNuxt from './.nuxt/eslint.config.mjs' 3 | 4 | export default withNuxt({ 5 | rules: { 6 | 'vue/multi-word-component-names': 'off', 7 | 'vue/max-attributes-per-line': ['error', { singleline: 3 }] 8 | } 9 | }) 10 | -------------------------------------------------------------------------------- /nuxt.config.ts: -------------------------------------------------------------------------------- 1 | // https://nuxt.com/docs/api/configuration/nuxt-config 2 | export default defineNuxtConfig({ 3 | modules: [ 4 | '@nuxt/eslint', 5 | '@nuxt/ui-pro', 6 | '@nuxtjs/mdc', 7 | '@nuxthub/core', 8 | 'nuxt-auth-utils' 9 | ], 10 | 11 | devtools: { 12 | enabled: true 13 | }, 14 | 15 | css: ['~/assets/css/main.css'], 16 | 17 | mdc: { 18 | highlight: { 19 | // noApiRoute: true 20 | shikiEngine: 'javascript' 21 | } 22 | }, 23 | 24 | future: { 25 | compatibilityVersion: 4 26 | }, 27 | 28 | experimental: { 29 | viewTransition: true 30 | }, 31 | 32 | compatibilityDate: '2024-07-11', 33 | 34 | nitro: { 35 | experimental: { 36 | openAPI: true 37 | } 38 | }, 39 | 40 | hub: { 41 | ai: true, 42 | database: true 43 | }, 44 | 45 | eslint: { 46 | config: { 47 | stylistic: { 48 | commaDangle: 'never', 49 | braceStyle: '1tbs' 50 | } 51 | } 52 | } 53 | }) 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nuxt-ui-pro-template-chat", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "build": "nuxt build", 7 | "dev": "nuxt dev", 8 | "generate": "nuxt generate", 9 | "preview": "nuxt preview", 10 | "postinstall": "nuxt prepare", 11 | "lint": "eslint .", 12 | "typecheck": "nuxt typecheck", 13 | "db:generate": "drizzle-kit generate" 14 | }, 15 | "dependencies": { 16 | "@ai-sdk/vue": "^1.2.12", 17 | "@iconify-json/logos": "^1.2.4", 18 | "@iconify-json/lucide": "^1.2.44", 19 | "@iconify-json/simple-icons": "^1.2.35", 20 | "@nuxt/ui-pro": "^3.1.3", 21 | "@nuxthub/core": "^0.8.27", 22 | "@nuxtjs/mdc": "^0.17.0", 23 | "ai": "^4.3.16", 24 | "date-fns": "^4.1.0", 25 | "drizzle-orm": "^0.43.1", 26 | "nuxt": "^3.17.4", 27 | "nuxt-auth-utils": "^0.5.20", 28 | "shiki-stream": "^0.1.2", 29 | "workers-ai-provider": "^0.5.2" 30 | }, 31 | "devDependencies": { 32 | "@nuxt/eslint": "^1.4.1", 33 | "@types/node": "^22.15.21", 34 | "drizzle-kit": "^0.31.1", 35 | "eslint": "^9.27.0", 36 | "typescript": "^5.8.3", 37 | "vue-tsc": "^2.2.10", 38 | "wrangler": "^4.16.1" 39 | }, 40 | "resolutions": { 41 | "unimport": "4.1.1" 42 | }, 43 | "pnpm": { 44 | "ignoredBuiltDependencies": [ 45 | "workerd" 46 | ] 47 | }, 48 | "packageManager": "pnpm@10.11.0" 49 | } 50 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuxt-ui-pro/chat/b6efbcb28d69296aafc3dd138630f16c1d7e0fa8/public/favicon.ico -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "github>nuxt/renovate-config-nuxt" 4 | ], 5 | "lockFileMaintenance": { 6 | "enabled": true 7 | }, 8 | "baseBranches": ["v1", "main"], 9 | "packageRules": [{ 10 | "matchDepTypes": ["resolutions"], 11 | "enabled": false 12 | }], 13 | "postUpdateOptions": ["pnpmDedupe"] 14 | } 15 | -------------------------------------------------------------------------------- /server/api/chats.get.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(async (event) => { 2 | const session = await getUserSession(event) 3 | 4 | return (await useDrizzle().select().from(tables.chats).where(eq(tables.chats.userId, session.user?.id || session.id))).sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) 5 | }) 6 | -------------------------------------------------------------------------------- /server/api/chats.post.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(async (event) => { 2 | const session = await getUserSession(event) 3 | 4 | const { input } = await readBody(event) 5 | const db = useDrizzle() 6 | 7 | const chat = await db.insert(tables.chats).values({ 8 | title: '', 9 | userId: session.user?.id || session.id 10 | }).returning().get() 11 | 12 | await db.insert(tables.messages).values({ 13 | chatId: chat.id, 14 | role: 'user', 15 | content: input 16 | }) 17 | 18 | return chat 19 | }) 20 | -------------------------------------------------------------------------------- /server/api/chats/[id].delete.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(async (event) => { 2 | const session = await getUserSession(event) 3 | 4 | const { id } = getRouterParams(event) 5 | 6 | const db = useDrizzle() 7 | 8 | return await db.delete(tables.chats) 9 | .where(and(eq(tables.chats.id, id as string), eq(tables.chats.userId, session.user?.id || session.id))) 10 | .returning() 11 | }) 12 | -------------------------------------------------------------------------------- /server/api/chats/[id].get.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(async (event) => { 2 | const session = await getUserSession(event) 3 | 4 | const { id } = getRouterParams(event) 5 | 6 | const chat = await useDrizzle().query.chats.findFirst({ 7 | where: (chat, { eq }) => and(eq(chat.id, id as string), eq(chat.userId, session.user?.id || session.id)), 8 | with: { 9 | messages: true 10 | } 11 | }) 12 | 13 | return chat 14 | }) 15 | -------------------------------------------------------------------------------- /server/api/chats/[id].post.ts: -------------------------------------------------------------------------------- 1 | import { streamText } from 'ai' 2 | import { createWorkersAI } from 'workers-ai-provider' 3 | 4 | defineRouteMeta({ 5 | openAPI: { 6 | description: 'Chat with AI.', 7 | tags: ['ai'] 8 | } 9 | }) 10 | 11 | export default defineEventHandler(async (event) => { 12 | const session = await getUserSession(event) 13 | 14 | const { id } = getRouterParams(event) 15 | // TODO: Use readValidatedBody 16 | const { model, messages } = await readBody(event) 17 | 18 | const db = useDrizzle() 19 | // Enable AI Gateway if defined in environment variables 20 | const gateway = process.env.CLOUDFLARE_AI_GATEWAY_ID 21 | ? { 22 | id: process.env.CLOUDFLARE_AI_GATEWAY_ID, 23 | cacheTtl: 60 * 60 * 24 // 24 hours 24 | } 25 | : undefined 26 | const workersAI = createWorkersAI({ binding: hubAI(), gateway }) 27 | 28 | const chat = await db.query.chats.findFirst({ 29 | where: (chat, { eq }) => and(eq(chat.id, id as string), eq(chat.userId, session.user?.id || session.id)), 30 | with: { 31 | messages: true 32 | } 33 | }) 34 | if (!chat) { 35 | throw createError({ statusCode: 404, statusMessage: 'Chat not found' }) 36 | } 37 | 38 | if (!chat.title) { 39 | // @ts-expect-error - response is not typed 40 | const { response: title } = await hubAI().run('@cf/meta/llama-3.1-8b-instruct-fast', { 41 | stream: false, 42 | messages: [{ 43 | role: 'system', 44 | content: `You are a title generator for a chat: 45 | - Generate a short title based on the first user's message 46 | - The title should be less than 30 characters long 47 | - The title should be a summary of the user's message 48 | - Do not use quotes (' or ") or colons (:) or any other punctuation 49 | - Do not use markdown, just plain text` 50 | }, { 51 | role: 'user', 52 | content: chat.messages[0]!.content 53 | }] 54 | }, { 55 | gateway 56 | }) 57 | setHeader(event, 'X-Chat-Title', title.replace(/:/g, '').split('\n')[0]) 58 | await db.update(tables.chats).set({ title }).where(eq(tables.chats.id, id as string)) 59 | } 60 | 61 | const lastMessage = messages[messages.length - 1] 62 | if (lastMessage.role === 'user' && messages.length > 1) { 63 | await db.insert(tables.messages).values({ 64 | chatId: id as string, 65 | role: 'user', 66 | content: lastMessage.content 67 | }) 68 | } 69 | 70 | return streamText({ 71 | model: workersAI(model), 72 | maxTokens: 10000, 73 | system: 'You are a helpful assistant that can answer questions and help.', 74 | messages, 75 | async onFinish(response) { 76 | await db.insert(tables.messages).values({ 77 | chatId: chat.id, 78 | role: 'assistant', 79 | content: response.text 80 | }) 81 | } 82 | }).toDataStreamResponse() 83 | }) 84 | -------------------------------------------------------------------------------- /server/database/migrations/0000_moaning_mastermind.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `chats` ( 2 | `id` text PRIMARY KEY NOT NULL, 3 | `title` text, 4 | `userId` text NOT NULL, 5 | `createdAt` integer DEFAULT (unixepoch()) NOT NULL 6 | ); 7 | --> statement-breakpoint 8 | CREATE INDEX `userIdIdx` ON `chats` (`userId`);--> statement-breakpoint 9 | CREATE TABLE `messages` ( 10 | `id` text PRIMARY KEY NOT NULL, 11 | `chatId` text NOT NULL, 12 | `role` text NOT NULL, 13 | `content` text NOT NULL, 14 | `createdAt` integer DEFAULT (unixepoch()) NOT NULL, 15 | FOREIGN KEY (`chatId`) REFERENCES `chats`(`id`) ON UPDATE no action ON DELETE cascade 16 | ); 17 | --> statement-breakpoint 18 | CREATE INDEX `chatIdIdx` ON `messages` (`chatId`);--> statement-breakpoint 19 | CREATE TABLE `users` ( 20 | `id` text PRIMARY KEY NOT NULL, 21 | `email` text NOT NULL, 22 | `name` text NOT NULL, 23 | `avatar` text NOT NULL, 24 | `username` text NOT NULL, 25 | `provider` text NOT NULL, 26 | `providerId` integer NOT NULL, 27 | `createdAt` integer DEFAULT (unixepoch()) NOT NULL 28 | ); 29 | --> statement-breakpoint 30 | CREATE UNIQUE INDEX `users_provider_providerId_unique` ON `users` (`provider`,`providerId`); -------------------------------------------------------------------------------- /server/database/migrations/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "6", 3 | "dialect": "sqlite", 4 | "id": "0e701624-d921-47da-aff4-977754abd01f", 5 | "prevId": "00000000-0000-0000-0000-000000000000", 6 | "tables": { 7 | "chats": { 8 | "name": "chats", 9 | "columns": { 10 | "id": { 11 | "name": "id", 12 | "type": "text", 13 | "primaryKey": true, 14 | "notNull": true, 15 | "autoincrement": false 16 | }, 17 | "title": { 18 | "name": "title", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": false, 22 | "autoincrement": false 23 | }, 24 | "userId": { 25 | "name": "userId", 26 | "type": "text", 27 | "primaryKey": false, 28 | "notNull": true, 29 | "autoincrement": false 30 | }, 31 | "createdAt": { 32 | "name": "createdAt", 33 | "type": "integer", 34 | "primaryKey": false, 35 | "notNull": true, 36 | "autoincrement": false, 37 | "default": "(unixepoch())" 38 | } 39 | }, 40 | "indexes": { 41 | "userIdIdx": { 42 | "name": "userIdIdx", 43 | "columns": [ 44 | "userId" 45 | ], 46 | "isUnique": false 47 | } 48 | }, 49 | "foreignKeys": {}, 50 | "compositePrimaryKeys": {}, 51 | "uniqueConstraints": {}, 52 | "checkConstraints": {} 53 | }, 54 | "messages": { 55 | "name": "messages", 56 | "columns": { 57 | "id": { 58 | "name": "id", 59 | "type": "text", 60 | "primaryKey": true, 61 | "notNull": true, 62 | "autoincrement": false 63 | }, 64 | "chatId": { 65 | "name": "chatId", 66 | "type": "text", 67 | "primaryKey": false, 68 | "notNull": true, 69 | "autoincrement": false 70 | }, 71 | "role": { 72 | "name": "role", 73 | "type": "text", 74 | "primaryKey": false, 75 | "notNull": true, 76 | "autoincrement": false 77 | }, 78 | "content": { 79 | "name": "content", 80 | "type": "text", 81 | "primaryKey": false, 82 | "notNull": true, 83 | "autoincrement": false 84 | }, 85 | "createdAt": { 86 | "name": "createdAt", 87 | "type": "integer", 88 | "primaryKey": false, 89 | "notNull": true, 90 | "autoincrement": false, 91 | "default": "(unixepoch())" 92 | } 93 | }, 94 | "indexes": { 95 | "chatIdIdx": { 96 | "name": "chatIdIdx", 97 | "columns": [ 98 | "chatId" 99 | ], 100 | "isUnique": false 101 | } 102 | }, 103 | "foreignKeys": { 104 | "messages_chatId_chats_id_fk": { 105 | "name": "messages_chatId_chats_id_fk", 106 | "tableFrom": "messages", 107 | "tableTo": "chats", 108 | "columnsFrom": [ 109 | "chatId" 110 | ], 111 | "columnsTo": [ 112 | "id" 113 | ], 114 | "onDelete": "cascade", 115 | "onUpdate": "no action" 116 | } 117 | }, 118 | "compositePrimaryKeys": {}, 119 | "uniqueConstraints": {}, 120 | "checkConstraints": {} 121 | }, 122 | "users": { 123 | "name": "users", 124 | "columns": { 125 | "id": { 126 | "name": "id", 127 | "type": "text", 128 | "primaryKey": true, 129 | "notNull": true, 130 | "autoincrement": false 131 | }, 132 | "email": { 133 | "name": "email", 134 | "type": "text", 135 | "primaryKey": false, 136 | "notNull": true, 137 | "autoincrement": false 138 | }, 139 | "name": { 140 | "name": "name", 141 | "type": "text", 142 | "primaryKey": false, 143 | "notNull": true, 144 | "autoincrement": false 145 | }, 146 | "avatar": { 147 | "name": "avatar", 148 | "type": "text", 149 | "primaryKey": false, 150 | "notNull": true, 151 | "autoincrement": false 152 | }, 153 | "username": { 154 | "name": "username", 155 | "type": "text", 156 | "primaryKey": false, 157 | "notNull": true, 158 | "autoincrement": false 159 | }, 160 | "provider": { 161 | "name": "provider", 162 | "type": "text", 163 | "primaryKey": false, 164 | "notNull": true, 165 | "autoincrement": false 166 | }, 167 | "providerId": { 168 | "name": "providerId", 169 | "type": "integer", 170 | "primaryKey": false, 171 | "notNull": true, 172 | "autoincrement": false 173 | }, 174 | "createdAt": { 175 | "name": "createdAt", 176 | "type": "integer", 177 | "primaryKey": false, 178 | "notNull": true, 179 | "autoincrement": false, 180 | "default": "(unixepoch())" 181 | } 182 | }, 183 | "indexes": { 184 | "users_provider_providerId_unique": { 185 | "name": "users_provider_providerId_unique", 186 | "columns": [ 187 | "provider", 188 | "providerId" 189 | ], 190 | "isUnique": true 191 | } 192 | }, 193 | "foreignKeys": {}, 194 | "compositePrimaryKeys": {}, 195 | "uniqueConstraints": {}, 196 | "checkConstraints": {} 197 | } 198 | }, 199 | "views": {}, 200 | "enums": {}, 201 | "_meta": { 202 | "schemas": {}, 203 | "tables": {}, 204 | "columns": {} 205 | }, 206 | "internal": { 207 | "indexes": {} 208 | } 209 | } -------------------------------------------------------------------------------- /server/database/migrations/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "sqlite", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "6", 8 | "when": 1744299418910, 9 | "tag": "0000_moaning_mastermind", 10 | "breakpoints": true 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /server/database/schema.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from 'node:crypto' 2 | import { sql, relations } from 'drizzle-orm' 3 | import { sqliteTable, text, integer, index, unique } from 'drizzle-orm/sqlite-core' 4 | 5 | export const users = sqliteTable('users', { 6 | id: text().primaryKey().$defaultFn(() => randomUUID()), 7 | email: text().notNull(), 8 | name: text().notNull(), 9 | avatar: text().notNull(), 10 | username: text().notNull(), 11 | provider: text({ enum: ['github'] }).notNull(), 12 | providerId: integer().notNull(), 13 | createdAt: integer({ mode: 'timestamp' }).notNull().default(sql`(unixepoch())`) 14 | }, t => [ 15 | unique().on(t.provider, t.providerId) 16 | ]) 17 | 18 | export const usersRelations = relations(users, ({ many }) => ({ 19 | chats: many(chats) 20 | })) 21 | 22 | export const chats = sqliteTable('chats', { 23 | id: text().primaryKey().$defaultFn(() => randomUUID()), 24 | title: text(), 25 | userId: text().notNull(), 26 | createdAt: integer({ mode: 'timestamp' }).notNull().default(sql`(unixepoch())`) 27 | }, t => [ 28 | index('userIdIdx').on(t.userId) 29 | ]) 30 | 31 | export const chatsRelations = relations(chats, ({ one, many }) => ({ 32 | user: one(users, { 33 | fields: [chats.userId], 34 | references: [users.id] 35 | }), 36 | messages: many(messages) 37 | })) 38 | 39 | export const messages = sqliteTable('messages', { 40 | id: text().primaryKey().$defaultFn(() => randomUUID()), 41 | chatId: text().notNull().references(() => chats.id, { onDelete: 'cascade' }), 42 | role: text({ enum: ['user', 'assistant'] }).notNull(), 43 | content: text().notNull(), 44 | createdAt: integer({ mode: 'timestamp' }).notNull().default(sql`(unixepoch())`) 45 | }, t => [ 46 | index('chatIdIdx').on(t.chatId) 47 | ]) 48 | 49 | export const messagesRelations = relations(messages, ({ one }) => ({ 50 | chat: one(chats, { 51 | fields: [messages.chatId], 52 | references: [chats.id] 53 | }) 54 | })) 55 | -------------------------------------------------------------------------------- /server/routes/auth/github.get.ts: -------------------------------------------------------------------------------- 1 | export default defineOAuthGitHubEventHandler({ 2 | async onSuccess(event, { user: ghUser }) { 3 | const db = useDrizzle() 4 | const session = await getUserSession(event) 5 | 6 | let user = await db.query.users.findFirst({ 7 | where: (user, { eq }) => and(eq(user.provider, 'github'), eq(user.providerId, ghUser.id)) 8 | }) 9 | if (!user) { 10 | user = await db.insert(tables.users).values({ 11 | id: session.id, 12 | name: ghUser.name || '', 13 | email: ghUser.email || '', 14 | avatar: ghUser.avatar_url || '', 15 | username: ghUser.login, 16 | provider: 'github', 17 | providerId: ghUser.id 18 | }).returning().get() 19 | } else { 20 | // Assign anonymous chats with session id to user 21 | await db.update(tables.chats).set({ 22 | userId: user.id 23 | }).where(eq(tables.chats.userId, session.id)) 24 | } 25 | 26 | await setUserSession(event, { user }) 27 | 28 | return sendRedirect(event, '/') 29 | }, 30 | // Optional, will return a json error and 401 status code by default 31 | onError(event, error) { 32 | console.error('GitHub OAuth error:', error) 33 | return sendRedirect(event, '/') 34 | } 35 | }) 36 | -------------------------------------------------------------------------------- /server/utils/drizzle.ts: -------------------------------------------------------------------------------- 1 | import { drizzle } from 'drizzle-orm/d1' 2 | 3 | import * as schema from '../database/schema' 4 | 5 | export { sql, eq, and, or } from 'drizzle-orm' 6 | 7 | export const tables = schema 8 | 9 | export function useDrizzle() { 10 | return drizzle(hubDatabase(), { schema }) 11 | } 12 | 13 | export type Chat = typeof schema.chats.$inferSelect 14 | export type Message = typeof schema.messages.$inferSelect 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://nuxt.com/docs/guide/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json" 4 | } 5 | --------------------------------------------------------------------------------