diff --git a/ai-pdf-chatbot/.env.example b/ai-pdf-chatbot/.env.example index 77bb26c..e4fd9b1 100644 --- a/ai-pdf-chatbot/.env.example +++ b/ai-pdf-chatbot/.env.example @@ -15,3 +15,11 @@ NEXT_PUBLIC_BETTER_AUTH_URL=http://localhost:3000 # Same secret InsForge uses internally; the bridge route signs HS256 # with this. Get it via `npx @insforge/cli secrets get JWT_SECRET`. INSFORGE_JWT_SECRET=replace-with-output-of-cli-secrets-get-JWT_SECRET + +# ─── Audio Overview (optional) ──────────────────────────────────────── +# OpenAI key for TTS-driven workspace audio overviews (NotebookLM-style +# two-host podcast). Leave blank to disable — the workspace audio tab +# will surface a friendly "configure to enable" prompt instead of +# crashing. Embeddings + chat still route through InsForge AI without +# this key. +OPENAI_API_KEY= diff --git a/ai-pdf-chatbot/README.md b/ai-pdf-chatbot/README.md index 3d6416d..df6e98a 100644 --- a/ai-pdf-chatbot/README.md +++ b/ai-pdf-chatbot/README.md @@ -1,9 +1,9 @@ -

InsForge AI PDF Chatbot

+

InsForge AI Notebook

- Upload PDFs and chat with them. RAG with citations powered by InsForge pgvector + InsForge AI. + Open-source NotebookLM for students. Workspaces of PDFs come with auto-generated mindmaps, spaced-repetition flashcards, two-host podcast summaries, and RAG chat that highlights cited passages inside the source PDF.

@@ -17,18 +17,44 @@ ## Features +NotebookLM-style learning features: + +- **Workspaces.** Group related PDFs, chats, mindmaps, and flashcards into one notebook-style container. Cross-document RAG inside a workspace. +- **Inline PDF viewer.** Click a `[n]` citation, the source PDF slides in from the right at the cited page with the passage highlighted. +- **Mindmap.** Workspace-wide concept tree, generated from PDF summaries and rendered as an interactive Markmap. +- **Spaced-repetition flashcards.** Generated from the PDF content, dropped into a per-workspace SRS queue (SM-2 lite, three review grades). +- **Audio Overview.** Two-host podcast summary, opt-in via `OPENAI_API_KEY`. Prompt adapted from [open-notebooklm](https://github.com/gabrielchua/open-notebooklm). + +Production-grade plumbing under the hood: + - Next.js 16 App Router - PDF upload (≤ 10 MB) with server-side extraction via `pdfjs-dist` - Vector search on InsForge pgvector (`vector(1536)` + ivfflat cosine) -- Streaming chat with bracketed `[n]` source citations -- **Better Auth** for email + password sign-in — user/session tables live in your InsForge Postgres -- HS256 bridge JWT from BA → InsForge: RLS reads `requesting_user_id()` so every user only sees their own documents and chats +- Streaming RAG chat with bracketed `[n]` source citations (NDJSON) +- **Better Auth** for email + password sign-in. User/session tables live in your InsForge Postgres. +- HS256 bridge JWT from BA → InsForge: RLS reads `requesting_user_id()` so every user only sees their own data. - shadcn/ui + Tailwind 4 design tokens ## Demo [aipdfchat.insforge.site](https://aipdfchat.insforge.site) — sign up with any email, upload a PDF, ask away. +## Why this template + +| | NotebookLM | ChatPDF | LangChain RAG demo | **This template** | +|---|---|---|---|---| +| Workspaces + cross-PDF RAG | yes | no | no | yes | +| Inline PDF passage highlight | partial | no | no | yes | +| Mindmap from PDFs | yes | no | no | yes | +| Spaced-repetition flashcards | no | partial | no | yes | +| Two-host audio podcast | yes | no | no | yes (opt-in) | +| Self-hosted, your data | no | no | yes | yes | +| Open source, swap any model | no | no | yes | yes | +| Multi-tenant out of the box | no | yes | no | yes (Better Auth + RLS) | +| Ready to ship a real product | no | yes | no | yes | + +If you want to fork a NotebookLM-style study tool and run it on infrastructure you control, this is the closest open-source starting point. + ## Quick Launch Fastest path: @@ -93,6 +119,7 @@ npm install - **pgvector extension** — the migration runs `create extension if not exists vector;` against the `public` schema. Supported on any standard InsForge project. - **Node 18+** for `node:test` and the ESM `pdfjs-dist` build. - **InsForge SMTP** configured if you want password resets to send mail. Cloud projects: configured automatically. Self-hosted: `PUT /api/auth/smtp-config`. +- **OpenAI API key (optional)** — required only for the Audio Overview tab. Set `OPENAI_API_KEY` in `.env.local`; without it the workspace audio tab shows a friendly "configure to enable" prompt and everything else still works. ## Architecture @@ -128,6 +155,25 @@ Chat ──► /api/chat (NDJSON stream) ──► embed(question) ──► mat persist assistant message with citations payload ``` +## Audio Overview + +The Audio tab generates a NotebookLM-style two-host podcast summary of every PDF in a workspace. + +**How it works:** + +1. `lib/ai/audio-script.ts` calls InsForge AI (chat completion) with a producer-style prompt adapted from [open-notebooklm](https://github.com/gabrielchua/open-notebooklm). Sarah hosts and interviews Mike, a subject-matter expert. The asymmetric framing prevents the "two co-hosts agreeing with each other" failure mode. +2. `lib/audio/tts.ts` synthesizes each turn through OpenAI TTS (`gpt-4o-mini-tts`, `nova` voice for Sarah, `onyx` for Mike), 4 turns in parallel. +3. mp3 frames are concatenated naively (good enough for a study tool; swap in `ffmpeg.wasm` if you need gapless playback). +4. The final mp3 lands in the `audio-overviews` storage bucket (public read), and `workspaces.audio_url` + `audio_script` are cached so the tab renders instantly on revisit. + +**Cost:** about $0.006 per regeneration (700 chars TTS at `tts-1` pricing). The chat completion that drafts the script runs on InsForge AI and counts against your existing AI quota. + +**Without `OPENAI_API_KEY`:** the Audio tab shows a friendly "configure to enable" prompt. Every other feature works. + +**Want a different TTS?** Edit `lib/audio/tts.ts`. ElevenLabs and Cartesia have OpenAI-compatible HTTP endpoints. The voice-per-speaker mapping (`VOICE_BY_SPEAKER`) is the only thing to swap. + +**Want a better script?** Set `UTILITY_MODEL` in `lib/ai/constants.ts` from `gpt-4o-mini` to `gpt-4o`. Cost goes from $0.006 to ~$0.04 per generation, but you start getting analogies, anecdotes, and "aha moments" the mini model can't produce. + ## Customizing - **Switch embedding model:** edit `lib/ai/constants.ts` (`EMBEDDING_MODEL`, `EMBEDDING_DIMENSIONS`). If the dimension changes, also `ALTER TABLE document_chunks ALTER COLUMN embedding TYPE vector(N)` and re-ingest existing documents. diff --git a/ai-pdf-chatbot/app/api/chat/route.ts b/ai-pdf-chatbot/app/api/chat/route.ts index d20aea4..6809b09 100644 --- a/ai-pdf-chatbot/app/api/chat/route.ts +++ b/ai-pdf-chatbot/app/api/chat/route.ts @@ -13,6 +13,9 @@ type Body = { chatId?: string; input: string; documentIds?: string[]; + // If set when chatId is absent, the new chat is created under that + // workspace and RAG retrieval is scoped to that workspace's documents. + workspaceId?: string | null; }; function extractDeltaText(content: unknown): string { @@ -44,20 +47,32 @@ export async function POST(req: Request) { const client = createInsforgeServerClient({ accessToken: auth.accessToken }); let chatId = body.chatId; + let workspaceId: string | null = body.workspaceId ?? null; if (!chatId) { const ins = await client.database .from('chat_sessions') .insert({ user_id: ownerId, + workspace_id: workspaceId, title: body.input.trim().slice(0, 60) || 'New chat', document_ids: body.documentIds ?? [], }) - .select('id') + .select('id, workspace_id') .single(); if (ins.error || !ins.data) { return NextResponse.json({ error: ins.error?.message ?? 'Failed to create chat' }, { status: 500 }); } - chatId = (ins.data as { id: string }).id; + chatId = (ins.data as { id: string; workspace_id: string | null }).id; + workspaceId = (ins.data as { id: string; workspace_id: string | null }).workspace_id; + } else { + // For existing chats, read the workspace_id off the row so a stale + // client-side cache can't widen retrieval scope. + const lookup = await client.database + .from('chat_sessions') + .select('workspace_id') + .eq('id', chatId) + .single(); + workspaceId = (lookup.data as { workspace_id: string | null } | null)?.workspace_id ?? null; } const lastMsg = await client.database @@ -89,7 +104,7 @@ export async function POST(req: Request) { async start(controller) { const send = (obj: unknown) => controller.enqueue(encodeNdjson(obj)); try { - const chunks = await retrieveForQuestion(client, ownerId, inputText, docIds); + const chunks = await retrieveForQuestion(client, ownerId, inputText, docIds, workspaceId); const citations = toCitations(chunks); send({ type: 'chat', chatId: resolvedChatId }); send({ type: 'citations', data: citations }); diff --git a/ai-pdf-chatbot/app/api/chats/route.ts b/ai-pdf-chatbot/app/api/chats/route.ts index ca2e05b..3ae4d70 100644 --- a/ai-pdf-chatbot/app/api/chats/route.ts +++ b/ai-pdf-chatbot/app/api/chats/route.ts @@ -5,17 +5,30 @@ import { getCurrentAuthState } from '@/lib/auth-state'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -export async function GET() { +export async function GET(req: Request) { const auth = await getCurrentAuthState(); if (!auth.viewer.isAuthenticated || !auth.accessToken) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } + + // Same workspace filter shape as the documents list: ?workspace= or + // ?workspace=unsorted. No param returns every chat the user owns. + const url = new URL(req.url); + const workspace = url.searchParams.get('workspace'); + const client = createInsforgeServerClient({ accessToken: auth.accessToken }); - const { data, error } = await client.database + let query = client.database .from('chat_sessions') - .select('id, title, document_ids, created_at, last_message_at') + .select('id, workspace_id, title, document_ids, created_at, last_message_at') .order('last_message_at', { ascending: false }); + if (workspace === 'unsorted') { + query = query.is('workspace_id', null); + } else if (workspace) { + query = query.eq('workspace_id', workspace); + } + + const { data, error } = await query; if (error) return NextResponse.json({ error: error.message }, { status: 500 }); return NextResponse.json({ chats: data ?? [] }); } @@ -26,13 +39,18 @@ export async function POST(req: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } - const body = (await req.json().catch(() => ({}))) as { title?: string; documentIds?: string[] }; + const body = (await req.json().catch(() => ({}))) as { + title?: string; + documentIds?: string[]; + workspaceId?: string | null; + }; const client = createInsforgeServerClient({ accessToken: auth.accessToken }); const { data, error } = await client.database .from('chat_sessions') .insert({ user_id: auth.viewer.id, + workspace_id: body.workspaceId ?? null, title: body.title?.trim() || 'New chat', document_ids: body.documentIds ?? [], }) diff --git a/ai-pdf-chatbot/app/api/documents/[id]/flashcards/route.ts b/ai-pdf-chatbot/app/api/documents/[id]/flashcards/route.ts index 7533f6e..209e47a 100644 --- a/ai-pdf-chatbot/app/api/documents/[id]/flashcards/route.ts +++ b/ai-pdf-chatbot/app/api/documents/[id]/flashcards/route.ts @@ -41,7 +41,7 @@ export async function POST(_req: Request, { params }: { params: Promise<{ id: st const docRes = await client.database .from('documents') - .select('id, storage_bucket, storage_key, file_name, status') + .select('id, workspace_id, storage_bucket, storage_key, file_name, status') .eq('id', id) .single(); @@ -50,6 +50,7 @@ export async function POST(_req: Request, { params }: { params: Promise<{ id: st } const doc = docRes.data as { id: string; + workspace_id: string | null; storage_bucket: string; storage_key: string; file_name: string; @@ -80,10 +81,14 @@ export async function POST(_req: Request, { params }: { params: Promise<{ id: st const rows = cards.map((c, i) => ({ document_id: id, + workspace_id: doc.workspace_id, user_id: auth.viewer.id, question: c.question, answer: c.answer, sort_order: i, + // Fresh cards land in the SRS queue immediately; defaults on the + // ease/interval/reps columns take care of the rest. + due_at: new Date().toISOString(), })); const ins = await client.database diff --git a/ai-pdf-chatbot/app/api/documents/[id]/route.ts b/ai-pdf-chatbot/app/api/documents/[id]/route.ts index 2a045d5..4eede6f 100644 --- a/ai-pdf-chatbot/app/api/documents/[id]/route.ts +++ b/ai-pdf-chatbot/app/api/documents/[id]/route.ts @@ -5,6 +5,43 @@ import { getCurrentAuthState } from '@/lib/auth-state'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; +export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const auth = await getCurrentAuthState(); + if (!auth.viewer.isAuthenticated || !auth.accessToken) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = (await req.json().catch(() => ({}))) as { workspace_id?: string | null }; + // Only workspace_id is mutable via this route today. Adding other fields + // here keeps the surface flat instead of one route per column. + if (!('workspace_id' in body)) { + return NextResponse.json({ error: 'No mutable fields provided' }, { status: 400 }); + } + + const client = createInsforgeServerClient({ accessToken: auth.accessToken }); + const { error } = await client.database + .from('documents') + .update({ workspace_id: body.workspace_id ?? null, updated_at: new Date().toISOString() }) + .eq('id', id); + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }); + + // Cascade the workspace_id to all flashcards generated from this doc so + // workspace-wide review queues see them. Documents that don't have + // flashcards yet just no-op. Failures here would silently leave the + // doc and its flashcards out of sync, so surface them. + const cascade = await client.database + .from('document_flashcards') + .update({ workspace_id: body.workspace_id ?? null }) + .eq('document_id', id); + if (cascade.error) { + return NextResponse.json({ error: cascade.error.message }, { status: 500 }); + } + + return NextResponse.json({ ok: true }); +} + export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; const auth = await getCurrentAuthState(); diff --git a/ai-pdf-chatbot/app/api/documents/route.ts b/ai-pdf-chatbot/app/api/documents/route.ts index 4a901d4..b1d1fb8 100644 --- a/ai-pdf-chatbot/app/api/documents/route.ts +++ b/ai-pdf-chatbot/app/api/documents/route.ts @@ -5,18 +5,31 @@ import { getCurrentAuthState } from '@/lib/auth-state'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -export async function GET() { +export async function GET(req: Request) { const auth = await getCurrentAuthState(); if (!auth.viewer.isAuthenticated || !auth.accessToken) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } + // ?workspace= scopes the list to one workspace. + // ?workspace=unsorted returns documents with workspace_id IS NULL. + // No param: returns all of the user's documents. + const url = new URL(req.url); + const workspace = url.searchParams.get('workspace'); + const client = createInsforgeServerClient({ accessToken: auth.accessToken }); - const { data, error } = await client.database + let query = client.database .from('documents') - .select('id, file_name, file_size, mime_type, status, error, page_count, summary, suggested_questions, created_at') + .select('id, workspace_id, file_name, file_size, mime_type, status, error, page_count, summary, suggested_questions, created_at') .order('created_at', { ascending: false }); + if (workspace === 'unsorted') { + query = query.is('workspace_id', null); + } else if (workspace) { + query = query.eq('workspace_id', workspace); + } + + const { data, error } = await query; if (error) return NextResponse.json({ error: error.message }, { status: 500 }); return NextResponse.json({ documents: data ?? [] }); } diff --git a/ai-pdf-chatbot/app/api/documents/upload/route.ts b/ai-pdf-chatbot/app/api/documents/upload/route.ts index 8c15914..9fc2fa3 100644 --- a/ai-pdf-chatbot/app/api/documents/upload/route.ts +++ b/ai-pdf-chatbot/app/api/documents/upload/route.ts @@ -17,6 +17,18 @@ export async function POST(req: Request) { const formData = await req.formData(); const file = formData.get('file'); + const rawWorkspaceId = formData.get('workspaceId'); + // Validate the client-supplied workspace id format up-front so a bad + // value returns a 400 instead of leaking a 500 from a downstream + // `invalid input syntax for type uuid` Postgres error. + const UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; + let workspaceId: string | null = null; + if (typeof rawWorkspaceId === 'string' && rawWorkspaceId) { + if (!UUID_RE.test(rawWorkspaceId)) { + return NextResponse.json({ error: 'workspaceId must be a UUID' }, { status: 400 }); + } + workspaceId = rawWorkspaceId; + } if (!(file instanceof File)) { return NextResponse.json({ error: 'No file provided' }, { status: 400 }); } @@ -33,6 +45,7 @@ export async function POST(req: Request) { .from('documents') .insert({ user_id: auth.viewer.id, + workspace_id: workspaceId, file_name: file.name, file_size: file.size, mime_type: file.type, diff --git a/ai-pdf-chatbot/app/api/flashcards/[id]/grade/route.ts b/ai-pdf-chatbot/app/api/flashcards/[id]/grade/route.ts new file mode 100644 index 0000000..2b7397f --- /dev/null +++ b/ai-pdf-chatbot/app/api/flashcards/[id]/grade/route.ts @@ -0,0 +1,64 @@ +import { NextResponse } from 'next/server'; +import { createInsforgeServerClient } from '@/lib/insforge'; +import { getCurrentAuthState } from '@/lib/auth-state'; +import { schedule, type Grade } from '@/lib/srs/schedule'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +const VALID: Grade[] = ['again', 'hard', 'good', 'easy']; + +// Applies an SRS grade to a flashcard and stores the new state. The +// algorithm is a pure function in lib/srs/schedule.ts so the same logic +// is testable in node:test and unchanged by HTTP plumbing. +export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const auth = await getCurrentAuthState(); + if (!auth.viewer.isAuthenticated || !auth.accessToken) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = (await req.json().catch(() => ({}))) as { grade?: string }; + const grade = body.grade as Grade | undefined; + if (!grade || !VALID.includes(grade)) { + return NextResponse.json({ error: 'Invalid grade' }, { status: 400 }); + } + + const client = createInsforgeServerClient({ accessToken: auth.accessToken }); + + const cardRes = await client.database + .from('document_flashcards') + .select('id, ease, interval_days, reps') + .eq('id', id) + .single(); + if (cardRes.error || !cardRes.data) { + return NextResponse.json({ error: 'Card not found' }, { status: 404 }); + } + const card = cardRes.data as { id: string; ease: number; interval_days: number; reps: number }; + + const next = schedule( + { ease: card.ease, interval_days: card.interval_days, reps: card.reps }, + grade, + ); + + const upd = await client.database + .from('document_flashcards') + .update({ + ease: next.ease, + interval_days: next.interval_days, + reps: next.reps, + last_grade: next.last_grade, + due_at: next.due_at.toISOString(), + }) + .eq('id', id); + + if (upd.error) return NextResponse.json({ error: upd.error.message }, { status: 500 }); + return NextResponse.json({ + ok: true, + next: { + ease: next.ease, + interval_days: next.interval_days, + due_at: next.due_at.toISOString(), + }, + }); +} diff --git a/ai-pdf-chatbot/app/api/workspaces/[id]/audio/route.ts b/ai-pdf-chatbot/app/api/workspaces/[id]/audio/route.ts new file mode 100644 index 0000000..6e2e432 --- /dev/null +++ b/ai-pdf-chatbot/app/api/workspaces/[id]/audio/route.ts @@ -0,0 +1,102 @@ +import { NextResponse } from 'next/server'; +import { createInsforgeServerClient } from '@/lib/insforge'; +import { getCurrentAuthState } from '@/lib/auth-state'; +import { generateAudioScript } from '@/lib/ai/audio-script'; +import { synthesizeScript } from '@/lib/audio/tts'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; +// TTS for ~10 turns easily runs 30-60s in series. Lift the timeout off +// the default 10s edge cap so the route can finish before Vercel kills +// it on production. +export const maxDuration = 300; + +const BUCKET = 'audio-overviews'; + +export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const auth = await getCurrentAuthState(); + if (!auth.viewer.isAuthenticated || !auth.viewer.id || !auth.accessToken) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Audio Overview is opt-in: users wire their own OpenAI key. We + // surface a clean 412 instead of throwing so the UI can show a + // friendly "Configure OPENAI_API_KEY" prompt. + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + return NextResponse.json( + { + error: + 'Audio Overview requires an OPENAI_API_KEY environment variable. See the README for setup.', + }, + { status: 412 }, + ); + } + + const client = createInsforgeServerClient({ accessToken: auth.accessToken }); + + const wsRes = await client.database + .from('workspaces') + .select('id, name') + .eq('id', id) + .single(); + if (wsRes.error || !wsRes.data) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }); + } + const ws = wsRes.data as { id: string; name: string }; + + const docsRes = await client.database + .from('documents') + .select('file_name, summary') + .eq('workspace_id', id) + .eq('status', 'ready'); + if (docsRes.error) { + return NextResponse.json({ error: docsRes.error.message }, { status: 500 }); + } + const docs = (docsRes.data ?? []) as Array<{ file_name: string; summary: string | null }>; + if (docs.length === 0) { + return NextResponse.json( + { error: 'Add at least one ready document before generating an audio overview.' }, + { status: 409 }, + ); + } + + const script = await generateAudioScript(client, ws.name, docs); + if (script.length < 4) { + return NextResponse.json({ error: 'Could not produce a usable script' }, { status: 500 }); + } + + const audioBuffer = await synthesizeScript(script, apiKey); + + // Versioned key so the public URL doesn't get stale-cached on + // regenerate. The previous audio file lingers but the DB only points + // at the latest one. + const key = `${auth.viewer.id}/${id}/${Date.now()}.mp3`; + const upload = await client.storage + .from(BUCKET) + .upload(key, new Blob([new Uint8Array(audioBuffer)], { type: 'audio/mpeg' })); + if (upload.error) { + return NextResponse.json({ error: upload.error.message ?? 'Upload failed' }, { status: 500 }); + } + + const url = client.storage.from(BUCKET).getPublicUrl(key); + const audioUrl = typeof url === 'string' ? url : (url as { data?: { publicUrl?: string } })?.data?.publicUrl; + if (!audioUrl) { + return NextResponse.json({ error: 'Could not resolve audio URL' }, { status: 500 }); + } + + const now = new Date().toISOString(); + const upd = await client.database + .from('workspaces') + .update({ + audio_url: audioUrl, + audio_script: script, + audio_generated_at: now, + updated_at: now, + }) + .eq('id', id); + if (upd.error) return NextResponse.json({ error: upd.error.message }, { status: 500 }); + + return NextResponse.json({ audio_url: audioUrl, script, generated_at: now }); +} diff --git a/ai-pdf-chatbot/app/api/workspaces/[id]/flashcards/due/route.ts b/ai-pdf-chatbot/app/api/workspaces/[id]/flashcards/due/route.ts new file mode 100644 index 0000000..798f13f --- /dev/null +++ b/ai-pdf-chatbot/app/api/workspaces/[id]/flashcards/due/route.ts @@ -0,0 +1,63 @@ +import { NextResponse } from 'next/server'; +import { createInsforgeServerClient } from '@/lib/insforge'; +import { getCurrentAuthState } from '@/lib/auth-state'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +// Returns up to 50 due cards in this workspace, joined with their source +// document file name so the review UI can show provenance under each +// question. Ordered by due_at so the most overdue card surfaces first. +export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const auth = await getCurrentAuthState(); + if (!auth.viewer.isAuthenticated || !auth.accessToken) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const client = createInsforgeServerClient({ accessToken: auth.accessToken }); + + const cardsRes = await client.database + .from('document_flashcards') + .select('id, document_id, question, answer, ease, interval_days, reps, due_at') + .eq('workspace_id', id) + .lte('due_at', new Date().toISOString()) + .order('due_at', { ascending: true }) + .limit(50); + + if (cardsRes.error) { + return NextResponse.json({ error: cardsRes.error.message }, { status: 500 }); + } + + const cards = (cardsRes.data ?? []) as Array<{ + id: string; + document_id: string; + question: string; + answer: string; + ease: number; + interval_days: number; + reps: number; + due_at: string; + }>; + + // Resolve file names in one query rather than per-card. RLS keeps + // anything outside this user's documents out by default. + const docIds = Array.from(new Set(cards.map((c) => c.document_id))); + let nameById = new Map(); + if (docIds.length > 0) { + const docsRes = await client.database + .from('documents') + .select('id, file_name') + .in('id', docIds); + if (docsRes.error) { + return NextResponse.json({ error: docsRes.error.message }, { status: 500 }); + } + nameById = new Map( + ((docsRes.data ?? []) as Array<{ id: string; file_name: string }>).map((d) => [d.id, d.file_name]), + ); + } + + return NextResponse.json({ + cards: cards.map((c) => ({ ...c, file_name: nameById.get(c.document_id) ?? 'document.pdf' })), + }); +} diff --git a/ai-pdf-chatbot/app/api/workspaces/[id]/mindmap/route.ts b/ai-pdf-chatbot/app/api/workspaces/[id]/mindmap/route.ts new file mode 100644 index 0000000..d56f81b --- /dev/null +++ b/ai-pdf-chatbot/app/api/workspaces/[id]/mindmap/route.ts @@ -0,0 +1,94 @@ +import { NextResponse } from 'next/server'; +import { createInsforgeServerClient } from '@/lib/insforge'; +import { getCurrentAuthState } from '@/lib/auth-state'; +import { UTILITY_MODEL } from '@/lib/ai/constants'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +// Hard cap on how much of each document summary we feed the LLM. Mindmap +// quality saturates quickly past a few sentences per doc; raising this +// just burns tokens. +const SUMMARY_CHAR_BUDGET = 600; + +function buildPrompt(workspaceName: string, docs: Array<{ file_name: string; summary: string | null }>): string { + const docBlock = docs + .map((d, i) => `${i + 1}. ${d.file_name}\n ${(d.summary ?? '').slice(0, SUMMARY_CHAR_BUDGET)}`) + .join('\n\n'); + + return `You build mindmaps from study materials. Below are PDF summaries grouped under a workspace named "${workspaceName}". Produce a Markmap-compatible mindmap as Markdown: + +- Use "#" for the top-level node (the workspace topic). +- Use "##", "###", "####" for nested topics. +- 3 to 5 levels deep. +- 10 to 30 leaf nodes total. +- Group related ideas across documents under shared parent nodes; don't just list documents. +- Concise leaves: short noun phrases, not full sentences. +- Output ONLY the Markdown. No commentary, no code fences. + +PDF summaries: +${docBlock}`; +} + +// POST regenerates the mindmap and caches the result on workspaces. +// GET returns the cached markdown (or null) so the client can decide +// whether to call POST. Keeping write separate from read makes the +// "regenerate" affordance obvious. +export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const auth = await getCurrentAuthState(); + if (!auth.viewer.isAuthenticated || !auth.accessToken) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const client = createInsforgeServerClient({ accessToken: auth.accessToken }); + + const wsRes = await client.database + .from('workspaces') + .select('id, name') + .eq('id', id) + .single(); + if (wsRes.error || !wsRes.data) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }); + } + const ws = wsRes.data as { id: string; name: string }; + + const docsRes = await client.database + .from('documents') + .select('file_name, summary') + .eq('workspace_id', id) + .eq('status', 'ready'); + if (docsRes.error) { + return NextResponse.json({ error: docsRes.error.message }, { status: 500 }); + } + const docs = (docsRes.data ?? []) as Array<{ file_name: string; summary: string | null }>; + if (docs.length === 0) { + return NextResponse.json( + { error: 'Add at least one ready document to this workspace before generating a mindmap.' }, + { status: 409 }, + ); + } + + const completion = (await client.ai.chat.completions.create({ + model: UTILITY_MODEL, + messages: [{ role: 'user', content: buildPrompt(ws.name, docs) }], + })) as { choices: Array<{ message: { content: string } }> }; + + const raw = completion.choices?.[0]?.message?.content ?? ''; + const markdown = raw + .replace(/```[a-zA-Z]*\n?/g, '') + .replace(/```/g, '') + .trim(); + if (!markdown) { + return NextResponse.json({ error: 'Empty mindmap response' }, { status: 500 }); + } + + const now = new Date().toISOString(); + const upd = await client.database + .from('workspaces') + .update({ mindmap_markdown: markdown, mindmap_generated_at: now, updated_at: now }) + .eq('id', id); + if (upd.error) return NextResponse.json({ error: upd.error.message }, { status: 500 }); + + return NextResponse.json({ markdown, generated_at: now }); +} diff --git a/ai-pdf-chatbot/app/api/workspaces/[id]/route.ts b/ai-pdf-chatbot/app/api/workspaces/[id]/route.ts new file mode 100644 index 0000000..7b23656 --- /dev/null +++ b/ai-pdf-chatbot/app/api/workspaces/[id]/route.ts @@ -0,0 +1,117 @@ +import { NextResponse } from 'next/server'; +import { createInsforgeServerClient } from '@/lib/insforge'; +import { getCurrentAuthState } from '@/lib/auth-state'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const auth = await getCurrentAuthState(); + if (!auth.viewer.isAuthenticated || !auth.accessToken) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const client = createInsforgeServerClient({ accessToken: auth.accessToken }); + + const wsRes = await client.database + .from('workspaces') + .select('id, name, description, mindmap_markdown, mindmap_generated_at, audio_url, audio_script, audio_generated_at, created_at, updated_at') + .eq('id', id) + .single(); + + if (wsRes.error || !wsRes.data) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + // Fetch related counts in parallel. RLS keeps everything scoped to the + // current user automatically — the workspace SELECT above already proved + // ownership. + const [docsRes, chatsRes, dueRes] = await Promise.all([ + client.database.from('documents').select('id', { count: 'exact', head: true }).eq('workspace_id', id), + client.database.from('chat_sessions').select('id', { count: 'exact', head: true }).eq('workspace_id', id), + client.database + .from('document_flashcards') + .select('id', { count: 'exact', head: true }) + .eq('workspace_id', id) + .lte('due_at', new Date().toISOString()), + ]); + + const firstError = docsRes.error ?? chatsRes.error ?? dueRes.error; + if (firstError) { + return NextResponse.json({ error: firstError.message }, { status: 500 }); + } + + return NextResponse.json({ + workspace: wsRes.data, + counts: { + documents: docsRes.count ?? 0, + chats: chatsRes.count ?? 0, + due_flashcards: dueRes.count ?? 0, + }, + }); +} + +export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const auth = await getCurrentAuthState(); + if (!auth.viewer.isAuthenticated || !auth.accessToken) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = (await req.json().catch(() => ({}))) as { name?: string; description?: string }; + const patch: Record = { updated_at: new Date().toISOString() }; + if (typeof body.name === 'string') { + const trimmed = body.name.trim(); + if (!trimmed) return NextResponse.json({ error: 'Name cannot be empty' }, { status: 400 }); + patch.name = trimmed; + } + if (typeof body.description === 'string') { + patch.description = body.description.trim() || null; + } + + const client = createInsforgeServerClient({ accessToken: auth.accessToken }); + // Use `select` with single-row coerce so we can tell apart "0 rows + // matched" (404, e.g. someone else's workspace under RLS) from a + // successful update. Without this PATCH was returning ok for any id. + const { data, error } = await client.database + .from('workspaces') + .update(patch) + .eq('id', id) + .select('id'); + if (error) return NextResponse.json({ error: error.message }, { status: 500 }); + if (!data || (Array.isArray(data) && data.length === 0)) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }); + } + return NextResponse.json({ ok: true }); +} + +export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const auth = await getCurrentAuthState(); + if (!auth.viewer.isAuthenticated || !auth.accessToken) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const client = createInsforgeServerClient({ accessToken: auth.accessToken }); + // Documents/chats keep existing rows alive by setting workspace_id to + // null via the FK ON DELETE SET NULL. The workspace's own cached audio + // file in storage still needs an explicit cleanup pass. + const wsRes = await client.database + .from('workspaces') + .select('audio_url') + .eq('id', id) + .single(); + const audioUrl = (wsRes.data as { audio_url: string | null } | null)?.audio_url ?? null; + if (audioUrl) { + const match = audioUrl.match(/audio-overviews\/(.+?)(?:\?|$)/); + const key = match?.[1]; + if (key) { + await client.storage.from('audio-overviews').remove(decodeURIComponent(key)).catch(() => undefined); + } + } + + const { error } = await client.database.from('workspaces').delete().eq('id', id); + if (error) return NextResponse.json({ error: error.message }, { status: 500 }); + return NextResponse.json({ ok: true }); +} diff --git a/ai-pdf-chatbot/app/api/workspaces/route.ts b/ai-pdf-chatbot/app/api/workspaces/route.ts new file mode 100644 index 0000000..4c987c5 --- /dev/null +++ b/ai-pdf-chatbot/app/api/workspaces/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from 'next/server'; +import { createInsforgeServerClient } from '@/lib/insforge'; +import { getCurrentAuthState } from '@/lib/auth-state'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function GET() { + const auth = await getCurrentAuthState(); + if (!auth.viewer.isAuthenticated || !auth.accessToken) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const client = createInsforgeServerClient({ accessToken: auth.accessToken }); + const { data, error } = await client.database + .from('workspaces') + .select('id, name, description, mindmap_generated_at, audio_url, audio_generated_at, created_at, updated_at') + .order('updated_at', { ascending: false }); + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }); + return NextResponse.json({ workspaces: data ?? [] }); +} + +export async function POST(req: Request) { + const auth = await getCurrentAuthState(); + if (!auth.viewer.isAuthenticated || !auth.viewer.id || !auth.accessToken) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = (await req.json().catch(() => ({}))) as { name?: unknown; description?: unknown }; + // .trim() throws on non-strings, so check types before reaching for it. + const name = typeof body.name === 'string' ? body.name.trim() : ''; + if (!name) { + return NextResponse.json({ error: 'Name is required' }, { status: 400 }); + } + const description = + typeof body.description === 'string' ? body.description.trim() || null : null; + + const client = createInsforgeServerClient({ accessToken: auth.accessToken }); + const { data, error } = await client.database + .from('workspaces') + .insert({ + user_id: auth.viewer.id, + name, + description, + }) + .select() + .single(); + + if (error || !data) return NextResponse.json({ error: error?.message ?? 'Insert failed' }, { status: 500 }); + return NextResponse.json({ workspace: data }); +} diff --git a/ai-pdf-chatbot/app/chat/[chatId]/page.tsx b/ai-pdf-chatbot/app/chat/[chatId]/page.tsx index ebe2f9e..c845527 100644 --- a/ai-pdf-chatbot/app/chat/[chatId]/page.tsx +++ b/ai-pdf-chatbot/app/chat/[chatId]/page.tsx @@ -2,12 +2,11 @@ import { use, useCallback, useEffect, useState } from 'react'; import { toast } from 'sonner'; -import { ChatShell } from '@/components/chat-shell'; import { ChatInput } from '@/components/chat-input'; import { ChatMessage } from '@/components/chat-message'; -import { CitationRail } from '@/components/citation-rail'; import { ShareChatButton } from '@/components/share-chat-button'; import { useChatStream } from '@/lib/stream/use-chat-stream'; +import { useSetRailCitations } from '@/lib/chat/rail-context'; import type { ChatMessageRow } from '@/lib/types'; type Citation = ChatMessageRow['citations'][number]; @@ -20,6 +19,7 @@ export default function ChatDetailPage({ params }: { params: Promise<{ chatId: s const [chatTitle, setChatTitle] = useState(''); const [shareToken, setShareToken] = useState(null); const { state, send } = useChatStream(); + const setRailCitations = useSetRailCitations(); const load = useCallback(async () => { const res = await fetch(`/api/chats/${chatId}`); @@ -59,9 +59,16 @@ export default function ChatDetailPage({ params }: { params: Promise<{ chatId: s const streamingText = state.phase === 'streaming' ? state.text : ''; const streamingCitations = state.phase === 'streaming' ? state.citations : activeCitations; + // Sync the right-rail with whatever this page considers the live + // citation set. The layout-hosted ChatShell renders the rail from + // this context, so we don't return one here. + useEffect(() => { + setRailCitations(streamingCitations); + }, [setRailCitations, streamingCitations]); + return ( - }> -

+ <> +

{chatTitle || 'Chat'}

- + ); } diff --git a/ai-pdf-chatbot/app/chat/layout.tsx b/ai-pdf-chatbot/app/chat/layout.tsx new file mode 100644 index 0000000..b65e33a --- /dev/null +++ b/ai-pdf-chatbot/app/chat/layout.tsx @@ -0,0 +1,17 @@ +'use client'; + +import { ChatShell } from '@/components/chat-shell'; +import { RailProvider } from '@/lib/chat/rail-context'; + +// Hoists ChatShell out of the per-page render so that navigating between +// /chat and /chat/[id] does NOT unmount + remount the sidebar (which +// was the cause of the recent-chats list visibly flashing on each +// click). The page itself only renders the main content; the citation +// rail is pushed up through RailContext. +export default function ChatLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/ai-pdf-chatbot/app/chat/page.tsx b/ai-pdf-chatbot/app/chat/page.tsx index 7d24f38..1c3ad9b 100644 --- a/ai-pdf-chatbot/app/chat/page.tsx +++ b/ai-pdf-chatbot/app/chat/page.tsx @@ -1,14 +1,13 @@ 'use client'; -import { useEffect, useState } from 'react'; -import { useRouter } from 'next/navigation'; +import { Suspense, useEffect, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; import { Sparkles } from 'lucide-react'; import { toast } from 'sonner'; -import { ChatShell } from '@/components/chat-shell'; import { ChatInput } from '@/components/chat-input'; import { ChatMessage } from '@/components/chat-message'; -import { CitationRail } from '@/components/citation-rail'; import { useChatStream } from '@/lib/stream/use-chat-stream'; +import { useSetRailCitations } from '@/lib/chat/rail-context'; import type { ChatMessageRow } from '@/lib/types'; type Citation = ChatMessageRow['citations'][number]; @@ -21,20 +20,37 @@ type DocSummaryRow = { suggested_questions: string[]; }; +// useSearchParams() requires a Suspense boundary in Next 16 so the page +// can pre-render. We wrap the inner component instead of marking the +// whole page dynamic. export default function ChatHomePage() { + return ( + + + + ); +} + +function ChatHomePageInner() { const router = useRouter(); + const searchParams = useSearchParams(); + // `/chat?workspace=` scopes new chats to that workspace. Empty when + // the user lands on /chat directly (Unsorted chats). + const workspaceId = searchParams?.get('workspace') ?? null; const { state, send } = useChatStream(); const [pendingInput, setPendingInput] = useState(null); const [citations] = useState([]); const [suggestions, setSuggestions] = useState([]); + const setRailCitations = useSetRailCitations(); // Pull a few suggested questions from the most recently uploaded, ready - // document so a brand-new chat has a one-tap starting point. Falls back - // to the empty state if no doc is ready yet. + // document. When a workspace is set, only consider docs in that + // workspace so suggestions stay relevant. useEffect(() => { let cancelled = false; void (async () => { - const res = await fetch('/api/documents'); + const url = workspaceId ? `/api/documents?workspace=${workspaceId}` : '/api/documents'; + const res = await fetch(url); if (!res.ok) return; const data = (await res.json()) as { documents: DocSummaryRow[] }; if (cancelled) return; @@ -46,11 +62,11 @@ export default function ChatHomePage() { return () => { cancelled = true; }; - }, []); + }, [workspaceId]); async function handleSubmit(input: string) { setPendingInput(input); - const result = await send({ input }); + const result = await send({ input, workspaceId }); if (!result) { if (state.phase === 'error') toast.error(state.message); return; @@ -61,8 +77,12 @@ export default function ChatHomePage() { const streamingText = state.phase === 'streaming' ? state.text : ''; const streamingCitations = state.phase === 'streaming' ? state.citations : citations; + useEffect(() => { + setRailCitations(streamingCitations); + }, [setRailCitations, streamingCitations]); + return ( - }> + <>
{pendingInput ? (
@@ -104,6 +124,6 @@ export default function ChatHomePage() { )}
- + ); } diff --git a/ai-pdf-chatbot/app/documents/page.tsx b/ai-pdf-chatbot/app/documents/page.tsx index 4f34cd5..ed76d7c 100644 --- a/ai-pdf-chatbot/app/documents/page.tsx +++ b/ai-pdf-chatbot/app/documents/page.tsx @@ -9,6 +9,7 @@ import { DocumentUploader } from '@/components/document-uploader'; type DocRow = { id: string; + workspace_id: string | null; file_name: string; file_size: number; status: 'processing' | 'ready' | 'failed'; diff --git a/ai-pdf-chatbot/app/globals.css b/ai-pdf-chatbot/app/globals.css index 6033898..36ebc63 100644 --- a/ai-pdf-chatbot/app/globals.css +++ b/ai-pdf-chatbot/app/globals.css @@ -120,6 +120,18 @@ } } +/* + * pdfjs text layer highlight for cited chunks. The text layer spans sit + * on top of the rendered PDF and default to transparent backgrounds, so + * a translucent yellow fill is enough to make the cited passage pop + * without obscuring the underlying glyphs. + */ +.rag-citation-highlight { + background-color: rgba(255, 213, 79, 0.55); + border-radius: 2px; + box-shadow: 0 0 0 1px rgba(245, 158, 11, 0.35); +} + @layer utilities { .page-shell { @apply mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8; diff --git a/ai-pdf-chatbot/app/workspaces/[id]/page.tsx b/ai-pdf-chatbot/app/workspaces/[id]/page.tsx new file mode 100644 index 0000000..ad2d13f --- /dev/null +++ b/ai-pdf-chatbot/app/workspaces/[id]/page.tsx @@ -0,0 +1,454 @@ +'use client'; + +import { use, useCallback, useEffect, useMemo, useState } from 'react'; +import dynamic from 'next/dynamic'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { + ArrowLeft, + AudioLines, + Brain, + FileText, + GraduationCap, + Loader2, + MessageSquare, + Pencil, + RefreshCw, + Trash2, +} from 'lucide-react'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui/button'; +import { AudioOverview } from '@/components/audio-overview'; +import { DocumentList } from '@/components/document-list'; +import { DocumentUploader } from '@/components/document-uploader'; +import type { AudioScriptTurn } from '@/lib/types'; +import { cn } from '@/lib/utils'; + +// markmap-view depends on d3 + a browser-only SVG mount, so it can't run +// during the server-side render pass for this client page. +const MindmapView = dynamic( + () => import('@/components/mindmap-view').then((m) => m.MindmapView), + { ssr: false, loading: () =>

Loading mindmap…

}, +); + +type WorkspaceDetail = { + workspace: { + id: string; + name: string; + description: string | null; + mindmap_markdown: string | null; + mindmap_generated_at: string | null; + audio_url: string | null; + audio_script: AudioScriptTurn[] | null; + audio_generated_at: string | null; + }; + counts: { + documents: number; + chats: number; + due_flashcards: number; + }; +}; + +type DocRow = { + id: string; + workspace_id: string | null; + file_name: string; + file_size: number; + status: 'processing' | 'ready' | 'failed'; + error: string | null; + page_count: number | null; + summary: string | null; + created_at: string; +}; + +type ChatRow = { + id: string; + title: string; + last_message_at: string; +}; + +type Tab = 'documents' | 'chats' | 'mindmap' | 'review' | 'audio'; + +const TABS: { id: Tab; label: string; icon: React.ComponentType<{ className?: string }> }[] = [ + { id: 'documents', label: 'Documents', icon: FileText }, + { id: 'chats', label: 'Chats', icon: MessageSquare }, + { id: 'mindmap', label: 'Mindmap', icon: Brain }, + { id: 'review', label: 'Review', icon: GraduationCap }, + { id: 'audio', label: 'Audio', icon: AudioLines }, +]; + +export default function WorkspaceDetailPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = use(params); + const router = useRouter(); + const [detail, setDetail] = useState(null); + const [tab, setTab] = useState('documents'); + const [editing, setEditing] = useState(false); + const [editName, setEditName] = useState(''); + const [editDescription, setEditDescription] = useState(''); + + const refresh = useCallback(async () => { + try { + const res = await fetch(`/api/workspaces/${id}`); + if (res.ok) { + const data = (await res.json()) as WorkspaceDetail; + setDetail(data); + setEditName(data.workspace.name); + setEditDescription(data.workspace.description ?? ''); + return; + } + if (res.status === 404) { + toast.error('Workspace not found'); + router.push('/workspaces'); + return; + } + toast.error('Could not load workspace'); + } catch { + toast.error('Could not load workspace'); + } + }, [id, router]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + async function handleSaveMeta() { + const trimmed = editName.trim(); + if (!trimmed) return; + const res = await fetch(`/api/workspaces/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: trimmed, description: editDescription.trim() }), + }); + if (!res.ok) { + toast.error('Could not save changes'); + return; + } + setEditing(false); + await refresh(); + } + + async function handleDelete() { + if (!confirm('Delete this workspace? Its PDFs and chats will move to Unsorted.')) return; + const res = await fetch(`/api/workspaces/${id}`, { method: 'DELETE' }); + if (!res.ok) { + toast.error('Could not delete workspace'); + return; + } + router.push('/workspaces'); + } + + if (!detail) { + return ( +
+

Loading…

+
+ ); + } + + const ws = detail.workspace; + + return ( +
+
+ +
+ + +
+
+ + {editing ? ( +
+ setEditName(e.target.value)} + className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" + /> +