diff --git a/.env.example b/.env.example index 857ae98..eed2f74 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,13 @@ VISION_MODEL=gemma4:31b-cloud # Paid/credit: glm-5.3-flash:cloud, kimi-k3:cloud. TEXT_MODEL=gpt-oss:120b-cloud +# CHAT MODELS (optional) ------------------------------------------------------- +# The allowlist the chat model picker offers, comma-separated. The server never +# accepts a model name outside this list. The first entry is the default. +# When unset, the configured TEXT_MODEL and VISION_MODEL are used. +# Example: CHAT_MODELS=gemma4:31b-cloud,gpt-oss:120b-cloud +# CHAT_MODELS= + # RATE LIMITS (per client IP, sliding window, in-memory) ---------------------- # Generation decks are expensive, so they are limited tightly by default. # RATE_LIMIT_WINDOW_MS=300000 @@ -49,6 +56,8 @@ TEXT_MODEL=gpt-oss:120b-cloud # RATE_LIMIT_SYNC=60 # Push subscription changes and test sends. # RATE_LIMIT_PUSH=30 +# Chat turns (one model call each; users send them in bursts). +# RATE_LIMIT_CHAT=60 # PUSH REMINDERS (optional) ---------------------------------------------------- # Web Push reminders only appear in the UI when BOTH VAPID keys are set. diff --git a/README.md b/README.md index d72c95c..0f78f7a 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ no database, no data leaving your machine except the requests you send to Ollama - [Install as an app (PWA)](#install-as-an-app-pwa) - [Security notes](#security-notes) - [How it works](#how-it-works) +- [Chat](#chat) - [Project structure](#project-structure) - [API routes](#api-routes) - [Scripts](#scripts) @@ -65,6 +66,11 @@ no database, no data leaving your machine except the requests you send to Ollama study sessions and homework guides; wizard drafts survive a reload or an OS kill; re-tapping the active tab scrolls to the top; an offline pill appears and generation is disabled with a reason until the connection is back. +- **Chat with your notes**: an LLM-style conversation grounded in your decks. Ask from the + Library, a deck's menu, or a result; pick the scope (all notes, folders/decks, or none) and the + model; photos and documents are transcribed first, so any model works. Answers stream with stop, + copy, regenerate and edit-and-resend, render safe markdown + math, and hide reasoning behind a + **Thinking** row. See [Chat](#chat). - **Learning engine**: every answer is recorded (deck, activity, topic, correct/wrong, time) in an IndexedDB log, and each activity gets a light **SM-2 schedule** — correct answers come back after 1 day, then 3, then interval × ease; wrong ones after ten minutes. The Library shows a **Smart @@ -441,6 +447,7 @@ client IP with a sliding window. Everything is configurable in `.env.local`: | `POST /api/homework` | 12 per 5 minutes | `RATE_LIMIT_HOMEWORK` | | `POST /api/grade` | 40 per 5 minutes | `RATE_LIMIT_GRADE` | | `POST /api/homework/refine` | 30 per 5 minutes | `RATE_LIMIT_REFINE` | +| `POST /api/chat`, `POST /api/chat/extract` | 60 per 5 minutes | `RATE_LIMIT_CHAT` | | `GET /api/health` | 90 per 5 minutes | `RATE_LIMIT_HEALTH` | | `POST/GET/DELETE /api/sync` | 60 per 5 minutes | `RATE_LIMIT_SYNC` | | `GET/POST/DELETE /api/push`, `POST /api/push/test` | 30 per 5 minutes | `RATE_LIMIT_PUSH` | @@ -455,6 +462,10 @@ Caveats, stated honestly: counters live **in memory**, so they reset on restart between multiple instances or Node workers. That is fine for a self-hosted single-instance app; if you put Freezmo behind a reverse proxy, keep the proxy's own limits in place too. +Chat has its own allowlist: `CHAT_MODELS` (comma-separated) lists what the model chip may offer, +with the first entry as the default; when unset it falls back to `TEXT_MODEL` and `VISION_MODEL`. +The server rejects any model name outside that list. + Model names are normalised automatically: when the base URL points at `ollama.com`, Freezmo strips the local routing suffix (`gemma4:31b-cloud` → `gemma4:31b`, `gpt-oss:120b-cloud` → `gpt-oss:120b`) because the cloud API expects plain library names, while a local daemon wants the `-cloud` form. @@ -672,6 +683,47 @@ Limits and behaviour worth knowing: --- +## Chat + +Chat mode is an LLM-style conversation that knows your notes — ask about a deck, get quizzed, or +have a step explained without leaving the app. + +- **Entry points.** The Library header has **Ask** (a labelled button on desktop, an icon on + phones), every deck's menu has **Ask about this deck** (opens scoped to that deck), the desktop + sidebar has a **Chat** row, and study/homework results have **Ask a question** (the compact + result travels along as extra context). On phones the chat is a full-screen route with a back + button and **New chat** — the tab bar steps aside so the composer gets the space. Desktop keeps + the sidebar. +- **Grounded answers, retrieved in the browser.** Chat reads each deck's stored notes text + (`sourceText` — the same text *Regenerate* uses, already kept and size-capped per deck). The + client chunks the scoped decks (~800 tokens per chunk), ranks the chunks against your question + and the recent turns with a small BM25 scorer, and sends only the top chunks (~7k tokens, each + labelled with its deck and section). The server stays stateless: the chosen context and the + conversation window travel with every request, exactly like the API key. Decks without notes text + fall back to their generated study content, and the chat says so. +- **Scope.** The context chip picks **All notes**, **selected folders/decks** or **No notes** + (general chat); opening from a deck defaults to that deck. A Settings toggle (**Keep notes text + for chat**) makes chat ignore the notes text, and each deck's menu has **Delete notes text** to + remove it from that deck entirely. +- **Models.** The model chip lists the server's allowlist (`CHAT_MODELS`, defaulting to the + configured text and vision models) with text/vision badges and availability from the health + check; the last choice is remembered. The server validates every request against the same + allowlist and never accepts a model name from the client. +- **Attachments.** Photos, PDFs and documents go through the existing vision/extraction step first + (plain text), so any chat model works — text-only included. +- **Streaming and controls.** Tokens stream with a caret and a stop button; auto-scroll pauses when + you scroll up and a jump-to-bottom button appears; answers render safe markdown (lists, tables, + code blocks with copy — no HTML injection) and KaTeX loads dynamically only when a message + contains math; reasoning models' thinking is hidden behind a collapsible **Thinking** row and + never mixed into the answer. Every answer offers copy / regenerate, the last question edit and + resend. The composer auto-grows at 16px, sends on Enter on desktop (newline on phones), tracks + the on-screen keyboard through `visualViewport` and respects reduced motion. +- **Privacy.** Notes text and conversations stay in this browser; nothing is stored server-side. + The API key still only ever travels in the `x-ollama-key` header to Freezmo's own route for that + single request. + +--- + ## Project structure ``` @@ -684,15 +736,19 @@ app/ create/page.tsx upload screen deck/[id]/page.tsx study feed for a saved deck homework/page.tsx “Make my homework” (assignment in, homework or guide out) + chat/page.tsx chat mode (full-screen on phones, sidebar on desktop) api/generate/route.ts images/text in → NDJSON progress → deck JSON out api/homework/route.ts assignment in → NDJSON progress → homework/guide JSON out api/grade/route.ts short-answer grading + api/chat/route.ts grounded chat turns → NDJSON token/thinking stream + api/chat/extract/route.ts chat attachments → plain text (vision step) api/health/route.ts Ollama reachability + model availability components/ activities/ one component per activity type + shared shell Flashcard.tsx MultipleChoice.tsx TrueFalse.tsx FillBlank.tsx Matching.tsx Ordering.tsx ShortAnswer.tsx shared.tsx index.tsx homework/ make-my-homework screens (input, result, guide feed, history) + chat/ chat screen, composer, message rows, markdown + math, pickers UploadScreen.tsx StudyFeed.tsx ResultsScreen.tsx DeckCard.tsx ApiKeyGate.tsx InstallSheet.tsx TabBar.tsx ServiceWorkerRegistrar.tsx FolderBar.tsx LibraryScreen.tsx SettingsDialog.tsx AppHeader.tsx ThemeToggle.tsx icons.tsx ui.tsx @@ -717,6 +773,11 @@ lib/ clipboard.ts copy helper with a non-secure-context fallback image.ts client-side validation, resize and compression client-api.ts browser client for the API routes (NDJSON reader) + chat.ts chat types + last-model memory + chat-context.ts client-side chunking + BM25 retrieval over the scoped decks + chat-client.ts streaming chat client + attachment extraction + chat-seed.ts one-shot hand-off from results into the chat + chat-trim.ts token budgeting + the sliding history window (server + client) xp.ts utils.ts deck-ops.ts confetti.ts public/ sw.js service worker (production only) @@ -740,7 +801,9 @@ scripts/ | `POST /api/homework` | `{ mode: "make" \| "guide" \| "check", images?: string[], text?, solution?, language? }` | NDJSON stream: `{type:"stage"}`, `{type:"homework",homework,meta}`, `{type:"error"}` | | `POST /api/homework/refine` | `{ instruction: "shorter" \| "more" \| "explain", text, context?, language? }` | `{ text }` — one rewritten piece, plain text | | `POST /api/grade` | `{ question, referenceAnswer, keyPoints, userAnswer, language? }` | `{ correct, score, feedback, missing }` | -| `GET /api/health` | – | `{ ok, reachable, mode, baseUrl, hasApiKey, vision, text, latencyMs, modelCount, error }` | +| `POST /api/chat` | `{ model?, messages: [{role,content}], context?: [{deckId,deckTitle,section,text}] }` | NDJSON stream: `{type:"token"}`, `{type:"thinking"}`, `{type:"done",model}`, `{type:"error"}` | +| `POST /api/chat/extract` | `{ images?: string[], text? }` | `{ text }` — attachments reduced to plain text via the vision step | +| `GET /api/health` | – | `{ ok, reachable, mode, baseUrl, hasApiKey, vision, text, chatModels, latencyMs, modelCount, error }` | | `POST /api/sync` | `{ id, envelope }` (opaque AES-GCM blob) | `{ updatedAt }` — 413 over `SYNC_MAX_BYTES`, 507 over `SYNC_TOTAL_BYTES` | | `GET /api/sync?id=` | – | `{ updatedAt, envelope }` or 404 | | `DELETE /api/sync?id=` | – | `{ ok: true }` (revokes the synced copy) | @@ -749,7 +812,7 @@ scripts/ | `DELETE /api/push?endpoint=` | – | `{ ok, count }` | | `POST /api/push/test` | `{ endpoint }` | `{ ok: true }` or 502 when the push service refuses | -All four routes accept an optional **`x-ollama-key`** header (the browser's own key; required in +The model routes accept an optional **`x-ollama-key`** header (the browser's own key; required in `user-key` auth mode). Missing keys answer **401** with `kind: "needs_key"`, a rejected key answers `kind: "auth"` (or `kind: "no_credits"` for HTTP 402), and exceeding a rate limit answers **429** with `Retry-After`. Keys are used for the single request and discarded. @@ -1072,8 +1135,8 @@ Things I chose while building, with the reasoning: (native), i18n, the freeze loading screen, Smart Review, and phases 1–3 (backup/sync/usage limits, the learning engine, homework check/follow-ups). Suites that need a stub Ollama or VAPID keys start their own `next start` instance on a spare port, verify the port is free and - kill the process group on exit; test scratch stays in `tests/.tmp/` (gitignored). Eleven suites, - 424 checks. + kill the process group on exit; test scratch stays in `tests/.tmp/` (gitignored). Twelve suites, + 487 checks. 46. **The ice orbs are rising bubbles now, composited and cheaper to read through.** The glossy orbs (the glacier blobs stay exactly as they were) rise from below the bottom edge to above the @@ -1096,6 +1159,30 @@ Things I chose while building, with the reasoning: tab-hidden parking) plus the 16-run axe contrast matrix (360/390/1280 × dark/light × EN/CS). `tests/bubble-perf.mjs` keeps the measurement repeatable. +47. **Chat grounds on the notes that were already there, and the server stays stateless.** The + reconnaissance before this feature found the extracted notes text already kept per deck + (`sourceText`, size-capped, used by Regenerate), so the "store it in IndexedDB" branch of the + brief didn't apply — chat reads the deck record instead, with the generated study content as + the documented fallback for old or imported decks. Retrieval is client-side and dependency-free: + ~800-token chunks, BM25 (k1 = 1.2, b = 0.75, stopwords, diacritics folded) scored against the + question plus the last turns, top chunks under a ~7k-token budget, each labelled with deck and + section. The request carries the chosen context and the conversation window (like the key + does); the server trims the window to ~6k tokens, caps the context at ~9k, validates the model + against `CHAT_MODELS` and streams `token` / `thinking` / `done` / `error` NDJSON. Reasoning is a + separate event, so it can be hidden behind the Thinking row and never mixed into the answer. + Attachments reuse the vision step (`/api/chat/extract`) so text-only chat models work. Markdown + is a small React-node parser (no HTML injection, no dependency); KaTeX is dynamically imported + only when a message contains math and writes into its container imperatively — there is still + no `dangerouslySetInnerHTML` anywhere in the app. Stopping mid-stream aborts the upstream call; + the abort arrives as a reader rejection and is mapped back to a clean "stopped" state instead of + an error card. Usage-limit failures keep the draft and show the shared message with + Settings/Ollama actions in an inline banner. The Settings toggle "Keep notes text for chat" + makes chat ignore notes text, and a deck's menu can delete the notes text outright. Verified by + the chat suite (63 checks against a stub Ollama: validation and the allowlist, context travel, + the history window, thinking separation, usage-limit mapping, attachment extraction, entry + points, streaming, stop, markdown + KaTeX, the model and scope pickers, edit/regenerate, + offline, attachments and reduced motion). + 36. **The mobile declutter pass reshapes, it doesn't fork.** Phones (≤639px) get a 3-step deck flow and a 2-step homework flow that share the exact state and handlers with the desktop cards (one `UploadScreen` / one `HomeworkInput`, two layouts switched by `useIsPhone()`); diff --git a/app/api/chat/extract/route.ts b/app/api/chat/extract/route.ts new file mode 100644 index 0000000..4b93cfa --- /dev/null +++ b/app/api/chat/extract/route.ts @@ -0,0 +1,107 @@ +import { z } from "zod"; + +import { extractNotes, notesToText } from "@/lib/ai"; +import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth } from "@/lib/config"; +import { friendlyErrorMessage, OllamaError } from "@/lib/ollama"; +import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; +import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; +import { translate } from "@/lib/i18n-dict"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const maxDuration = 300; + +/** Same ceiling as deck generation: up to 16 resized photos. */ +const MAX_BODY_BYTES = 33554432; +const MAX_IMAGE_CHARS = 8_000_000; + +const requestSchema = z.object({ + images: z.array(z.string().min(32).max(MAX_IMAGE_CHARS)).max(16).optional(), + text: z.string().max(120_000).optional(), +}); + +/** + * Turns chat attachments (photos, scanned pages, pasted document text) into + * plain text with the existing vision pipeline, so any chat model — including + * text-only ones — can use them. Never stores anything. + */ +export async function POST(request: Request) { + const locale = requestLocale(request); + const crossOrigin = rejectCrossOrigin(request); + if (crossOrigin) return crossOrigin; + const oversized = rejectOversizedBody(request, MAX_BODY_BYTES); + if (oversized) return oversized; + + const config = getServerConfig(); + + const limit = rateLimit(`chat:${clientKey(request)}`, config.rateLimits.chat, config.rateLimits.windowMs); + if (!limit.allowed) return rateLimitResponse(limit, locale); + + const auth = resolveAuth(config, request); + if ("error" in auth) { + return Response.json( + { error: authErrorMessage(locale, auth.error), kind: authErrorKind(auth.error) }, + { status: 401 }, + ); + } + + let payload: unknown; + try { + payload = await request.json(); + } catch { + return Response.json({ error: translate(locale, "server.bodyNotJson") }, { status: 400 }); + } + + const parsed = requestSchema.safeParse(payload); + if (!parsed.success) { + return Response.json( + { error: translate(locale, "server.invalidRequest", { detail: parsed.error.issues[0]?.message ?? "" }) }, + { status: 400 }, + ); + } + + const images = (parsed.data.images ?? []).map(stripDataUrlPrefix).filter(Boolean); + const text = parsed.data.text?.trim() ?? ""; + if (images.length === 0 && !text) { + return Response.json({ error: translate(locale, "server.needContent") }, { status: 400 }); + } + + const abort = new AbortController(); + const timer = setTimeout(() => abort.abort(), config.timeoutMs); + try { + if (images.length === 0) { + // Plain text needs no model call at all. + return Response.json({ text: text.slice(0, 48_000) }); + } + const extraction = await extractNotes({ + model: config.visionModel, + repairModel: config.textModel, + auth: auth.auth, + images, + text: text || undefined, + signal: abort.signal, + }); + const extracted = notesToText(extraction.notes).slice(0, 48_000); + if (!extracted.trim()) { + return Response.json({ error: translate(locale, "chat.attachEmpty"), kind: "bad_response" }, { status: 422 }); + } + return Response.json({ text: extracted }); + } catch (error) { + console.error("[api/chat/extract]", error instanceof OllamaError ? `${error.kind}: ${error.message}` : error); + return Response.json( + { + error: friendlyErrorMessage(error, auth.auth.baseUrl, locale), + kind: error instanceof OllamaError ? error.kind : "unknown", + }, + { status: 502 }, + ); + } finally { + clearTimeout(timer); + } +} + +function stripDataUrlPrefix(value: string): string { + const commaIndex = value.indexOf(","); + if (value.startsWith("data:") && commaIndex !== -1) return value.slice(commaIndex + 1); + return value; +} diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts new file mode 100644 index 0000000..9e03022 --- /dev/null +++ b/app/api/chat/route.ts @@ -0,0 +1,193 @@ +import { z } from "zod"; + +import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth, resolveModelName } from "@/lib/config"; +import { friendlyErrorMessage, ollamaChatStream, OllamaError } from "@/lib/ollama"; +import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; +import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; +import { translate } from "@/lib/i18n-dict"; +import { buildChatSystemPrompt } from "@/lib/prompts"; +import { estimateTokens, trimHistory } from "@/lib/chat-trim"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const maxDuration = 300; + +/** Context chunks (~7k tokens) plus a trimmed window plus JSON overhead. */ +const MAX_BODY_BYTES = 1_000_000; + +/** Hard cap on the attached context, whatever the client sends. */ +const CONTEXT_TOKEN_BUDGET = 9000; + +const requestSchema = z.object({ + model: z.string().min(1).max(120).optional(), + messages: z + .array( + z.object({ + role: z.enum(["user", "assistant"]), + content: z.string().min(1).max(20_000), + }), + ) + .min(1) + .max(60), + context: z + .array( + z.object({ + deckId: z.string().max(120), + deckTitle: z.string().max(200), + section: z.string().max(200), + text: z.string().min(1).max(12_000), + }), + ) + .max(40) + .optional(), +}); + +type StreamEvent = + | { type: "token"; text: string } + | { type: "thinking"; text: string } + | { type: "done"; model: string } + | { type: "error"; error: string; kind?: string }; + +function encoder() { + const enc = new TextEncoder(); + return (event: StreamEvent) => enc.encode(`${JSON.stringify(event)}\n`); +} + +export async function POST(request: Request) { + const locale = requestLocale(request); + const crossOrigin = rejectCrossOrigin(request); + if (crossOrigin) return crossOrigin; + const oversized = rejectOversizedBody(request, MAX_BODY_BYTES); + if (oversized) return oversized; + + const config = getServerConfig(); + + const limit = rateLimit(`chat:${clientKey(request)}`, config.rateLimits.chat, config.rateLimits.windowMs); + if (!limit.allowed) return rateLimitResponse(limit, locale); + + const auth = resolveAuth(config, request); + if ("error" in auth) { + return Response.json( + { error: authErrorMessage(locale, auth.error), kind: authErrorKind(auth.error) }, + { status: 401 }, + ); + } + + let payload: unknown; + try { + payload = await request.json(); + } catch { + return Response.json({ error: translate(locale, "server.bodyNotJson") }, { status: 400 }); + } + + const parsed = requestSchema.safeParse(payload); + if (!parsed.success) { + return Response.json( + { + error: translate(locale, "server.invalidRequest", { + detail: parsed.error.issues + .slice(0, 3) + .map((issue) => `${issue.path.join(".") || "body"} ${issue.message}`) + .join("; "), + }), + }, + { status: 400 }, + ); + } + + const { messages, context } = parsed.data; + + // The model must come from the server's allowlist — never from the client. + const allowed = config.chatModels.length > 0 ? config.chatModels : [config.textModel]; + const model = parsed.data.model ?? allowed[0]; + if (!allowed.some((entry) => entry.toLowerCase() === model.toLowerCase())) { + return Response.json( + { error: translate(locale, "server.chatModelNotAllowed"), kind: "bad_model" }, + { status: 400 }, + ); + } + + const last = messages[messages.length - 1]; + if (last.role !== "user") { + return Response.json({ error: translate(locale, "server.chatNeedsQuestion") }, { status: 400 }); + } + + // Cap the context, then trim the conversation window. + const keptContext: typeof context = []; + let contextTokens = 0; + for (const chunk of context ?? []) { + const cost = estimateTokens(chunk.text); + if (contextTokens + cost > CONTEXT_TOKEN_BUDGET) break; + keptContext.push(chunk); + contextTokens += cost; + } + const history = trimHistory(messages); + + const system = buildChatSystemPrompt({ + context: keptContext.map((chunk) => ({ + deckTitle: chunk.deckTitle, + section: chunk.section, + text: chunk.text, + })), + }); + + const encode = encoder(); + const abort = new AbortController(); + const globalTimer = setTimeout(() => abort.abort(), config.timeoutMs); + + const stream = new ReadableStream({ + async start(controller) { + const send = (event: StreamEvent) => { + try { + controller.enqueue(encode(event)); + } catch { + /* client went away */ + } + }; + + try { + const result = await ollamaChatStream({ + model, + auth: auth.auth, + messages: [{ role: "system", content: system }, ...history], + signal: abort.signal, + onToken: (text) => send({ type: "token", text }), + onThinking: (text) => send({ type: "thinking", text }), + }); + send({ type: "done", model: resolveModelName(result.model, auth.auth.directCloud) }); + } catch (error) { + // A client-side stop aborts the upstream call: nothing to report. + if (abort.signal.aborted && request.signal.aborted) { + /* stopped by the user */ + } else { + const message = friendlyErrorMessage(error, auth.auth.baseUrl, locale); + console.error("[api/chat]", error instanceof OllamaError ? `${error.kind}: ${error.message}` : error); + send({ + type: "error", + error: message, + kind: error instanceof OllamaError ? error.kind : "unknown", + }); + } + } finally { + clearTimeout(globalTimer); + try { + controller.close(); + } catch { + /* already closed */ + } + } + }, + cancel() { + clearTimeout(globalTimer); + abort.abort(); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "application/x-ndjson; charset=utf-8", + "Cache-Control": "no-store, no-transform", + Connection: "keep-alive", + }, + }); +} diff --git a/app/api/health/route.ts b/app/api/health/route.ts index a013da8..6f6aa2a 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -14,6 +14,12 @@ export type ModelHealth = { note?: string; }; +export type ChatModelHealth = { + name: string; + badge: "text" | "vision" | "both"; + available: boolean | null; +}; + export type HealthResponse = { ok: boolean; reachable: boolean; @@ -27,6 +33,8 @@ export type HealthResponse = { hasApiKey: boolean; vision: ModelHealth; text: ModelHealth; + /** The chat model allowlist (CHAT_MODELS) with availability from the list. */ + chatModels: ChatModelHealth[]; latencyMs: number | null; modelCount: number | null; error: string | null; @@ -44,6 +52,15 @@ export async function GET(request: Request) { const started = Date.now(); const resolvedAuth = resolveAuth(config, request); + const badgeFor = (name: string): ChatModelHealth["badge"] => { + const canonical = canonicalModelName(name); + const isText = canonical === canonicalModelName(config.textModel); + const isVision = canonical === canonicalModelName(config.visionModel); + if (isText && isVision) return "both"; + if (isVision) return "vision"; + return "text"; + }; + const health: HealthResponse = { ok: false, reachable: false, @@ -63,6 +80,7 @@ export async function GET(request: Request) { resolved: resolveModelName(config.textModel, config.directCloud), available: null, }, + chatModels: config.chatModels.map((name) => ({ name, badge: badgeFor(name), available: null })), latencyMs: null, modelCount: null, error: null, @@ -121,6 +139,9 @@ export async function GET(request: Request) { : translate(locale, "server.healthModelNoteCloud", { url: config.baseUrl }); } } + for (const entry of health.chatModels) { + entry.available = names.has(canonicalModelName(entry.name)); + } health.ok = Boolean(health.vision.available && health.text.available && health.keyAccepted !== false); if (!health.ok && !health.error) { diff --git a/app/chat/page.tsx b/app/chat/page.tsx new file mode 100644 index 0000000..7e6a95e --- /dev/null +++ b/app/chat/page.tsx @@ -0,0 +1,32 @@ +import { Suspense } from "react"; + +import { ApiKeyGate } from "@/components/ApiKeyGate"; +import { AppHeader } from "@/components/AppHeader"; +import { Sidebar, SidebarOffset } from "@/components/Sidebar"; +import { ChatScreen } from "@/components/chat/ChatScreen"; + +export const metadata = { + title: "Chat", +}; + +/** + * Chat mode. Phones get a full-screen conversation (the tab bar steps aside, + * see TabBar); desktop keeps the sidebar. The hidden AppHeader is only there + * to mount the settings dialog the sidebar opens. + */ +export default function ChatPage() { + return ( + +
+ + +
+ + + + +
+
+
+ ); +} diff --git a/components/DeckCard.tsx b/components/DeckCard.tsx index 8c01438..e42fce4 100644 --- a/components/DeckCard.tsx +++ b/components/DeckCard.tsx @@ -12,12 +12,12 @@ import { useI18n } from "@/lib/i18n"; import { springGentle } from "@/lib/motion"; import { UNORGANIZED_ID, type Deck } from "@/lib/schemas"; import { shareDeck } from "@/lib/share"; -import { appendActivities, deleteDeck, downloadDeck, renameDeck, upsertDeck } from "@/lib/storage"; +import { appendActivities, deleteDeck, deleteNotesText, downloadDeck, renameDeck, upsertDeck } from "@/lib/storage"; import { showToast } from "@/lib/toast"; import { useBackClose } from "@/lib/use-back-close"; import { useIsPhone } from "@/lib/use-phone"; import { cn, formatDate, truncate } from "@/lib/utils"; -import { ChevronDownIcon, DownloadIcon, EllipsisIcon, FolderIcon, PlayIcon, RefreshIcon, ShareIcon } from "@/components/icons"; +import { ChatIcon, ChevronDownIcon, DownloadIcon, EllipsisIcon, FolderIcon, PlayIcon, RefreshIcon, ShareIcon, XCircleIcon } from "@/components/icons"; import { Button, ProgressBar } from "@/components/ui"; type Busy = null | "more" | "regenerate"; @@ -378,6 +378,13 @@ export function DeckCard({ {t("deck.share")} + + + {t("chat.askAboutDeck")} + + ) : null} + {/* Deleting is reversible: no confirm, just an Undo toast. */} + + {/* chat grounding: may chat read the notes text? */} + { + saveSettings({ chatNotes: !settings.chatNotes }); + haptic("toggle"); + }} + /> {/* ------------------------------------------- data & sync */} @@ -709,6 +720,16 @@ export function SettingsDialog({ ) : null} + {/* ------------------------------------------- chat grounding */} +
+ saveSettings({ chatNotes: !settings.chatNotes })} + /> +
+ {/* ------------------------------------------- data & sync */} @@ -947,3 +968,44 @@ function ModelRow({ ); } + +/** Boolean toggle row used by the chat-grounding setting. */ +function ToggleRow({ + label, + note, + checked, + onChange, +}: { + label: string; + note: string; + checked: boolean; + onChange: () => void; +}) { + return ( +
+ + {label} + {note} + + +
+ ); +} diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index 0ed7791..78424e9 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { useMemo, useState, useSyncExternalStore, type ReactNode } from "react"; import { + ChatIcon, ChevronLeftIcon, ChevronRightIcon, CompassIcon, @@ -137,6 +138,16 @@ function NavContent({ collapsed = false }: { collapsed?: boolean }) { {!collapsed ? {t("homework.nav")} : null} + {/* chat with your notes --------------------------------------------- */} + + + {!collapsed ? {t("chat.nav")} : null} + +