diff --git a/.gitignore b/.gitignore index ff6979b..eb4edf3 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,6 @@ wheels/ # Virtual environments .venv slack_stuff/ -data/ secret/ .env .bernd_history diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..09b565f --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,27 @@ +{ + "biome.lsp.bin": "./ui/node_modules/@biomejs/biome/bin/biome", + "biome.configurationPath": "./ui/biome.json", + "[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..193108f --- /dev/null +++ b/ui/actions/chats.ts @@ -0,0 +1,21 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { PATHS } from "@/lib/constants"; +import { getFS } from "@/lib/context"; + +export async function deleteChatAction(id: string, isActive?: boolean) { + const fs = await getFS(); + + await Promise.all([ + fs.delete(`${PATHS.CHATS}/${id}.json`), + fs.clearPrefix(`${PATHS.CHAT_ASSETS}/${id}/`), + ]); + + revalidatePath("/chat"); + + if (isActive) { + redirect("/chat"); + } +} diff --git a/ui/actions/files.ts b/ui/actions/files.ts new file mode 100644 index 0000000..3fab7b4 --- /dev/null +++ b/ui/actions/files.ts @@ -0,0 +1,203 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { PATHS } from "@/lib/constants"; +import { getFS } from "@/lib/context"; +import type { FileItem } from "@/types"; +import { + type FileUploadMetadata, + type FolderMarkerMetadata, + isFileUploadMetadata, +} from "@/types"; + +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; + + // Skip folder marker files + if (name === "folder_meta.json") continue; + + if (isFolder) { + if (seen.has(name)) continue; + seen.add(name); + items.push({ + name, + path: `${path}/${name}`, + type: "folder", + }); + } else { + const meta = file.metadata; + items.push({ + name, + path: file.path, + type: "file", + size: isFileUploadMetadata(meta) ? meta.size : undefined, + mime_type: isFileUploadMetadata(meta) ? meta.mime_type : undefined, + created_at: meta.created_at ?? 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 }> { + if (!path.startsWith(PATHS.FILES)) { + throw new Error("Can only download files under /files/"); + } + const fs = await getFS(); + const result = await fs.read(path); + + const meta = result.metadata; + return { + content: result.content, + mimeType: isFileUploadMetadata(meta) ? meta.mime_type : "text/plain", + }; +} + +export async function downloadBinaryFileAction( + path: string, +): Promise<{ data: number[]; mimeType: string }> { + if (!path.startsWith(PATHS.FILES)) { + throw new Error("Can only download files under /files/"); + } + const fs = await getFS(); + const result = await fs.readBinary(path); + + // Convert ArrayBuffer to array of numbers for serialization + const data = Array.from(new Uint8Array(result.data)); + + const meta = result.metadata; + return { + data, + mimeType: isFileUploadMetadata(meta) + ? meta.mime_type + : "application/octet-stream", + }; +} + +export async function uploadFileAction( + path: string, + content: string, + metadata?: Record, +) { + const fs = await getFS(); + + // Ensure path starts with /files/ + const fullPath = path.startsWith(PATHS.FILES) + ? path + : `${PATHS.FILES}${path}`; + const filename = fullPath.split("/").pop() ?? "unnamed"; + + const fileMetadata: Omit< + FileUploadMetadata, + "path" | "created_at" | "updated_at" + > = { + type: "file", + mime_type: (metadata?.mime_type as string) ?? "text/plain", + size: (metadata?.size as number) ?? content.length, + is_base64: false, + original_name: (metadata?.original_name as string) ?? filename, + }; + await fs.write(fullPath, content, fileMetadata); + + revalidatePath("/files"); +} + +export async function uploadBinaryFileAction( + path: string, + data: ArrayBuffer, + mimeType: string, + metadata?: Record, +) { + const fs = await getFS(); + + // Ensure path starts with /files/ + const fullPath = path.startsWith(PATHS.FILES) + ? path + : `${PATHS.FILES}${path}`; + const filename = fullPath.split("/").pop() ?? "unnamed"; + + const fileMetadata: Omit< + FileUploadMetadata, + "path" | "created_at" | "updated_at" + > = { + type: "file", + mime_type: mimeType, + size: (metadata?.size as number) ?? data.byteLength, + is_base64: true, + original_name: (metadata?.original_name as string) ?? filename, + }; + await fs.writeBinary(fullPath, data, mimeType, fileMetadata); + + revalidatePath("/files"); +} + +export async function createFolderAction(path: string) { + const fs = await getFS(); + + // Ensure path starts with /files/ + const fullPath = path.startsWith(PATHS.FILES) + ? path + : `${PATHS.FILES}${path}`; + const folderName = fullPath.split("/").pop() ?? "folder"; + const createdAt = new Date().toISOString(); + + const content = JSON.stringify({ + type: "folder", + name: folderName, + created_at: createdAt, + }); + + const folderMetadata: Omit< + FolderMarkerMetadata, + "path" | "created_at" | "updated_at" + > = { + type: "folder_marker", + }; + await fs.write(`${fullPath}/folder_meta.json`, content, folderMetadata); + + revalidatePath("/files"); +} + +export async function deleteFileAction(path: string) { + if (!path.startsWith(PATHS.FILES)) { + throw new Error("Can only delete files under /files/"); + } + const fs = await getFS(); + + await fs.delete(path); + + revalidatePath("/files"); +} + +export async function deleteFolderAction(path: string) { + if (!path.startsWith(PATHS.FILES)) { + throw new Error("Can only delete folders under /files/"); + } + const fs = await getFS(); + + const prefix = path.endsWith("/") ? path : `${path}/`; + await fs.clearPrefix(prefix); + + revalidatePath("/files"); +} diff --git a/ui/actions/google-auth.ts b/ui/actions/google-auth.ts new file mode 100644 index 0000000..3375e0b --- /dev/null +++ b/ui/actions/google-auth.ts @@ -0,0 +1,52 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { PATHS } from "@/lib/constants"; +import { getFS } from "@/lib/context"; +import { encrypt } from "@/lib/crypto"; + +export async function getGoogleStatusAction(): Promise<{ + connected: boolean; + connected_at?: string; +}> { + try { + const fs = await getFS(); + const result = await fs.read(PATHS.GOOGLE_AUTH); + const tokens = JSON.parse(result.content); + const connected = !!(tokens?.access_token && tokens?.refresh_token); + return { + connected, + connected_at: connected ? result.metadata.created_at : undefined, + }; + } catch { + return { connected: false }; + } +} + +export async function disconnectGoogleAction() { + const fs = await getFS(); + + await fs.delete(PATHS.GOOGLE_AUTH); + + revalidatePath("/settings"); +} + +export async function saveGoogleTokensAction(tokens: { + access_token: string; + refresh_token: string; + expiry?: string; +}) { + const fs = await getFS(); + + const encryptedTokens = { + ...tokens, + access_token: encrypt(tokens.access_token), + refresh_token: encrypt(tokens.refresh_token), + }; + + await fs.write(PATHS.GOOGLE_AUTH, JSON.stringify(encryptedTokens, null, 2), { + type: "auth", + }); + + revalidatePath("/settings"); +} diff --git a/ui/actions/notes.ts b/ui/actions/notes.ts new file mode 100644 index 0000000..d7cde90 --- /dev/null +++ b/ui/actions/notes.ts @@ -0,0 +1,51 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { PATHS } from "@/lib/constants"; +import { getFS } from "@/lib/context"; +import { generateTimestamp } from "@/lib/utils"; +import type { NoteCreate, NoteMetadata, NoteUpdate } from "@/types"; + +function generateNoteId(): string { + const ts = generateTimestamp(); + return `${ts.slice(0, 8)}_${ts.slice(8)}`; +} + +export async function createNoteAction(data: NoteCreate) { + const fs = await getFS(); + + const noteId = generateNoteId(); + const content = data.content || " "; + + const metadata: Omit = { + type: "note", + title: data.title, + }; + await fs.write(`${PATHS.NOTES}/${noteId}.md`, content, metadata); + + revalidatePath("/notes"); + redirect(`/notes/${noteId}`); +} + +export async function updateNoteAction(id: string, data: NoteUpdate) { + const fs = await getFS(); + + const content = data.content || " "; + + const metadata: Omit = { + type: "note", + title: data.title ?? "", + }; + await fs.write(`${PATHS.NOTES}/${id}.md`, content, metadata); + + revalidatePath("/notes"); +} + +export async function deleteNoteAction(id: string) { + const fs = await getFS(); + + await fs.delete(`${PATHS.NOTES}/${id}.md`); + + revalidatePath("/notes"); +} diff --git a/ui/actions/search.ts b/ui/actions/search.ts new file mode 100644 index 0000000..9803c6d --- /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..3cd5958 --- /dev/null +++ b/ui/actions/todos.ts @@ -0,0 +1,171 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { PATHS } from "@/lib/constants"; +import { getFS, getGoogleCalendar } from "@/lib/context"; +import { generateTimestamp } from "@/lib/utils"; +import { + isTodoMetadata, + type TodoCreate, + type TodoMetadata, + type TodoUpdate, +} from "@/types"; + +function makeSafeTitle(title: string): string { + return title + .replace(/[^\w\s-]/g, "") + .trim() + .replace(/\s+/g, "-") + .toLowerCase() + .slice(0, 50); +} + +function generateTodoFilename(title: string): string { + const safeTitle = makeSafeTitle(title); + return `${safeTitle}-${generateTimestamp()}.md`; +} + +export async function createTodoAction(data: TodoCreate) { + const fs = await getFS(); + const gcal = await getGoogleCalendar(); + + const content = `# ${data.title}\n\n${data.description ?? ""}`; + const metadata: Omit = { + type: "todo", + title: data.title, + due_date: data.due_date ?? "", + priority: data.priority ?? "medium", + status: data.status ?? "pending", + tags: data.tags ?? [], + }; + + // Create calendar event if due_date is set and gcal is configured + if (gcal && data.due_date) { + const calResult = await gcal.createEvent({ + title: data.title, + description: data.description ?? "", + startTime: data.due_date, + durationMinutes: 30, + }); + if (calResult.event_id) { + metadata.calendar_event_id = calResult.event_id; + } + } + + const filename = generateTodoFilename(data.title); + await fs.write(`${PATHS.TODOS}/${filename}`, content, metadata); + + revalidatePath("/"); +} + +export async function updateTodoAction(id: string, data: TodoUpdate) { + const fs = await getFS(); + const gcal = await getGoogleCalendar(); + + // Read existing todo to preserve fields not included in the partial update + let eventId: string | undefined; + let currentTitle = id; + let existingDescription = ""; + let existingMeta: Omit< + TodoMetadata, + "type" | "path" | "created_at" | "updated_at" + > = {}; + try { + const existing = await fs.read(`${PATHS.TODOS}/${id}.md`); + if (isTodoMetadata(existing.metadata)) { + const { type, path, created_at, updated_at, ...rest } = existing.metadata; + existingMeta = rest; + eventId = existing.metadata.calendar_event_id; + } + const titleMatch = existing.content.match(/^# (.+)$/m); + if (titleMatch) { + currentTitle = titleMatch[1]; + } + existingDescription = existing.content.replace(/^#.*\n\n?/, ""); + } catch { + // Todo doesn't exist yet + } + + const newTitle = data.new_title ?? currentTitle; + const description = data.description ?? existingDescription; + const content = `# ${newTitle}\n\n${description}`; + + const metadata: Omit & { + calendar_event_id?: string; + } = { + ...existingMeta, + type: "todo", + title: newTitle, + ...(data.due_date !== undefined && { due_date: data.due_date }), + ...(data.priority !== undefined && { priority: data.priority }), + ...(data.status !== undefined && { status: data.status }), + ...(data.tags !== undefined && { tags: data.tags }), + }; + + // Handle calendar event + if (gcal) { + if (data.status === "completed" && eventId) { + await gcal.deleteEvent(eventId); + delete metadata.calendar_event_id; + } else if (eventId && data.due_date) { + await gcal.updateEvent(eventId, { + title: newTitle, + description, + startTime: data.due_date, + durationMinutes: 30, + }); + metadata.calendar_event_id = eventId; + } else if (!eventId && data.due_date && data.status !== "completed") { + const calResult = await gcal.createEvent({ + title: newTitle, + description, + startTime: data.due_date, + durationMinutes: 30, + }); + if (calResult.event_id) { + metadata.calendar_event_id = calResult.event_id; + } + } + } + + // If title changed, write new file first, then delete old to avoid data loss + if (newTitle !== currentTitle) { + const newFilename = generateTodoFilename(newTitle); + await fs.write(`${PATHS.TODOS}/${newFilename}`, content, metadata); + try { + await fs.delete(`${PATHS.TODOS}/${id}.md`); + } catch { + // Old file may not exist (e.g. updating a non-existent todo with a new title) + } + } else { + await fs.write(`${PATHS.TODOS}/${id}.md`, content, metadata); + } + + revalidatePath("/"); +} + +export async function deleteTodoAction(id: string) { + const fs = await getFS(); + const gcal = await getGoogleCalendar(); + + // Get existing todo to check for calendar event + let eventId: string | undefined; + try { + const existing = await fs.read(`${PATHS.TODOS}/${id}.md`); + eventId = + existing.metadata && isTodoMetadata(existing.metadata) + ? existing.metadata.calendar_event_id + : undefined; + } catch { + // Todo doesn't exist + } + + // Delete calendar event if exists + if (gcal && eventId) { + await gcal.deleteEvent(eventId); + } + + await fs.delete(`${PATHS.TODOS}/${id}.md`); + + revalidatePath("/"); +} 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..74fa1b2 --- /dev/null +++ b/ui/app/api/auth/google/callback/route.ts @@ -0,0 +1,95 @@ +import { google } from "googleapis"; +import { type NextRequest, NextResponse } from "next/server"; +import { saveGoogleTokensAction } 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 saveGoogleTokensAction({ + 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); + } +} + +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..4c33f9a --- /dev/null +++ b/ui/app/api/chat/route.ts @@ -0,0 +1,206 @@ +import { openai } from "@ai-sdk/openai"; +import { + convertToModelMessages, + generateText, + smoothStream, + stepCountIs, + streamText, + type UIMessage, +} from "ai"; +import { getSystemPrompt } from "@/lib/agent/prompts"; +import { createTools } from "@/lib/agent/tools"; +import { getApiKey, getFS, getGoogleCalendar } from "@/lib/context"; +import { getChat, processImagesForSave, saveChat } from "@/lib/data/chats"; +import type { SemanticFS } from "@/lib/services/semantic-fs"; +import type { ImageAttachment, Message } from "@/types"; + +export const maxDuration = 60; + +async function generateChatTitle(messages: Message[]): Promise { + if (messages.length === 0) return "New Chat"; + + const conversationText = messages + .slice(0, 6) + .map((msg) => `${msg.role.toUpperCase()}: ${msg.content.slice(0, 500)}`) + .join("\n"); + + try { + const { text } = await generateText({ + model: openai("gpt-4o-mini"), + system: + "Generate a short, descriptive title (3-6 words) for this conversation. Return ONLY the title, no quotes or punctuation at the end.", + prompt: conversationText, + maxOutputTokens: 20, + temperature: 0.7, + }); + + const title = text + .trim() + .replace(/^["']|["']$/g, "") + .replace(/\.$/, ""); + return title.slice(0, 50); + } catch { + // Fallback to first user message + for (const msg of messages) { + if (msg.role === "user") { + const title = msg.content.slice(0, 50); + return msg.content.length > 50 ? `${title}...` : title; + } + } + return "New Chat"; + } +} + +/** + * Resolve storage-path images in UIMessages to base64 data URLs so the model + * can process them. The original messages (with storage paths) are kept for saving. + */ +async function resolveImagesForModel( + fs: SemanticFS, + messages: UIMessage[], +): Promise { + return Promise.all( + messages.map(async (msg) => { + const hasStorageImages = msg.parts.some( + (part) => + part.type === "file" && + part.url && + !part.url.startsWith("data:") && + !part.url.startsWith("http"), + ); + if (!hasStorageImages) return msg; + + const parts = await Promise.all( + msg.parts.map(async (part) => { + if ( + part.type !== "file" || + !part.url || + part.url.startsWith("data:") || + part.url.startsWith("http") + ) { + return part; + } + try { + const result = await fs.readBinary(part.url); + const base64 = Buffer.from(result.data).toString("base64"); + return { ...part, url: `data:${part.mediaType};base64,${base64}` }; + } catch { + return part; + } + }), + ); + + return { ...msg, parts }; + }), + ); +} + +export async function POST(req: Request) { + const body = await req.json(); + const { messages }: { messages: UIMessage[] } = body; + const chatId = body.chatId as string | undefined; + + const [fs, gcal, apiKey] = await Promise.all([ + getFS(), + getGoogleCalendar(), + getApiKey(), + ]); + + const [tools, systemPrompt] = await Promise.all([ + Promise.resolve(createTools(fs, () => gcal, apiKey)), + getSystemPrompt(fs), + ]); + + // Resolve storage-path images to base64 for the model while keeping + // the original `messages` (with storage paths) for saving in onFinish. + const modelMessages = await resolveImagesForModel(fs, messages); + + const result = streamText({ + model: openai("gpt-5.2"), + system: systemPrompt, + messages: await convertToModelMessages(modelMessages), + tools, + providerOptions: { + openai: { reasoningEffort: "medium" }, + }, + stopWhen: stepCountIs(15), + experimental_transform: smoothStream({ + chunking: "word", + delayInMs: 40, + }), + onFinish: async ({ response }) => { + try { + // 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) { + if (typeof lastAssistantMessage.content === "string") { + assistantText = lastAssistantMessage.content; + } else if (Array.isArray(lastAssistantMessage.content)) { + for (const part of lastAssistantMessage.content) { + if (part.type === "text" && part.text) { + assistantText += part.text; + } + } + } + } + + const existingChat = await getChat(fs, chatId); + const existingMessages = existingChat?.messages ?? []; + + // Only convert new messages from the request; existing ones + // already have storage paths for images and don't need + // re-processing (which would create duplicate files). + const newUIMessages = messages.slice(existingMessages.length); + const newStoredMessages: Message[] = newUIMessages.map((msg) => { + let content = ""; + const images: ImageAttachment[] = []; + for (const part of msg.parts) { + if (part.type === "text" && part.text) { + content += part.text; + } else if ( + part.type === "file" && + part.mediaType?.startsWith("image/") && + part.url + ) { + images.push({ + type: "image", + data: part.url, + mimeType: part.mediaType, + }); + } + } + return { + role: msg.role as "user" | "assistant", + content, + ...(images.length > 0 ? { images } : {}), + }; + }); + + const processedNew = await processImagesForSave( + fs, + chatId, + [...newStoredMessages, { role: "assistant", content: assistantText }], + ); + const allMessages = [...existingMessages, ...processedNew]; + const title = + existingChat && existingChat.title !== "New Chat" + ? existingChat.title + : await generateChatTitle(allMessages); + await saveChat(fs, chatId, title, allMessages); + } + } catch (e) { + console.error("[chat] Failed to save chat:", e); + } + }, + }); + + return result.toUIMessageStreamResponse(); +} diff --git a/ui/app/api/images/route.ts b/ui/app/api/images/route.ts new file mode 100644 index 0000000..4e33e29 --- /dev/null +++ b/ui/app/api/images/route.ts @@ -0,0 +1,34 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getFS } from "@/lib/context"; + +const MIME_MAP: Record = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", +}; + +export async function GET(req: NextRequest) { + const path = req.nextUrl.searchParams.get("path"); + if (!path || !path.startsWith("/chat_assets/")) { + return NextResponse.json({ error: "Invalid path" }, { status: 400 }); + } + + try { + const fs = await getFS(); + const result = await fs.readBinary(path); + + const ext = path.split(".").pop()?.toLowerCase() ?? "png"; + const contentType = MIME_MAP[ext] ?? "application/octet-stream"; + + return new NextResponse(result.data, { + headers: { + "Content-Type": contentType, + "Cache-Control": "private, max-age=3600", + }, + }); + } catch { + return NextResponse.json({ error: "Image not found" }, { status: 404 }); + } +} diff --git a/ui/app/chat/ChatClient.tsx b/ui/app/chat/ChatClient.tsx deleted file mode 100644 index ab8a1cc..0000000 --- a/ui/app/chat/ChatClient.tsx +++ /dev/null @@ -1,569 +0,0 @@ -"use client"; - -import { useState, useRef, useEffect, useCallback } from "react"; -import { ImageIcon, XIcon, ClockIcon, ChevronRightIcon, Loader2Icon } from "lucide-react"; -import ReactMarkdown, { Components } from "react-markdown"; - -const markdownComponents: Components = { - a: ({ href, children }) => ( - - {children} - - ), -}; -import { - useChat, - handleChatKeyDown, - handlePasteWithImages, - fileToImageAttachment, -} from "../hooks/useChat"; -import { ToolCall, ChatSummary, ImageAttachment } from "../types"; -import { API_ENDPOINTS } from "../config"; -import { api } from "../lib/api"; - -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 ( - - ); -} - -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)}
-              
-
- )} -
- )} -
- ); -} - -function ToolCallsList({ toolCalls }: { toolCalls: ToolCall[] }) { - return ( -
- {toolCalls.map((tc, j) => ( - - ))} -
- ); -} - -function ImagePreview({ - images, - onRemove, -}: { - images: ImageAttachment[]; - onRemove: (index: number) => void; -}) { - 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 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()} - /> -
- ); -} - -interface ChatClientProps { - 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 = ( -
- -
-