├── public ├── robots.txt ├── ui.png └── favicon.ico ├── server ├── api │ ├── logs │ │ ├── .gitkeep │ │ └── index.get.ts │ ├── validations │ │ ├── chat.ts │ │ ├── thread.ts │ │ └── auth.ts │ ├── auth │ │ ├── signin.post.ts │ │ └── signup.post.ts │ ├── threads │ │ ├── index.post.ts │ │ ├── index.get.ts │ │ └── [id] │ │ │ ├── index.delete.ts │ │ │ └── files │ │ │ └── index.post.ts │ ├── files │ │ └── [id] │ │ │ └── index.delete.ts │ ├── messages │ │ └── [threadId].get.ts │ └── chat.post.ts ├── tsconfig.json ├── plugins │ └── db.ts ├── database │ ├── migrations │ │ ├── 0001_bumpy_hitman.sql │ │ ├── meta │ │ │ ├── _journal.json │ │ │ ├── 0000_snapshot.json │ │ │ ├── 0001_snapshot.json │ │ │ └── 0002_snapshot.json │ │ ├── 0000_fearless_matthew_murdock.sql │ │ └── 0002_square_roxanne_simpson.sql │ └── schema.ts └── utils │ ├── db.ts │ └── fileParser.ts ├── .github └── FUNDING.yml ├── .env.example ├── tsconfig.json ├── app.config.ts ├── eslint.config.mjs ├── app.vue ├── middleware ├── guest.ts └── auth.ts ├── composables ├── useLoader.ts ├── useApp.ts ├── useCustomModal.ts ├── useFileHandler.ts └── useAutoScroll.ts ├── drizzle.config.ts ├── .gitignore ├── nuxt.config.ts ├── components ├── DarkModeToggle.vue ├── ColorPickerPill.vue ├── AuthForm.vue ├── MessageList.vue ├── ColorPicker.vue ├── FileAttachments.vue ├── ChatMessage.vue ├── CreateThreadModal.vue ├── Sidebar.vue └── MessageInput.vue ├── LICENSE.md ├── package.json ├── pages ├── logs.vue ├── login.vue ├── signup.vue ├── index.vue └── threads │ └── [id].vue ├── assets └── css │ └── custom.css ├── README.md └── plugins └── markdownit.client.ts /public/robots.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /server/api/logs/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: chihebnabil 2 | -------------------------------------------------------------------------------- /public/ui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chihebnabil/claude-ui/HEAD/public/ui.png -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.nuxt/tsconfig.server.json" 3 | } 4 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | ANTHROPIC_KEY= 2 | DATABASE_URL=./database.db 3 | NUXT_SESSION_PASSWORD= -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chihebnabil/claude-ui/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://nuxt.com/docs/guide/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /app.config.ts: -------------------------------------------------------------------------------- 1 | export default defineAppConfig({ 2 | ui: { 3 | primary: "blue", 4 | gray: "slate", 5 | }, 6 | }); 7 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import withNuxt from "./.nuxt/eslint.config.mjs"; 3 | 4 | export default withNuxt(); 5 | // Your custom configs here 6 | -------------------------------------------------------------------------------- /app.vue: -------------------------------------------------------------------------------- 1 | 11 | -------------------------------------------------------------------------------- /server/api/validations/chat.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const messageRequest = z.object({ 4 | threadId: z.string(), 5 | prompt: z.string(), 6 | selectedFiles: z.array(z.number()).optional(), 7 | }); 8 | -------------------------------------------------------------------------------- /middleware/guest.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtRouteMiddleware(async (to, from) => { 2 | const { loggedIn, fetch } = useUserSession(); 3 | await fetch(); 4 | if (loggedIn.value) { 5 | return navigateTo("/"); 6 | } 7 | }); 8 | -------------------------------------------------------------------------------- /middleware/auth.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtRouteMiddleware(async (to, from) => { 2 | const { loggedIn, fetch } = useUserSession(); 3 | await fetch(); 4 | if (!loggedIn.value) { 5 | return navigateTo("/signup"); 6 | } 7 | }); 8 | -------------------------------------------------------------------------------- /server/plugins/db.ts: -------------------------------------------------------------------------------- 1 | // server/plugins/db.ts 2 | import { getDb } from "../utils/db"; 3 | 4 | export default defineNitroPlugin(() => { 5 | // Initialize DB connection once 6 | getDb(); 7 | console.log("Database initialized in Nitro plugin"); 8 | }); 9 | -------------------------------------------------------------------------------- /composables/useLoader.ts: -------------------------------------------------------------------------------- 1 | export const useLoader = () => { 2 | const loader = useState("loader", () => false); 3 | const start = () => (loader.value = true); 4 | const stop = () => (loader.value = false); 5 | return { loader, start, stop }; 6 | }; 7 | -------------------------------------------------------------------------------- /server/api/validations/thread.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const createThreadRequest = z.object({ 4 | name: z.string().min(1), 5 | systemMessage: z.string().min(5), 6 | temperature: z.number().min(0).max(1), 7 | model: z.string(), 8 | maxTokens: z.number().min(1), 9 | }); 10 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "drizzle-kit"; 2 | 3 | export default { 4 | dialect: "sqlite", 5 | schema: "./server/database/schema.ts", 6 | out: "./server/database/migrations", 7 | dbCredentials: { 8 | url: process.env.DATABASE_URL || "./database.db", 9 | }, 10 | } satisfies Config; 11 | -------------------------------------------------------------------------------- /server/database/migrations/0001_bumpy_hitman.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `logs` ( 2 | `id` integer PRIMARY KEY NOT NULL, 3 | `input_tokens` integer DEFAULT 0, 4 | `output_tokens` integer DEFAULT 0, 5 | `cache_creation_input_tokens` integer DEFAULT 0, 6 | `cache_read_input_tokens` integer DEFAULT 0, 7 | `created_at` integer NOT NULL 8 | ); 9 | -------------------------------------------------------------------------------- /.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 | !server/api/logs/ 17 | !server/api/logs/.gitkeep 18 | 19 | # Misc 20 | .DS_Store 21 | .fleet 22 | .idea 23 | 24 | # Local env files 25 | .env 26 | .env.* 27 | !.env.example 28 | 29 | database.db -------------------------------------------------------------------------------- /composables/useApp.ts: -------------------------------------------------------------------------------- 1 | export const useApp = () => { 2 | const messages = useState>("messages", () => []); 3 | const threads = useState>("threads", () => []); 4 | 5 | const getThread = async (id: any) => { 6 | return threads.value.find((thread) => thread.id === Number(id)); 7 | }; 8 | 9 | return { 10 | messages, 11 | getThread, 12 | threads, 13 | }; 14 | }; 15 | -------------------------------------------------------------------------------- /nuxt.config.ts: -------------------------------------------------------------------------------- 1 | // https://nuxt.com/docs/api/configuration/nuxt-config 2 | export default defineNuxtConfig({ 3 | compatibilityDate: "2024-04-03", 4 | devtools: { enabled: true }, 5 | modules: ["@nuxt/ui", "@nuxt/eslint", "nuxt-auth-utils"], 6 | css: ["~/assets/css/custom.css"], 7 | runtimeConfig: { 8 | anthropicKey: process.env.ANTHROPIC_KEY, 9 | databaseUrl: process.env.DATABASE_URL || "./database.db", 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /composables/useCustomModal.ts: -------------------------------------------------------------------------------- 1 | export const useCustomModal = () => { 2 | const isModalOpen = useState("isModalOpen", () => false); 3 | const modalType = useState("modalType", () => "ADD_THREAD"); 4 | const openModal = () => { 5 | isModalOpen.value = true; 6 | }; 7 | const closeModal = () => { 8 | isModalOpen.value = false; 9 | }; 10 | return { 11 | isModalOpen, 12 | modalType, 13 | openModal, 14 | closeModal, 15 | }; 16 | }; 17 | -------------------------------------------------------------------------------- /components/DarkModeToggle.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 19 | -------------------------------------------------------------------------------- /server/utils/db.ts: -------------------------------------------------------------------------------- 1 | import { drizzle } from "drizzle-orm/better-sqlite3"; 2 | import Database from "better-sqlite3"; 3 | import * as schema from "../database/schema"; 4 | 5 | let db: ReturnType; 6 | 7 | // Singleton pattern 8 | export function getDb() { 9 | if (!db) { 10 | const sqlite = new Database(useRuntimeConfig().databaseUrl); 11 | sqlite.pragma("foreign_keys = ON"); 12 | db = drizzle(sqlite, { schema }); 13 | console.log("New database connection established"); 14 | } 15 | return db; 16 | } 17 | 18 | export default getDb(); 19 | -------------------------------------------------------------------------------- /server/api/validations/auth.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const signInRequest = z.object({ 4 | email: z.string().email(), 5 | password: z 6 | .string({ 7 | errorMap: () => ({ 8 | message: "Password must be at least 6 characters long", 9 | }), 10 | }) 11 | .min(6), 12 | }); 13 | 14 | export const signUpRequest = z.object({ 15 | email: z.string().email(), 16 | password: z 17 | .string({ 18 | errorMap: () => ({ 19 | message: "Password must be at least 6 characters long", 20 | }), 21 | }) 22 | .min(6), 23 | }); 24 | -------------------------------------------------------------------------------- /composables/useFileHandler.ts: -------------------------------------------------------------------------------- 1 | import { ref } from "vue"; 2 | 3 | export function useFileHandler() { 4 | const file = ref(null); 5 | 6 | const triggerFileInput = (inputRef: HTMLInputElement) => { 7 | inputRef.click(); 8 | }; 9 | 10 | const handleFileSelect = (event: Event) => { 11 | const target = event.target as HTMLInputElement; 12 | if (target.files && target.files.length > 0) { 13 | file.value = target.files[0]; 14 | } 15 | }; 16 | 17 | const removeFile = () => { 18 | file.value = null; 19 | }; 20 | 21 | return { file, triggerFileInput, handleFileSelect, removeFile }; 22 | } 23 | -------------------------------------------------------------------------------- /server/database/migrations/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "sqlite", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "6", 8 | "when": 1731845906399, 9 | "tag": "0000_fearless_matthew_murdock", 10 | "breakpoints": true 11 | }, 12 | { 13 | "idx": 1, 14 | "version": "6", 15 | "when": 1731864518706, 16 | "tag": "0001_bumpy_hitman", 17 | "breakpoints": true 18 | }, 19 | { 20 | "idx": 2, 21 | "version": "6", 22 | "when": 1731864728002, 23 | "tag": "0002_square_roxanne_simpson", 24 | "breakpoints": true 25 | } 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /components/ColorPickerPill.vue: -------------------------------------------------------------------------------- 1 | 25 | 32 | -------------------------------------------------------------------------------- /server/api/auth/signin.post.ts: -------------------------------------------------------------------------------- 1 | import db from "~/server/utils/db"; 2 | import { eq } from "drizzle-orm"; 3 | import { users } from "~/server/database/schema"; 4 | import bcrypt from "bcrypt"; 5 | import { signInRequest } from "~/server/api/validations/auth"; 6 | 7 | export default defineEventHandler(async (event) => { 8 | try { 9 | const body = signInRequest.parse(await readBody(event)); 10 | 11 | const [user] = await db 12 | .select() 13 | .from(users) 14 | .where(eq(users.email, body.email)); 15 | 16 | if (!user || !(await bcrypt.compare(body.password, user.password))) { 17 | throw createError({ 18 | statusCode: 401, 19 | statusMessage: "Invalid username or password", 20 | }); 21 | } 22 | 23 | const session = { 24 | user: { 25 | email: user.email, 26 | id: user.id, 27 | }, 28 | loggedInAt: new Date(), 29 | }; 30 | 31 | await setUserSession(event, session); 32 | 33 | return { message: "Logged in successfully" }; 34 | } catch (error) { 35 | throw error; 36 | } 37 | }); 38 | -------------------------------------------------------------------------------- /components/AuthForm.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 42 | -------------------------------------------------------------------------------- /server/api/threads/index.post.ts: -------------------------------------------------------------------------------- 1 | import db from "~/server/utils/db"; 2 | import { threads } from "~/server/database/schema"; 3 | import { createThreadRequest } from "~/server/api/validations/thread"; 4 | 5 | export default defineEventHandler(async (event) => { 6 | try { 7 | // Require a user session (send back 401 if no `user` key in session) 8 | const session = await requireUserSession(event); 9 | const body = createThreadRequest.parse(await readBody(event)); 10 | const stmt = await db.insert(threads).values({ 11 | name: body.name, 12 | systemMessage: body.systemMessage, 13 | temperature: body.temperature, 14 | model: body.model, 15 | maxTokens: body.maxTokens, 16 | createdAt: new Date(), 17 | userId: session.user.id, 18 | }); 19 | return { 20 | id: stmt.lastInsertRowid, 21 | }; 22 | } catch (error) { 23 | console.error("Error in threads.post handler:", error); 24 | throw createError({ 25 | statusCode: error.status || 500, 26 | message: error.message || "Internal server error", 27 | }); 28 | } 29 | }); 30 | -------------------------------------------------------------------------------- /server/database/migrations/0000_fearless_matthew_murdock.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `files` ( 2 | `id` integer PRIMARY KEY NOT NULL, 3 | `name` text, 4 | `path` text, 5 | `text` text, 6 | `tokens` integer, 7 | `created_at` integer NOT NULL, 8 | `thread_id` integer NOT NULL, 9 | `user_id` integer NOT NULL 10 | ); 11 | --> statement-breakpoint 12 | CREATE TABLE `messages` ( 13 | `id` integer PRIMARY KEY NOT NULL, 14 | `content` text, 15 | `role` text, 16 | `created_at` integer NOT NULL, 17 | `thread_id` integer NOT NULL, 18 | `user_id` integer NOT NULL 19 | ); 20 | --> statement-breakpoint 21 | CREATE TABLE `threads` ( 22 | `id` integer PRIMARY KEY NOT NULL, 23 | `name` text, 24 | `system_message` text, 25 | `created_at` integer NOT NULL, 26 | `temperature` real DEFAULT 0.5, 27 | `model` text DEFAULT 'claude-3-5-sonnet-20241022', 28 | `max_tokens` integer DEFAULT 1024, 29 | `user_id` integer NOT NULL 30 | ); 31 | --> statement-breakpoint 32 | CREATE TABLE `users` ( 33 | `id` integer PRIMARY KEY NOT NULL, 34 | `name` text, 35 | `email` text, 36 | `password` text, 37 | `created_at` integer NOT NULL 38 | ); 39 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Nabil Chiheb 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /server/api/files/[id]/index.delete.ts: -------------------------------------------------------------------------------- 1 | import db from "~/server/utils/db"; 2 | import { eq } from "drizzle-orm"; 3 | import { files } from "~/server/database/schema"; 4 | 5 | export default defineEventHandler(async (event) => { 6 | try { 7 | // Require a user session (send back 401 if no `user` key in session) 8 | const session = await requireUserSession(event); 9 | 10 | const id = event.context.params.id; 11 | 12 | // check if user is the owner of the thread 13 | if (id) { 14 | const [file] = await db.select().from(files).where(eq(files.id, id)); 15 | if (!file) { 16 | throw createError({ 17 | statusCode: 404, 18 | message: "File not found", 19 | }); 20 | } 21 | if (file.userId !== session.user.id) { 22 | throw createError({ 23 | statusCode: 403, 24 | message: "You are not authorized to delete this file", 25 | }); 26 | } 27 | 28 | const result = await db.delete(files).where(eq(files.id, id)); 29 | 30 | return { deleted: result.rowsAffected }; 31 | } 32 | } catch (error) { 33 | console.error("Error in files.delete handler:", error); 34 | throw createError({ 35 | statusCode: error.status || 500, 36 | message: error.message || "Internal server error", 37 | }); 38 | } 39 | }); 40 | -------------------------------------------------------------------------------- /server/api/logs/index.get.ts: -------------------------------------------------------------------------------- 1 | import db from "~/server/utils/db"; 2 | import { eq, count, desc } from "drizzle-orm"; 3 | import { logs } from "~/server/database/schema"; 4 | 5 | export default defineEventHandler(async (event) => { 6 | // Require a user session (send back 401 if no `user` key in session) 7 | const session = await requireUserSession(event); 8 | 9 | // Get pagination parameters from query 10 | const query = getQuery(event); 11 | const page = Math.max(1, parseInt(query.page as string) || 1); 12 | const pageSize = Math.max( 13 | 1, 14 | Math.min(100, parseInt(query.pageSize as string) || 10), 15 | ); 16 | const offset = (page - 1) * pageSize; 17 | 18 | // Get total count for pagination 19 | const [{ value: totalCount }] = await db 20 | .select({ value: count() }) 21 | .from(logs) 22 | .where(eq(logs.userId, session.user.id)); 23 | 24 | // Get paginated results 25 | const items = await db 26 | .select() 27 | .from(logs) 28 | .where(eq(logs.userId, session.user.id)) 29 | .limit(pageSize) 30 | .offset(offset) 31 | .orderBy(desc(logs.id)); 32 | 33 | return { 34 | items, 35 | pagination: { 36 | page, 37 | pageSize, 38 | totalCount, 39 | totalPages: Math.ceil(totalCount / pageSize), 40 | hasNextPage: page * pageSize < totalCount, 41 | hasPreviousPage: page > 1, 42 | }, 43 | }; 44 | }); 45 | -------------------------------------------------------------------------------- /server/api/threads/index.get.ts: -------------------------------------------------------------------------------- 1 | import db from "~/server/utils/db"; 2 | 3 | export default defineEventHandler(async (event) => { 4 | try { 5 | // Require a user session (send back 401 if no `user` key in session) 6 | const session = await requireUserSession(event); 7 | // First get threads with basic info 8 | const threads = await db.all( 9 | ` 10 | SELECT 11 | t.id, 12 | t.name, 13 | t.model as model, 14 | t.created_at as createdAt, 15 | ( 16 | SELECT json_group_array( 17 | json_object( 18 | 'id', f.id, 19 | 'name', f.name, 20 | 'tokens', f.tokens 21 | ) 22 | ) 23 | FROM files f 24 | WHERE f.thread_id = t.id 25 | ) as files 26 | FROM threads t 27 | WHERE t.user_id = ${session.user.id} 28 | ORDER BY t.created_at DESC 29 | `, 30 | ); 31 | 32 | // Parse the JSON string in files column 33 | return threads.map((thread) => ({ 34 | ...thread, 35 | files: JSON.parse(thread.files || "[]"), 36 | })); 37 | } catch (error) { 38 | console.error("Error in threads.get handler:", error); 39 | throw createError({ 40 | statusCode: error.status || 500, 41 | message: error.message || "Internal server error", 42 | }); 43 | } 44 | }); 45 | -------------------------------------------------------------------------------- /server/api/messages/[threadId].get.ts: -------------------------------------------------------------------------------- 1 | import db from "~/server/utils/db"; 2 | import { eq } from "drizzle-orm"; 3 | import { messages, threads } from "~/server/database/schema"; 4 | 5 | export default defineEventHandler(async (event) => { 6 | try { 7 | // Require a user session (send back 401 if no `user` key in session) 8 | const session = await requireUserSession(event); 9 | 10 | const threadId = event.context.params.threadId; 11 | 12 | // check if user is the owner of the thread 13 | const [thread] = await db 14 | .select() 15 | .from(threads) 16 | .where(eq(threads.id, threadId)); 17 | if (!thread) { 18 | throw createError({ 19 | statusCode: 404, 20 | message: "Thread not found", 21 | }); 22 | } 23 | if (thread.userId !== session.user.id) { 24 | throw createError({ 25 | statusCode: 403, 26 | message: "You are not authorized to access this thread", 27 | }); 28 | } 29 | 30 | const msgs = await db 31 | .select() 32 | .from(messages) 33 | .where(eq(messages.threadId, threadId)) 34 | .orderBy(messages.createdAt); 35 | 36 | return msgs; 37 | } catch (error) { 38 | console.error("Error in messages.get handler:", error); 39 | throw createError({ 40 | statusCode: error.status || 500, 41 | message: error.message || "Internal server error", 42 | }); 43 | } 44 | }); 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "claude-ui", 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 && npm run db:migrate", 11 | "db:generate": "drizzle-kit generate", 12 | "db:migrate": "drizzle-kit migrate", 13 | "lint": "npm run lint:eslint && npm run lint:prettier", 14 | "lint:eslint": "eslint .", 15 | "lint:prettier": "prettier . --check --write", 16 | "lint:fix": "eslint . --fix && prettier --write --list-different ." 17 | }, 18 | "dependencies": { 19 | "@anthropic-ai/sdk": "^0.31.0", 20 | "@iconify-json/heroicons": "^1.2.1", 21 | "@nosferatu500/textract": "^3.1.3", 22 | "@nuxt/eslint": "^0.6.1", 23 | "@nuxt/ui": "^2.18.7", 24 | "bcrypt": "^5.1.1", 25 | "better-sqlite3": "^9.6.0", 26 | "drizzle-orm": "^0.36.1", 27 | "eslint": "^9.14.0", 28 | "eslint-config-prettier": "^9.1.0", 29 | "eslint-plugin-prettier": "^5.2.1", 30 | "highlight.js": "^11.10.0", 31 | "markdown-it": "^14.1.0", 32 | "nuxt": "^3.13.2", 33 | "nuxt-auth-utils": "^0.5.5", 34 | "prettier": "^3.3.3", 35 | "typescript": "^5.6.3", 36 | "vue": "latest", 37 | "vue-router": "latest" 38 | }, 39 | "devDependencies": { 40 | "@types/better-sqlite3": "^7.6.11", 41 | "@types/markdown-it": "^14.1.2", 42 | "drizzle-kit": "^0.27.2", 43 | "wrangler": "^3.84.1" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /server/api/threads/[id]/index.delete.ts: -------------------------------------------------------------------------------- 1 | import db from "~/server/utils/db"; 2 | import { eq } from "drizzle-orm"; 3 | import { threads, messages, files } from "~/server/database/schema"; 4 | 5 | export default defineEventHandler(async (event) => { 6 | try { 7 | // Require a user session (send back 401 if no `user` key in session) 8 | const session = await requireUserSession(event); 9 | const id = event.context.params.id; 10 | 11 | let userId = session.user.id; 12 | // check if user is the owner of the thread 13 | if (event.context.params.id) { 14 | const [thread] = await db 15 | .select() 16 | .from(threads) 17 | .where(eq(threads.id, id)); 18 | if (!thread) { 19 | throw createError({ 20 | statusCode: 404, 21 | message: "Thread not found", 22 | }); 23 | } 24 | userId = thread.userId; 25 | } 26 | 27 | // Delete related records first (to maintain referential integrity) 28 | await db.delete(messages).where(eq(messages.threadId, id)); 29 | // Delete files 30 | await db.delete(files).where(eq(files.threadId, id)); 31 | // Delete the thread last 32 | const result = await db.delete(threads).where(eq(threads.id, id)); 33 | return { deleted: result.rowsAffected }; 34 | } catch (error) { 35 | console.error("Error in threads.delete handler:", error); 36 | throw createError({ 37 | statusCode: error.status || 500, 38 | message: error.message || "Internal server error", 39 | }); 40 | } 41 | }); 42 | -------------------------------------------------------------------------------- /server/api/auth/signup.post.ts: -------------------------------------------------------------------------------- 1 | import db from "~/server/utils/db"; 2 | import { eq } from "drizzle-orm"; 3 | import { users } from "~/server/database/schema"; 4 | import bcrypt from "bcrypt"; 5 | import { signUpRequest } from "~/server/api/validations/auth"; 6 | 7 | export default defineEventHandler(async (event) => { 8 | try { 9 | const body = signUpRequest.parse(await readBody(event)); 10 | 11 | // Check if the user already exists 12 | const existingUser = await db 13 | .select() 14 | .from(users) 15 | .where(eq(users.email, body.email)); 16 | 17 | if (existingUser.length > 0) { 18 | throw createError({ 19 | statusCode: 400, 20 | statusMessage: "User already exists", 21 | }); 22 | } 23 | 24 | // Hash the password and create a new user 25 | const hashedPassword = await bcrypt.hash(body.password, 10); 26 | 27 | await db.insert(users).values({ 28 | email: body.email, 29 | password: hashedPassword, 30 | createdAt: new Date(), 31 | }); 32 | 33 | return { message: "User created successfully" }; 34 | } catch (error) { 35 | // Log the error for debugging 36 | console.error("Error in auth.signup handler:", error); 37 | 38 | // Return a consistent error response 39 | if (error.data) { 40 | throw createError({ 41 | statusCode: error.statusCode || 400, 42 | message: error.statusMessage || "Validation error", 43 | data: error.data, 44 | }); 45 | } 46 | 47 | throw createError({ 48 | statusCode: error.statusCode || 500, 49 | message: error.message || "Internal server error", 50 | }); 51 | } 52 | }); 53 | -------------------------------------------------------------------------------- /composables/useAutoScroll.ts: -------------------------------------------------------------------------------- 1 | import { ref, nextTick, watch, onUnmounted, readonly, type Ref } from "vue"; 2 | 3 | export function useAutoScroll(containerRef?: Ref) { 4 | const shouldAutoScroll = ref(true); 5 | const isUserScrolling = ref(false); 6 | 7 | const scrollToBottom = () => { 8 | if (!containerRef?.value) return; 9 | 10 | nextTick(() => { 11 | if (containerRef.value) { 12 | containerRef.value.scrollTop = containerRef.value.scrollHeight; 13 | } 14 | }); 15 | }; 16 | 17 | const handleScroll = () => { 18 | if (!containerRef?.value) return; 19 | 20 | const { scrollTop, scrollHeight, clientHeight } = containerRef.value; 21 | const isNearBottom = scrollHeight - scrollTop - clientHeight < 100; 22 | 23 | // Update auto-scroll preference based on user behavior 24 | shouldAutoScroll.value = isNearBottom; 25 | 26 | // Detect if user is actively scrolling 27 | if (!isNearBottom) { 28 | isUserScrolling.value = true; 29 | setTimeout(() => { 30 | isUserScrolling.value = false; 31 | }, 1000); 32 | } 33 | }; 34 | 35 | // Attach scroll listener if container ref is provided 36 | if (containerRef) { 37 | watch( 38 | containerRef, 39 | (newContainer, oldContainer) => { 40 | if (oldContainer) { 41 | oldContainer.removeEventListener("scroll", handleScroll); 42 | } 43 | if (newContainer) { 44 | newContainer.addEventListener("scroll", handleScroll, { 45 | passive: true, 46 | }); 47 | } 48 | }, 49 | { immediate: true }, 50 | ); 51 | 52 | // Cleanup on unmount 53 | onUnmounted(() => { 54 | if (containerRef.value) { 55 | containerRef.value.removeEventListener("scroll", handleScroll); 56 | } 57 | }); 58 | } 59 | 60 | return { 61 | shouldAutoScroll: readonly(shouldAutoScroll), 62 | isUserScrolling: readonly(isUserScrolling), 63 | scrollToBottom, 64 | handleScroll, 65 | }; 66 | } 67 | -------------------------------------------------------------------------------- /server/database/schema.ts: -------------------------------------------------------------------------------- 1 | import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core"; 2 | 3 | export const threads = sqliteTable("threads", { 4 | id: integer("id").primaryKey(), 5 | name: text("name"), 6 | systemMessage: text("system_message"), 7 | createdAt: integer("created_at", { mode: "timestamp" }).notNull(), 8 | temperature: real("temperature").default(0.5), 9 | model: text("model").default("claude-3-5-sonnet-20241022"), 10 | maxTokens: integer("max_tokens").default(1024), 11 | userId: integer("user_id").references(() => users.id), 12 | }); 13 | 14 | export const messages = sqliteTable("messages", { 15 | id: integer("id").primaryKey(), 16 | content: text("content"), 17 | role: text("role"), 18 | createdAt: integer("created_at", { mode: "timestamp" }).notNull(), 19 | threadId: integer("thread_id").notNull(), 20 | userId: integer("user_id").references(() => users.id), 21 | }); 22 | 23 | export const files = sqliteTable("files", { 24 | id: integer("id").primaryKey(), 25 | name: text("name"), 26 | path: text("path"), 27 | text: text("text"), 28 | tokens: integer("tokens"), 29 | createdAt: integer("created_at", { mode: "timestamp" }).notNull(), 30 | threadId: integer("thread_id").notNull(), 31 | userId: integer("user_id").references(() => users.id), 32 | }); 33 | 34 | export const users = sqliteTable("users", { 35 | id: integer("id").primaryKey(), 36 | name: text("name"), 37 | email: text("email"), 38 | password: text("password"), 39 | createdAt: integer("created_at", { mode: "timestamp" }).notNull(), 40 | }); 41 | 42 | export const logs = sqliteTable("logs", { 43 | id: integer("id").primaryKey(), 44 | inputTokens: integer("input_tokens").default(0), 45 | outputTokens: integer("output_tokens").default(0), 46 | cacheCreationInputTokens: integer("cache_creation_input_tokens").default(0), 47 | cacheReadInputTokens: integer("cache_read_input_tokens").default(0), 48 | createdAt: integer("created_at", { mode: "timestamp" }).notNull(), 49 | userId: integer("users_id").references(() => users.id), 50 | }); 51 | -------------------------------------------------------------------------------- /pages/logs.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 91 | -------------------------------------------------------------------------------- /server/database/migrations/0002_square_roxanne_simpson.sql: -------------------------------------------------------------------------------- 1 | PRAGMA foreign_keys=OFF;--> statement-breakpoint 2 | CREATE TABLE `__new_files` ( 3 | `id` integer PRIMARY KEY NOT NULL, 4 | `name` text, 5 | `path` text, 6 | `text` text, 7 | `tokens` integer, 8 | `created_at` integer NOT NULL, 9 | `thread_id` integer NOT NULL, 10 | `user_id` integer, 11 | FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action 12 | ); 13 | --> statement-breakpoint 14 | INSERT INTO `__new_files`("id", "name", "path", "text", "tokens", "created_at", "thread_id", "user_id") SELECT "id", "name", "path", "text", "tokens", "created_at", "thread_id", "user_id" FROM `files`;--> statement-breakpoint 15 | DROP TABLE `files`;--> statement-breakpoint 16 | ALTER TABLE `__new_files` RENAME TO `files`;--> statement-breakpoint 17 | PRAGMA foreign_keys=ON;--> statement-breakpoint 18 | CREATE TABLE `__new_messages` ( 19 | `id` integer PRIMARY KEY NOT NULL, 20 | `content` text, 21 | `role` text, 22 | `created_at` integer NOT NULL, 23 | `thread_id` integer NOT NULL, 24 | `user_id` integer, 25 | FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action 26 | ); 27 | --> statement-breakpoint 28 | INSERT INTO `__new_messages`("id", "content", "role", "created_at", "thread_id", "user_id") SELECT "id", "content", "role", "created_at", "thread_id", "user_id" FROM `messages`;--> statement-breakpoint 29 | DROP TABLE `messages`;--> statement-breakpoint 30 | ALTER TABLE `__new_messages` RENAME TO `messages`;--> statement-breakpoint 31 | CREATE TABLE `__new_threads` ( 32 | `id` integer PRIMARY KEY NOT NULL, 33 | `name` text, 34 | `system_message` text, 35 | `created_at` integer NOT NULL, 36 | `temperature` real DEFAULT 0.5, 37 | `model` text DEFAULT 'claude-3-5-sonnet-20241022', 38 | `max_tokens` integer DEFAULT 1024, 39 | `user_id` integer, 40 | FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action 41 | ); 42 | --> statement-breakpoint 43 | INSERT INTO `__new_threads`("id", "name", "system_message", "created_at", "temperature", "model", "max_tokens", "user_id") SELECT "id", "name", "system_message", "created_at", "temperature", "model", "max_tokens", "user_id" FROM `threads`;--> statement-breakpoint 44 | DROP TABLE `threads`;--> statement-breakpoint 45 | ALTER TABLE `__new_threads` RENAME TO `threads`;--> statement-breakpoint 46 | ALTER TABLE `logs` ADD `users_id` integer REFERENCES users(id); -------------------------------------------------------------------------------- /server/api/threads/[id]/files/index.post.ts: -------------------------------------------------------------------------------- 1 | import Anthropic from "@anthropic-ai/sdk"; 2 | import { parseFile } from "~/server/utils/fileParser"; 3 | import db from "~/server/utils/db"; 4 | import { files } from "~/server/database/schema"; 5 | 6 | export default defineEventHandler(async (event) => { 7 | try { 8 | // Require a user session (send back 401 if no `user` key in session) 9 | const session = await requireUserSession(event); 10 | 11 | // Get configuration and request body 12 | const { anthropicKey } = useRuntimeConfig(); 13 | 14 | if (!anthropicKey || anthropicKey === "your_anthropic_api_key_here") { 15 | throw createError({ 16 | statusCode: 500, 17 | message: 18 | "Anthropic API key is not configured. Please set the ANTHROPIC_KEY environment variable.", 19 | }); 20 | } 21 | 22 | // Initialize Anthropic client 23 | const anthropic = new Anthropic({ 24 | apiKey: anthropicKey, 25 | }); 26 | 27 | // Get thread ID from URL parameters 28 | const threadId = event.context.params.id; 29 | 30 | const formData = await readMultipartFormData(event); 31 | 32 | for (const field of formData) { 33 | if (!field.data || !field.filename) continue; 34 | 35 | const text = await parseFile(field.filename, field.data, field.type); 36 | 37 | const tokens = await anthropic.beta.messages.countTokens({ 38 | model: "claude-3-5-sonnet-20241022", 39 | messages: [ 40 | { 41 | role: "user", 42 | content: text, 43 | }, 44 | ], 45 | }); 46 | 47 | // Insert file using Drizzle 48 | const [insertedFile] = await db 49 | .insert(files) 50 | .values({ 51 | name: field.filename, 52 | path: field.filename, 53 | text: text, 54 | tokens: tokens.input_tokens, 55 | createdAt: new Date(), 56 | threadId: threadId, 57 | userId: session.user.id, 58 | }) 59 | .returning({ id: files.id }); // Return the inserted ID 60 | 61 | return { 62 | threadId, 63 | last_row_id: insertedFile.id, 64 | file: { 65 | filename: field.filename, 66 | type: field.type, 67 | tokens: tokens.input_tokens, 68 | size: field.data.length, 69 | }, 70 | }; 71 | } 72 | } catch (error) { 73 | console.error("Error parsing file:", error); 74 | throw createError({ 75 | statusCode: 500, 76 | message: error.message || "Error parsing file", 77 | }); 78 | } 79 | }); 80 | -------------------------------------------------------------------------------- /components/MessageList.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 97 | -------------------------------------------------------------------------------- /components/ColorPicker.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 100 | -------------------------------------------------------------------------------- /server/utils/fileParser.ts: -------------------------------------------------------------------------------- 1 | import textract from "@nosferatu500/textract"; 2 | import { promisify } from "util"; 3 | 4 | const textractFromBuffer = promisify(textract.fromBufferWithName); 5 | 6 | const TEXT_EXTENSIONS = new Set([ 7 | "txt", 8 | "js", 9 | "ts", 10 | "json", 11 | "html", 12 | "htm", 13 | "atom", 14 | "rss", 15 | "md", 16 | "markdown", 17 | "epub", 18 | "xml", 19 | "xsl", 20 | "pdf", 21 | "doc", 22 | "docx", 23 | "odt", 24 | "ott", 25 | "rtf", 26 | "xls", 27 | "xlsx", 28 | "xlsb", 29 | "xlsm", 30 | "xltx", 31 | "csv", 32 | "ods", 33 | "ots", 34 | "pptx", 35 | "potx", 36 | "odp", 37 | "otp", 38 | "odg", 39 | "otg", 40 | "png", 41 | "jpg", 42 | "jpeg", 43 | "gif", 44 | "dxf", 45 | ]); 46 | 47 | const TEXT_MIMETYPES = new Set([ 48 | "text/html", 49 | "text/htm", 50 | "application/atom+xml", 51 | "application/rss+xml", 52 | "text/markdown", 53 | "application/epub+zip", 54 | "application/xml", 55 | "text/xml", 56 | "application/pdf", 57 | "application/msword", 58 | "application/vnd.openxmlformats-officedocument.wordprocessingml.document", 59 | "application/vnd.oasis.opendocument.text", 60 | "application/rtf", 61 | "application/vnd.ms-excel", 62 | "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", 63 | "text/csv", 64 | "application/vnd.oasis.opendocument.spreadsheet", 65 | "application/vnd.openxmlformats-officedocument.presentationml.presentation", 66 | "application/vnd.oasis.opendocument.presentation", 67 | "application/vnd.oasis.opendocument.graphics", 68 | "image/png", 69 | "image/jpeg", 70 | "image/gif", 71 | "application/dxf", 72 | "application/javascript", 73 | ]); 74 | 75 | export async function parseFile( 76 | filename: string, 77 | buffer: Buffer, 78 | mimeType?: string, 79 | ): Promise { 80 | try { 81 | const ext = filename.split(".").pop()?.toLowerCase(); 82 | 83 | // Use buffer.toString() for plain text files and specific cases 84 | if ( 85 | mimeType?.startsWith("text/") || 86 | mimeType === "application/json" || 87 | mimeType === "application/javascript" || 88 | ext === "ts" || 89 | ext === "js" || 90 | ext === "json" 91 | ) { 92 | return buffer.toString(); 93 | } 94 | 95 | // Use textract for supported file types 96 | if ( 97 | TEXT_EXTENSIONS.has(ext) || 98 | (mimeType && TEXT_MIMETYPES.has(mimeType)) 99 | ) { 100 | const text = await textractFromBuffer(filename, buffer); 101 | return text; 102 | } 103 | 104 | // Default to buffer.toString() if no specific handling is defined 105 | return buffer.toString(); 106 | } catch (error) { 107 | console.error("Error parsing file:", error); 108 | throw new Error(`Failed to parse file: ${error.message}`); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /assets/css/custom.css: -------------------------------------------------------------------------------- 1 | /* Custom animations and transitions */ 2 | @keyframes fadeIn { 3 | from { 4 | opacity: 0; 5 | transform: translateY(10px); 6 | } 7 | to { 8 | opacity: 1; 9 | transform: translateY(0); 10 | } 11 | } 12 | 13 | @keyframes slideInRight { 14 | from { 15 | opacity: 0; 16 | transform: translateX(20px); 17 | } 18 | to { 19 | opacity: 1; 20 | transform: translateX(0); 21 | } 22 | } 23 | 24 | @keyframes pulse-subtle { 25 | 0%, 26 | 100% { 27 | opacity: 1; 28 | } 29 | 50% { 30 | opacity: 0.5; 31 | } 32 | } 33 | 34 | /* Custom utility classes */ 35 | .animate-fade-in { 36 | animation: fadeIn 0.3s ease-out; 37 | } 38 | 39 | .animate-slide-in-right { 40 | animation: slideInRight 0.3s ease-out; 41 | } 42 | 43 | .animate-pulse-subtle { 44 | animation: pulse-subtle 2s infinite; 45 | } 46 | 47 | /* Improved scrollbar styling */ 48 | .scrollbar-thin::-webkit-scrollbar { 49 | width: 6px; 50 | } 51 | 52 | .scrollbar-thin::-webkit-scrollbar-track { 53 | @apply bg-gray-100 dark:bg-gray-800 rounded-full; 54 | } 55 | 56 | .scrollbar-thin::-webkit-scrollbar-thumb { 57 | @apply bg-gray-300 dark:bg-gray-600 rounded-full; 58 | } 59 | 60 | .scrollbar-thin::-webkit-scrollbar-thumb:hover { 61 | @apply bg-gray-400 dark:bg-gray-500; 62 | } 63 | 64 | /* Custom focus styles */ 65 | .focus-ring { 66 | @apply focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-opacity-50; 67 | } 68 | 69 | /* Prose styling improvements for markdown content */ 70 | .prose code { 71 | @apply bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded text-sm; 72 | } 73 | 74 | .prose pre { 75 | @apply bg-gray-900 dark:bg-gray-950 border border-gray-200 dark:border-gray-700; 76 | } 77 | 78 | .prose blockquote { 79 | @apply border-l-4 border-primary-500 bg-primary-50 dark:bg-primary-900/20 pl-4 py-2; 80 | } 81 | 82 | /* Card hover effects */ 83 | .card-hover { 84 | @apply transition-all duration-200 hover:shadow-lg hover:-translate-y-0.5; 85 | } 86 | 87 | /* Gradient backgrounds */ 88 | .gradient-bg { 89 | background: linear-gradient( 90 | 135deg, 91 | rgb(99 102 241 / 0.1) 0%, 92 | rgb(168 85 247 / 0.1) 50%, 93 | rgb(59 130 246 / 0.1) 100% 94 | ); 95 | } 96 | 97 | /* Message bubble animations */ 98 | .message-bubble { 99 | @apply animate-fade-in; 100 | } 101 | 102 | .message-bubble.user { 103 | @apply animate-slide-in-right; 104 | } 105 | 106 | /* Typing indicator */ 107 | .typing-indicator { 108 | @apply flex items-center gap-1; 109 | } 110 | 111 | .typing-indicator .dot { 112 | @apply w-2 h-2 bg-current rounded-full animate-pulse; 113 | } 114 | 115 | .typing-indicator .dot:nth-child(2) { 116 | animation-delay: 0.2s; 117 | } 118 | 119 | .typing-indicator .dot:nth-child(3) { 120 | animation-delay: 0.4s; 121 | } 122 | 123 | /* Custom button styles */ 124 | .btn-primary-gradient { 125 | @apply bg-gradient-to-r from-primary-500 to-primary-600 hover:from-primary-600 hover:to-primary-700 transition-all duration-200; 126 | } 127 | 128 | /* Loading states */ 129 | .loading-shimmer { 130 | background: linear-gradient( 131 | 90deg, 132 | rgba(255, 255, 255, 0) 0%, 133 | rgba(255, 255, 255, 0.2) 50%, 134 | rgba(255, 255, 255, 0) 100% 135 | ); 136 | background-size: 200% 100%; 137 | animation: shimmer 1.5s infinite; 138 | } 139 | 140 | @keyframes shimmer { 141 | 0% { 142 | background-position: -200% 0; 143 | } 144 | 100% { 145 | background-position: 200% 0; 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Claude UI 2 | 3 | A modern chat interface for Anthropic's Claude AI models built with Nuxt.js. Experience seamless conversations with Claude in a clean user interface. 4 | 5 | ## Prerequisites 6 | 7 | - Node.js (v18 or higher) 8 | - npm or yarn 9 | - Anthropic API key 10 | 11 |

12 | Claude UI Screenshot 13 |

14 | 15 | ## 🌟 Features 16 | 17 | - 💾 Conversation history management 18 | - 🎭 Multiple Claude model support 19 | - 📝 Markdown and code syntax highlighting 20 | - 🌙 Dark/Light mode toggle 21 | - 🤖 Personlize behavior using system prompts for each chat 22 | - 🎯 Limit output tokens for each chat 23 | - 🔄 Custome temperature (Randomness) for each chat 24 | - 📎💾 Prompt Caching for attachments 25 | - 📝🔍 Text extraction and parsing 26 | 27 | ## Tech Stack 28 | 29 | - 🚀 Built with [Nuxt 3](https://nuxt.com/) 30 | - 💾 Database integration with [Drizzle ORM](https://orm.drizzle.team/) 31 | - 🎨 UI components from [@nuxt/ui](https://ui.nuxt.com/) 32 | - 🤖 AI integration with [@anthropic-ai/sdk](https://www.anthropic.com/) 33 | - 📝 Text extraction capabilities with [@nosferatu500/textract](https://www.npmjs.com/package/@nosferatu500/textract) 34 | - ✨ Markdown support with [markdown-it](https://github.com/markdown-it/markdown-it) 35 | - 🎯 Code highlighting with [highlight.js](https://highlightjs.org/) 36 | 37 | ## Setup 38 | 39 | Make sure to install the dependencies: 40 | 41 | ```bash 42 | # npm 43 | npm install 44 | 45 | # pnpm 46 | pnpm install 47 | 48 | # yarn 49 | yarn install 50 | ``` 51 | 52 | ## Environment Configuration 53 | 54 | Create a `.env` file in the root directory and add your Anthropic API key: 55 | 56 | ```bash 57 | # Required: Get your API key from https://console.anthropic.com/ 58 | ANTHROPIC_KEY=your_anthropic_api_key_here 59 | 60 | # Optional: Custom database path (defaults to ./database.db) 61 | DATABASE_URL=./database.db 62 | ``` 63 | 64 | **To get your Anthropic API key:** 65 | 66 | 1. Visit [https://console.anthropic.com/](https://console.anthropic.com/) 67 | 2. Sign up or log in to your account 68 | 3. Navigate to API Keys section 69 | 4. Create a new API key 70 | 5. Copy the key and paste it in your `.env` file 71 | 72 | ## Parsing PDFs 73 | 74 | Ensure `poppler-utils` is part of your environment by installing it: 75 | 76 | ```bash 77 | sudo apt update 78 | sudo apt install poppler-utils 79 | ``` 80 | 81 | ## ENV 82 | 83 | Create a .env file in the root directory and add your `ANTHROPIC_KEY` API key as shown above in the Setup section. 84 | 85 | ## Development Server 86 | 87 | Start the development server on http://localhost:3000: 88 | 89 | ```bash 90 | # npm 91 | npm run dev 92 | 93 | # pnpm 94 | pnpm dev 95 | 96 | # yarn 97 | yarn dev 98 | ``` 99 | 100 | ## Production 101 | 102 | Build the application for production: 103 | 104 | ```bash 105 | # npm 106 | npm run build 107 | 108 | # pnpm 109 | pnpm build 110 | 111 | # yarn 112 | yarn build 113 | ``` 114 | 115 | ## Database 116 | 117 | The application uses a SQLite database to store thread and message data. 118 | 119 | ### Database Management 120 | 121 | This project uses Drizzle ORM for database management. Available commands: 122 | 123 | ```bash 124 | # Generate database schema 125 | npm run db:generate 126 | 127 | # Migrate database schema 128 | npm run db:migrate 129 | ``` 130 | 131 | ## Todo 132 | 133 | - [x] Add streaming support for long-running chats 134 | - [ ] Add server-side validation for form inputs 135 | - [x] Add user authentication 136 | -------------------------------------------------------------------------------- /pages/login.vue: -------------------------------------------------------------------------------- 1 | 73 | 74 | 113 | -------------------------------------------------------------------------------- /plugins/markdownit.client.ts: -------------------------------------------------------------------------------- 1 | import markdownit from "markdown-it"; 2 | import hljs from "highlight.js"; 3 | import "highlight.js/styles/github-dark.min.css"; 4 | 5 | export default defineNuxtPlugin((nuxtApp) => { 6 | let codeBlockId = 0; 7 | 8 | const md = markdownit({ 9 | highlight: function (str, lang) { 10 | const currentId = `code-block-${codeBlockId++}`; 11 | 12 | if (lang && hljs.getLanguage(lang)) { 13 | try { 14 | const highlighted = hljs.highlight(str, { 15 | language: lang, 16 | ignoreIllegals: true, 17 | }).value; 18 | // Return without the surrounding pre/code tags since markdown-it will add them 19 | return highlighted; 20 | } catch (__) {} 21 | } 22 | 23 | return md.utils.escapeHtml(str); 24 | }, 25 | }); 26 | 27 | // Override the fence renderer to add our custom wrapper 28 | md.renderer.rules.fence = function (tokens, idx, options, env, slf) { 29 | const token = tokens[idx]; 30 | const info = token.info ? md.utils.unescapeAll(token.info).trim() : ""; 31 | const lang = info ? info.split(/\s+/g)[0] : ""; 32 | const currentId = `code-block-${codeBlockId++}`; 33 | 34 | const code = options.highlight 35 | ? options.highlight(token.content, lang, "") 36 | : token.content; 37 | 38 | const codeBlock = ` 39 |
40 |
${code}
41 | 54 |
55 | `; 56 | 57 | return codeBlock; 58 | }; 59 | 60 | // Add the copy functionality to the window object 61 | if (import.meta.client) { 62 | window.copyCode = async function (id: string) { 63 | const codeBlock = document.getElementById(id); 64 | if (!codeBlock) return; 65 | 66 | const code = codeBlock.textContent || ""; 67 | 68 | try { 69 | await navigator.clipboard.writeText(code); 70 | 71 | // Get the button associated with this code block 72 | const button = 73 | codeBlock.parentElement?.parentElement?.querySelector(".copy-button"); 74 | const copyIcon = button?.querySelector(".copy-icon"); 75 | const checkIcon = button?.querySelector(".check-icon"); 76 | 77 | if (copyIcon && checkIcon) { 78 | copyIcon.classList.add("hidden"); 79 | checkIcon.classList.remove("hidden"); 80 | 81 | // Reset button after 2 seconds 82 | setTimeout(() => { 83 | copyIcon.classList.remove("hidden"); 84 | checkIcon.classList.add("hidden"); 85 | }, 2000); 86 | } 87 | } catch (err) { 88 | console.error("Failed to copy code:", err); 89 | } 90 | }; 91 | } 92 | 93 | return { 94 | provide: { 95 | mdRenderer: md, 96 | }, 97 | }; 98 | }); 99 | -------------------------------------------------------------------------------- /pages/signup.vue: -------------------------------------------------------------------------------- 1 | 85 | 86 | 133 | -------------------------------------------------------------------------------- /pages/index.vue: -------------------------------------------------------------------------------- 1 | 126 | 133 | -------------------------------------------------------------------------------- /server/api/chat.post.ts: -------------------------------------------------------------------------------- 1 | // /server/api/chat.post.ts 2 | import Anthropic from "@anthropic-ai/sdk"; 3 | import { messageRequest } from "~/server/api/validations/chat"; 4 | import type { H3Event } from "h3"; 5 | import { eq, and, inArray, desc } from "drizzle-orm"; 6 | import { threads, messages, files, logs } from "~/server/database/schema"; 7 | import db from "~/server/utils/db"; 8 | 9 | const MAX_MESSAGES = 4; 10 | 11 | function resolveAnthropicModel(model?: string): string { 12 | const fallback = "claude-3-5-sonnet-20241022"; 13 | if (!model) return fallback; 14 | return model; 15 | } 16 | 17 | export default defineEventHandler(async (event: H3Event) => { 18 | try { 19 | const session = await requireUserSession(event); 20 | const { anthropicKey } = useRuntimeConfig(); 21 | 22 | if (!anthropicKey || anthropicKey === "your_anthropic_api_key_here") { 23 | throw createError({ 24 | statusCode: 500, 25 | message: 26 | "Anthropic API key is not configured. Please set the ANTHROPIC_KEY environment variable.", 27 | }); 28 | } 29 | 30 | const body = messageRequest.parse(await readBody(event)); 31 | const anthropic = new Anthropic({ apiKey: anthropicKey }); 32 | 33 | // Get thread 34 | const [thread] = await db 35 | .select() 36 | .from(threads) 37 | .where(eq(threads.id, body.threadId)); 38 | 39 | if (!thread) { 40 | throw createError({ 41 | statusCode: 404, 42 | message: "Thread not found", 43 | }); 44 | } 45 | 46 | // Insert user message 47 | await db.insert(messages).values({ 48 | content: body.prompt, 49 | role: "user", 50 | createdAt: new Date(), 51 | threadId: body.threadId, 52 | userId: session.user.id, 53 | }); 54 | 55 | // Fetch previous messages 56 | const dbMessages = await db 57 | .select() 58 | .from(messages) 59 | .where(eq(messages.threadId, body.threadId)) 60 | .orderBy(desc(messages.createdAt)) 61 | .limit(MAX_MESSAGES); 62 | 63 | const processedMessages = preprocessMessages(dbMessages); 64 | 65 | const systemMessage = [ 66 | { 67 | type: "text", 68 | text: thread.systemMessage || "You are a helpful assistant", 69 | cache_control: { type: "ephemeral" }, 70 | }, 71 | ]; 72 | 73 | if (body.selectedFiles?.length > 0) { 74 | const selectedFiles = await db 75 | .select({ 76 | name: files.name, 77 | text: files.text, 78 | }) 79 | .from(files) 80 | .where( 81 | and( 82 | eq(files.threadId, body.threadId), 83 | inArray(files.id, body.selectedFiles), 84 | ), 85 | ); 86 | 87 | for (const file of selectedFiles) { 88 | systemMessage.push({ 89 | type: "text", 90 | text: file.text, 91 | cache_control: { type: "ephemeral" }, 92 | }); 93 | } 94 | } 95 | 96 | // Create response headers for SSE 97 | setResponseHeaders(event, { 98 | "Content-Type": "text/event-stream", 99 | "Cache-Control": "no-cache", 100 | Connection: "keep-alive", 101 | }); 102 | 103 | const stream = await anthropic.beta.promptCaching.messages.create({ 104 | model: resolveAnthropicModel(thread.model), 105 | max_tokens: thread.maxTokens || 1024, 106 | messages: processedMessages, 107 | temperature: thread.temperature || 0.5, 108 | system: systemMessage, 109 | stream: true, 110 | }); 111 | 112 | let fullResponse = ""; 113 | 114 | // Stream the response 115 | for await (const chunk of stream) { 116 | if (chunk.type === "content_block_delta") { 117 | fullResponse += chunk.delta?.text || ""; 118 | // Send chunk to client 119 | event.node.res.write(`data: ${JSON.stringify(chunk)}\n\n`); 120 | } 121 | 122 | if (chunk.type === "message_start") { 123 | await db.insert(logs).values({ 124 | inputTokens: chunk.message.usage.input_tokens, 125 | outputTokens: chunk.message.usage.output_tokens, 126 | cacheCreationInputTokens: 127 | chunk.message.usage.cache_creation_input_tokens, 128 | cacheReadInputTokens: chunk.message.usage.cache_read_input_tokens, 129 | createdAt: new Date(), 130 | userId: session.user.id, 131 | }); 132 | } 133 | } 134 | 135 | if (fullResponse.length > 0) { 136 | // Insert complete message to database 137 | await db.insert(messages).values({ 138 | content: fullResponse, 139 | role: "assistant", 140 | createdAt: new Date(), 141 | threadId: body.threadId, 142 | userId: session.user.id, 143 | }); 144 | } 145 | // Close the stream 146 | event.node.res.end(); 147 | } catch (error) { 148 | console.error("Error in Anthropic API handler:", error); 149 | throw createError({ 150 | statusCode: error.status || 500, 151 | message: error.message || "Internal server error", 152 | }); 153 | } 154 | }); 155 | 156 | function preprocessMessages(messages: any[]) { 157 | return messages 158 | .map(({ role, content }: any) => ({ role, content })) 159 | .slice(-MAX_MESSAGES); 160 | } 161 | -------------------------------------------------------------------------------- /components/FileAttachments.vue: -------------------------------------------------------------------------------- 1 | 87 | 88 | 155 | -------------------------------------------------------------------------------- /components/ChatMessage.vue: -------------------------------------------------------------------------------- 1 | 118 | 119 | 193 | -------------------------------------------------------------------------------- /server/database/migrations/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "6", 3 | "dialect": "sqlite", 4 | "id": "2c6b884a-95e6-4412-9ecd-855cafe2e3d6", 5 | "prevId": "00000000-0000-0000-0000-000000000000", 6 | "tables": { 7 | "files": { 8 | "name": "files", 9 | "columns": { 10 | "id": { 11 | "name": "id", 12 | "type": "integer", 13 | "primaryKey": true, 14 | "notNull": true, 15 | "autoincrement": false 16 | }, 17 | "name": { 18 | "name": "name", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": false, 22 | "autoincrement": false 23 | }, 24 | "path": { 25 | "name": "path", 26 | "type": "text", 27 | "primaryKey": false, 28 | "notNull": false, 29 | "autoincrement": false 30 | }, 31 | "text": { 32 | "name": "text", 33 | "type": "text", 34 | "primaryKey": false, 35 | "notNull": false, 36 | "autoincrement": false 37 | }, 38 | "tokens": { 39 | "name": "tokens", 40 | "type": "integer", 41 | "primaryKey": false, 42 | "notNull": false, 43 | "autoincrement": false 44 | }, 45 | "created_at": { 46 | "name": "created_at", 47 | "type": "integer", 48 | "primaryKey": false, 49 | "notNull": true, 50 | "autoincrement": false 51 | }, 52 | "thread_id": { 53 | "name": "thread_id", 54 | "type": "integer", 55 | "primaryKey": false, 56 | "notNull": true, 57 | "autoincrement": false 58 | }, 59 | "user_id": { 60 | "name": "user_id", 61 | "type": "integer", 62 | "primaryKey": false, 63 | "notNull": true, 64 | "autoincrement": false 65 | } 66 | }, 67 | "indexes": {}, 68 | "foreignKeys": {}, 69 | "compositePrimaryKeys": {}, 70 | "uniqueConstraints": {}, 71 | "checkConstraints": {} 72 | }, 73 | "messages": { 74 | "name": "messages", 75 | "columns": { 76 | "id": { 77 | "name": "id", 78 | "type": "integer", 79 | "primaryKey": true, 80 | "notNull": true, 81 | "autoincrement": false 82 | }, 83 | "content": { 84 | "name": "content", 85 | "type": "text", 86 | "primaryKey": false, 87 | "notNull": false, 88 | "autoincrement": false 89 | }, 90 | "role": { 91 | "name": "role", 92 | "type": "text", 93 | "primaryKey": false, 94 | "notNull": false, 95 | "autoincrement": false 96 | }, 97 | "created_at": { 98 | "name": "created_at", 99 | "type": "integer", 100 | "primaryKey": false, 101 | "notNull": true, 102 | "autoincrement": false 103 | }, 104 | "thread_id": { 105 | "name": "thread_id", 106 | "type": "integer", 107 | "primaryKey": false, 108 | "notNull": true, 109 | "autoincrement": false 110 | }, 111 | "user_id": { 112 | "name": "user_id", 113 | "type": "integer", 114 | "primaryKey": false, 115 | "notNull": true, 116 | "autoincrement": false 117 | } 118 | }, 119 | "indexes": {}, 120 | "foreignKeys": {}, 121 | "compositePrimaryKeys": {}, 122 | "uniqueConstraints": {}, 123 | "checkConstraints": {} 124 | }, 125 | "threads": { 126 | "name": "threads", 127 | "columns": { 128 | "id": { 129 | "name": "id", 130 | "type": "integer", 131 | "primaryKey": true, 132 | "notNull": true, 133 | "autoincrement": false 134 | }, 135 | "name": { 136 | "name": "name", 137 | "type": "text", 138 | "primaryKey": false, 139 | "notNull": false, 140 | "autoincrement": false 141 | }, 142 | "system_message": { 143 | "name": "system_message", 144 | "type": "text", 145 | "primaryKey": false, 146 | "notNull": false, 147 | "autoincrement": false 148 | }, 149 | "created_at": { 150 | "name": "created_at", 151 | "type": "integer", 152 | "primaryKey": false, 153 | "notNull": true, 154 | "autoincrement": false 155 | }, 156 | "temperature": { 157 | "name": "temperature", 158 | "type": "real", 159 | "primaryKey": false, 160 | "notNull": false, 161 | "autoincrement": false, 162 | "default": 0.5 163 | }, 164 | "model": { 165 | "name": "model", 166 | "type": "text", 167 | "primaryKey": false, 168 | "notNull": false, 169 | "autoincrement": false, 170 | "default": "'claude-3-5-sonnet-20241022'" 171 | }, 172 | "max_tokens": { 173 | "name": "max_tokens", 174 | "type": "integer", 175 | "primaryKey": false, 176 | "notNull": false, 177 | "autoincrement": false, 178 | "default": 1024 179 | }, 180 | "user_id": { 181 | "name": "user_id", 182 | "type": "integer", 183 | "primaryKey": false, 184 | "notNull": true, 185 | "autoincrement": false 186 | } 187 | }, 188 | "indexes": {}, 189 | "foreignKeys": {}, 190 | "compositePrimaryKeys": {}, 191 | "uniqueConstraints": {}, 192 | "checkConstraints": {} 193 | }, 194 | "users": { 195 | "name": "users", 196 | "columns": { 197 | "id": { 198 | "name": "id", 199 | "type": "integer", 200 | "primaryKey": true, 201 | "notNull": true, 202 | "autoincrement": false 203 | }, 204 | "name": { 205 | "name": "name", 206 | "type": "text", 207 | "primaryKey": false, 208 | "notNull": false, 209 | "autoincrement": false 210 | }, 211 | "email": { 212 | "name": "email", 213 | "type": "text", 214 | "primaryKey": false, 215 | "notNull": false, 216 | "autoincrement": false 217 | }, 218 | "password": { 219 | "name": "password", 220 | "type": "text", 221 | "primaryKey": false, 222 | "notNull": false, 223 | "autoincrement": false 224 | }, 225 | "created_at": { 226 | "name": "created_at", 227 | "type": "integer", 228 | "primaryKey": false, 229 | "notNull": true, 230 | "autoincrement": false 231 | } 232 | }, 233 | "indexes": {}, 234 | "foreignKeys": {}, 235 | "compositePrimaryKeys": {}, 236 | "uniqueConstraints": {}, 237 | "checkConstraints": {} 238 | } 239 | }, 240 | "views": {}, 241 | "enums": {}, 242 | "_meta": { 243 | "schemas": {}, 244 | "tables": {}, 245 | "columns": {} 246 | }, 247 | "internal": { 248 | "indexes": {} 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /components/CreateThreadModal.vue: -------------------------------------------------------------------------------- 1 | 99 | 204 | -------------------------------------------------------------------------------- /pages/threads/[id].vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 255 | 256 | 269 | -------------------------------------------------------------------------------- /components/Sidebar.vue: -------------------------------------------------------------------------------- 1 | 154 | 155 | 257 | -------------------------------------------------------------------------------- /server/database/migrations/meta/0001_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "6", 3 | "dialect": "sqlite", 4 | "id": "98335176-9126-4c2f-a472-7d90f9e096fb", 5 | "prevId": "2c6b884a-95e6-4412-9ecd-855cafe2e3d6", 6 | "tables": { 7 | "files": { 8 | "name": "files", 9 | "columns": { 10 | "id": { 11 | "name": "id", 12 | "type": "integer", 13 | "primaryKey": true, 14 | "notNull": true, 15 | "autoincrement": false 16 | }, 17 | "name": { 18 | "name": "name", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": false, 22 | "autoincrement": false 23 | }, 24 | "path": { 25 | "name": "path", 26 | "type": "text", 27 | "primaryKey": false, 28 | "notNull": false, 29 | "autoincrement": false 30 | }, 31 | "text": { 32 | "name": "text", 33 | "type": "text", 34 | "primaryKey": false, 35 | "notNull": false, 36 | "autoincrement": false 37 | }, 38 | "tokens": { 39 | "name": "tokens", 40 | "type": "integer", 41 | "primaryKey": false, 42 | "notNull": false, 43 | "autoincrement": false 44 | }, 45 | "created_at": { 46 | "name": "created_at", 47 | "type": "integer", 48 | "primaryKey": false, 49 | "notNull": true, 50 | "autoincrement": false 51 | }, 52 | "thread_id": { 53 | "name": "thread_id", 54 | "type": "integer", 55 | "primaryKey": false, 56 | "notNull": true, 57 | "autoincrement": false 58 | }, 59 | "user_id": { 60 | "name": "user_id", 61 | "type": "integer", 62 | "primaryKey": false, 63 | "notNull": true, 64 | "autoincrement": false 65 | } 66 | }, 67 | "indexes": {}, 68 | "foreignKeys": {}, 69 | "compositePrimaryKeys": {}, 70 | "uniqueConstraints": {}, 71 | "checkConstraints": {} 72 | }, 73 | "logs": { 74 | "name": "logs", 75 | "columns": { 76 | "id": { 77 | "name": "id", 78 | "type": "integer", 79 | "primaryKey": true, 80 | "notNull": true, 81 | "autoincrement": false 82 | }, 83 | "input_tokens": { 84 | "name": "input_tokens", 85 | "type": "integer", 86 | "primaryKey": false, 87 | "notNull": false, 88 | "autoincrement": false, 89 | "default": 0 90 | }, 91 | "output_tokens": { 92 | "name": "output_tokens", 93 | "type": "integer", 94 | "primaryKey": false, 95 | "notNull": false, 96 | "autoincrement": false, 97 | "default": 0 98 | }, 99 | "cache_creation_input_tokens": { 100 | "name": "cache_creation_input_tokens", 101 | "type": "integer", 102 | "primaryKey": false, 103 | "notNull": false, 104 | "autoincrement": false, 105 | "default": 0 106 | }, 107 | "cache_read_input_tokens": { 108 | "name": "cache_read_input_tokens", 109 | "type": "integer", 110 | "primaryKey": false, 111 | "notNull": false, 112 | "autoincrement": false, 113 | "default": 0 114 | }, 115 | "created_at": { 116 | "name": "created_at", 117 | "type": "integer", 118 | "primaryKey": false, 119 | "notNull": true, 120 | "autoincrement": false 121 | } 122 | }, 123 | "indexes": {}, 124 | "foreignKeys": {}, 125 | "compositePrimaryKeys": {}, 126 | "uniqueConstraints": {}, 127 | "checkConstraints": {} 128 | }, 129 | "messages": { 130 | "name": "messages", 131 | "columns": { 132 | "id": { 133 | "name": "id", 134 | "type": "integer", 135 | "primaryKey": true, 136 | "notNull": true, 137 | "autoincrement": false 138 | }, 139 | "content": { 140 | "name": "content", 141 | "type": "text", 142 | "primaryKey": false, 143 | "notNull": false, 144 | "autoincrement": false 145 | }, 146 | "role": { 147 | "name": "role", 148 | "type": "text", 149 | "primaryKey": false, 150 | "notNull": false, 151 | "autoincrement": false 152 | }, 153 | "created_at": { 154 | "name": "created_at", 155 | "type": "integer", 156 | "primaryKey": false, 157 | "notNull": true, 158 | "autoincrement": false 159 | }, 160 | "thread_id": { 161 | "name": "thread_id", 162 | "type": "integer", 163 | "primaryKey": false, 164 | "notNull": true, 165 | "autoincrement": false 166 | }, 167 | "user_id": { 168 | "name": "user_id", 169 | "type": "integer", 170 | "primaryKey": false, 171 | "notNull": true, 172 | "autoincrement": false 173 | } 174 | }, 175 | "indexes": {}, 176 | "foreignKeys": {}, 177 | "compositePrimaryKeys": {}, 178 | "uniqueConstraints": {}, 179 | "checkConstraints": {} 180 | }, 181 | "threads": { 182 | "name": "threads", 183 | "columns": { 184 | "id": { 185 | "name": "id", 186 | "type": "integer", 187 | "primaryKey": true, 188 | "notNull": true, 189 | "autoincrement": false 190 | }, 191 | "name": { 192 | "name": "name", 193 | "type": "text", 194 | "primaryKey": false, 195 | "notNull": false, 196 | "autoincrement": false 197 | }, 198 | "system_message": { 199 | "name": "system_message", 200 | "type": "text", 201 | "primaryKey": false, 202 | "notNull": false, 203 | "autoincrement": false 204 | }, 205 | "created_at": { 206 | "name": "created_at", 207 | "type": "integer", 208 | "primaryKey": false, 209 | "notNull": true, 210 | "autoincrement": false 211 | }, 212 | "temperature": { 213 | "name": "temperature", 214 | "type": "real", 215 | "primaryKey": false, 216 | "notNull": false, 217 | "autoincrement": false, 218 | "default": 0.5 219 | }, 220 | "model": { 221 | "name": "model", 222 | "type": "text", 223 | "primaryKey": false, 224 | "notNull": false, 225 | "autoincrement": false, 226 | "default": "'claude-3-5-sonnet-20241022'" 227 | }, 228 | "max_tokens": { 229 | "name": "max_tokens", 230 | "type": "integer", 231 | "primaryKey": false, 232 | "notNull": false, 233 | "autoincrement": false, 234 | "default": 1024 235 | }, 236 | "user_id": { 237 | "name": "user_id", 238 | "type": "integer", 239 | "primaryKey": false, 240 | "notNull": true, 241 | "autoincrement": false 242 | } 243 | }, 244 | "indexes": {}, 245 | "foreignKeys": {}, 246 | "compositePrimaryKeys": {}, 247 | "uniqueConstraints": {}, 248 | "checkConstraints": {} 249 | }, 250 | "users": { 251 | "name": "users", 252 | "columns": { 253 | "id": { 254 | "name": "id", 255 | "type": "integer", 256 | "primaryKey": true, 257 | "notNull": true, 258 | "autoincrement": false 259 | }, 260 | "name": { 261 | "name": "name", 262 | "type": "text", 263 | "primaryKey": false, 264 | "notNull": false, 265 | "autoincrement": false 266 | }, 267 | "email": { 268 | "name": "email", 269 | "type": "text", 270 | "primaryKey": false, 271 | "notNull": false, 272 | "autoincrement": false 273 | }, 274 | "password": { 275 | "name": "password", 276 | "type": "text", 277 | "primaryKey": false, 278 | "notNull": false, 279 | "autoincrement": false 280 | }, 281 | "created_at": { 282 | "name": "created_at", 283 | "type": "integer", 284 | "primaryKey": false, 285 | "notNull": true, 286 | "autoincrement": false 287 | } 288 | }, 289 | "indexes": {}, 290 | "foreignKeys": {}, 291 | "compositePrimaryKeys": {}, 292 | "uniqueConstraints": {}, 293 | "checkConstraints": {} 294 | } 295 | }, 296 | "views": {}, 297 | "enums": {}, 298 | "_meta": { 299 | "schemas": {}, 300 | "tables": {}, 301 | "columns": {} 302 | }, 303 | "internal": { 304 | "indexes": {} 305 | } 306 | } 307 | -------------------------------------------------------------------------------- /server/database/migrations/meta/0002_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "6", 3 | "dialect": "sqlite", 4 | "id": "a6b1c933-5de2-4ccc-b768-c6bc6d597017", 5 | "prevId": "98335176-9126-4c2f-a472-7d90f9e096fb", 6 | "tables": { 7 | "files": { 8 | "name": "files", 9 | "columns": { 10 | "id": { 11 | "name": "id", 12 | "type": "integer", 13 | "primaryKey": true, 14 | "notNull": true, 15 | "autoincrement": false 16 | }, 17 | "name": { 18 | "name": "name", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": false, 22 | "autoincrement": false 23 | }, 24 | "path": { 25 | "name": "path", 26 | "type": "text", 27 | "primaryKey": false, 28 | "notNull": false, 29 | "autoincrement": false 30 | }, 31 | "text": { 32 | "name": "text", 33 | "type": "text", 34 | "primaryKey": false, 35 | "notNull": false, 36 | "autoincrement": false 37 | }, 38 | "tokens": { 39 | "name": "tokens", 40 | "type": "integer", 41 | "primaryKey": false, 42 | "notNull": false, 43 | "autoincrement": false 44 | }, 45 | "created_at": { 46 | "name": "created_at", 47 | "type": "integer", 48 | "primaryKey": false, 49 | "notNull": true, 50 | "autoincrement": false 51 | }, 52 | "thread_id": { 53 | "name": "thread_id", 54 | "type": "integer", 55 | "primaryKey": false, 56 | "notNull": true, 57 | "autoincrement": false 58 | }, 59 | "user_id": { 60 | "name": "user_id", 61 | "type": "integer", 62 | "primaryKey": false, 63 | "notNull": false, 64 | "autoincrement": false 65 | } 66 | }, 67 | "indexes": {}, 68 | "foreignKeys": { 69 | "files_user_id_users_id_fk": { 70 | "name": "files_user_id_users_id_fk", 71 | "tableFrom": "files", 72 | "tableTo": "users", 73 | "columnsFrom": ["user_id"], 74 | "columnsTo": ["id"], 75 | "onDelete": "no action", 76 | "onUpdate": "no action" 77 | } 78 | }, 79 | "compositePrimaryKeys": {}, 80 | "uniqueConstraints": {}, 81 | "checkConstraints": {} 82 | }, 83 | "logs": { 84 | "name": "logs", 85 | "columns": { 86 | "id": { 87 | "name": "id", 88 | "type": "integer", 89 | "primaryKey": true, 90 | "notNull": true, 91 | "autoincrement": false 92 | }, 93 | "input_tokens": { 94 | "name": "input_tokens", 95 | "type": "integer", 96 | "primaryKey": false, 97 | "notNull": false, 98 | "autoincrement": false, 99 | "default": 0 100 | }, 101 | "output_tokens": { 102 | "name": "output_tokens", 103 | "type": "integer", 104 | "primaryKey": false, 105 | "notNull": false, 106 | "autoincrement": false, 107 | "default": 0 108 | }, 109 | "cache_creation_input_tokens": { 110 | "name": "cache_creation_input_tokens", 111 | "type": "integer", 112 | "primaryKey": false, 113 | "notNull": false, 114 | "autoincrement": false, 115 | "default": 0 116 | }, 117 | "cache_read_input_tokens": { 118 | "name": "cache_read_input_tokens", 119 | "type": "integer", 120 | "primaryKey": false, 121 | "notNull": false, 122 | "autoincrement": false, 123 | "default": 0 124 | }, 125 | "created_at": { 126 | "name": "created_at", 127 | "type": "integer", 128 | "primaryKey": false, 129 | "notNull": true, 130 | "autoincrement": false 131 | }, 132 | "users_id": { 133 | "name": "users_id", 134 | "type": "integer", 135 | "primaryKey": false, 136 | "notNull": false, 137 | "autoincrement": false 138 | } 139 | }, 140 | "indexes": {}, 141 | "foreignKeys": { 142 | "logs_users_id_users_id_fk": { 143 | "name": "logs_users_id_users_id_fk", 144 | "tableFrom": "logs", 145 | "tableTo": "users", 146 | "columnsFrom": ["users_id"], 147 | "columnsTo": ["id"], 148 | "onDelete": "no action", 149 | "onUpdate": "no action" 150 | } 151 | }, 152 | "compositePrimaryKeys": {}, 153 | "uniqueConstraints": {}, 154 | "checkConstraints": {} 155 | }, 156 | "messages": { 157 | "name": "messages", 158 | "columns": { 159 | "id": { 160 | "name": "id", 161 | "type": "integer", 162 | "primaryKey": true, 163 | "notNull": true, 164 | "autoincrement": false 165 | }, 166 | "content": { 167 | "name": "content", 168 | "type": "text", 169 | "primaryKey": false, 170 | "notNull": false, 171 | "autoincrement": false 172 | }, 173 | "role": { 174 | "name": "role", 175 | "type": "text", 176 | "primaryKey": false, 177 | "notNull": false, 178 | "autoincrement": false 179 | }, 180 | "created_at": { 181 | "name": "created_at", 182 | "type": "integer", 183 | "primaryKey": false, 184 | "notNull": true, 185 | "autoincrement": false 186 | }, 187 | "thread_id": { 188 | "name": "thread_id", 189 | "type": "integer", 190 | "primaryKey": false, 191 | "notNull": true, 192 | "autoincrement": false 193 | }, 194 | "user_id": { 195 | "name": "user_id", 196 | "type": "integer", 197 | "primaryKey": false, 198 | "notNull": false, 199 | "autoincrement": false 200 | } 201 | }, 202 | "indexes": {}, 203 | "foreignKeys": { 204 | "messages_user_id_users_id_fk": { 205 | "name": "messages_user_id_users_id_fk", 206 | "tableFrom": "messages", 207 | "tableTo": "users", 208 | "columnsFrom": ["user_id"], 209 | "columnsTo": ["id"], 210 | "onDelete": "no action", 211 | "onUpdate": "no action" 212 | } 213 | }, 214 | "compositePrimaryKeys": {}, 215 | "uniqueConstraints": {}, 216 | "checkConstraints": {} 217 | }, 218 | "threads": { 219 | "name": "threads", 220 | "columns": { 221 | "id": { 222 | "name": "id", 223 | "type": "integer", 224 | "primaryKey": true, 225 | "notNull": true, 226 | "autoincrement": false 227 | }, 228 | "name": { 229 | "name": "name", 230 | "type": "text", 231 | "primaryKey": false, 232 | "notNull": false, 233 | "autoincrement": false 234 | }, 235 | "system_message": { 236 | "name": "system_message", 237 | "type": "text", 238 | "primaryKey": false, 239 | "notNull": false, 240 | "autoincrement": false 241 | }, 242 | "created_at": { 243 | "name": "created_at", 244 | "type": "integer", 245 | "primaryKey": false, 246 | "notNull": true, 247 | "autoincrement": false 248 | }, 249 | "temperature": { 250 | "name": "temperature", 251 | "type": "real", 252 | "primaryKey": false, 253 | "notNull": false, 254 | "autoincrement": false, 255 | "default": 0.5 256 | }, 257 | "model": { 258 | "name": "model", 259 | "type": "text", 260 | "primaryKey": false, 261 | "notNull": false, 262 | "autoincrement": false, 263 | "default": "'claude-3-5-sonnet-20241022'" 264 | }, 265 | "max_tokens": { 266 | "name": "max_tokens", 267 | "type": "integer", 268 | "primaryKey": false, 269 | "notNull": false, 270 | "autoincrement": false, 271 | "default": 1024 272 | }, 273 | "user_id": { 274 | "name": "user_id", 275 | "type": "integer", 276 | "primaryKey": false, 277 | "notNull": false, 278 | "autoincrement": false 279 | } 280 | }, 281 | "indexes": {}, 282 | "foreignKeys": { 283 | "threads_user_id_users_id_fk": { 284 | "name": "threads_user_id_users_id_fk", 285 | "tableFrom": "threads", 286 | "tableTo": "users", 287 | "columnsFrom": ["user_id"], 288 | "columnsTo": ["id"], 289 | "onDelete": "no action", 290 | "onUpdate": "no action" 291 | } 292 | }, 293 | "compositePrimaryKeys": {}, 294 | "uniqueConstraints": {}, 295 | "checkConstraints": {} 296 | }, 297 | "users": { 298 | "name": "users", 299 | "columns": { 300 | "id": { 301 | "name": "id", 302 | "type": "integer", 303 | "primaryKey": true, 304 | "notNull": true, 305 | "autoincrement": false 306 | }, 307 | "name": { 308 | "name": "name", 309 | "type": "text", 310 | "primaryKey": false, 311 | "notNull": false, 312 | "autoincrement": false 313 | }, 314 | "email": { 315 | "name": "email", 316 | "type": "text", 317 | "primaryKey": false, 318 | "notNull": false, 319 | "autoincrement": false 320 | }, 321 | "password": { 322 | "name": "password", 323 | "type": "text", 324 | "primaryKey": false, 325 | "notNull": false, 326 | "autoincrement": false 327 | }, 328 | "created_at": { 329 | "name": "created_at", 330 | "type": "integer", 331 | "primaryKey": false, 332 | "notNull": true, 333 | "autoincrement": false 334 | } 335 | }, 336 | "indexes": {}, 337 | "foreignKeys": {}, 338 | "compositePrimaryKeys": {}, 339 | "uniqueConstraints": {}, 340 | "checkConstraints": {} 341 | } 342 | }, 343 | "views": {}, 344 | "enums": {}, 345 | "_meta": { 346 | "schemas": {}, 347 | "tables": {}, 348 | "columns": {} 349 | }, 350 | "internal": { 351 | "indexes": {} 352 | } 353 | } 354 | -------------------------------------------------------------------------------- /components/MessageInput.vue: -------------------------------------------------------------------------------- 1 | 95 | 96 | 347 | --------------------------------------------------------------------------------