From ebd1c7666baaf5493af2dcce7118d2d473b194b9 Mon Sep 17 00:00:00 2001 From: Naing Linn Khant Date: Wed, 28 Jan 2026 00:46:26 +0700 Subject: [PATCH 01/42] feat: add ai sdk --- .vscode/settings.json | 26 + ui/actions/chats.ts | 33 + ui/actions/files.ts | 175 ++++ ui/actions/google-auth.ts | 29 + ui/actions/notes.ts | 83 ++ ui/actions/search.ts | 16 + ui/actions/todos.ts | 173 ++++ ui/app/api/auth/google/callback/route.ts | 96 ++ ui/app/api/chat/route.ts | 110 ++ ui/app/chat/ChatClient.tsx | 1118 +++++++++++---------- ui/app/chat/page.tsx | 26 +- ui/app/components/AuthGate.tsx | 38 - ui/app/components/AuthenticatedLayout.tsx | 24 - ui/app/components/FloatingChat.tsx | 415 -------- ui/app/components/Loading.tsx | 7 - ui/app/components/Navbar.tsx | 126 --- ui/app/components/OrgGate.tsx | 56 -- ui/app/components/OrgSwitcher.tsx | 88 -- ui/app/config.ts | 39 - ui/app/context/AuthContext.tsx | 58 -- ui/app/context/OrgSwitchContext.tsx | 28 - ui/app/context/ThemeContext.tsx | 13 - ui/app/files/FilesClient.tsx | 615 ++++++++++++ ui/app/files/page.tsx | 610 ++--------- ui/app/globals.css | 158 +-- ui/app/hooks/useChat.ts | 263 ----- ui/app/layout.tsx | 56 +- ui/app/lib/api.server.ts | 59 -- ui/app/lib/api.ts | 42 - ui/app/lib/auth.ts | 34 - ui/app/notes/page.tsx | 274 +---- ui/app/page.tsx | 441 +------- ui/app/search/page.tsx | 216 ++-- ui/app/settings/page.tsx | 442 ++++---- ui/app/sign-in/page.tsx | 172 ++-- ui/app/types/index.ts | 61 -- ui/biome.json | 54 + ui/bun.lock | 657 ++++-------- ui/components/AuthGate.tsx | 38 + ui/components/AuthenticatedLayout.tsx | 24 + ui/components/FloatingChat.tsx | 432 ++++++++ ui/components/Loading.tsx | 7 + ui/components/Navbar.tsx | 127 +++ ui/components/NotesClient.tsx | 306 ++++++ ui/components/OrgGate.tsx | 70 ++ ui/components/OrgSwitcher.tsx | 94 ++ ui/components/TodosClient.tsx | 476 +++++++++ ui/config.ts | 45 + ui/context/AuthContext.tsx | 58 ++ ui/context/OrgSwitchContext.tsx | 28 + ui/context/ThemeContext.tsx | 13 + ui/eslint.config.mjs | 18 - ui/hooks/useChat.ts | 269 +++++ ui/lib/agent/prompts.ts | 132 +++ ui/lib/agent/skills.ts | 134 +++ ui/lib/agent/tools.ts | 604 +++++++++++ ui/lib/api.server.ts | 59 ++ ui/lib/api.ts | 42 + ui/lib/auth.ts | 35 + ui/lib/constants.ts | 38 + ui/lib/context.ts | 130 +++ ui/lib/crypto.ts | 56 ++ ui/lib/services/google-calendar.ts | 352 +++++++ ui/lib/services/semantic-fs.ts | 329 ++++++ ui/lib/services/web-search.ts | 45 + ui/lib/utils/org.ts | 66 +- ui/next.config.ts | 2 +- ui/package.json | 69 +- ui/postcss.config.mjs | 6 +- ui/tsconfig.json | 64 +- ui/types/index.ts | 66 ++ 71 files changed, 6889 insertions(+), 4276 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 ui/actions/chats.ts create mode 100644 ui/actions/files.ts create mode 100644 ui/actions/google-auth.ts create mode 100644 ui/actions/notes.ts create mode 100644 ui/actions/search.ts create mode 100644 ui/actions/todos.ts create mode 100644 ui/app/api/auth/google/callback/route.ts create mode 100644 ui/app/api/chat/route.ts delete mode 100644 ui/app/components/AuthGate.tsx delete mode 100644 ui/app/components/AuthenticatedLayout.tsx delete mode 100644 ui/app/components/FloatingChat.tsx delete mode 100644 ui/app/components/Loading.tsx delete mode 100644 ui/app/components/Navbar.tsx delete mode 100644 ui/app/components/OrgGate.tsx delete mode 100644 ui/app/components/OrgSwitcher.tsx delete mode 100644 ui/app/config.ts delete mode 100644 ui/app/context/AuthContext.tsx delete mode 100644 ui/app/context/OrgSwitchContext.tsx delete mode 100644 ui/app/context/ThemeContext.tsx create mode 100644 ui/app/files/FilesClient.tsx delete mode 100644 ui/app/hooks/useChat.ts delete mode 100644 ui/app/lib/api.server.ts delete mode 100644 ui/app/lib/api.ts delete mode 100644 ui/app/lib/auth.ts delete mode 100644 ui/app/types/index.ts create mode 100644 ui/biome.json create mode 100644 ui/components/AuthGate.tsx create mode 100644 ui/components/AuthenticatedLayout.tsx create mode 100644 ui/components/FloatingChat.tsx create mode 100644 ui/components/Loading.tsx create mode 100644 ui/components/Navbar.tsx create mode 100644 ui/components/NotesClient.tsx create mode 100644 ui/components/OrgGate.tsx create mode 100644 ui/components/OrgSwitcher.tsx create mode 100644 ui/components/TodosClient.tsx create mode 100644 ui/config.ts create mode 100644 ui/context/AuthContext.tsx create mode 100644 ui/context/OrgSwitchContext.tsx create mode 100644 ui/context/ThemeContext.tsx delete mode 100644 ui/eslint.config.mjs create mode 100644 ui/hooks/useChat.ts create mode 100644 ui/lib/agent/prompts.ts create mode 100644 ui/lib/agent/skills.ts create mode 100644 ui/lib/agent/tools.ts create mode 100644 ui/lib/api.server.ts create mode 100644 ui/lib/api.ts create mode 100644 ui/lib/auth.ts create mode 100644 ui/lib/constants.ts create mode 100644 ui/lib/context.ts create mode 100644 ui/lib/crypto.ts create mode 100644 ui/lib/services/google-calendar.ts create mode 100644 ui/lib/services/semantic-fs.ts create mode 100644 ui/lib/services/web-search.ts create mode 100644 ui/types/index.ts diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..9df7efd --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,26 @@ +{ + "biome.lsp.bin": "./ui/node_modules/@biomejs/biome/bin/biome", + "[javascript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[json]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[jsonc]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "editor.codeActionsOnSave": { + "source.fixAll.biome": "explicit" + }, + "editor.defaultFormatter": "biomejs.biome", + "editor.formatOnPaste": true, + "editor.formatOnSave": true, + "typescript.tsdk": "ui/node_modules/typescript/lib", + "prettier.enable": false +} diff --git a/ui/actions/chats.ts b/ui/actions/chats.ts new file mode 100644 index 0000000..26b1cf2 --- /dev/null +++ b/ui/actions/chats.ts @@ -0,0 +1,33 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { PATHS } from "@/lib/constants"; +import { getFS } from "@/lib/context"; + +export async function deleteChat( + id: string, +): Promise<{ status: string; path: string }> { + const fs = await getFS(); + + const result = await fs.delete(`${PATHS.CHATS}/${id}.json`); + + revalidatePath("/chat"); + + if ("error" in result) { + return { status: "error", path: `${PATHS.CHATS}/${id}.json` }; + } + return result; +} + +export async function clearAllChats(): Promise<{ + status: string; + prefix: string; + deleted: number; +}> { + const fs = await getFS(); + + const result = await fs.clearPrefix(PATHS.CHATS); + + revalidatePath("/chat"); + return result; +} diff --git a/ui/actions/files.ts b/ui/actions/files.ts new file mode 100644 index 0000000..cbb100f --- /dev/null +++ b/ui/actions/files.ts @@ -0,0 +1,175 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { PATHS } from "@/lib/constants"; +import { getFS } from "@/lib/context"; +import type { FileItem } from "../app/files/page"; + +export async function listFilesAction(path: string): Promise { + const fs = await getFS(); + const files = await fs.list(path, 100); + const basePath = path.endsWith("/") ? path : `${path}/`; + + const seen = new Set(); + const items: FileItem[] = []; + + for (const file of files) { + const relativePath = file.path.replace(basePath, "").replace(/^\//, ""); + const parts = relativePath.split("/"); + const name = parts[0]; + const isFolder = parts.length > 1; + + if (isFolder) { + if (seen.has(name)) continue; + seen.add(name); + items.push({ + name, + path: `${path}/${name}`, + type: "folder", + }); + } else { + items.push({ + name, + path: file.path, + type: "file", + size: file.metadata.size as number | undefined, + mime_type: file.metadata.mime_type as string | undefined, + created_at: file.metadata.created_at as string | undefined, + }); + } + } + + items.sort((a, b) => { + if (a.type !== b.type) { + return a.type === "folder" ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); + + return items; +} + +export async function downloadFileAction( + path: string, +): Promise<{ content: string; mimeType: string } | { error: string }> { + const fs = await getFS(); + const result = await fs.read(path); + + if ("error" in result) { + return { error: result.error }; + } + + return { + content: result.content, + mimeType: (result.metadata.mime_type as string) || "text/plain", + }; +} + +export async function downloadBinaryFileAction( + path: string, +): Promise<{ data: number[]; mimeType: string } | { error: string }> { + const fs = await getFS(); + const result = await fs.readBinary(path); + + if ("error" in result) { + return { error: result.error }; + } + + // Convert ArrayBuffer to array of numbers for serialization + const data = Array.from(new Uint8Array(result.data)); + + return { + data, + mimeType: + (result.metadata.mime_type as string) || "application/octet-stream", + }; +} + +export async function uploadFile( + path: string, + content: string, + metadata?: Record, +): Promise<{ status: string; path: string }> { + const fs = await getFS(); + + // Ensure path starts with /files/ + const fullPath = path.startsWith(PATHS.FILES) + ? path + : `${PATHS.FILES}${path}`; + + const result = await fs.write(fullPath, content, { + type: "file", + ...metadata, + }); + + revalidatePath("/files"); + return result; +} + +export async function uploadBinaryFile( + path: string, + data: ArrayBuffer, + mimeType: string, + metadata?: Record, +): Promise<{ status: string; path: string }> { + const fs = await getFS(); + + // Ensure path starts with /files/ + const fullPath = path.startsWith(PATHS.FILES) + ? path + : `${PATHS.FILES}${path}`; + + const result = await fs.writeBinary(fullPath, data, mimeType, { + type: "file", + mime_type: mimeType, + ...metadata, + }); + + revalidatePath("/files"); + return result; +} + +export async function createFolder( + path: string, +): Promise<{ status: string; path: string }> { + const fs = await getFS(); + + // Ensure path starts with /files/ + const fullPath = path.startsWith(PATHS.FILES) + ? path + : `${PATHS.FILES}${path}`; + + // Create a placeholder file to represent the folder + const result = await fs.write(`${fullPath}/.folder`, " ", { + type: "folder", + }); + + revalidatePath("/files"); + return result; +} + +export async function deleteFile( + path: string, +): Promise<{ status: string; path: string }> { + const fs = await getFS(); + + const result = await fs.delete(path); + + revalidatePath("/files"); + + if ("error" in result) { + return { status: "error", path }; + } + return result; +} + +export async function deleteFolder( + path: string, +): Promise<{ status: string; prefix: string; deleted: number }> { + const fs = await getFS(); + + const result = await fs.clearPrefix(path); + + revalidatePath("/files"); + return result; +} diff --git a/ui/actions/google-auth.ts b/ui/actions/google-auth.ts new file mode 100644 index 0000000..9884571 --- /dev/null +++ b/ui/actions/google-auth.ts @@ -0,0 +1,29 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { PATHS } from "@/lib/constants"; +import { getFS } from "@/lib/context"; + +export async function disconnectGoogle(): Promise<{ status: string }> { + const fs = await getFS(); + + await fs.delete(PATHS.GOOGLE_AUTH); + + revalidatePath("/settings"); + return { status: "disconnected" }; +} + +export async function saveGoogleTokens(tokens: { + access_token: string; + refresh_token: string; + expiry?: string; +}): Promise<{ status: string }> { + const fs = await getFS(); + + await fs.write(PATHS.GOOGLE_AUTH, JSON.stringify(tokens, null, 2), { + type: "auth", + }); + + revalidatePath("/settings"); + return { status: "saved" }; +} diff --git a/ui/actions/notes.ts b/ui/actions/notes.ts new file mode 100644 index 0000000..aa22e7e --- /dev/null +++ b/ui/actions/notes.ts @@ -0,0 +1,83 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { PATHS } from "@/lib/constants"; +import { getFS } from "@/lib/context"; +import type { Note } from "@/types"; + +export interface NoteCreate { + title: string; + content?: string; +} + +export interface NoteUpdate { + title?: string; + content?: string; +} + +export async function createNote( + data: NoteCreate, +): Promise<{ status: string; path: string }> { + const fs = await getFS(); + + const content = data.content ?? ""; + const result = await fs.write(`${PATHS.NOTES}/${data.title}.md`, content, { + type: "note", + }); + + revalidatePath("/notes"); + return result; +} + +export async function updateNote( + id: string, + data: NoteUpdate, +): Promise<{ status: string; path: string }> { + const fs = await getFS(); + + const newTitle = data.title ?? id; + const content = data.content ?? ""; + + // Delete old file if title changed + if (newTitle !== id) { + await fs.delete(`${PATHS.NOTES}/${id}.md`); + } + + const result = await fs.write(`${PATHS.NOTES}/${newTitle}.md`, content, { + type: "note", + }); + + revalidatePath("/notes"); + return result; +} + +export async function deleteNote( + id: string, +): Promise<{ status: string; path: string }> { + const fs = await getFS(); + + const result = await fs.delete(`${PATHS.NOTES}/${id}.md`); + + revalidatePath("/notes"); + + if ("error" in result) { + return { status: "error", path: `${PATHS.NOTES}/${id}.md` }; + } + return result; +} + +export async function getNoteAction(id: string): Promise { + const fs = await getFS(); + + const result = await fs.read(`${PATHS.NOTES}/${id}.md`); + if ("error" in result) { + return null; + } + + return { + id, + title: id, + content: result.content, + updated_at: (result.metadata.updated_at as string) ?? undefined, + }; +} diff --git a/ui/actions/search.ts b/ui/actions/search.ts new file mode 100644 index 0000000..1e214bd --- /dev/null +++ b/ui/actions/search.ts @@ -0,0 +1,16 @@ +"use server"; + +import { getFS } from "@/lib/context"; +import { type SearchAllResult, searchAll } from "@/lib/data/search"; + +export async function searchAction( + query: string, + topK = 20, +): Promise { + if (!query.trim()) { + return []; + } + + const fs = await getFS(); + return searchAll(fs, query, topK); +} diff --git a/ui/actions/todos.ts b/ui/actions/todos.ts new file mode 100644 index 0000000..8b44807 --- /dev/null +++ b/ui/actions/todos.ts @@ -0,0 +1,173 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { + FileType, + PATHS, + Priority, + type PriorityType, + TodoStatus, + type TodoStatusType, +} from "@/lib/constants"; +import { getFS, getGoogleCalendar } from "@/lib/context"; + +export interface TodoCreate { + title: string; + description?: string; + due_date?: string; + priority?: PriorityType; + status?: TodoStatusType; + tags?: string[]; +} + +export interface TodoUpdate { + new_title?: string; + description?: string; + due_date?: string; + priority?: PriorityType; + status?: TodoStatusType; + tags?: string[]; +} + +export async function createTodo( + data: TodoCreate, +): Promise<{ status: string; path: string; calendar?: unknown }> { + const fs = await getFS(); + const gcal = await getGoogleCalendar(); + + const content = `# ${data.title}\n\n${data.description ?? ""}`; + const metadata: Record = { + type: FileType.TODO, + due_date: data.due_date ?? "", + priority: data.priority ?? Priority.MEDIUM, + status: data.status ?? TodoStatus.PENDING, + tags: data.tags ?? [], + }; + + // Create calendar event if due_date is set and gcal is configured + let calResult: unknown; + if (gcal && data.due_date) { + calResult = await gcal.createEvent({ + title: data.title, + description: data.description ?? "", + startTime: data.due_date, + durationMinutes: 30, + }); + const calObj = calResult as Record; + if (calObj?.event_id) { + metadata.calendar_event_id = calObj.event_id; + } + } + + const result = await fs.write( + `${PATHS.TODOS}/${data.title}.md`, + content, + metadata, + ); + + revalidatePath("/"); + + return calResult ? { ...result, calendar: calResult } : result; +} + +export async function updateTodo( + id: string, + data: TodoUpdate, +): Promise<{ status: string; path: string; calendar?: unknown }> { + const fs = await getFS(); + const gcal = await getGoogleCalendar(); + + const title = id; + const newTitle = data.new_title ?? title; + const content = `# ${newTitle}\n\n${data.description ?? ""}`; + + // Get existing todo metadata + const existing = await fs.read(`${PATHS.TODOS}/${title}.md`); + const existingMeta = "error" in existing ? {} : existing.metadata; + const eventId = existingMeta.calendar_event_id as string | undefined; + + const metadata: Record = { + type: FileType.TODO, + due_date: data.due_date ?? "", + priority: data.priority ?? Priority.MEDIUM, + status: data.status ?? TodoStatus.PENDING, + tags: data.tags ?? [], + }; + + // Handle calendar event + let calResult: unknown; + if (gcal) { + if (data.status === TodoStatus.COMPLETED && eventId) { + // Delete calendar event when todo is completed + calResult = await gcal.deleteEvent(eventId); + } else if (eventId && data.due_date) { + // Update existing event + calResult = await gcal.updateEvent(eventId, { + title: newTitle, + description: data.description ?? "", + startTime: data.due_date, + durationMinutes: 30, + }); + metadata.calendar_event_id = eventId; + } else if ( + !eventId && + data.due_date && + data.status !== TodoStatus.COMPLETED + ) { + // Create new event if todo didn't have one but now has due_date + calResult = await gcal.createEvent({ + title: newTitle, + description: data.description ?? "", + startTime: data.due_date, + durationMinutes: 30, + }); + const calObj = calResult as Record; + if (calObj?.event_id) { + metadata.calendar_event_id = calObj.event_id; + } + } + } + + // Delete old file if title changed + if (newTitle !== title) { + await fs.delete(`${PATHS.TODOS}/${title}.md`); + } + + const result = await fs.write( + `${PATHS.TODOS}/${newTitle}.md`, + content, + metadata, + ); + + revalidatePath("/"); + + return calResult ? { ...result, calendar: calResult } : result; +} + +export async function deleteTodo( + id: string, +): Promise<{ status: string; path: string }> { + const fs = await getFS(); + const gcal = await getGoogleCalendar(); + + // Get existing todo to check for calendar event + const existing = await fs.read(`${PATHS.TODOS}/${id}.md`); + const eventId = + "error" in existing + ? undefined + : (existing.metadata.calendar_event_id as string | undefined); + + // Delete calendar event if exists + if (gcal && eventId) { + await gcal.deleteEvent(eventId); + } + + const result = await fs.delete(`${PATHS.TODOS}/${id}.md`); + + revalidatePath("/"); + + if ("error" in result) { + return { status: "error", path: `${PATHS.TODOS}/${id}.md` }; + } + return result; +} diff --git a/ui/app/api/auth/google/callback/route.ts b/ui/app/api/auth/google/callback/route.ts new file mode 100644 index 0000000..a71c2d5 --- /dev/null +++ b/ui/app/api/auth/google/callback/route.ts @@ -0,0 +1,96 @@ +import { google } from "googleapis"; +import { type NextRequest, NextResponse } from "next/server"; +import { saveGoogleTokens } from "@/actions/google-auth"; + +const SCOPES = ["https://www.googleapis.com/auth/calendar"]; + +export async function GET(req: NextRequest) { + const searchParams = req.nextUrl.searchParams; + const code = searchParams.get("code"); + const error = searchParams.get("error"); + + // Base redirect URL + const redirectUrl = new URL("/settings", req.url); + + if (error) { + redirectUrl.searchParams.set("error", error); + return NextResponse.redirect(redirectUrl); + } + + if (!code) { + redirectUrl.searchParams.set("error", "No authorization code provided"); + return NextResponse.redirect(redirectUrl); + } + + const clientId = process.env.GOOGLE_CLIENT_ID; + const clientSecret = process.env.GOOGLE_CLIENT_SECRET; + const redirectUri = + process.env.GOOGLE_REDIRECT_URI || + `${req.nextUrl.origin}/api/auth/google/callback`; + + if (!clientId || !clientSecret) { + redirectUrl.searchParams.set("error", "Google OAuth not configured"); + return NextResponse.redirect(redirectUrl); + } + + try { + const oauth2Client = new google.auth.OAuth2( + clientId, + clientSecret, + redirectUri, + ); + + const { tokens } = await oauth2Client.getToken(code); + + if (!tokens.access_token || !tokens.refresh_token) { + redirectUrl.searchParams.set("error", "Failed to get tokens"); + return NextResponse.redirect(redirectUrl); + } + + // Save tokens using Server Action + await saveGoogleTokens({ + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + expiry: tokens.expiry_date + ? new Date(tokens.expiry_date).toISOString() + : undefined, + }); + + redirectUrl.searchParams.set("success", "Google Calendar connected"); + return NextResponse.redirect(redirectUrl); + } catch (e) { + console.error("[Google OAuth] Error exchanging code:", e); + redirectUrl.searchParams.set("error", "Failed to connect Google Calendar"); + return NextResponse.redirect(redirectUrl); + } +} + +// Start OAuth flow +export async function POST(req: NextRequest) { + const clientId = process.env.GOOGLE_CLIENT_ID; + const clientSecret = process.env.GOOGLE_CLIENT_SECRET; + const redirectUri = + process.env.GOOGLE_REDIRECT_URI || + `${req.nextUrl.origin}/api/auth/google/callback`; + + if (!clientId || !clientSecret) { + return NextResponse.json( + { error: "Google OAuth not configured" }, + { status: 500 }, + ); + } + + const oauth2Client = new google.auth.OAuth2( + clientId, + clientSecret, + redirectUri, + ); + + const authUrl = oauth2Client.generateAuthUrl({ + access_type: "offline", + scope: SCOPES, + prompt: "consent", // Force consent to get refresh token + }); + + return NextResponse.json({ authUrl }); +} diff --git a/ui/app/api/chat/route.ts b/ui/app/api/chat/route.ts new file mode 100644 index 0000000..9fb3097 --- /dev/null +++ b/ui/app/api/chat/route.ts @@ -0,0 +1,110 @@ +import { openai } from "@ai-sdk/openai"; +import { convertToModelMessages, stepCountIs, streamText } from "ai"; +import { getSystemPrompt } from "@/lib/agent/prompts"; +import { createTools } from "@/lib/agent/tools"; +import { getApiKey, getFS, getGoogleCalendar } from "@/lib/context"; +import { saveChat } from "@/lib/data/chats"; + +// Allow streaming responses up to 60 seconds +export const maxDuration = 60; + +export async function POST(req: Request) { + const body = await req.json(); + const messages = body.messages ?? []; + const chatId = body.chatId as string | undefined; + + const fs = await getFS(); + const gcal = await getGoogleCalendar(); + const apiKey = await getApiKey(); + + const tools = createTools(fs, () => gcal, apiKey); + const systemPrompt = await getSystemPrompt(fs); + + const result = streamText({ + model: openai("gpt-4o"), + system: systemPrompt, + messages: await convertToModelMessages(messages), + tools, + stopWhen: stepCountIs(15), + onFinish: async ({ response }) => { + // Auto-save chat after completion + if (chatId) { + // Extract text from the response + const assistantMessages = response.messages.filter( + (m) => m.role === "assistant", + ); + const lastAssistantMessage = + assistantMessages[assistantMessages.length - 1]; + + let assistantText = ""; + if ( + lastAssistantMessage && + Array.isArray(lastAssistantMessage.content) + ) { + for (const part of lastAssistantMessage.content) { + if ( + typeof part === "object" && + part !== null && + "text" in part && + typeof part.text === "string" + ) { + assistantText += part.text; + } + } + } + + // Extract title from first user message in input + let title = "New Chat"; + for (const msg of messages) { + if ( + msg && + typeof msg === "object" && + msg.role === "user" && + Array.isArray(msg.parts) + ) { + for (const part of msg.parts) { + if ( + part && + typeof part === "object" && + part.type === "text" && + typeof part.text === "string" + ) { + title = part.text.slice(0, 50); + break; + } + } + break; + } + } + + // Build simplified message history for storage + const storedMessages = [ + ...messages.map( + (msg: { + role?: string; + parts?: Array<{ type?: string; text?: string }>; + }) => { + let content = ""; + if (msg.parts) { + for (const part of msg.parts) { + if (part.type === "text" && part.text) { + content += part.text; + } + } + } + return { + role: (msg.role ?? "user") as "user" | "assistant", + content, + }; + }, + ), + { role: "assistant" as const, content: assistantText }, + ]; + + await saveChat(fs, chatId, title, storedMessages); + } + }, + }); + + return result.toUIMessageStreamResponse(); +} diff --git a/ui/app/chat/ChatClient.tsx b/ui/app/chat/ChatClient.tsx index ab8a1cc..69973a2 100644 --- a/ui/app/chat/ChatClient.tsx +++ b/ui/app/chat/ChatClient.tsx @@ -1,569 +1,611 @@ "use client"; -import { useState, useRef, useEffect, useCallback } from "react"; -import { ImageIcon, XIcon, ClockIcon, ChevronRightIcon, Loader2Icon } from "lucide-react"; -import ReactMarkdown, { Components } from "react-markdown"; +import { + ChevronRightIcon, + ClockIcon, + ImageIcon, + Loader2Icon, + XIcon, +} from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import ReactMarkdown, { type Components } from "react-markdown"; const markdownComponents: Components = { - a: ({ href, children }) => ( - - {children} - - ), + a: ({ href, children }) => ( + + {children} + + ), }; + +import { deleteChat as deleteChatAction } from "../../actions/chats"; import { - useChat, - handleChatKeyDown, - handlePasteWithImages, - fileToImageAttachment, -} from "../hooks/useChat"; -import { ToolCall, ChatSummary, ImageAttachment } from "../types"; -import { API_ENDPOINTS } from "../config"; -import { api } from "../lib/api"; + fileToImageAttachment, + handleChatKeyDown, + handlePasteWithImages, + useChat, +} from "../../hooks/useChat"; +import type { ChatSummary, ImageAttachment, ToolCall } from "../../types"; function CopyButton({ text }: { text: string }) { - const [copied, setCopied] = useState(false); - - const handleCopy = useCallback(async () => { - await navigator.clipboard.writeText(text); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }, [text]); - - return ( - - ); + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(async () => { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }, [text]); + + return ( + + ); } function ToolCallItem({ toolCall }: { toolCall: ToolCall }) { - const [isOpen, setIsOpen] = useState(false); - const hasResult = toolCall.result !== undefined; - - return ( -
- - - {isOpen && ( -
-
-
input
-
-              {JSON.stringify(toolCall.args, null, 2)}
-            
-
- {hasResult && ( -
-
output
-
-                {JSON.stringify(toolCall.result, null, 2)}
-              
-
- )} -
- )} -
- ); + const [isOpen, setIsOpen] = useState(false); + const hasResult = toolCall.result !== undefined; + + return ( +
+ + + {isOpen && ( +
+
+
input
+
+							{JSON.stringify(toolCall.args, null, 2)}
+						
+
+ {hasResult && ( +
+
output
+
+								{JSON.stringify(toolCall.result, null, 2)}
+							
+
+ )} +
+ )} +
+ ); } function ToolCallsList({ toolCalls }: { toolCalls: ToolCall[] }) { - return ( -
- {toolCalls.map((tc, j) => ( - - ))} -
- ); + return ( +
+ {toolCalls.map((tc, j) => ( + + ))} +
+ ); } function ImagePreview({ - images, - onRemove, + images, + onRemove, }: { - images: ImageAttachment[]; - onRemove: (index: number) => void; + images: ImageAttachment[]; + onRemove: (index: number) => void; }) { - if (images.length === 0) return null; - - return ( -
- {images.map((img, i) => ( -
- {`Attachment - -
- ))} -
- ); + if (images.length === 0) return null; + + return ( +
+ {images.map((img, i) => ( +
+ {`Attachment + +
+ ))} +
+ ); } -function MessageImages({ images, onImageClick }: { images?: ImageAttachment[]; onImageClick?: (src: string) => void }) { - if (!images || images.length === 0) return null; - - return ( -
- {images.map((img, i) => ( - {`Image onImageClick?.(img.data)} - className="max-h-48 max-w-full rounded-lg cursor-pointer hover:opacity-90 transition-opacity border border-border" - /> - ))} -
- ); +function MessageImages({ + images, + onImageClick, +}: { + images?: ImageAttachment[]; + onImageClick?: (src: string) => void; +}) { + if (!images || images.length === 0) return null; + + return ( +
+ {images.map((img, i) => ( + {`Image onImageClick?.(img.data)} + className="max-h-48 max-w-full rounded-lg cursor-pointer hover:opacity-90 transition-opacity border border-border" + /> + ))} +
+ ); } function ImageModal({ src, onClose }: { src: string; onClose: () => void }) { - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); - }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [onClose]); - - return ( -
- - Expanded view e.stopPropagation()} - /> -
- ); + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [onClose]); + + return ( +
+ + Expanded view e.stopPropagation()} + /> +
+ ); } interface ChatClientProps { - initialChats: ChatSummary[]; + initialChats: ChatSummary[]; } export default function ChatClient({ initialChats }: ChatClientProps) { - const [chatId, setChatId] = useState(null); - const [chats, setChats] = useState(initialChats); - const [chatSearch, setChatSearch] = useState(""); - const [showHistory, setShowHistory] = useState(false); - const scrollContainerRef = useRef(null); - const messagesEndRef = useRef(null); - const [userScrolledUp, setUserScrolledUp] = useState(false); - const fileInputRef = useRef(null); - const [expandedImage, setExpandedImage] = useState(null); - - const { - messages, - setMessages, - input, - setInput, - images, - addImage, - removeImage, - loading, - streamingToolCalls, - streamingContent, - sendMessage, - clearChat, - } = useChat({ - onChatSaved: (newChatId) => { - setChatId(newChatId); - fetchChats(); - }, - }); - - const scrollToBottom = useCallback(() => { - if (scrollContainerRef.current) { - scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight; - } - }, []); - - // Auto-scroll only when not manually scrolled up - useEffect(() => { - if (!userScrolledUp) { - scrollToBottom(); - } - }, [messages, streamingContent, streamingToolCalls, userScrolledUp, scrollToBottom]); - - // Track previous messages to detect new user messages - const prevMessagesLengthRef = useRef(messages.length); - useEffect(() => { - if (messages.length > prevMessagesLengthRef.current) { - const lastMsg = messages[messages.length - 1]; - if (lastMsg.role === "user") { - // Reset scroll state when user sends a new message - // Using setTimeout to avoid synchronous setState in effect - setTimeout(() => setUserScrolledUp(false), 0); - } - } - prevMessagesLengthRef.current = messages.length; - }, [messages]); - - const handleScroll = () => { - if (!scrollContainerRef.current) return; - const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current; - const isNearBottom = scrollHeight - scrollTop - clientHeight < 150; - setUserScrolledUp(!isNearBottom); - }; - - const fetchChats = async () => { - try { - const res = await api.get(API_ENDPOINTS.chats); - const data = await res.json(); - setChats(data); - } catch (e) { - console.error("Failed to fetch chats", e); - } - }; - - const loadChat = async (id: string) => { - try { - const res = await api.get(API_ENDPOINTS.chatById(id)); - const data = await res.json(); - if (data.error) { - console.error("Chat not found:", data.error); - return; - } - if (data.messages && Array.isArray(data.messages)) { - setMessages(data.messages); - setChatId(id); - setShowHistory(false); - } - } catch (e) { - console.error("Failed to load chat", e); - } - }; - - const startNewChat = () => { - clearChat(); - setChatId(null); - setShowHistory(false); - }; - - const deleteChat = async (id: string, e: React.MouseEvent) => { - e.stopPropagation(); - try { - await api.delete(API_ENDPOINTS.chatById(id)); - if (chatId === id) { - startNewChat(); - } - fetchChats(); - } catch (err) { - console.error("Failed to delete chat", err); - } - }; - - const handleKeyDownLocal = (e: React.KeyboardEvent) => { - handleChatKeyDown(e, input, setInput, () => sendMessage(chatId)); - }; - - const handlePaste = async (e: React.ClipboardEvent) => { - await handlePasteWithImages(e, addImage); - }; - - const handleFileSelect = async (e: React.ChangeEvent) => { - const files = e.target.files; - if (!files) return; - - for (const file of files) { - const attachment = await fileToImageAttachment(file); - if (attachment) { - addImage(attachment); - } - } - if (fileInputRef.current) { - fileInputRef.current.value = ""; - } - }; - - const filteredChats = chats.filter((chat) => - chat.title.toLowerCase().includes(chatSearch.toLowerCase()) - ); - - // Input element - const inputElement = ( -
- -
-