Chat mode, phase 1: grounded streaming chat over your notes
Entry points (no sixth tab): Ask in the Library header (desktop button, phone icon), Ask about this deck in the deck menu, Ask a question on study/homework results (compact result travels as context), and a Chat row in the desktop sidebar. Phones get a full-screen route with back + New chat; the tab bar steps aside. - Client-side retrieval: chunk scoped decks (~800 tokens), BM25 against the question + recent turns, top chunks under ~7k tokens, each labelled with deck + section. Notes come from the deck's stored sourceText (already kept for Regenerate); decks without it fall back to generated content and the UI says so. - Stateless server: /api/chat validates against the CHAT_MODELS allowlist, trims the history to ~6k tokens, caps context at ~9k and streams token/thinking/done/error NDJSON. /api/chat/extract reduces attachments to text through the existing vision step. - UI: user bubbles + full-width assistant text, streaming with stop, auto-scroll that pauses with a jump-to-bottom button, safe markdown (React nodes, no HTML injection; KaTeX dynamically imported only when a message has math), Thinking row for reasoning, copy/regenerate/ edit-and-resend, model + context pickers (last model remembered), composer with visualViewport keyboard handling, safe-area, 16px textarea, Enter-to-send on desktop. - Usage-limit failures keep the draft and show the shared message with Settings/Ollama actions; stopping maps the reader abort to a clean stopped state; offline disables sending with a reason. - Settings: Keep notes text for chat toggle; deck menu: Delete notes text. RATE_LIMIT_CHAT + CHAT_MODELS in .env.example and README. - Tests: chat suite (63 checks, stub Ollama) wired into run-all. Full set: 12/12 suites, 487 checks. SW -> freezmo-v32. Also: tests/model-compare.mjs (diagnostic) benchmarks two models on the same notes (latency, repair rounds, activity types) so the gpt-oss vs gemma default can be decided on numbers; defaults unchanged.
This commit is contained in:
parent
6b6a6ea158
commit
756a6257a2
46 changed files with 3840 additions and 56 deletions
|
|
@ -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.
|
||||
|
|
|
|||
95
README.md
95
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()`);
|
||||
|
|
|
|||
107
app/api/chat/extract/route.ts
Normal file
107
app/api/chat/extract/route.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
193
app/api/chat/route.ts
Normal file
193
app/api/chat/route.ts
Normal file
|
|
@ -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<Uint8Array>({
|
||||
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",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
32
app/chat/page.tsx
Normal file
32
app/chat/page.tsx
Normal file
|
|
@ -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 (
|
||||
<ApiKeyGate>
|
||||
<div className="flex min-h-dvh">
|
||||
<Sidebar />
|
||||
<SidebarOffset />
|
||||
<main className="flex h-dvh min-h-dvh min-w-0 flex-1 flex-col">
|
||||
<AppHeader className="hidden" />
|
||||
<Suspense fallback={null}>
|
||||
<ChatScreen />
|
||||
</Suspense>
|
||||
</main>
|
||||
</div>
|
||||
</ApiKeyGate>
|
||||
);
|
||||
}
|
||||
|
|
@ -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({
|
|||
<ShareIcon className="size-4 text-[var(--text-subtle)]" />
|
||||
{t("deck.share")}
|
||||
</button>
|
||||
<Link
|
||||
href={`/chat?deck=${deck.id}`}
|
||||
className={menuRow}
|
||||
>
|
||||
<ChatIcon className="size-4 text-[var(--text-subtle)]" />
|
||||
{t("chat.askAboutDeck")}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className={menuRow}
|
||||
|
|
@ -388,6 +395,21 @@ export function DeckCard({
|
|||
{t("deck.regenerate")}
|
||||
</button>
|
||||
|
||||
{hasSource ? (
|
||||
<button
|
||||
type="button"
|
||||
className={menuRow}
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
deleteNotesText(deck.id);
|
||||
showToast({ message: t("chat.notesDeleted") });
|
||||
}}
|
||||
>
|
||||
<XCircleIcon className="size-4 text-[var(--text-subtle)]" />
|
||||
{t("chat.deleteNotes")}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{/* Deleting is reversible: no confirm, just an Undo toast. */}
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "reac
|
|||
import { DeckCard } from "@/components/DeckCard";
|
||||
import { FolderBar } from "@/components/FolderBar";
|
||||
import { HomeworkCard } from "@/components/HomeworkCard";
|
||||
import { EllipsisIcon, FolderIcon, UploadIcon } from "@/components/icons";
|
||||
import { ChatIcon, EllipsisIcon, FolderIcon, UploadIcon } from "@/components/icons";
|
||||
import { LinkLoadingScreen } from "@/components/LinkLoadingScreen";
|
||||
import { SegmentedPill } from "@/components/SegmentedPill";
|
||||
import { Button, EmptyState, ProgressBar } from "@/components/ui";
|
||||
|
|
@ -195,6 +195,10 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
|
|||
{/* Desktop keeps Import above the tabs; phones get a small icon
|
||||
button in the tab row instead. */}
|
||||
<div className="hidden flex-wrap items-center gap-2 sm:flex">
|
||||
<Link href="/chat" className="touch-target inline-flex items-center gap-2 rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] px-4 py-2.5 text-sm font-semibold text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]">
|
||||
<ChatIcon className="size-4" />
|
||||
{t("chat.ask")}
|
||||
</Link>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
|
|
@ -236,6 +240,14 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
|
|||
<span className="seg-label relative z-10">{t("tabHomework")}</span>
|
||||
</Link>
|
||||
</div>
|
||||
<Link
|
||||
href="/chat"
|
||||
aria-label={t("chat.ask")}
|
||||
title={t("chat.ask")}
|
||||
className="touch-target grid size-11 shrink-0 place-items-center rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] sm:hidden"
|
||||
>
|
||||
<ChatIcon className="size-5" />
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSheetOpen(true)}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { useIsPhone } from "@/lib/use-phone";
|
|||
import { cn } from "@/lib/utils";
|
||||
import { BoltIcon, DownloadIcon, FlameIcon, HomeIcon, RefreshIcon, SnowflakeIcon, SparklesIcon } from "@/components/icons";
|
||||
import { Button, Chip } from "@/components/ui";
|
||||
import { AskButton } from "@/components/chat/AskButton";
|
||||
|
||||
import type { AttemptResult } from "./StudyFeed";
|
||||
|
||||
|
|
@ -193,6 +194,17 @@ export function ResultsScreen({
|
|||
{regenerateError}
|
||||
</p>
|
||||
) : null}
|
||||
<AskButton
|
||||
className="w-full"
|
||||
seed={{
|
||||
deckId: deck.id,
|
||||
label: t("chat.seedResults"),
|
||||
text: [
|
||||
`${t("chat.seedResults")}: ${deck.title} — ${t("results.gotOf", { correct, total, retry: "" })}`,
|
||||
...missed.map((activity) => `${promptFor(activity)} — ${t("results.answer")} ${answerFor(activity, t)}`),
|
||||
].join("\n"),
|
||||
}}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button variant="ghost" onClick={() => downloadDeck(deck)}>
|
||||
<DownloadIcon className="size-4" /> {t("results.export")}
|
||||
|
|
|
|||
|
|
@ -391,6 +391,17 @@ export function SettingsDialog({
|
|||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* chat grounding: may chat read the notes text? */}
|
||||
<ToggleRow
|
||||
label={t("settings.chatNotes")}
|
||||
note={t("settings.chatNotesNote")}
|
||||
checked={settings.chatNotes}
|
||||
onChange={() => {
|
||||
saveSettings({ chatNotes: !settings.chatNotes });
|
||||
haptic("toggle");
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* ------------------------------------------- data & sync */}
|
||||
|
|
@ -709,6 +720,16 @@ export function SettingsDialog({
|
|||
) : null}
|
||||
</section>
|
||||
|
||||
{/* ------------------------------------------- chat grounding */}
|
||||
<section className="mb-4 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
|
||||
<ToggleRow
|
||||
label={t("settings.chatNotes")}
|
||||
note={t("settings.chatNotesNote")}
|
||||
checked={settings.chatNotes}
|
||||
onChange={() => saveSettings({ chatNotes: !settings.chatNotes })}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* ------------------------------------------- data & sync */}
|
||||
<LearningSection />
|
||||
<DataSection />
|
||||
|
|
@ -947,3 +968,44 @@ function ModelRow({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Boolean toggle row used by the chat-grounding setting. */
|
||||
function ToggleRow({
|
||||
label,
|
||||
note,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
note: string;
|
||||
checked: boolean;
|
||||
onChange: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 border-t border-[var(--hairline-soft)] pt-3">
|
||||
<span className="min-w-0">
|
||||
<span className="block text-[13px] font-semibold text-[var(--text)]">{label}</span>
|
||||
<span className="block text-[11.5px] leading-snug text-[var(--text-subtle)]">{note}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
onClick={onChange}
|
||||
className={cn(
|
||||
"touch-target relative h-7 w-12 shrink-0 rounded-full border transition-colors",
|
||||
checked ? "grad-ice border-transparent" : "border-[var(--hairline)] bg-[var(--surface-sunken)]",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"absolute top-1/2 size-5 -translate-y-1/2 rounded-full bg-white shadow transition-[left] duration-200",
|
||||
checked ? "left-6" : "left-0.5",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ? <span className="flex-1 truncate text-left">{t("homework.nav")}</span> : null}
|
||||
</Link>
|
||||
|
||||
{/* chat with your notes --------------------------------------------- */}
|
||||
<Link
|
||||
href="/chat"
|
||||
className={cn("nav-row mb-3", collapsed && "justify-center px-2")}
|
||||
title={t("chat.nav")}
|
||||
>
|
||||
<ChatIcon className="size-4 shrink-0" />
|
||||
{!collapsed ? <span className="flex-1 truncate text-left">{t("chat.nav")}</span> : null}
|
||||
</Link>
|
||||
|
||||
<nav aria-label={t("sidebar.library")} className="thin-scroll min-h-0 flex-1 space-y-0.5 overflow-y-auto">
|
||||
{collapsed ? (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -25,9 +25,10 @@ export function TabBar() {
|
|||
const { t } = useI18n();
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
/* The wizards keep their own bottom action bar (Cancel / Next / Freeze) —
|
||||
the flow is deliberately focused, so the tab bar steps aside there. */
|
||||
const inWizard = pathname === "/create" || pathname === "/homework";
|
||||
/* The wizards keep their own bottom action bar (Cancel / Next / Freeze) and
|
||||
chat keeps its own composer — the flow is deliberately focused, so the tab
|
||||
bar steps aside there. */
|
||||
const inWizard = pathname === "/create" || pathname === "/homework" || pathname === "/chat";
|
||||
|
||||
const item =
|
||||
"relative flex min-h-[52px] min-w-0 flex-1 flex-col items-center justify-center gap-1 py-2 text-[10.5px] font-medium leading-none transition-colors";
|
||||
|
|
|
|||
32
components/chat/AskButton.tsx
Normal file
32
components/chat/AskButton.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { ChatIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/ui";
|
||||
import { setChatSeed, type ChatSeed } from "@/lib/chat-seed";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* "Ask a question" entry point for results screens: hands the compact result
|
||||
* summary to the chat (sessionStorage, one-shot) and opens it.
|
||||
*/
|
||||
export function AskButton({ seed, className }: { seed: ChatSeed; className?: string }) {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className={cn("touch-target", className)}
|
||||
onClick={() => {
|
||||
setChatSeed(seed);
|
||||
router.push("/chat");
|
||||
}}
|
||||
>
|
||||
<ChatIcon className="size-4" />
|
||||
{t("chat.askQuestion")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
520
components/chat/ChatScreen.tsx
Normal file
520
components/chat/ChatScreen.tsx
Normal file
|
|
@ -0,0 +1,520 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useReducedMotion } from "framer-motion";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Composer, type PendingAttachment } from "./Composer";
|
||||
import { ContextPicker } from "./ContextPicker";
|
||||
import { MessageRow } from "./MessageRow";
|
||||
import { ModelPicker } from "./ModelPicker";
|
||||
import { ChatIcon, ChevronDownIcon, ChevronLeftIcon, PlusIcon, RefreshIcon } from "@/components/icons";
|
||||
import { ModelErrorActions } from "@/components/ModelErrorActions";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
import { Button } from "@/components/ui";
|
||||
import { ApiError } from "@/lib/client-api";
|
||||
import { extractChatAttachment, streamChat } from "@/lib/chat-client";
|
||||
import { buildChatContext, deckHasNotes } from "@/lib/chat-context";
|
||||
import { takeChatSeed, type ChatSeed } from "@/lib/chat-seed";
|
||||
import {
|
||||
chatMessageId,
|
||||
readStoredChatModel,
|
||||
storeChatModel,
|
||||
type ChatAttachment,
|
||||
type ChatMessage,
|
||||
type ChatModelChoice,
|
||||
type ChatScope,
|
||||
} from "@/lib/chat";
|
||||
import { extractDocument } from "@/lib/documents";
|
||||
import { folderStore } from "@/lib/folders";
|
||||
import { isImageFile, prepareImage } from "@/lib/image";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { showToast } from "@/lib/toast";
|
||||
import { deckStore } from "@/lib/storage";
|
||||
import { settingsStore } from "@/lib/settings";
|
||||
import { useHealth } from "@/lib/use-health";
|
||||
import { useOnline } from "@/lib/use-online";
|
||||
|
||||
type ExtraChunk = { deckId: string; deckTitle: string; section: string; text: string };
|
||||
|
||||
/**
|
||||
* Chat mode: an LLM-style conversation that knows the user's notes. The
|
||||
* retrieval runs here in the browser (BM25 over the scoped decks), and only
|
||||
* the top chunks plus the recent turns travel with each request.
|
||||
*/
|
||||
export function ChatScreen() {
|
||||
const { t, plural } = useI18n();
|
||||
const online = useOnline();
|
||||
const reduceMotion = useReducedMotion();
|
||||
const router = useRouter();
|
||||
const search = useSearchParams();
|
||||
|
||||
const decks = useSyncExternalStore(deckStore.subscribe, deckStore.getSnapshot, deckStore.getServerSnapshot);
|
||||
const folders = useSyncExternalStore(folderStore.subscribe, folderStore.getSnapshot, folderStore.getServerSnapshot);
|
||||
const settings = useSyncExternalStore(settingsStore.subscribe, settingsStore.getSnapshot, settingsStore.getServerSnapshot);
|
||||
const { health } = useHealth();
|
||||
|
||||
const deckParam = search.get("deck");
|
||||
|
||||
const [scope, setScope] = useState<ChatScope>(() =>
|
||||
deckParam ? { kind: "decks", deckIds: [deckParam] } : { kind: "all" },
|
||||
);
|
||||
const [seed, setSeed] = useState<ChatSeed | null>(null);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
|
||||
const [attachBusy, setAttachBusy] = useState(false);
|
||||
const [model, setModel] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [pinned, setPinned] = useState(true);
|
||||
const [scopeOpen, setScopeOpen] = useState(false);
|
||||
const [modelOpen, setModelOpen] = useState(false);
|
||||
const [error, setError] = useState<{ message: string; kind?: string } | null>(null);
|
||||
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const pinnedRef = useRef(true);
|
||||
const extrasRef = useRef<Record<string, ExtraChunk[]>>({});
|
||||
|
||||
/* Hand-offs from a deck menu / results screen. */
|
||||
useEffect(() => {
|
||||
const taken = takeChatSeed();
|
||||
if (!taken) return;
|
||||
setSeed(taken);
|
||||
if (taken.deckId) setScope({ kind: "decks", deckIds: [taken.deckId] });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setModel(readStoredChatModel() ?? "");
|
||||
}, []);
|
||||
|
||||
/* Keyboard: keep the composer above the on-screen keyboard. */
|
||||
useEffect(() => {
|
||||
const viewport = window.visualViewport;
|
||||
if (!viewport) return;
|
||||
const apply = () => {
|
||||
const inset = Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop);
|
||||
document.documentElement.style.setProperty("--chat-keyboard", `${Math.round(inset)}px`);
|
||||
};
|
||||
apply();
|
||||
viewport.addEventListener("resize", apply);
|
||||
viewport.addEventListener("scroll", apply);
|
||||
return () => {
|
||||
viewport.removeEventListener("resize", apply);
|
||||
viewport.removeEventListener("scroll", apply);
|
||||
document.documentElement.style.removeProperty("--chat-keyboard");
|
||||
};
|
||||
}, []);
|
||||
|
||||
const chatModels = useMemo<ChatModelChoice[]>(() => {
|
||||
const fromHealth = health?.chatModels ?? [];
|
||||
const list =
|
||||
fromHealth.length > 0
|
||||
? fromHealth
|
||||
: [
|
||||
{ name: health?.text?.configured ?? "", badge: "text" as const, available: health?.text?.available ?? null },
|
||||
{ name: health?.vision?.configured ?? "", badge: "vision" as const, available: health?.vision?.available ?? null },
|
||||
];
|
||||
return list.filter((entry) => entry.name);
|
||||
}, [health]);
|
||||
|
||||
const activeModel = useMemo(() => {
|
||||
if (model && chatModels.some((entry) => entry.name === model)) return model;
|
||||
return chatModels[0]?.name ?? model;
|
||||
}, [model, chatModels]);
|
||||
|
||||
const scopeText = useMemo(() => {
|
||||
if (scope.kind === "all") return t("chat.scope.all");
|
||||
if (scope.kind === "none") return t("chat.scope.none");
|
||||
return plural(scope.deckIds.length, "chat.scope.decks");
|
||||
}, [scope, t, plural]);
|
||||
|
||||
const scopedDecks = useMemo(() => {
|
||||
if (scope.kind === "none") return [];
|
||||
if (scope.kind === "all") return decks;
|
||||
return decks.filter((deck) => scope.deckIds.includes(deck.id));
|
||||
}, [decks, scope]);
|
||||
|
||||
const missingNotes = useMemo(
|
||||
() => (settings.chatNotes ? scopedDecks.some((deck) => !deckHasNotes(deck)) : false),
|
||||
[scopedDecks, settings.chatNotes],
|
||||
);
|
||||
|
||||
const suggestions = useMemo(() => {
|
||||
const chosen = scope.kind === "decks" ? scopedDecks : [];
|
||||
if (chosen.length === 1) {
|
||||
return [
|
||||
t("chat.suggest.explain", { deck: chosen[0].title }),
|
||||
t("chat.suggest.quiz", { deck: chosen[0].title }),
|
||||
t("chat.suggest.summarize"),
|
||||
];
|
||||
}
|
||||
if (scope.kind === "none") return [t("chat.suggest.general"), t("chat.suggest.explainTopic")];
|
||||
return [t("chat.suggest.summarize"), t("chat.suggest.quizAll"), t("chat.suggest.studyFirst")];
|
||||
}, [scope, scopedDecks, t]);
|
||||
|
||||
const scrollToBottom = useCallback(
|
||||
(smooth = false) => {
|
||||
const element = scrollRef.current;
|
||||
if (!element) return;
|
||||
element.scrollTo({ top: element.scrollHeight, behavior: smooth && !reduceMotion ? "smooth" : "auto" });
|
||||
},
|
||||
[reduceMotion],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (pinnedRef.current) scrollToBottom(false);
|
||||
}, [messages, scrollToBottom]);
|
||||
|
||||
const onScroll = () => {
|
||||
const element = scrollRef.current;
|
||||
if (!element) return;
|
||||
const distance = element.scrollHeight - element.scrollTop - element.clientHeight;
|
||||
const next = distance < 80;
|
||||
pinnedRef.current = next;
|
||||
setPinned(next);
|
||||
};
|
||||
|
||||
const extraFor = (text: string, pending: PendingAttachment[]): ExtraChunk[] => {
|
||||
const chunks: ExtraChunk[] = [];
|
||||
if (seed) chunks.push({ deckId: seed.deckId ?? "seed", deckTitle: seed.label, section: seed.label, text: seed.text });
|
||||
if (pending.length > 0) {
|
||||
chunks.push({
|
||||
deckId: "attachment",
|
||||
deckTitle: t("chat.attachmentLabel"),
|
||||
section: t("chat.attachmentLabel"),
|
||||
text: pending.map((entry) => `${entry.attachment.name}\n${entry.text}`).join("\n\n").slice(0, 24_000),
|
||||
});
|
||||
}
|
||||
void text;
|
||||
return chunks;
|
||||
};
|
||||
|
||||
const run = async (history: ChatMessage[], extraChunks: ExtraChunk[]) => {
|
||||
const lastUser = history[history.length - 1];
|
||||
const context = buildChatContext({
|
||||
decks,
|
||||
scope,
|
||||
question: lastUser.content,
|
||||
history: history.slice(0, -1),
|
||||
useNotes: settings.chatNotes,
|
||||
extraChunks,
|
||||
});
|
||||
const sources = context.chunks.map(({ deckId, deckTitle, section }) => ({ deckId, deckTitle, section }));
|
||||
const assistantId = chatMessageId("a");
|
||||
|
||||
setMessages([...history, { id: assistantId, role: "assistant", content: "", sources, at: Date.now() }]);
|
||||
setBusy(true);
|
||||
pinnedRef.current = true;
|
||||
setPinned(true);
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
let produced = false;
|
||||
|
||||
try {
|
||||
await streamChat({
|
||||
model: activeModel || undefined,
|
||||
messages: history.map((message) => ({ role: message.role, content: message.content })),
|
||||
context: context.chunks.map(({ deckId, deckTitle, section, text }) => ({ deckId, deckTitle, section, text })),
|
||||
signal: controller.signal,
|
||||
onToken: (text) => {
|
||||
produced = true;
|
||||
setMessages((previous) =>
|
||||
previous.map((message) =>
|
||||
message.id === assistantId ? { ...message, content: message.content + text } : message,
|
||||
),
|
||||
);
|
||||
},
|
||||
onThinking: (text) => {
|
||||
setMessages((previous) =>
|
||||
previous.map((message) =>
|
||||
message.id === assistantId ? { ...message, thinking: (message.thinking ?? "") + text } : message,
|
||||
),
|
||||
);
|
||||
},
|
||||
onDone: (modelName) => {
|
||||
setMessages((previous) =>
|
||||
previous.map((message) => (message.id === assistantId ? { ...message, model: modelName } : message)),
|
||||
);
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const apiError = error instanceof ApiError ? error : new ApiError(String(error), "unknown");
|
||||
if (apiError.kind === "aborted") {
|
||||
// Stopped by the user: keep whatever streamed in.
|
||||
} else {
|
||||
setError({ message: apiError.message, kind: apiError.kind });
|
||||
if (!produced) {
|
||||
// Nothing arrived: take the placeholder away and give the text back.
|
||||
setMessages(history);
|
||||
setDraft(lastUser.content);
|
||||
} else {
|
||||
setMessages((previous) =>
|
||||
previous.map((message) =>
|
||||
message.id === assistantId
|
||||
? { ...message, error: { message: apiError.message, kind: apiError.kind } }
|
||||
: message,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const send = (text?: string) => {
|
||||
const content = (text ?? draft).trim();
|
||||
if (!content || busy || !online) return;
|
||||
const pending = attachments;
|
||||
const userMessage: ChatMessage = {
|
||||
id: chatMessageId("u"),
|
||||
role: "user",
|
||||
content,
|
||||
attachments: pending.length > 0 ? pending.map((entry) => entry.attachment) : undefined,
|
||||
at: Date.now(),
|
||||
};
|
||||
const extras = extraFor(content, pending);
|
||||
extrasRef.current[userMessage.id] = extras;
|
||||
const history = [...messages, userMessage];
|
||||
setError(null);
|
||||
setMessages(history);
|
||||
setDraft("");
|
||||
setAttachments([]);
|
||||
void run(history, extras);
|
||||
};
|
||||
|
||||
const retryLast = () => {
|
||||
const lastUserIndex = messages.map((message) => message.role).lastIndexOf("user");
|
||||
if (lastUserIndex < 0) return;
|
||||
const history = messages.slice(0, lastUserIndex + 1);
|
||||
setMessages(history);
|
||||
void run(history, extrasRef.current[history[lastUserIndex].id] ?? []);
|
||||
};
|
||||
|
||||
const regenerate = () => {
|
||||
const lastUserIndex = messages.map((message) => message.role).lastIndexOf("user");
|
||||
if (lastUserIndex < 0) return;
|
||||
const history = messages.slice(0, lastUserIndex + 1);
|
||||
setMessages(history);
|
||||
void run(history, extrasRef.current[history[lastUserIndex].id] ?? []);
|
||||
};
|
||||
|
||||
const editLast = () => {
|
||||
const lastUserIndex = messages.map((message) => message.role).lastIndexOf("user");
|
||||
if (lastUserIndex < 0) return;
|
||||
setDraft(messages[lastUserIndex].content);
|
||||
setMessages(messages.slice(0, lastUserIndex));
|
||||
};
|
||||
|
||||
const newChat = () => {
|
||||
abortRef.current?.abort();
|
||||
setMessages([]);
|
||||
setSeed(null);
|
||||
setAttachments([]);
|
||||
setDraft("");
|
||||
};
|
||||
|
||||
const handleAttach = async (file: File) => {
|
||||
setAttachBusy(true);
|
||||
try {
|
||||
if (isImageFile(file)) {
|
||||
const prepared = await prepareImage(file);
|
||||
const text = await extractChatAttachment({ images: [prepared.dataUrl] });
|
||||
const attachment: ChatAttachment = { name: file.name || t("chat.attachPhoto"), kind: "photo", chars: text.length };
|
||||
setAttachments((previous) => [...previous, { attachment, text }]);
|
||||
} else {
|
||||
const document = await extractDocument(file);
|
||||
if (!document.text.trim()) {
|
||||
showToast({ message: t("chat.attachNoText") });
|
||||
} else {
|
||||
const attachment: ChatAttachment = { name: document.name, kind: "document", chars: document.text.length };
|
||||
setAttachments((previous) => [...previous, { attachment, text: document.text }]);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
showToast({ message: error instanceof ApiError ? error.message : t("chat.attachFailed") });
|
||||
} finally {
|
||||
setAttachBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const lastAssistantId = [...messages].reverse().find((message) => message.role === "assistant")?.id;
|
||||
const lastUserId = [...messages].reverse().find((message) => message.role === "user")?.id;
|
||||
const lastFailedAssistant = [...messages].reverse().find((message) => message.role === "assistant" && message.error)?.id;
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full min-h-0 flex-col" style={{ paddingBottom: "var(--chat-keyboard, 0px)" }}>
|
||||
<header className="glass sticky top-0 z-30 flex h-14 shrink-0 items-center gap-1.5 px-2 sm:px-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (window.history.length > 1 ? router.back() : router.push("/"))}
|
||||
aria-label={t("chat.back")}
|
||||
title={t("chat.back")}
|
||||
className="grid size-10 shrink-0 place-items-center rounded-2xl text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] lg:hidden"
|
||||
>
|
||||
<ChevronLeftIcon className="size-5" />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="truncate text-[15px] font-semibold text-[var(--text)]">{t("chat.title")}</h1>
|
||||
<p className="truncate text-[11.5px] text-[var(--text-subtle)]">
|
||||
{scopeText}
|
||||
{seed ? ` · ${seed.label}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<ThemeToggle className="hidden lg:grid" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={newChat}
|
||||
aria-label={t("chat.new")}
|
||||
title={t("chat.new")}
|
||||
className="flex h-10 shrink-0 items-center gap-1.5 rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] px-3 text-[12.5px] font-medium text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
<span className="hidden sm:inline">{t("chat.new")}</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<div ref={scrollRef} onScroll={onScroll} className="h-full overflow-y-auto overscroll-contain">
|
||||
<div className="mx-auto w-full max-w-3xl space-y-4 px-4 py-4">
|
||||
{messages.length === 0 ? (
|
||||
<div className="space-y-5 py-6">
|
||||
<div className="text-center">
|
||||
<ChatIcon className="mx-auto size-10 text-accent-400" />
|
||||
<h2 className="mt-3 text-[20px] font-semibold tracking-[-0.01em] text-[var(--text)]">{t("chat.emptyTitle")}</h2>
|
||||
<p className="mx-auto mt-1.5 max-w-sm text-[13.5px] leading-relaxed text-[var(--text-subtle)]">
|
||||
{t("chat.emptyHint")}
|
||||
</p>
|
||||
</div>
|
||||
{missingNotes ? (
|
||||
<p className="rounded-2xl border border-amber-300/50 bg-amber-50/70 px-3.5 py-3 text-[12.5px] leading-relaxed text-amber-800 dark:border-amber-400/25 dark:bg-amber-400/10 dark:text-amber-200">
|
||||
{t("chat.oldDecksNote")}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{suggestions.map((suggestion) => (
|
||||
<button
|
||||
key={suggestion}
|
||||
type="button"
|
||||
disabled={!online || busy}
|
||||
onClick={() => send(suggestion)}
|
||||
className="max-w-full rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] px-3.5 py-2.5 text-left text-[13px] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] disabled:opacity-55"
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{seed ? (
|
||||
<div className="flex items-center gap-2 rounded-2xl border border-[var(--hairline-soft)] bg-[var(--surface-sunken)] px-3 py-2 text-[12px] text-[var(--text-subtle)]">
|
||||
<span className="truncate">
|
||||
{t("chat.fromLabel")}: {seed.label}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{messages.map((message) => (
|
||||
<MessageRow
|
||||
key={message.id}
|
||||
message={message}
|
||||
streaming={busy && message.id === lastAssistantId}
|
||||
isLastAssistant={message.id === lastAssistantId}
|
||||
isLastUser={message.id === lastUserId}
|
||||
busy={busy}
|
||||
onRegenerate={regenerate}
|
||||
onEdit={editLast}
|
||||
onRetry={message.id === lastFailedAssistant ? retryLast : undefined}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<div className="h-2" aria-hidden />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!pinned && messages.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
pinnedRef.current = true;
|
||||
setPinned(true);
|
||||
scrollToBottom(true);
|
||||
}}
|
||||
aria-label={t("chat.jumpToBottom")}
|
||||
title={t("chat.jumpToBottom")}
|
||||
className="glass-panel absolute bottom-4 left-1/2 grid size-10 -translate-x-1/2 place-items-center rounded-full text-[var(--text-muted)] shadow-lg"
|
||||
>
|
||||
<ChevronDownIcon className="size-5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="mx-3 mb-2 shrink-0 rounded-2xl border border-rose-300/60 bg-rose-50/80 p-3 dark:border-rose-400/30 dark:bg-rose-500/10"
|
||||
>
|
||||
<p className="text-[13px] leading-relaxed text-rose-800 dark:text-rose-200">{error.message}</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
{messages.some((message) => message.role === "user") ? (
|
||||
<Button size="sm" variant="secondary" onClick={retryLast}>
|
||||
<RefreshIcon className="size-4" /> {t("chat.retry")}
|
||||
</Button>
|
||||
) : null}
|
||||
<ModelErrorActions kind={error.kind} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="relative shrink-0">
|
||||
<Composer
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
onSend={() => send()}
|
||||
onStop={() => abortRef.current?.abort()}
|
||||
busy={busy}
|
||||
online={online}
|
||||
scopeLabel={scopeText}
|
||||
modelLabel={activeModel || t("chat.modelTitle")}
|
||||
onOpenScope={() => setScopeOpen(true)}
|
||||
onOpenModel={() => setModelOpen(true)}
|
||||
onAttachFile={(file) => void handleAttach(file)}
|
||||
attachBusy={attachBusy}
|
||||
attachments={attachments}
|
||||
onRemoveAttachment={(index) => setAttachments((previous) => previous.filter((_, i) => i !== index))}
|
||||
/>
|
||||
<ModelPicker
|
||||
open={modelOpen}
|
||||
onClose={() => setModelOpen(false)}
|
||||
models={chatModels}
|
||||
value={activeModel}
|
||||
onChange={(next) => {
|
||||
setModel(next);
|
||||
storeChatModel(next);
|
||||
}}
|
||||
/>
|
||||
<ContextPicker
|
||||
open={scopeOpen}
|
||||
onClose={() => setScopeOpen(false)}
|
||||
scope={scope}
|
||||
onChange={setScope}
|
||||
decks={decks}
|
||||
folders={folders}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{settings.chatNotes === false && scope.kind !== "none" ? (
|
||||
<p className="border-t border-[var(--hairline-soft)] bg-[var(--surface-sunken)] px-4 py-2 text-[11.5px] text-[var(--text-subtle)]">
|
||||
{t("chat.notesOffNote")}{" "}
|
||||
<Link href="/" className="underline decoration-dotted">
|
||||
{t("chat.notesOffLink")}
|
||||
</Link>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
180
components/chat/Composer.tsx
Normal file
180
components/chat/Composer.tsx
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { ChatIcon, LayersIcon, PaperclipIcon, SendIcon, StopIcon, XIcon } from "@/components/icons";
|
||||
import { Spinner } from "@/components/ui";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { useIsPhone } from "@/lib/use-phone";
|
||||
import type { ChatAttachment } from "@/lib/chat";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type PendingAttachment = { attachment: ChatAttachment; text: string };
|
||||
|
||||
/**
|
||||
* The pinned composer: auto-growing 16px textarea, send/stop, attach, and the
|
||||
* context + model chips. Enter sends on desktop, newline on phones; the
|
||||
* keyboard inset is handled by the screen around it.
|
||||
*/
|
||||
export function Composer({
|
||||
value,
|
||||
onChange,
|
||||
onSend,
|
||||
onStop,
|
||||
busy,
|
||||
online,
|
||||
scopeLabel,
|
||||
modelLabel,
|
||||
onOpenScope,
|
||||
onOpenModel,
|
||||
onAttachFile,
|
||||
attachBusy,
|
||||
attachments,
|
||||
onRemoveAttachment,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSend: () => void;
|
||||
onStop: () => void;
|
||||
busy: boolean;
|
||||
online: boolean;
|
||||
scopeLabel: string;
|
||||
modelLabel: string;
|
||||
onOpenScope: () => void;
|
||||
onOpenModel: () => void;
|
||||
onAttachFile: (file: File) => void;
|
||||
attachBusy: boolean;
|
||||
attachments: PendingAttachment[];
|
||||
onRemoveAttachment: (index: number) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const isPhone = useIsPhone();
|
||||
const textarea = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const element = textarea.current;
|
||||
if (!element) return;
|
||||
element.style.height = "auto";
|
||||
element.style.height = `${Math.min(element.scrollHeight, 180)}px`;
|
||||
}, [value]);
|
||||
|
||||
const canSend = online && !busy && value.trim().length > 0;
|
||||
|
||||
return (
|
||||
<div className="border-t border-[var(--glass-border)] bg-[var(--glass-bg)] px-3 pb-safe pt-2 backdrop-blur-xl">
|
||||
{attachments.length > 0 ? (
|
||||
<ul className="mb-2 flex flex-wrap gap-1.5">
|
||||
{attachments.map((entry, index) => (
|
||||
<li
|
||||
key={`${entry.attachment.name}-${index}`}
|
||||
className="flex items-center gap-1.5 rounded-xl border border-[var(--hairline-soft)] bg-[var(--surface-sunken)] py-1 pl-2.5 pr-1 text-[12px] text-[var(--text-muted)]"
|
||||
>
|
||||
<span className="max-w-[160px] truncate">{entry.attachment.name}</span>
|
||||
<span className="text-[var(--text-subtle)]">{entry.attachment.kind === "photo" ? t("chat.attachPhoto") : t("chat.attachDoc")}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("chat.attachRemove")}
|
||||
title={t("chat.attachRemove")}
|
||||
onClick={() => onRemoveAttachment(index)}
|
||||
className="grid size-6 place-items-center rounded-lg text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("chat.attach")}
|
||||
title={t("chat.attach")}
|
||||
disabled={attachBusy}
|
||||
onClick={() => fileInput.current?.click()}
|
||||
className="grid size-10 shrink-0 place-items-center rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] disabled:opacity-60"
|
||||
>
|
||||
{attachBusy ? <Spinner className="size-4" /> : <PaperclipIcon className="size-5" />}
|
||||
</button>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept="image/*,application/pdf,.docx,.pptx,.odt,.txt,.md,.csv"
|
||||
className="sr-only"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) onAttachFile(file);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
|
||||
<textarea
|
||||
ref={textarea}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" || event.shiftKey || isPhone) return;
|
||||
if (event.nativeEvent.isComposing) return;
|
||||
event.preventDefault();
|
||||
if (canSend) onSend();
|
||||
}}
|
||||
rows={1}
|
||||
placeholder={t("chat.placeholder")}
|
||||
aria-label={t("chat.placeholder")}
|
||||
className="max-h-[180px] min-h-[44px] min-w-0 flex-1 resize-none rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] px-3.5 py-2.5 text-[16px] leading-relaxed text-[var(--text)] outline-none transition placeholder:text-[var(--text-subtle)] focus:border-accent-400/60"
|
||||
/>
|
||||
|
||||
{busy ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
aria-label={t("chat.stop")}
|
||||
title={t("chat.stop")}
|
||||
className="grid size-10 shrink-0 place-items-center rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text)] transition hover:bg-[var(--hover)]"
|
||||
>
|
||||
<StopIcon className="size-5" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSend}
|
||||
disabled={!canSend}
|
||||
aria-label={t("chat.send")}
|
||||
title={t("chat.send")}
|
||||
className={cn(
|
||||
"grid size-10 shrink-0 place-items-center rounded-2xl transition",
|
||||
canSend ? "grad-ice text-[#06111f]" : "border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-subtle)] opacity-60",
|
||||
)}
|
||||
>
|
||||
<SendIcon className="size-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center gap-2 overflow-x-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenScope}
|
||||
className="flex min-w-0 items-center gap-1.5 rounded-xl border border-[var(--hairline-soft)] bg-[var(--surface-sunken)] px-2.5 py-1.5 text-[12px] font-medium text-[var(--text-muted)] transition hover:text-[var(--text)]"
|
||||
>
|
||||
<LayersIcon className="size-3.5 shrink-0" />
|
||||
<span className="max-w-[170px] truncate">{scopeLabel}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenModel}
|
||||
className="flex min-w-0 items-center gap-1.5 rounded-xl border border-[var(--hairline-soft)] bg-[var(--surface-sunken)] px-2.5 py-1.5 text-[12px] font-medium text-[var(--text-muted)] transition hover:text-[var(--text)]"
|
||||
>
|
||||
<ChatIcon className="size-3.5 shrink-0" />
|
||||
<span className="max-w-[170px] truncate">{modelLabel}</span>
|
||||
</button>
|
||||
{!online ? (
|
||||
<span role="status" className="ml-auto shrink-0 text-[11.5px] text-amber-600 dark:text-amber-300">
|
||||
{t("chat.offlineNote")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
199
components/chat/ContextPicker.tsx
Normal file
199
components/chat/ContextPicker.tsx
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { CheckCircleIcon, FolderIcon, LayersIcon } from "@/components/icons";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { useIsPhone } from "@/lib/use-phone";
|
||||
import { deckHasNotes } from "@/lib/chat-context";
|
||||
import type { ChatScope } from "@/lib/chat";
|
||||
import type { Deck, Folder } from "@/lib/schemas";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Scope picker for the context chip: All notes · selected folders/decks ·
|
||||
* None (general chat). Only what is selected here can ever travel as context.
|
||||
*/
|
||||
export function ContextPicker({
|
||||
open,
|
||||
onClose,
|
||||
scope,
|
||||
onChange,
|
||||
decks,
|
||||
folders,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
scope: ChatScope;
|
||||
onChange: (scope: ChatScope) => void;
|
||||
decks: Deck[];
|
||||
folders: Folder[];
|
||||
}) {
|
||||
const { t, plural } = useI18n();
|
||||
const isPhone = useIsPhone();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const selected = scope.kind === "decks" ? new Set(scope.deckIds) : new Set<string>();
|
||||
|
||||
const choose = (next: ChatScope) => {
|
||||
onChange(next);
|
||||
if (next.kind !== "decks") onClose();
|
||||
};
|
||||
|
||||
const toggleDeck = (deckId: string) => {
|
||||
const next = new Set(selected);
|
||||
if (next.has(deckId)) next.delete(deckId);
|
||||
else next.add(deckId);
|
||||
onChange({ kind: "decks", deckIds: [...next] });
|
||||
};
|
||||
|
||||
const toggleFolder = (folderId: string) => {
|
||||
const inFolder = decks.filter((deck) => deck.folderId === folderId).map((deck) => deck.id);
|
||||
const allSelected = inFolder.length > 0 && inFolder.every((id) => selected.has(id));
|
||||
const next = new Set(selected);
|
||||
for (const id of inFolder) {
|
||||
if (allSelected) next.delete(id);
|
||||
else next.add(id);
|
||||
}
|
||||
onChange({ kind: "decks", deckIds: [...next] });
|
||||
};
|
||||
|
||||
const modeRow = (
|
||||
value: ChatScope["kind"],
|
||||
title: string,
|
||||
note: string,
|
||||
icon: React.ReactNode,
|
||||
) => (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={scope.kind === value}
|
||||
onClick={() =>
|
||||
choose(
|
||||
value === "all"
|
||||
? { kind: "all" }
|
||||
: value === "none"
|
||||
? { kind: "none" }
|
||||
: { kind: "decks", deckIds: selected.size > 0 ? [...selected] : decks.map((deck) => deck.id) },
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left transition",
|
||||
scope.kind === value
|
||||
? "border-[var(--nav-active-border)] bg-[var(--nav-active-bg)]"
|
||||
: "border-[var(--hairline-soft)] bg-[var(--surface-bg)] hover:bg-[var(--hover)]",
|
||||
)}
|
||||
>
|
||||
<span className="shrink-0 text-[var(--text-muted)]">{icon}</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-[13.5px] font-semibold text-[var(--text)]">{title}</span>
|
||||
<span className="mt-0.5 block text-[11.5px] leading-snug text-[var(--text-subtle)]">{note}</span>
|
||||
</span>
|
||||
{scope.kind === value ? <CheckCircleIcon className="size-5 shrink-0 text-accent-500" /> : null}
|
||||
</button>
|
||||
);
|
||||
|
||||
const body = (
|
||||
<div className="space-y-2">
|
||||
{modeRow("all", t("chat.scope.all"), t("chat.scope.allNote"), <LayersIcon className="size-5" />)}
|
||||
{modeRow("decks", t("chat.scope.pick"), t("chat.scope.pickNote"), <FolderIcon className="size-5" />)}
|
||||
{scope.kind === "decks" ? (
|
||||
<div className="max-h-[38dvh] space-y-1 overflow-y-auto overscroll-contain rounded-2xl border border-[var(--hairline-soft)] bg-[var(--surface-sunken)] p-2">
|
||||
{folders.map((folder) => {
|
||||
const inFolder = decks.filter((deck) => deck.folderId === folder.id);
|
||||
if (inFolder.length === 0) return null;
|
||||
const allSelected = inFolder.every((deck) => selected.has(deck.id));
|
||||
return (
|
||||
<button
|
||||
key={folder.id}
|
||||
type="button"
|
||||
onClick={() => toggleFolder(folder.id)}
|
||||
className="flex w-full items-center gap-2 rounded-xl px-2 py-2 text-left text-[12.5px] font-semibold text-[var(--text-muted)] transition hover:bg-[var(--hover)]"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"grid size-4 place-items-center rounded-[0.35rem] border",
|
||||
allSelected ? "border-accent-500 bg-accent-500 text-white" : "border-[var(--hairline)]",
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
{allSelected ? <CheckCircleIcon className="size-3" /> : null}
|
||||
</span>
|
||||
<FolderIcon className="size-3.5" />
|
||||
<span className="truncate">{folder.name}</span>
|
||||
<span className="ml-auto text-[11px] font-normal text-[var(--text-subtle)]">
|
||||
{plural(inFolder.length, "chat.scope.deckCount")}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{decks.map((deck) => {
|
||||
const active = selected.has(deck.id);
|
||||
return (
|
||||
<button
|
||||
key={deck.id}
|
||||
type="button"
|
||||
onClick={() => toggleDeck(deck.id)}
|
||||
className="flex w-full items-center gap-2 rounded-xl px-2 py-2 text-left transition hover:bg-[var(--hover)]"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"grid size-4 shrink-0 place-items-center rounded-[0.35rem] border",
|
||||
active ? "border-accent-500 bg-accent-500 text-white" : "border-[var(--hairline)]",
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
{active ? <CheckCircleIcon className="size-3" /> : null}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[13px] text-[var(--text)]">{deck.title}</span>
|
||||
<span className="block text-[11px] text-[var(--text-subtle)]">
|
||||
{deckHasNotes(deck) ? t("chat.scope.hasNotes") : t("chat.scope.generatedOnly")}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{modeRow("none", t("chat.scope.none"), t("chat.scope.noneNote"), <span className="text-[18px]">💬</span>)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isPhone) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={t("chat.scope.title")}>
|
||||
<button type="button" className="absolute inset-0 bg-black/45" aria-label={t("common.close")} onClick={onClose} />
|
||||
<div className="glass-panel absolute inset-x-0 bottom-0 max-h-[85dvh] overflow-y-auto rounded-b-none px-4 pb-safe pt-2">
|
||||
<div className="sheet-grabber mx-auto" aria-hidden />
|
||||
<h2 className="px-1 pb-3 pt-2 text-[15px] font-semibold text-[var(--text)]">{t("chat.scope.title")}</h2>
|
||||
{body}
|
||||
<p className="px-1 py-3 text-[11.5px] text-[var(--text-subtle)]">{t("chat.scope.note")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="glass-panel absolute bottom-full left-0 z-40 mb-2 w-96 p-3"
|
||||
role="dialog"
|
||||
aria-label={t("chat.scope.title")}
|
||||
>
|
||||
<h2 className="px-1 pb-2 text-[13px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||
{t("chat.scope.title")}
|
||||
</h2>
|
||||
{body}
|
||||
<p className="px-1 pt-2 text-[11.5px] text-[var(--text-subtle)]">{t("chat.scope.note")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
components/chat/CopyIconButton.tsx
Normal file
39
components/chat/CopyIconButton.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { CheckCircleIcon, CopyIcon, XCircleIcon } from "@/components/icons";
|
||||
import { copyText } from "@/lib/clipboard";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Small square copy control used by chat message actions and code blocks. */
|
||||
export function CopyIconButton({ text, className }: { text: string; className?: string }) {
|
||||
const { t } = useI18n();
|
||||
const [state, setState] = useState<"idle" | "copied" | "failed">("idle");
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("chat.copy")}
|
||||
title={state === "copied" ? t("chat.copied") : state === "failed" ? t("chat.copyFailed") : t("chat.copy")}
|
||||
onClick={async () => {
|
||||
const ok = await copyText(text);
|
||||
setState(ok ? "copied" : "failed");
|
||||
window.setTimeout(() => setState("idle"), 1800);
|
||||
}}
|
||||
className={cn(
|
||||
"grid size-9 shrink-0 place-items-center rounded-xl border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{state === "copied" ? (
|
||||
<CheckCircleIcon className="size-4 text-emerald-500" />
|
||||
) : state === "failed" ? (
|
||||
<XCircleIcon className="size-4 text-rose-500" />
|
||||
) : (
|
||||
<CopyIcon className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
316
components/chat/Markdown.tsx
Normal file
316
components/chat/Markdown.tsx
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
"use client";
|
||||
|
||||
import { memo, useMemo } from "react";
|
||||
|
||||
import { CopyIconButton } from "./CopyIconButton";
|
||||
import { MathSpan } from "./MathSpan";
|
||||
|
||||
/**
|
||||
* Safe, dependency-free markdown for chat answers: lists, tables, code blocks
|
||||
* (with a copy button), quotes, headings, inline code/bold/italic/links and
|
||||
* $...$ math. It parses to React nodes — nothing is ever set as HTML.
|
||||
*/
|
||||
|
||||
type Block =
|
||||
| { type: "p"; lines: string[] }
|
||||
| { type: "heading"; level: number; text: string }
|
||||
| { type: "list"; ordered: boolean; items: { text: string; depth: number }[] }
|
||||
| { type: "code"; lang: string; code: string }
|
||||
| { type: "table"; head: string[]; rows: string[][] }
|
||||
| { type: "quote"; lines: string[] }
|
||||
| { type: "math"; tex: string }
|
||||
| { type: "hr" };
|
||||
|
||||
const FENCE = /^(```|~~~)\s*([\w+-]*)\s*$/;
|
||||
const HEADING = /^(#{1,6})\s+(.*)$/;
|
||||
const LIST_ITEM = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/;
|
||||
const QUOTE = /^>\s?(.*)$/;
|
||||
const HR = /^\s*([-*_])\1{2,}\s*$/;
|
||||
|
||||
function isTableSeparator(line: string): boolean {
|
||||
return /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/.test(line) && line.includes("-");
|
||||
}
|
||||
|
||||
function splitTableRow(line: string): string[] {
|
||||
const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
|
||||
return trimmed.split("|").map((cell) => cell.trim());
|
||||
}
|
||||
|
||||
export function parseBlocks(text: string): Block[] {
|
||||
const blocks: Block[] = [];
|
||||
const lines = text.replace(/\r\n/g, "\n").split("\n");
|
||||
let index = 0;
|
||||
|
||||
const flushParagraph = (collected: string[]) => {
|
||||
const joined = collected.join("\n").trim();
|
||||
if (joined) blocks.push({ type: "p", lines: collected });
|
||||
};
|
||||
|
||||
let paragraph: string[] = [];
|
||||
|
||||
while (index < lines.length) {
|
||||
const line = lines[index];
|
||||
|
||||
const fence = FENCE.exec(line);
|
||||
if (fence) {
|
||||
flushParagraph(paragraph);
|
||||
paragraph = [];
|
||||
const lang = fence[2] ?? "";
|
||||
const code: string[] = [];
|
||||
index += 1;
|
||||
while (index < lines.length && !FENCE.test(lines[index])) {
|
||||
code.push(lines[index]);
|
||||
index += 1;
|
||||
}
|
||||
index += 1; // closing fence (or end of stream)
|
||||
blocks.push({ type: "code", lang, code: code.join("\n") });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (HR.test(line)) {
|
||||
flushParagraph(paragraph);
|
||||
paragraph = [];
|
||||
blocks.push({ type: "hr" });
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const heading = HEADING.exec(line);
|
||||
if (heading) {
|
||||
flushParagraph(paragraph);
|
||||
paragraph = [];
|
||||
blocks.push({ type: "heading", level: heading[1].length, text: heading[2].trim() });
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// $$ ... $$ on its own line(s)
|
||||
if (line.trim().startsWith("$$")) {
|
||||
flushParagraph(paragraph);
|
||||
paragraph = [];
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.length > 4 && trimmed.endsWith("$$")) {
|
||||
blocks.push({ type: "math", tex: trimmed.slice(2, -2).trim() });
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
const math: string[] = [trimmed.slice(2)];
|
||||
index += 1;
|
||||
while (index < lines.length && !lines[index].trim().endsWith("$$")) {
|
||||
math.push(lines[index]);
|
||||
index += 1;
|
||||
}
|
||||
if (index < lines.length) {
|
||||
math.push(lines[index].trim().slice(0, -2));
|
||||
index += 1;
|
||||
}
|
||||
blocks.push({ type: "math", tex: math.join("\n").trim() });
|
||||
continue;
|
||||
}
|
||||
|
||||
const quote = QUOTE.exec(line);
|
||||
if (quote) {
|
||||
flushParagraph(paragraph);
|
||||
paragraph = [];
|
||||
const collected = [quote[1]];
|
||||
index += 1;
|
||||
while (index < lines.length) {
|
||||
const next = QUOTE.exec(lines[index]);
|
||||
if (!next) break;
|
||||
collected.push(next[1]);
|
||||
index += 1;
|
||||
}
|
||||
blocks.push({ type: "quote", lines: collected });
|
||||
continue;
|
||||
}
|
||||
|
||||
const item = LIST_ITEM.exec(line);
|
||||
if (item) {
|
||||
flushParagraph(paragraph);
|
||||
paragraph = [];
|
||||
const ordered = /\d/.test(item[2]);
|
||||
const items: { text: string; depth: number }[] = [];
|
||||
while (index < lines.length) {
|
||||
const next = LIST_ITEM.exec(lines[index]);
|
||||
if (!next) break;
|
||||
const depth = Math.min(2, Math.floor(next[1].replace(/\t/g, " ").length / 2));
|
||||
items.push({ text: next[3], depth });
|
||||
index += 1;
|
||||
}
|
||||
blocks.push({ type: "list", ordered, items });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Table: header row followed by a separator row.
|
||||
if (line.includes("|") && index + 1 < lines.length && isTableSeparator(lines[index + 1])) {
|
||||
flushParagraph(paragraph);
|
||||
paragraph = [];
|
||||
const head = splitTableRow(line);
|
||||
index += 2;
|
||||
const rows: string[][] = [];
|
||||
while (index < lines.length && lines[index].includes("|") && lines[index].trim()) {
|
||||
rows.push(splitTableRow(lines[index]));
|
||||
index += 1;
|
||||
}
|
||||
blocks.push({ type: "table", head, rows });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!line.trim()) {
|
||||
flushParagraph(paragraph);
|
||||
paragraph = [];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
paragraph.push(line);
|
||||
index += 1;
|
||||
}
|
||||
flushParagraph(paragraph);
|
||||
return blocks;
|
||||
}
|
||||
|
||||
const INLINE =
|
||||
/(\*\*[^*]+\*\*|__[^_]+__|`[^`]+`|\[[^\]]+\]\([^)\s]+\)|\$\$[^$]+\$\$|\$[^$\n]+\$|\*[^*\n]+\*|_[^_\n]+_)/g;
|
||||
|
||||
function inlineNodes(text: string, keyPrefix: string): React.ReactNode[] {
|
||||
return text.split(INLINE).map((part, index) => {
|
||||
const key = `${keyPrefix}-${index}`;
|
||||
if (!part) return null;
|
||||
if (part.startsWith("**") && part.endsWith("**") && part.length > 4) {
|
||||
return <strong key={key}>{part.slice(2, -2)}</strong>;
|
||||
}
|
||||
if (part.startsWith("__") && part.endsWith("__") && part.length > 4) {
|
||||
return <strong key={key}>{part.slice(2, -2)}</strong>;
|
||||
}
|
||||
if (part.startsWith("`") && part.endsWith("`") && part.length > 2) {
|
||||
return (
|
||||
<code key={key} className="rounded-md bg-[var(--surface-sunken)] px-1.5 py-0.5 font-mono text-[0.85em]">
|
||||
{part.slice(1, -1)}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
if (part.startsWith("$$") && part.endsWith("$$") && part.length > 4) {
|
||||
return <MathSpan key={key} tex={part.slice(2, -2).trim()} display />;
|
||||
}
|
||||
if (part.startsWith("$") && part.endsWith("$") && part.length > 2) {
|
||||
return <MathSpan key={key} tex={part.slice(1, -1)} />;
|
||||
}
|
||||
const link = /^\[([^\]]+)\]\(([^)\s]+)\)$/.exec(part);
|
||||
if (link) {
|
||||
const href = link[2];
|
||||
if (/^https?:\/\//i.test(href)) {
|
||||
return (
|
||||
<a
|
||||
key={key}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium text-accent-600 underline decoration-accent-400/50 underline-offset-2 dark:text-accent-300"
|
||||
>
|
||||
{link[1]}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return <span key={key}>{link[1]}</span>;
|
||||
}
|
||||
if (part.startsWith("*") && part.endsWith("*") && part.length > 2) {
|
||||
return <em key={key}>{part.slice(1, -1)}</em>;
|
||||
}
|
||||
if (part.startsWith("_") && part.endsWith("_") && part.length > 2) {
|
||||
return <em key={key}>{part.slice(1, -1)}</em>;
|
||||
}
|
||||
return <span key={key}>{part}</span>;
|
||||
});
|
||||
}
|
||||
|
||||
function renderBlock(block: Block, index: number) {
|
||||
const key = `b-${index}`;
|
||||
switch (block.type) {
|
||||
case "heading": {
|
||||
const Tag = (block.level <= 2 ? "h3" : "h4") as "h3" | "h4";
|
||||
return (
|
||||
<Tag key={key} className="text-[15px] font-semibold text-[var(--text)]">
|
||||
{inlineNodes(block.text, key)}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
case "list": {
|
||||
const Tag = block.ordered ? "ol" : "ul";
|
||||
return (
|
||||
<Tag key={key} className={block.ordered ? "list-decimal space-y-1 pl-5" : "list-disc space-y-1 pl-5"}>
|
||||
{block.items.map((item, itemIndex) => (
|
||||
<li
|
||||
key={`${key}-${itemIndex}`}
|
||||
className={item.depth > 0 ? "ml-4 list-[circle]" : undefined}
|
||||
>
|
||||
{inlineNodes(item.text, `${key}-${itemIndex}`)}
|
||||
</li>
|
||||
))}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
case "code":
|
||||
return (
|
||||
<div key={key} className="group relative">
|
||||
<pre className="overflow-x-auto rounded-xl border border-[var(--hairline-soft)] bg-[var(--surface-sunken)] p-3 text-[12.5px] leading-relaxed">
|
||||
<code className="font-mono">{block.code}</code>
|
||||
</pre>
|
||||
<div className="absolute right-2 top-2 opacity-90">
|
||||
<CopyIconButton text={block.code} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case "table":
|
||||
return (
|
||||
<div key={key} className="overflow-x-auto">
|
||||
<table className="w-full min-w-[280px] border-collapse text-[13px]">
|
||||
<thead>
|
||||
<tr>
|
||||
{block.head.map((cell, cellIndex) => (
|
||||
<th
|
||||
key={`${key}-h-${cellIndex}`}
|
||||
className="border-b border-[var(--hairline)] px-2 py-1.5 text-left font-semibold text-[var(--text)]"
|
||||
>
|
||||
{inlineNodes(cell, `${key}-h-${cellIndex}`)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{block.rows.map((row, rowIndex) => (
|
||||
<tr key={`${key}-r-${rowIndex}`}>
|
||||
{block.head.map((_, cellIndex) => (
|
||||
<td key={`${key}-r-${rowIndex}-${cellIndex}`} className="border-b border-[var(--hairline-soft)] px-2 py-1.5 align-top">
|
||||
{inlineNodes(row[cellIndex] ?? "", `${key}-r-${rowIndex}-${cellIndex}`)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
case "quote":
|
||||
return (
|
||||
<blockquote key={key} className="border-l-2 border-[var(--hairline)] pl-3 text-[var(--text-muted)]">
|
||||
{inlineNodes(block.lines.join("\n"), key)}
|
||||
</blockquote>
|
||||
);
|
||||
case "math":
|
||||
return <MathSpan key={key} tex={block.tex} display />;
|
||||
case "hr":
|
||||
return <hr key={key} className="border-[var(--hairline-soft)]" />;
|
||||
default:
|
||||
return (
|
||||
<p key={key} className="whitespace-pre-wrap break-words">
|
||||
{inlineNodes(block.lines.join("\n"), key)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const Markdown = memo(function Markdown({ text }: { text: string }) {
|
||||
const blocks = useMemo(() => parseBlocks(text), [text]);
|
||||
return <div className="chat-md space-y-3 text-[15px] leading-relaxed text-[var(--text)]">{blocks.map(renderBlock)}</div>;
|
||||
});
|
||||
41
components/chat/MathSpan.tsx
Normal file
41
components/chat/MathSpan.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Math rendering, loaded only when a message actually contains math: KaTeX is
|
||||
* imported dynamically (its CSS with it) the first time a span mounts and
|
||||
* writes into this span imperatively — no HTML injection anywhere.
|
||||
*/
|
||||
export function MathSpan({ tex, display = false }: { tex: string; display?: boolean }) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const [katex] = await Promise.all([import("katex"), import("katex/dist/katex.min.css")]);
|
||||
if (cancelled || !ref.current) return;
|
||||
katex.default.render(tex, ref.current, {
|
||||
displayMode: display,
|
||||
throwOnError: false,
|
||||
strict: false,
|
||||
trust: false,
|
||||
});
|
||||
} catch {
|
||||
if (!cancelled && ref.current) ref.current.textContent = tex;
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [tex, display]);
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className={display ? "my-2 block overflow-x-auto text-center" : "inline-block max-w-full align-middle"}
|
||||
aria-label={tex}
|
||||
/>
|
||||
);
|
||||
}
|
||||
132
components/chat/MessageRow.tsx
Normal file
132
components/chat/MessageRow.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { CopyIconButton } from "./CopyIconButton";
|
||||
import { Markdown } from "./Markdown";
|
||||
import { ThinkingRow } from "./ThinkingRow";
|
||||
import { Button } from "@/components/ui";
|
||||
import { ModelErrorActions } from "@/components/ModelErrorActions";
|
||||
import { PencilIcon, RefreshIcon } from "@/components/icons";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { ChatMessage } from "@/lib/chat";
|
||||
|
||||
/**
|
||||
* One turn: user bubbles on the right, assistant answers as full-width text
|
||||
* (no bubble) with the Thinking row, sources, actions and error recovery.
|
||||
*/
|
||||
export function MessageRow({
|
||||
message,
|
||||
streaming,
|
||||
isLastAssistant,
|
||||
isLastUser,
|
||||
busy,
|
||||
onRegenerate,
|
||||
onEdit,
|
||||
onRetry,
|
||||
}: {
|
||||
message: ChatMessage;
|
||||
streaming?: boolean;
|
||||
isLastAssistant?: boolean;
|
||||
isLastUser?: boolean;
|
||||
busy?: boolean;
|
||||
onRegenerate?: () => void;
|
||||
onEdit?: () => void;
|
||||
onRetry?: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
|
||||
if (message.role === "user") {
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<div className="max-w-[88%] rounded-3xl rounded-br-lg border border-[var(--glass-border)] bg-[var(--surface-strong)] px-3.5 py-2.5">
|
||||
<p className="whitespace-pre-wrap break-words text-[15px] leading-relaxed text-[var(--text)]">{message.content}</p>
|
||||
{message.attachments && message.attachments.length > 0 ? (
|
||||
<p className="mt-1.5 text-[11.5px] text-[var(--text-subtle)]">
|
||||
{message.attachments.map((attachment) => attachment.name).join(" · ")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{isLastUser && !busy && onEdit ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
className="flex items-center gap-1 rounded-lg px-1.5 py-1 text-[11.5px] text-[var(--text-subtle)] transition hover:text-[var(--text)]"
|
||||
>
|
||||
<PencilIcon className="size-3.5" />
|
||||
{t("chat.edit")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{message.thinking ? <ThinkingRow thinking={message.thinking} streaming={streaming && !message.content} /> : null}
|
||||
{message.content ? (
|
||||
<Markdown text={message.content} />
|
||||
) : streaming ? (
|
||||
<span className="flex items-center gap-1 py-1" role="status" aria-label={t("chat.thinking")}>
|
||||
{[0, 1, 2].map((dot) => (
|
||||
<span
|
||||
key={dot}
|
||||
className="size-1.5 animate-bounce rounded-full bg-[var(--text-subtle)] motion-reduce:animate-none motion-reduce:opacity-60"
|
||||
style={{ animationDelay: `${dot * 120}ms` }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{message.error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-2xl border border-rose-300/60 bg-rose-50/70 p-3 dark:border-rose-400/30 dark:bg-rose-500/10"
|
||||
>
|
||||
<p className="text-[13px] leading-relaxed text-rose-800 dark:text-rose-200">{message.error.message}</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
{onRetry ? (
|
||||
<Button size="sm" variant="secondary" onClick={onRetry}>
|
||||
<RefreshIcon className="size-4" /> {t("chat.retry")}
|
||||
</Button>
|
||||
) : null}
|
||||
<ModelErrorActions kind={message.error.kind} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!streaming && message.content && !message.error ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<CopyIconButton text={message.content} />
|
||||
{isLastAssistant && !busy && onRegenerate ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRegenerate}
|
||||
className="flex items-center gap-1.5 rounded-xl border border-[var(--glass-border)] bg-[var(--surface-strong)] px-2.5 py-2 text-[12px] font-medium text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
<RefreshIcon className="size-3.5" />
|
||||
{t("chat.regenerate")}
|
||||
</button>
|
||||
) : null}
|
||||
{message.model ? <span className="text-[11px] text-[var(--text-subtle)]">{message.model}</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{message.sources && message.sources.length > 0 && !streaming ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-[11px] uppercase tracking-wide text-[var(--text-subtle)]">{t("chat.sources")}</span>
|
||||
{message.sources.slice(0, 5).map((source, index) => (
|
||||
<Link
|
||||
key={`${source.deckId}-${source.section}-${index}`}
|
||||
href={`/deck/${source.deckId}`}
|
||||
className="max-w-[220px] truncate rounded-lg border border-[var(--hairline-soft)] bg-[var(--surface-sunken)] px-2 py-1 text-[11.5px] text-[var(--text-muted)] transition hover:text-[var(--text)]"
|
||||
title={`${source.deckTitle} — ${source.section}`}
|
||||
>
|
||||
{source.deckTitle} · {source.section}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
components/chat/ModelPicker.tsx
Normal file
115
components/chat/ModelPicker.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { CheckCircleIcon } from "@/components/icons";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { useIsPhone } from "@/lib/use-phone";
|
||||
import type { ChatModelChoice } from "@/lib/chat";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Model chip picker: a bottom sheet on phones, a small dropdown on desktop.
|
||||
* The list is the server's CHAT_MODELS allowlist (from the health check) with
|
||||
* text/vision badges and availability; picking one remembers it.
|
||||
*/
|
||||
export function ModelPicker({
|
||||
open,
|
||||
onClose,
|
||||
models,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
models: ChatModelChoice[];
|
||||
value: string;
|
||||
onChange: (model: string) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const isPhone = useIsPhone();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const badge = (model: ChatModelChoice) =>
|
||||
model.badge === "vision" ? t("chat.modelVision") : model.badge === "both" ? t("chat.modelBoth") : t("chat.modelText");
|
||||
|
||||
const rows = (
|
||||
<ul className="max-h-[60dvh] space-y-1 overflow-y-auto overscroll-contain">
|
||||
{models.map((model) => {
|
||||
const selected = model.name === value;
|
||||
const unavailable = model.available === false;
|
||||
return (
|
||||
<li key={model.name}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={unavailable}
|
||||
aria-pressed={selected}
|
||||
onClick={() => {
|
||||
onChange(model.name);
|
||||
onClose();
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left transition",
|
||||
selected
|
||||
? "border-[var(--nav-active-border)] bg-[var(--nav-active-bg)]"
|
||||
: "border-[var(--hairline-soft)] bg-[var(--surface-bg)] hover:bg-[var(--hover)]",
|
||||
unavailable && "opacity-55",
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[13.5px] font-semibold text-[var(--text)]">{model.name}</span>
|
||||
<span className="mt-0.5 flex items-center gap-2 text-[11.5px] text-[var(--text-subtle)]">
|
||||
<span className="rounded-md border border-[var(--hairline-soft)] px-1.5 py-0.5">{badge(model)}</span>
|
||||
{model.available === true ? (
|
||||
<span className="text-emerald-600 dark:text-emerald-300">{t("chat.modelAvailable")}</span>
|
||||
) : model.available === false ? (
|
||||
<span className="text-amber-600 dark:text-amber-300">{t("chat.modelUnavailable")}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
{selected ? <CheckCircleIcon className="size-5 shrink-0 text-accent-500" /> : null}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
|
||||
if (isPhone) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={t("chat.modelTitle")}>
|
||||
<button type="button" className="absolute inset-0 bg-black/45" aria-label={t("common.close")} onClick={onClose} />
|
||||
<div className="glass-panel absolute inset-x-0 bottom-0 max-h-[80dvh] rounded-b-none px-4 pb-safe pt-2">
|
||||
<div className="sheet-grabber mx-auto" aria-hidden />
|
||||
<h2 className="px-1 pb-3 pt-2 text-[15px] font-semibold text-[var(--text)]">{t("chat.modelTitle")}</h2>
|
||||
{rows}
|
||||
<p className="px-1 py-3 text-[11.5px] text-[var(--text-subtle)]">{t("chat.modelNote")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="glass-panel absolute bottom-full right-0 z-40 mb-2 w-80 p-3"
|
||||
role="dialog"
|
||||
aria-label={t("chat.modelTitle")}
|
||||
>
|
||||
<h2 className="px-1 pb-2 text-[13px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||
{t("chat.modelTitle")}
|
||||
</h2>
|
||||
{rows}
|
||||
<p className="px-1 pt-2 text-[11.5px] text-[var(--text-subtle)]">{t("chat.modelNote")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
components/chat/ThinkingRow.tsx
Normal file
36
components/chat/ThinkingRow.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { ChevronDownIcon, SparklesIcon } from "@/components/icons";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Reasoning models stream `thinking` separately from the answer. It is hidden
|
||||
* by default behind this row — the answer itself is never the raw reasoning.
|
||||
*/
|
||||
export function ThinkingRow({ thinking, streaming }: { thinking: string; streaming?: boolean }) {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-[var(--hairline-soft)] bg-[var(--surface-sunken)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
aria-expanded={open}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-[12.5px] font-medium text-[var(--text-muted)] transition hover:text-[var(--text)]"
|
||||
>
|
||||
<SparklesIcon className={cn("size-3.5 shrink-0", streaming && "animate-pulse")} />
|
||||
<span>{streaming ? t("chat.thinking") : t("chat.thought")}</span>
|
||||
<ChevronDownIcon className={cn("ml-auto size-4 transition-transform", open && "rotate-180")} />
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="whitespace-pre-wrap break-words px-3 pb-3 text-[12.5px] leading-relaxed text-[var(--text-subtle)]">
|
||||
{thinking}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui";
|
||||
import { AskButton } from "@/components/chat/AskButton";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { HomeworkCheckRecord } from "@/lib/schemas";
|
||||
|
||||
|
|
@ -97,6 +98,27 @@ export function HomeworkCheckResult({
|
|||
</article>
|
||||
|
||||
<div className="flex flex-wrap items-start gap-2">
|
||||
<AskButton
|
||||
seed={{
|
||||
label: record.title,
|
||||
text: [
|
||||
`${t("chat.seedAssignment")}:`,
|
||||
record.sourceText,
|
||||
"",
|
||||
`${t("chat.seedAttempt")}:`,
|
||||
record.attempt ?? "",
|
||||
"",
|
||||
`${t("chat.seedVerdict")}:`,
|
||||
record.verdict,
|
||||
...(record.wrong.length > 0
|
||||
? ["", `${t("chat.seedToFix")}:`, ...record.wrong.map((entry) => `- ${entry.issue}: ${entry.why} → ${entry.fix}`)]
|
||||
: []),
|
||||
"",
|
||||
`${t("chat.seedCorrected")}:`,
|
||||
corrected,
|
||||
].join("\n"),
|
||||
}}
|
||||
/>
|
||||
<Button variant="secondary" className="touch-target" onClick={onAgain} loading={againLoading}>
|
||||
{t("homework.followup.redo")}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { ActivityRenderer } from "@/components/activities";
|
|||
import { PromptCard, QuestionText } from "@/components/activities/shared";
|
||||
import { XIcon } from "@/components/icons";
|
||||
import { Button, ProgressBar } from "@/components/ui";
|
||||
import { AskButton } from "@/components/chat/AskButton";
|
||||
import { assembleSolution } from "@/lib/homework";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { HomeworkGuideRecord, HomeworkStep } from "@/lib/schemas";
|
||||
|
|
@ -283,6 +284,18 @@ export function HomeworkGuide({
|
|||
<p className="text-[12.5px] text-[var(--text-subtle)]">{t("homework.guide.solutionNote")}</p>
|
||||
<div className="flex flex-wrap items-start gap-2">
|
||||
<CopyButton text={solution} />
|
||||
<AskButton
|
||||
seed={{
|
||||
label: record.title,
|
||||
text: [
|
||||
`${t("chat.seedAssignment")}:`,
|
||||
record.sourceText,
|
||||
"",
|
||||
`${t("chat.seedSteps")}:`,
|
||||
...steps.map((step, index) => `${index + 1}. ${step.title}${step.explanation ? ` — ${step.explanation}` : ""}`),
|
||||
].join("\n"),
|
||||
}}
|
||||
/>
|
||||
<Button variant="secondary" className="touch-target" onClick={onAgain} loading={againLoading}>
|
||||
{t("homework.followup.redo")}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui";
|
||||
import { AskButton } from "@/components/chat/AskButton";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { HomeworkMakeRecord } from "@/lib/schemas";
|
||||
import { shareHomework } from "@/lib/share";
|
||||
|
|
@ -54,6 +55,18 @@ export function HomeworkResult({
|
|||
<Button variant="secondary" className="touch-target" onClick={() => void shareHomework({ ...record, answer })}>
|
||||
{t("deck.share")}
|
||||
</Button>
|
||||
<AskButton
|
||||
seed={{
|
||||
label: record.title,
|
||||
text: [
|
||||
`${t("chat.seedAssignment")}:`,
|
||||
record.sourceText,
|
||||
"",
|
||||
`${t("chat.seedAnswer")}:`,
|
||||
answer,
|
||||
].join("\n"),
|
||||
}}
|
||||
/>
|
||||
<Button variant="secondary" className="touch-target" onClick={onAgain} loading={againLoading}>
|
||||
{t("homework.followup.redo")}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -316,6 +316,70 @@ export function AddToHomeIcon({ className }: IconProps) {
|
|||
);
|
||||
}
|
||||
|
||||
/* --- Chat --------------------------------------------------------------- */
|
||||
|
||||
export function ChatIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M20 12a7.5 7.5 0 0 1-7.5 7.5H8.2L4 22v-4.6A7.5 7.5 0 0 1 12.5 4.5 7.5 7.5 0 0 1 20 12Z" />
|
||||
<path d="M9 10.5h7M9 13.8h4.5" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SendIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M12 19V5" />
|
||||
<path d="m6 11 6-6 6 6" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function StopIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<rect x="7" y="7" width="10" height="10" rx="2" fill="currentColor" stroke="none" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CopyIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<rect x="9" y="9" width="11" height="11" rx="2.5" />
|
||||
<path d="M5.5 15H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1v.5" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PaperclipIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="m20 11.5-7.8 7.8a5 5 0 0 1-7-7l8.3-8.3a3.3 3.3 0 0 1 4.7 4.7l-8.3 8.3a1.7 1.7 0 0 1-2.4-2.4l7.4-7.4" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PencilIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M4 20h4.2L20 8.2a2.1 2.1 0 0 0-3-3L5.2 17 4 20Z" />
|
||||
<path d="m14.5 6.7 2.8 2.8" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function HistoryIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M4.5 12a7.5 7.5 0 1 1 2.2 5.3" />
|
||||
<path d="M4.5 12H2m2.5 0 2.3-2.3M4.5 12l2.3 2.3" />
|
||||
<path d="M12 8.5V12l2.4 1.6" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChromeIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
|
|
|
|||
105
lib/chat-client.ts
Normal file
105
lib/chat-client.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
"use client";
|
||||
|
||||
import { ApiError, consumeNdjson } from "./client-api";
|
||||
import { localeHeader, t } from "./i18n";
|
||||
import { authHeaders } from "./settings";
|
||||
import type { ChatSource } from "./chat";
|
||||
|
||||
/**
|
||||
* Client for the streaming chat route. The conversation window and the chosen
|
||||
* context chunks travel with every request (the server never stores either);
|
||||
* the API key only ever goes to our own route in its usual header.
|
||||
*/
|
||||
|
||||
export type ChatStreamEvent =
|
||||
| { type: "token"; text: string }
|
||||
| { type: "thinking"; text: string }
|
||||
| { type: "done"; model: string }
|
||||
| { type: "error"; error: string; kind?: string };
|
||||
|
||||
export type StreamChatInput = {
|
||||
model?: string;
|
||||
messages: { role: "user" | "assistant"; content: string }[];
|
||||
context: (ChatSource & { text: string })[];
|
||||
signal?: AbortSignal;
|
||||
onToken: (text: string) => void;
|
||||
onThinking: (text: string) => void;
|
||||
onDone: (model: string) => void;
|
||||
};
|
||||
|
||||
export async function streamChat(input: StreamChatInput): Promise<void> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch("/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...authHeaders(), ...localeHeader() },
|
||||
body: JSON.stringify({
|
||||
model: input.model || undefined,
|
||||
messages: input.messages,
|
||||
context: input.context,
|
||||
}),
|
||||
signal: input.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new ApiError(t("api.cancelled"), "aborted");
|
||||
}
|
||||
throw new ApiError(t("api.unreachable"), "network");
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as { error?: string; kind?: string } | null;
|
||||
throw new ApiError(body?.error ?? t("api.httpError", { status: response.status }), body?.kind);
|
||||
}
|
||||
|
||||
let streamError: ApiError | null = null;
|
||||
try {
|
||||
await consumeNdjson(response, (raw) => {
|
||||
const event = raw as ChatStreamEvent;
|
||||
if (event.type === "token") input.onToken(event.text);
|
||||
else if (event.type === "thinking") input.onThinking(event.text);
|
||||
else if (event.type === "done") input.onDone(event.model);
|
||||
else if (event.type === "error") streamError = new ApiError(event.error, event.kind);
|
||||
});
|
||||
} catch (error) {
|
||||
// Aborting while the body is being read rejects the reader with an
|
||||
// AbortError, not a fetch rejection — treat it as a user stop.
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new ApiError(t("api.cancelled"), "aborted");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (streamError) throw streamError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns attached photos (and any client-extracted document text) into plain
|
||||
* text through the existing vision pipeline, so the chat model itself never
|
||||
* needs to be multimodal.
|
||||
*/
|
||||
export async function extractChatAttachment(
|
||||
input: { images?: string[]; text?: string },
|
||||
options: { signal?: AbortSignal } = {},
|
||||
): Promise<string> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch("/api/chat/extract", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...authHeaders(), ...localeHeader() },
|
||||
body: JSON.stringify(input),
|
||||
signal: options.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new ApiError(t("api.cancelled"), "aborted");
|
||||
}
|
||||
throw new ApiError(t("api.unreachable"), "network");
|
||||
}
|
||||
const body = (await response.json().catch(() => null)) as { text?: string; error?: string; kind?: string } | null;
|
||||
if (!response.ok) {
|
||||
throw new ApiError(body?.error ?? t("api.httpError", { status: response.status }), body?.kind);
|
||||
}
|
||||
if (!body?.text?.trim()) throw new ApiError(t("chat.attachEmpty"), "bad_response");
|
||||
return body.text.trim();
|
||||
}
|
||||
258
lib/chat-context.ts
Normal file
258
lib/chat-context.ts
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
"use client";
|
||||
|
||||
import type { ChatMessage, ChatScope, ChatSource } from "./chat";
|
||||
import { estimateTokens } from "./chat-trim";
|
||||
import type { Deck } from "./schemas";
|
||||
|
||||
/**
|
||||
* Client-side, dependency-free retrieval over the user's own notes.
|
||||
*
|
||||
* The server stays stateless: the chat route never reads the notes itself.
|
||||
* This module chunks the scoped decks (~800 tokens per chunk), ranks the
|
||||
* chunks against the question plus the recent turns with a small BM25 scorer
|
||||
* and returns the top chunks under a token budget, each labelled with its
|
||||
* deck and section. Only what comes back from here is sent to the model.
|
||||
*/
|
||||
|
||||
export type ContextChunk = ChatSource & { text: string; score: number };
|
||||
|
||||
const CHUNK_CHARS = 3200; // ≈ 800 tokens
|
||||
const CHUNK_OVERLAP = 320;
|
||||
const DEFAULT_BUDGET_TOKENS = 7000;
|
||||
const MAX_CHUNKS = 24;
|
||||
const K1 = 1.2;
|
||||
const B = 0.75;
|
||||
|
||||
export function deckHasNotes(deck: Deck): boolean {
|
||||
return Boolean(deck.sourceText?.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Older decks (or imported ones) may have no notes text at all. Their
|
||||
* generated content is the fallback — the UI says so explicitly.
|
||||
*/
|
||||
export function generatedDeckText(deck: Deck): string {
|
||||
const lines: string[] = [];
|
||||
if (deck.title) lines.push(`Deck: ${deck.title}`);
|
||||
if (deck.subject) lines.push(`Subject: ${deck.subject}`);
|
||||
if (deck.summary) lines.push(`Summary: ${deck.summary}`);
|
||||
const topics = new Set<string>();
|
||||
for (const activity of deck.activities) {
|
||||
if (activity.topic) topics.add(activity.topic);
|
||||
}
|
||||
if (topics.size) lines.push(`Topics: ${[...topics].join(", ")}`);
|
||||
lines.push("Generated study content:");
|
||||
for (const activity of deck.activities) {
|
||||
lines.push(...activityToLines(activity));
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function activityToLines(activity: Deck["activities"][number]): string[] {
|
||||
const out: string[] = [];
|
||||
switch (activity.type) {
|
||||
case "multiple_choice":
|
||||
out.push(`- Q: ${activity.question} → ${activity.options[activity.correctIndex] ?? ""}`);
|
||||
break;
|
||||
case "true_false":
|
||||
out.push(`- ${activity.statement} → ${activity.answer ? "true" : "false"}`);
|
||||
break;
|
||||
case "fill_blank":
|
||||
out.push(`- ${activity.sentence} → ${activity.answer}`);
|
||||
break;
|
||||
case "matching":
|
||||
out.push(`- ${activity.instruction}`);
|
||||
for (const pair of activity.pairs) out.push(` · ${pair.term} → ${pair.definition}`);
|
||||
break;
|
||||
case "ordering":
|
||||
out.push(`- ${activity.instruction}: ${activity.items.join(" → ")}`);
|
||||
break;
|
||||
case "short_answer":
|
||||
out.push(`- Q: ${activity.question} → ${activity.referenceAnswer}`);
|
||||
break;
|
||||
case "flashcard":
|
||||
out.push(`- ${activity.front} → ${activity.back}`);
|
||||
break;
|
||||
}
|
||||
out.push(` (${activity.explanation})`);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a deck's text into chunks. `### name` headings (added by the document
|
||||
* extractor) become section labels; anything else is numbered.
|
||||
*/
|
||||
function chunkText(text: string): { section: string; text: string }[] {
|
||||
const sections: { section: string; text: string }[] = [];
|
||||
let currentSection = "Notes";
|
||||
let current: string[] = [];
|
||||
const flush = () => {
|
||||
const joined = current.join("\n").trim();
|
||||
if (joined) sections.push({ section: currentSection, text: joined });
|
||||
current = [];
|
||||
};
|
||||
for (const line of text.split("\n")) {
|
||||
const heading = /^#{1,4}\s+(.+)$/.exec(line.trim());
|
||||
if (heading) {
|
||||
flush();
|
||||
currentSection = heading[1].trim().slice(0, 120) || "Notes";
|
||||
continue;
|
||||
}
|
||||
current.push(line);
|
||||
}
|
||||
flush();
|
||||
|
||||
const chunks: { section: string; text: string }[] = [];
|
||||
for (const section of sections) {
|
||||
if (section.text.length <= CHUNK_CHARS) {
|
||||
chunks.push(section);
|
||||
continue;
|
||||
}
|
||||
let start = 0;
|
||||
let part = 1;
|
||||
while (start < section.text.length) {
|
||||
const end = Math.min(section.text.length, start + CHUNK_CHARS);
|
||||
const slice = section.text.slice(start, end).trim();
|
||||
if (slice) {
|
||||
chunks.push({
|
||||
section: section.section === "Notes" ? `Part ${part}` : `${section.section} · ${part}`,
|
||||
text: slice,
|
||||
});
|
||||
}
|
||||
part += 1;
|
||||
if (end >= section.text.length) break;
|
||||
start = end - CHUNK_OVERLAP;
|
||||
}
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function tokenise(text: string): string[] {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^\p{L}\p{N}\s]/gu, " ")
|
||||
.split(/\s+/)
|
||||
.filter((word) => word.length > 2 && !STOPWORDS.has(word));
|
||||
}
|
||||
|
||||
const STOPWORDS = new Set([
|
||||
"the", "and", "for", "are", "was", "were", "with", "that", "this", "from",
|
||||
"have", "has", "had", "not", "you", "your", "what", "when", "where", "which",
|
||||
"how", "why", "who", "can", "could", "would", "should", "about", "into",
|
||||
"than", "then", "them", "they", "there", "here", "does", "did", "doing",
|
||||
"jak", "kdy", "kde", "kter", "která", "které", "který", "což", "podle",
|
||||
"jako", "jsou", "byl", "byla", "bylo", "být", "mám", "máte", "může",
|
||||
"proč", "proto", "tak", "také", "nebo", "ale", "ani", "už", "ještě",
|
||||
]);
|
||||
|
||||
/**
|
||||
* BM25 over the chunks. The query is the new question plus the last few turns,
|
||||
* so follow-ups like "and why?" still retrieve the right material.
|
||||
*/
|
||||
function rank(chunks: { section: string; text: string }[], query: string): number[] {
|
||||
const terms = [...new Set(tokenise(query))];
|
||||
if (terms.length === 0) return chunks.map(() => 0);
|
||||
const docs = chunks.map((chunk) => tokenise(chunk.text));
|
||||
const lengths = docs.map((doc) => doc.length);
|
||||
const avg = lengths.reduce((sum, value) => sum + value, 0) / Math.max(1, lengths.length);
|
||||
const df = new Map<string, number>();
|
||||
for (const doc of docs) {
|
||||
for (const term of new Set(doc)) df.set(term, (df.get(term) ?? 0) + 1);
|
||||
}
|
||||
const N = docs.length;
|
||||
return docs.map((doc, index) => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const term of doc) counts.set(term, (counts.get(term) ?? 0) + 1);
|
||||
let score = 0;
|
||||
for (const term of terms) {
|
||||
const freq = counts.get(term);
|
||||
if (!freq) continue;
|
||||
const docFreq = df.get(term) ?? 0;
|
||||
const idf = Math.log(1 + (N - docFreq + 0.5) / (docFreq + 0.5));
|
||||
const norm = K1 * (1 - B + (B * lengths[index]) / Math.max(1, avg));
|
||||
score += (idf * (freq * (K1 + 1))) / (freq + norm);
|
||||
}
|
||||
return score;
|
||||
});
|
||||
}
|
||||
|
||||
export type ChatContextResult = {
|
||||
chunks: ContextChunk[];
|
||||
/** Decks in scope whose notes text is missing (fallback content was used). */
|
||||
missingNotes: string[];
|
||||
scopedDecks: Deck[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the context for one request: scoped decks → chunks → BM25 top-k under
|
||||
* the token budget. `extraChunks` (e.g. a study result handed over from the
|
||||
* results screen) are always kept and come first.
|
||||
*/
|
||||
export function buildChatContext(input: {
|
||||
decks: Deck[];
|
||||
scope: ChatScope;
|
||||
question: string;
|
||||
history: ChatMessage[];
|
||||
useNotes: boolean;
|
||||
extraChunks?: { deckId: string; deckTitle: string; section: string; text: string }[];
|
||||
budgetTokens?: number;
|
||||
}): ChatContextResult {
|
||||
const scopedDecks =
|
||||
input.scope.kind === "none"
|
||||
? []
|
||||
: input.scope.kind === "all"
|
||||
? input.decks.filter((deck) => deck.activities.length > 0)
|
||||
: input.decks.filter((deck) => input.scope.kind === "decks" && input.scope.deckIds.includes(deck.id));
|
||||
|
||||
const budget = input.budgetTokens ?? DEFAULT_BUDGET_TOKENS;
|
||||
const chunks: ContextChunk[] = [];
|
||||
const missingNotes: string[] = [];
|
||||
let used = 0;
|
||||
|
||||
const push = (chunk: ContextChunk) => {
|
||||
const cost = estimateTokens(chunk.text);
|
||||
if (used + cost > budget) return false;
|
||||
chunks.push(chunk);
|
||||
used += cost;
|
||||
return true;
|
||||
};
|
||||
|
||||
for (const extra of input.extraChunks ?? []) {
|
||||
push({ ...extra, score: Number.POSITIVE_INFINITY });
|
||||
}
|
||||
|
||||
const recent = input.history
|
||||
.slice(-4)
|
||||
.map((message) => message.content)
|
||||
.join("\n");
|
||||
const query = `${input.question}\n${recent}`;
|
||||
|
||||
for (const deck of scopedDecks) {
|
||||
const hasNotes = deckHasNotes(deck);
|
||||
if (!hasNotes) missingNotes.push(deck.id);
|
||||
const text = hasNotes && input.useNotes ? deck.sourceText : generatedDeckText(deck);
|
||||
const parts = chunkText(text);
|
||||
const scores = rank(parts, query);
|
||||
const ranked = parts
|
||||
.map((part, index) => ({ part, score: scores[index] }))
|
||||
.sort((a, b) => b.score - a.score);
|
||||
const top = ranked.filter((entry) => entry.score > 0).slice(0, 6);
|
||||
const ordered = top.length > 0 ? top : ranked.slice(0, 2);
|
||||
for (const entry of ordered) {
|
||||
if (chunks.length >= MAX_CHUNKS) break;
|
||||
if (!push({
|
||||
deckId: deck.id,
|
||||
deckTitle: deck.title,
|
||||
section: entry.part.section,
|
||||
text: entry.part.text,
|
||||
score: entry.score,
|
||||
})) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { chunks, missingNotes, scopedDecks };
|
||||
}
|
||||
43
lib/chat-seed.ts
Normal file
43
lib/chat-seed.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Hand-off from a study/homework result into the chat: the compact result
|
||||
* summary and the deck it belongs to travel in sessionStorage and the chat
|
||||
* picks them up on mount, pre-scoped. One-shot, like the deck seed.
|
||||
*/
|
||||
|
||||
export type ChatSeed = {
|
||||
/** Deck to scope the chat to (when the result belongs to one). */
|
||||
deckId?: string;
|
||||
/** Human label for the context banner ("Study results"). */
|
||||
label: string;
|
||||
/** Compact text sent as an extra context chunk with the first request. */
|
||||
text: string;
|
||||
};
|
||||
|
||||
const KEY = "freezmo.chat.seed.v1";
|
||||
|
||||
export function setChatSeed(seed: ChatSeed): void {
|
||||
try {
|
||||
window.sessionStorage.setItem(KEY, JSON.stringify(seed));
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
}
|
||||
|
||||
export function takeChatSeed(): ChatSeed | null {
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(KEY);
|
||||
if (!raw) return null;
|
||||
window.sessionStorage.removeItem(KEY);
|
||||
const parsed = JSON.parse(raw) as ChatSeed;
|
||||
if (!parsed || typeof parsed.text !== "string" || !parsed.text.trim()) return null;
|
||||
return {
|
||||
deckId: typeof parsed.deckId === "string" ? parsed.deckId : undefined,
|
||||
label: typeof parsed.label === "string" && parsed.label.trim() ? parsed.label : "Study result",
|
||||
text: parsed.text.slice(0, 6000),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
30
lib/chat-trim.ts
Normal file
30
lib/chat-trim.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* Pure helpers shared by the chat route (server) and the chat UI (client):
|
||||
* token budgeting and the sliding history window. No directive, no deps, so
|
||||
* both sides can import it.
|
||||
*/
|
||||
|
||||
/** Rough token estimate (≈4 chars per token) — good enough for budgeting. */
|
||||
export function estimateTokens(text: string): number {
|
||||
return Math.ceil(text.length / 4);
|
||||
}
|
||||
|
||||
/** How much conversation history the sliding window keeps (≈6k tokens). */
|
||||
export const HISTORY_TOKEN_BUDGET = 6000;
|
||||
|
||||
/** Keeps the most recent turns that fit the budget (sliding window). */
|
||||
export function trimHistory<T extends { role: "user" | "assistant"; content: string }>(
|
||||
messages: T[],
|
||||
budget = HISTORY_TOKEN_BUDGET,
|
||||
): T[] {
|
||||
const kept: T[] = [];
|
||||
let used = 0;
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
const cost = estimateTokens(message.content);
|
||||
if (kept.length > 0 && used + cost > budget) break;
|
||||
kept.unshift(message);
|
||||
used += cost;
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
83
lib/chat.ts
Normal file
83
lib/chat.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Chat types shared by the chat UI, the context builder and the streaming
|
||||
* client. The API key rule applies here too: nothing in a chat request may
|
||||
* carry the key anywhere except the `x-ollama-key` header of our own route.
|
||||
*/
|
||||
|
||||
export type ChatRole = "user" | "assistant";
|
||||
|
||||
/** A context chunk that actually travelled with a request. */
|
||||
export type ChatSource = {
|
||||
deckId: string;
|
||||
deckTitle: string;
|
||||
section: string;
|
||||
};
|
||||
|
||||
export type ChatAttachment = {
|
||||
name: string;
|
||||
kind: "photo" | "document" | "text";
|
||||
chars: number;
|
||||
};
|
||||
|
||||
export type ChatError = { message: string; kind?: string };
|
||||
|
||||
export type ChatMessage = {
|
||||
id: string;
|
||||
role: ChatRole;
|
||||
content: string;
|
||||
/** Reasoning streamed separately; hidden behind the Thinking row. */
|
||||
thinking?: string;
|
||||
/** Model that produced an assistant turn. */
|
||||
model?: string;
|
||||
/** Context chunks sent with this turn (assistant turns display them). */
|
||||
sources?: ChatSource[];
|
||||
attachments?: ChatAttachment[];
|
||||
at: number;
|
||||
/** Set when an assistant turn failed; content may hold a partial answer. */
|
||||
error?: ChatError;
|
||||
};
|
||||
|
||||
/**
|
||||
* What the chat is allowed to ground on. `all` = every deck with notes,
|
||||
* `none` = general chat, `decks` = a chosen set.
|
||||
*/
|
||||
export type ChatScope =
|
||||
| { kind: "all" }
|
||||
| { kind: "none" }
|
||||
| { kind: "decks"; deckIds: string[] };
|
||||
|
||||
export type ChatModelChoice = {
|
||||
name: string;
|
||||
badge: "text" | "vision" | "both";
|
||||
available: boolean | null;
|
||||
};
|
||||
|
||||
/** Last model choice, remembered across visits. */
|
||||
const MODEL_KEY = "freezmo.chat.model.v1";
|
||||
|
||||
export function readStoredChatModel(): string | null {
|
||||
try {
|
||||
const value = window.localStorage.getItem(MODEL_KEY);
|
||||
return value && value.trim() ? value : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function storeChatModel(model: string): void {
|
||||
try {
|
||||
window.localStorage.setItem(MODEL_KEY, model);
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
}
|
||||
|
||||
export function chatMessageId(prefix: string): string {
|
||||
const rand =
|
||||
typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||
? crypto.randomUUID().slice(0, 8)
|
||||
: Math.random().toString(36).slice(2, 10);
|
||||
return `${prefix}_${Date.now().toString(36)}_${rand}`;
|
||||
}
|
||||
|
|
@ -49,7 +49,7 @@ export class ApiError extends Error {
|
|||
* callback. Malformed lines are skipped (a half-written line must never break
|
||||
* the stream), and the trailing buffer is flushed at the end.
|
||||
*/
|
||||
async function consumeNdjson(
|
||||
export async function consumeNdjson(
|
||||
response: Response,
|
||||
handleEvent: (event: unknown) => void,
|
||||
): Promise<void> {
|
||||
|
|
@ -333,6 +333,12 @@ export type HealthModel = {
|
|||
note?: string;
|
||||
};
|
||||
|
||||
export type HealthChatModel = {
|
||||
name: string;
|
||||
badge: "text" | "vision" | "both";
|
||||
available: boolean | null;
|
||||
};
|
||||
|
||||
export type Health = {
|
||||
ok: boolean;
|
||||
reachable: boolean;
|
||||
|
|
@ -344,6 +350,8 @@ export type Health = {
|
|||
hasApiKey: boolean;
|
||||
vision: HealthModel;
|
||||
text: HealthModel;
|
||||
/** Chat model allowlist; optional so older servers/mocks stay valid. */
|
||||
chatModels?: HealthChatModel[];
|
||||
latencyMs: number | null;
|
||||
modelCount: number | null;
|
||||
error: string | null;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ export type ServerConfig = {
|
|||
authMode: AuthMode;
|
||||
visionModel: string;
|
||||
textModel: string;
|
||||
/** Allowlist for the chat model picker (CHAT_MODELS). Never client-supplied. */
|
||||
chatModels: string[];
|
||||
/** True when talking straight to https://ollama.com (needs an API key). */
|
||||
directCloud: boolean;
|
||||
timeoutMs: number;
|
||||
|
|
@ -33,6 +35,7 @@ export type ServerConfig = {
|
|||
refine: number;
|
||||
sync: number;
|
||||
push: number;
|
||||
chat: number;
|
||||
};
|
||||
sync: {
|
||||
/** Where opaque encrypted sync blobs live (SYNC_DIR). */
|
||||
|
|
@ -100,12 +103,31 @@ export function getServerConfig(): ServerConfig {
|
|||
10,
|
||||
);
|
||||
|
||||
const visionModel = process.env.VISION_MODEL?.trim() || "gemma4:31b-cloud";
|
||||
const textModel = process.env.TEXT_MODEL?.trim() || "gpt-oss:120b-cloud";
|
||||
|
||||
// Chat model allowlist: CHAT_MODELS wins, otherwise the configured text and
|
||||
// vision models. Dedupe on the canonical name so `x` and `x:cloud` collapse.
|
||||
const requestedChatModels = (process.env.CHAT_MODELS ?? "")
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
const chatModels: string[] = [];
|
||||
const seenModels = new Set<string>();
|
||||
for (const entry of requestedChatModels.length > 0 ? requestedChatModels : [textModel, visionModel]) {
|
||||
const canonical = canonicalModelName(entry);
|
||||
if (seenModels.has(canonical)) continue;
|
||||
seenModels.add(canonical);
|
||||
chatModels.push(entry);
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
apiKey,
|
||||
authMode,
|
||||
visionModel: process.env.VISION_MODEL?.trim() || "gemma4:31b-cloud",
|
||||
textModel: process.env.TEXT_MODEL?.trim() || "gpt-oss:120b-cloud",
|
||||
visionModel,
|
||||
textModel,
|
||||
chatModels,
|
||||
directCloud,
|
||||
timeoutMs: Number.parseInt(process.env.OLLAMA_TIMEOUT_MS ?? "", 10) || 300_000,
|
||||
healthTimeoutMs:
|
||||
|
|
@ -125,6 +147,8 @@ export function getServerConfig(): ServerConfig {
|
|||
sync: positiveInt(process.env.RATE_LIMIT_SYNC, 60),
|
||||
// Push subscription changes and test sends.
|
||||
push: positiveInt(process.env.RATE_LIMIT_PUSH, 30),
|
||||
// Chat turns are one model call each, but users send them in bursts.
|
||||
chat: positiveInt(process.env.RATE_LIMIT_CHAT, 60),
|
||||
},
|
||||
sync: {
|
||||
dir: process.env.SYNC_DIR?.trim() || path.join(process.cwd(), ".freezmo", "sync"),
|
||||
|
|
|
|||
162
lib/i18n-dict.ts
162
lib/i18n-dict.ts
|
|
@ -898,6 +898,87 @@ const en: Record<string, string> = {
|
|||
"doc.readingSlides": "Reading the slides…",
|
||||
"doc.readingOpen": "Reading the OpenDocument file…",
|
||||
"doc.readingText": "Reading the text file…",
|
||||
|
||||
/* chat ------------------------------------------------------------ */
|
||||
"chat.nav": "Chat",
|
||||
"chat.title": "Chat",
|
||||
"chat.ask": "Ask",
|
||||
"chat.askAboutDeck": "Ask about this deck",
|
||||
"chat.askQuestion": "Ask a question",
|
||||
"chat.back": "Back",
|
||||
"chat.new": "New chat",
|
||||
"chat.placeholder": "Ask about your notes…",
|
||||
"chat.send": "Send",
|
||||
"chat.stop": "Stop",
|
||||
"chat.copy": "Copy",
|
||||
"chat.copied": "Copied",
|
||||
"chat.copyFailed": "Copy failed",
|
||||
"chat.edit": "Edit and resend",
|
||||
"chat.regenerate": "Regenerate",
|
||||
"chat.retry": "Retry",
|
||||
"chat.thinking": "Thinking…",
|
||||
"chat.thought": "Thinking",
|
||||
"chat.sources": "Sources",
|
||||
"chat.jumpToBottom": "Jump to the newest",
|
||||
"chat.offlineNote": "Offline — sending is paused",
|
||||
"chat.emptyTitle": "Ask your notes anything",
|
||||
"chat.emptyHint": "Answers come from your own decks. Pick what the chat may read with the context chip below.",
|
||||
"chat.oldDecksNote": "Older decks only have the generated study content. Re-add the notes for better answers.",
|
||||
"chat.fromLabel": "From",
|
||||
"chat.notesOffNote": "Chat is set to ignore notes text.",
|
||||
"chat.notesOffLink": "Turn it back on in Settings",
|
||||
"chat.attach": "Attach photos or documents",
|
||||
"chat.attachPhoto": "photo",
|
||||
"chat.attachDoc": "document",
|
||||
"chat.attachRemove": "Remove attachment",
|
||||
"chat.attachFailed": "That attachment could not be read.",
|
||||
"chat.attachNoText": "No text found in that file — try a photo instead.",
|
||||
"chat.attachEmpty": "Nothing readable was found in that attachment.",
|
||||
"chat.attachmentLabel": "Attachment",
|
||||
"chat.modelTitle": "Chat model",
|
||||
"chat.modelText": "Text",
|
||||
"chat.modelVision": "Vision",
|
||||
"chat.modelBoth": "Text + vision",
|
||||
"chat.modelAvailable": "available",
|
||||
"chat.modelUnavailable": "not found",
|
||||
"chat.modelNote": "Photos and documents are transcribed first, so any of these works.",
|
||||
"chat.scope.title": "What may chat read?",
|
||||
"chat.scope.all": "All notes",
|
||||
"chat.scope.allNote": "Every deck's notes can ground the answers.",
|
||||
"chat.scope.pick": "Selected decks",
|
||||
"chat.scope.pickNote": "Choose folders or single decks below.",
|
||||
"chat.scope.none": "No notes",
|
||||
"chat.scope.noneNote": "General chat — the model answers from general knowledge only.",
|
||||
"chat.scope.decks.one": "{n} deck",
|
||||
"chat.scope.decks.few": "{n} decks",
|
||||
"chat.scope.decks.many": "{n} decks",
|
||||
"chat.scope.deckCount.one": "{n} deck",
|
||||
"chat.scope.deckCount.few": "{n} decks",
|
||||
"chat.scope.deckCount.many": "{n} decks",
|
||||
"chat.scope.hasNotes": "notes text kept",
|
||||
"chat.scope.generatedOnly": "generated content only",
|
||||
"chat.scope.note": "Only what you pick here is sent as context — the API key never leaves this browser.",
|
||||
"chat.suggest.explain": "Explain the main idea of {deck}",
|
||||
"chat.suggest.quiz": "Quiz me on {deck}",
|
||||
"chat.suggest.summarize": "Summarize my notes",
|
||||
"chat.suggest.quizAll": "Quiz me on my decks",
|
||||
"chat.suggest.studyFirst": "What should I study first?",
|
||||
"chat.suggest.general": "Help me understand a topic",
|
||||
"chat.suggest.explainTopic": "Explain a topic step by step",
|
||||
"chat.seedResults": "Study results",
|
||||
"chat.seedAssignment": "Assignment",
|
||||
"chat.seedAnswer": "Answer",
|
||||
"chat.seedAttempt": "My attempt",
|
||||
"chat.seedVerdict": "Verdict",
|
||||
"chat.seedToFix": "To fix",
|
||||
"chat.seedCorrected": "Corrected version",
|
||||
"chat.seedSteps": "Steps",
|
||||
"chat.notesDeleted": "Notes text deleted",
|
||||
"chat.deleteNotes": "Delete notes text",
|
||||
"settings.chatNotes": "Keep notes text for chat",
|
||||
"settings.chatNotesNote": "Chat answers from the notes you added (stored with each deck). Turn off to use only the generated study content.",
|
||||
"server.chatModelNotAllowed": "That chat model is not on the server's allowlist.",
|
||||
"server.chatNeedsQuestion": "The conversation must end with a user message.",
|
||||
};
|
||||
|
||||
const cs: Record<string, string> = {
|
||||
|
|
@ -1737,6 +1818,87 @@ const cs: Record<string, string> = {
|
|||
"doc.readingSlides": "Čtu snímky…",
|
||||
"doc.readingOpen": "Čtu soubor OpenDocument…",
|
||||
"doc.readingText": "Čtu textový soubor…",
|
||||
|
||||
/* chat ------------------------------------------------------------ */
|
||||
"chat.nav": "Chat",
|
||||
"chat.title": "Chat",
|
||||
"chat.ask": "Zeptat se",
|
||||
"chat.askAboutDeck": "Zeptat se na tuto sadu",
|
||||
"chat.askQuestion": "Zeptat se na něco",
|
||||
"chat.back": "Zpět",
|
||||
"chat.new": "Nový chat",
|
||||
"chat.placeholder": "Zeptej se na svoje poznámky…",
|
||||
"chat.send": "Odeslat",
|
||||
"chat.stop": "Zastavit",
|
||||
"chat.copy": "Kopírovat",
|
||||
"chat.copied": "Zkopírováno",
|
||||
"chat.copyFailed": "Kopírování se nepovedlo",
|
||||
"chat.edit": "Upravit a poslat znovu",
|
||||
"chat.regenerate": "Vygenerovat znovu",
|
||||
"chat.retry": "Zkusit znovu",
|
||||
"chat.thinking": "Přemýšlím…",
|
||||
"chat.thought": "Myšlenkový postup",
|
||||
"chat.sources": "Zdroje",
|
||||
"chat.jumpToBottom": "Skočit na nejnovější",
|
||||
"chat.offlineNote": "Offline — posílání je pozastavené",
|
||||
"chat.emptyTitle": "Zeptej se svých poznámek na cokoli",
|
||||
"chat.emptyHint": "Odpovědi vycházejí z tvých sad. Pomocí chipu s kontextem níže vyber, co smí chat číst.",
|
||||
"chat.oldDecksNote": "Starší sady mají jen vygenerovaný obsah. Přidej znovu poznámky a odpovědi budou lepší.",
|
||||
"chat.fromLabel": "Z",
|
||||
"chat.notesOffNote": "Chat má vypnuté používání textu poznámek.",
|
||||
"chat.notesOffLink": "Zapnout v nastavení",
|
||||
"chat.attach": "Přiložit fotky nebo dokumenty",
|
||||
"chat.attachPhoto": "fotka",
|
||||
"chat.attachDoc": "dokument",
|
||||
"chat.attachRemove": "Odebrat přílohu",
|
||||
"chat.attachFailed": "Tuto přílohu se nepovedlo přečíst.",
|
||||
"chat.attachNoText": "V souboru nebyl žádný text — zkus místo toho fotku.",
|
||||
"chat.attachEmpty": "V příloze nebylo nic čitelného.",
|
||||
"chat.attachmentLabel": "Příloha",
|
||||
"chat.modelTitle": "Model chatu",
|
||||
"chat.modelText": "Text",
|
||||
"chat.modelVision": "Obrázky",
|
||||
"chat.modelBoth": "Text + obrázky",
|
||||
"chat.modelAvailable": "dostupný",
|
||||
"chat.modelUnavailable": "nenalezen",
|
||||
"chat.modelNote": "Fotky a dokumenty se nejdřív přepíšou na text, takže funguje kterýkoli z nich.",
|
||||
"chat.scope.title": "Co smí chat číst?",
|
||||
"chat.scope.all": "Všechny poznámky",
|
||||
"chat.scope.allNote": "Odpovědi mohou vycházet z poznámek všech sad.",
|
||||
"chat.scope.pick": "Vybrané sady",
|
||||
"chat.scope.pickNote": "Níže vyber složky nebo jednotlivé sady.",
|
||||
"chat.scope.none": "Bez poznámek",
|
||||
"chat.scope.noneNote": "Obecný chat — model odpovídá jen z obecných znalostí.",
|
||||
"chat.scope.decks.one": "{n} sada",
|
||||
"chat.scope.decks.few": "{n} sady",
|
||||
"chat.scope.decks.many": "{n} sad",
|
||||
"chat.scope.deckCount.one": "{n} sada",
|
||||
"chat.scope.deckCount.few": "{n} sady",
|
||||
"chat.scope.deckCount.many": "{n} sad",
|
||||
"chat.scope.hasNotes": "text poznámek uložen",
|
||||
"chat.scope.generatedOnly": "jen vygenerovaný obsah",
|
||||
"chat.scope.note": "Jako kontext se posílá jen to, co tady vybereš — API klíč nikdy neopustí tento prohlížeč.",
|
||||
"chat.suggest.explain": "Vysvětli hlavní myšlenku sady {deck}",
|
||||
"chat.suggest.quiz": "Vyzkoušej mě ze sady {deck}",
|
||||
"chat.suggest.summarize": "Shrň moje poznámky",
|
||||
"chat.suggest.quizAll": "Vyzkoušej mě ze všech sad",
|
||||
"chat.suggest.studyFirst": "Co mám studovat jako první?",
|
||||
"chat.suggest.general": "Pomoz mi pochopit nějaké téma",
|
||||
"chat.suggest.explainTopic": "Vysvětli téma krok za krokem",
|
||||
"chat.seedResults": "Výsledky studia",
|
||||
"chat.seedAssignment": "Zadání",
|
||||
"chat.seedAnswer": "Odpověď",
|
||||
"chat.seedAttempt": "Můj postup",
|
||||
"chat.seedVerdict": "Hodnocení",
|
||||
"chat.seedToFix": "Co opravit",
|
||||
"chat.seedCorrected": "Opravená verze",
|
||||
"chat.seedSteps": "Postup",
|
||||
"chat.notesDeleted": "Text poznámek smazán",
|
||||
"chat.deleteNotes": "Smazat text poznámek",
|
||||
"settings.chatNotes": "Uchovávat text poznámek pro chat",
|
||||
"settings.chatNotesNote": "Chat odpovídá z poznámek, které jsi přidal (jsou uložené u každé sady). Vypnutím bude používat jen vygenerovaný obsah.",
|
||||
"server.chatModelNotAllowed": "Tento model chatu není v povoleném seznamu serveru.",
|
||||
"server.chatNeedsQuestion": "Konverzace musí končit zprávou uživatele.",
|
||||
};
|
||||
|
||||
/** Used by tests/tools that want the raw tables. */
|
||||
|
|
|
|||
208
lib/ollama.ts
208
lib/ollama.ts
|
|
@ -168,45 +168,7 @@ export async function ollamaChat(options: ChatOptions): Promise<ChatResult> {
|
|||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new OllamaError("auth", "Ollama refused the request (unauthorised).", {
|
||||
status: response.status,
|
||||
detail: text.slice(0, 500),
|
||||
});
|
||||
}
|
||||
if (response.status === 402) {
|
||||
throw new OllamaError(
|
||||
"no_credits",
|
||||
`Your Ollama account can't use "${model}": this model is not included in your plan. Add usage credits at ollama.com/settings, or switch to a free-tier model in .env.local.`,
|
||||
{ status: response.status, detail: text.slice(0, 500) },
|
||||
);
|
||||
}
|
||||
if (response.status === 404) {
|
||||
throw new OllamaError(
|
||||
"model_missing",
|
||||
`Ollama could not find the model "${model}". Run \`ollama pull ${model}\` or pick another model in .env.local.`,
|
||||
{ status: response.status, detail: text.slice(0, 500) },
|
||||
);
|
||||
}
|
||||
if (response.status === 429) {
|
||||
if (classifyOllamaFailure(response.status, text) === "usage_limit") {
|
||||
throw new OllamaError(
|
||||
"usage_limit",
|
||||
"Your free monthly Ollama usage has run out. Upgrade your Ollama account, or create a new one and use the new API key.",
|
||||
{ status: response.status, detail: text.slice(0, 500) },
|
||||
);
|
||||
}
|
||||
throw new OllamaError(
|
||||
"throttled",
|
||||
"Ollama is temporarily rate limiting requests. Try again in a moment.",
|
||||
{ status: response.status, detail: text.slice(0, 500) },
|
||||
);
|
||||
}
|
||||
throw new OllamaError(
|
||||
"http",
|
||||
`Ollama responded with HTTP ${response.status}.`,
|
||||
{ status: response.status, detail: text.slice(0, 500) },
|
||||
);
|
||||
throwForStatus(response.status, text, model);
|
||||
}
|
||||
|
||||
let payload: unknown;
|
||||
|
|
@ -226,6 +188,174 @@ export async function ollamaChat(options: ChatOptions): Promise<ChatResult> {
|
|||
return { content, model, raw: payload };
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a non-2xx Ollama response to the right `OllamaError`. Shared by the
|
||||
* buffered and streaming chat calls so both classify 429s identically.
|
||||
*/
|
||||
function throwForStatus(status: number, text: string, model: string): never {
|
||||
if (status === 401 || status === 403) {
|
||||
throw new OllamaError("auth", "Ollama refused the request (unauthorised).", {
|
||||
status,
|
||||
detail: text.slice(0, 500),
|
||||
});
|
||||
}
|
||||
if (status === 402) {
|
||||
throw new OllamaError(
|
||||
"no_credits",
|
||||
`Your Ollama account can't use "${model}": this model is not included in your plan. Add usage credits at ollama.com/settings, or switch to a free-tier model in .env.local.`,
|
||||
{ status, detail: text.slice(0, 500) },
|
||||
);
|
||||
}
|
||||
if (status === 404) {
|
||||
throw new OllamaError(
|
||||
"model_missing",
|
||||
`Ollama could not find the model "${model}". Run \`ollama pull ${model}\` or pick another model in .env.local.`,
|
||||
{ status, detail: text.slice(0, 500) },
|
||||
);
|
||||
}
|
||||
if (status === 429) {
|
||||
if (classifyOllamaFailure(status, text) === "usage_limit") {
|
||||
throw new OllamaError(
|
||||
"usage_limit",
|
||||
"Your free monthly Ollama usage has run out. Upgrade your Ollama account, or create a new one and use the new API key.",
|
||||
{ status, detail: text.slice(0, 500) },
|
||||
);
|
||||
}
|
||||
throw new OllamaError(
|
||||
"throttled",
|
||||
"Ollama is temporarily rate limiting requests. Try again in a moment.",
|
||||
{ status, detail: text.slice(0, 500) },
|
||||
);
|
||||
}
|
||||
throw new OllamaError("http", `Ollama responded with HTTP ${status}.`, {
|
||||
status,
|
||||
detail: text.slice(0, 500),
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Streaming chat */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export type ChatStreamResult = {
|
||||
content: string;
|
||||
thinking: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
type StreamOptions = ChatOptions & {
|
||||
onToken?: (text: string) => void;
|
||||
onThinking?: (text: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Streaming counterpart of `ollamaChat`: POSTs with `stream: true` and hands
|
||||
* each delta to the callbacks. Reasoning models deliver `message.thinking`
|
||||
* separately — it is returned apart from the answer and never mixed in.
|
||||
*/
|
||||
export async function ollamaChatStream(options: StreamOptions): Promise<ChatStreamResult> {
|
||||
const config = getServerConfig();
|
||||
const { baseUrl, apiKey, directCloud } = options.auth;
|
||||
const model = resolveModelName(options.model, directCloud);
|
||||
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model,
|
||||
messages: options.messages,
|
||||
stream: true,
|
||||
};
|
||||
if (typeof options.temperature === "number") {
|
||||
body.options = { temperature: options.temperature };
|
||||
}
|
||||
if (config.think !== null) body.think = config.think;
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchWithTimeout(
|
||||
`${baseUrl}/api/chat`,
|
||||
{ method: "POST", headers, body: JSON.stringify(body), cache: "no-store" },
|
||||
options.timeoutMs ?? config.timeoutMs,
|
||||
options.signal,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new OllamaError("timeout", "The request to Ollama timed out.");
|
||||
}
|
||||
throw new OllamaError("unreachable", `Could not connect to Ollama at ${baseUrl}.`, {
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
throwForStatus(response.status, text, model);
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new OllamaError("bad_response", "Ollama sent an empty stream.");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let content = "";
|
||||
let thinking = "";
|
||||
|
||||
const handleLine = (line: string) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return;
|
||||
let event: unknown;
|
||||
try {
|
||||
event = JSON.parse(trimmed);
|
||||
} catch {
|
||||
return; // a half-written line must never break the stream
|
||||
}
|
||||
if (!event || typeof event !== "object") return;
|
||||
const record = event as Record<string, unknown>;
|
||||
if (typeof record.error === "string" && record.error) {
|
||||
throw new OllamaError("http", record.error.slice(0, 300));
|
||||
}
|
||||
const message = record.message as Record<string, unknown> | undefined;
|
||||
const delta = typeof message?.content === "string" ? message.content : "";
|
||||
const reason = typeof message?.thinking === "string" ? message.thinking : "";
|
||||
if (reason) {
|
||||
thinking += reason;
|
||||
options.onThinking?.(reason);
|
||||
}
|
||||
if (delta) {
|
||||
content += delta;
|
||||
options.onToken?.(delta);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let newline = buffer.indexOf("\n");
|
||||
while (newline !== -1) {
|
||||
handleLine(buffer.slice(0, newline));
|
||||
buffer = buffer.slice(newline + 1);
|
||||
newline = buffer.indexOf("\n");
|
||||
}
|
||||
}
|
||||
handleLine(buffer);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new OllamaError("timeout", "The request to Ollama timed out.");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!content.trim()) {
|
||||
throw new OllamaError("bad_response", "Ollama finished the stream without any answer text.");
|
||||
}
|
||||
|
||||
return { content, thinking, model };
|
||||
}
|
||||
|
||||
function extractContent(payload: unknown): string | null {
|
||||
if (!payload || typeof payload !== "object") return null;
|
||||
const record = payload as Record<string, unknown>;
|
||||
|
|
|
|||
|
|
@ -367,6 +367,36 @@ export function buildRefineUserPrompt(input: {
|
|||
.join("\n");
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Chat */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const CHAT_SYSTEM_PROMPT = `You are Freezmo's chat tutor. You talk with a student about their own study notes and help them understand, review and quiz themselves.
|
||||
|
||||
Rules:
|
||||
1. The attached notes are the primary source. Ground your answer in them and prefer them over general knowledge.
|
||||
2. If the notes do not cover something, say so plainly first ("Your notes don't cover this"), then answer from general knowledge and label that part clearly as general knowledge — never blur the two.
|
||||
3. Never claim the notes say something they do not, and never attribute invented facts to the notes.
|
||||
4. Answer in the language the student writes in (not necessarily the app's language), unless they ask for another language.
|
||||
5. Be a concise, friendly teacher: short paragraphs, lists when they help, no filler, no long preambles. If the student asks to be quizzed, ask one question at a time and wait for their answer before checking it.
|
||||
6. Markdown is supported (lists, tables, \`code\`, formulas as $...$). Keep tables narrow enough for a phone.`;
|
||||
|
||||
export function buildChatSystemPrompt(input: {
|
||||
context: { deckTitle: string; section: string; text: string }[];
|
||||
}): string {
|
||||
if (input.context.length === 0) {
|
||||
return [
|
||||
CHAT_SYSTEM_PROMPT,
|
||||
"",
|
||||
"No notes are attached to this conversation: the student chose general chat. Answer from general knowledge and, when it matters, remind them they can scope the chat to their notes.",
|
||||
].join("\n");
|
||||
}
|
||||
const blocks = input.context
|
||||
.map((chunk) => `### ${chunk.deckTitle} — ${chunk.section}\n${chunk.text}`)
|
||||
.join("\n\n");
|
||||
return [CHAT_SYSTEM_PROMPT, "", "The student's notes (primary source):", "", blocks].join("\n");
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Retry helper */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
|
|
|||
|
|
@ -24,11 +24,17 @@ export type BrowserSettings = {
|
|||
haptics: boolean;
|
||||
/** How many answers count as a finished day in the Library/stats. */
|
||||
dailyGoal: number;
|
||||
/**
|
||||
* Whether chat may ground its answers in the deck's stored notes text
|
||||
* (`sourceText`). On by default; off means chat only sees the generated
|
||||
* study content.
|
||||
*/
|
||||
chatNotes: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_GOAL = 20;
|
||||
|
||||
const EMPTY: BrowserSettings = { apiKey: "", haptics: true, dailyGoal: DEFAULT_GOAL };
|
||||
const EMPTY: BrowserSettings = { apiKey: "", haptics: true, dailyGoal: DEFAULT_GOAL, chatNotes: true };
|
||||
|
||||
let cached: BrowserSettings | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
|
@ -44,6 +50,7 @@ function read(): BrowserSettings {
|
|||
apiKey: typeof parsed.apiKey === "string" ? parsed.apiKey.slice(0, 512) : "",
|
||||
haptics: parsed.haptics !== false,
|
||||
dailyGoal: Number.isFinite(goal) ? Math.min(500, Math.max(1, goal)) : DEFAULT_GOAL,
|
||||
chatNotes: parsed.chatNotes !== false,
|
||||
};
|
||||
} catch {
|
||||
return EMPTY;
|
||||
|
|
@ -53,10 +60,10 @@ function read(): BrowserSettings {
|
|||
function write(next: BrowserSettings): void {
|
||||
cached = next;
|
||||
try {
|
||||
if (next.apiKey || next.haptics === false || next.dailyGoal !== DEFAULT_GOAL) {
|
||||
if (next.apiKey || next.haptics === false || next.dailyGoal !== DEFAULT_GOAL || next.chatNotes === false) {
|
||||
window.localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({ apiKey: next.apiKey, haptics: next.haptics, dailyGoal: next.dailyGoal }),
|
||||
JSON.stringify({ apiKey: next.apiKey, haptics: next.haptics, dailyGoal: next.dailyGoal, chatNotes: next.chatNotes }),
|
||||
);
|
||||
} else {
|
||||
window.localStorage.removeItem(STORAGE_KEY);
|
||||
|
|
|
|||
|
|
@ -123,6 +123,20 @@ export function deleteDeck(id: string): void {
|
|||
write(deckStore.getSnapshot().filter((deck) => deck.id !== id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the stored notes text from a deck (the deck and its activities stay).
|
||||
* Used by "Delete notes text" — the notes live in the deck record because
|
||||
* Regenerate needs them; chat reads them from here too.
|
||||
*/
|
||||
export function deleteNotesText(id: string): void {
|
||||
ensureLoaded();
|
||||
write(
|
||||
deckStore.getSnapshot().map((deck) =>
|
||||
deck.id === id ? { ...deck, sourceText: "", updatedAt: Date.now() } : deck,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function recordAttempt(
|
||||
id: string,
|
||||
result: { scorePercent: number; correct: number; total: number },
|
||||
|
|
|
|||
26
package-lock.json
generated
26
package-lock.json
generated
|
|
@ -11,6 +11,7 @@
|
|||
"canvas-confetti": "^1.9.4",
|
||||
"framer-motion": "^12.23.24",
|
||||
"jszip": "^3.10.2",
|
||||
"katex": "^0.19.0",
|
||||
"mammoth": "^1.13.0",
|
||||
"next": "15.5.27",
|
||||
"pdfjs-dist": "^6.4.299",
|
||||
|
|
@ -2914,6 +2915,15 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "15.0.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
|
||||
"integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
|
|
@ -4881,6 +4891,22 @@
|
|||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/katex": {
|
||||
"version": "0.19.0",
|
||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.19.0.tgz",
|
||||
"integrity": "sha512-v6Tznz3zJ7u3niRCoDTsumM2+HA2XXcCu+WAacCeHD2z3p9A9Ks987o5FzfTGBN0e8A0vjEgIDvLbICrXpdw/Q==",
|
||||
"funding": [
|
||||
"https://opencollective.com/katex",
|
||||
"https://github.com/sponsors/katex"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"commander": "^15.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"katex": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
"canvas-confetti": "^1.9.4",
|
||||
"framer-motion": "^12.23.24",
|
||||
"jszip": "^3.10.2",
|
||||
"katex": "^0.19.0",
|
||||
"mammoth": "^1.13.0",
|
||||
"next": "15.5.27",
|
||||
"pdfjs-dist": "^6.4.299",
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
*
|
||||
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
|
||||
*/
|
||||
const CACHE_VERSION = "freezmo-v31";
|
||||
const CACHE_VERSION = "freezmo-v32";
|
||||
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
||||
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ prints a per-suite summary at the end.
|
|||
| `phase1-test.mjs` | Backup export/import, persistence row, nudge, encrypted sync (enable/join/push/delete + API validation), usage-limit handling (client + a stub Ollama). |
|
||||
| `phase2-test.mjs` | Answer history + SRS from a real study run, review/mistakes, stats, exam mode, push reminders hidden without VAPID and validated with it. |
|
||||
| `phase3-test.mjs` | Check my work (API + phone/desktop UI), follow-ups, guide step explain, turn-into-a-study-deck. |
|
||||
| `chat-test.mjs` | Chat: streaming API (validation, allowlist, context travel, history window, thinking separation, usage-limit mapping), attachment extraction, entry points, streaming/stop, markdown + KaTeX, model/scope pickers, edit/regenerate, offline, reduced motion. |
|
||||
|
||||
## Extra instances
|
||||
|
||||
|
|
|
|||
423
tests/chat-test.mjs
Normal file
423
tests/chat-test.mjs
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
/**
|
||||
* Chat mode (phase 1): the streaming API (validation, allowlist, context
|
||||
* travel, history window, thinking separation, usage-limit mapping), the
|
||||
* attachment extraction route, and the phone/desktop UI (entry points, scope,
|
||||
* streaming, stop, model picker, context picker, edit/regenerate, markdown,
|
||||
* math, sources, offline, reduced motion).
|
||||
*
|
||||
* Runs against a stub Ollama, so no real model is needed.
|
||||
*/
|
||||
import http from "node:http";
|
||||
|
||||
import { DECK, launchBrowser, makeChecker, makePage, portInUse, seed, spawnApp, waitForHttp } from "./lib.mjs";
|
||||
|
||||
const { check, finish } = makeChecker();
|
||||
const browser = await launchBrowser();
|
||||
|
||||
const STUB_PORT = 11997;
|
||||
const APP_PORT = 3212;
|
||||
const APP = `http://127.0.0.1:${APP_PORT}`;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Stub Ollama */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
let mode = "ok"; // ok | thinking | slow | limit
|
||||
let lastChat = null;
|
||||
|
||||
const ANSWER = [
|
||||
"**Rayleigh** scattering makes the sky blue.",
|
||||
"",
|
||||
"- short wavelengths scatter more",
|
||||
"- violet is mostly absorbed",
|
||||
"",
|
||||
"| Layer | Effect |",
|
||||
"| --- | --- |",
|
||||
"| Troposphere | blue |",
|
||||
"",
|
||||
"```js",
|
||||
'const sky = "blue";',
|
||||
"```",
|
||||
"",
|
||||
"Mass-energy: $E = mc^2$ done.",
|
||||
].join("\n");
|
||||
|
||||
const stub = http.createServer((request, response) => {
|
||||
if (request.url?.startsWith("/api/tags")) {
|
||||
response.writeHead(200, { "Content-Type": "application/json" });
|
||||
response.end(JSON.stringify({ models: [{ name: "stub-text" }, { name: "stub-vision" }] }));
|
||||
return;
|
||||
}
|
||||
let raw = "";
|
||||
request.on("data", (chunk) => (raw += chunk));
|
||||
request.on("end", () => {
|
||||
let body = {};
|
||||
try {
|
||||
body = JSON.parse(raw || "{}");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (request.url?.startsWith("/api/chat")) {
|
||||
const system = String(body.messages?.[0]?.content ?? "");
|
||||
// Vision/extraction step (JSON mode).
|
||||
if (body.format === "json" || system.includes("transcriber")) {
|
||||
response.writeHead(200, { "Content-Type": "application/json" });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
message: {
|
||||
content: JSON.stringify({
|
||||
title: "Photo notes",
|
||||
subject: "Biology",
|
||||
language: "English",
|
||||
notes: "EXTRACTED-PHOTO-MARKER: photosynthesis needs light and water.",
|
||||
keyConcepts: ["Photosynthesis"],
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
lastChat = body;
|
||||
if (mode === "limit") {
|
||||
response.writeHead(429, { "Content-Type": "application/json" });
|
||||
response.end(JSON.stringify({ error: "You have reached your monthly usage limit. Upgrade your plan." }));
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(200, { "Content-Type": "application/x-ndjson" });
|
||||
const chunks = mode === "slow" ? ["Slow ", "answer ", "still ", "coming"] : [ANSWER.slice(0, 24), ANSWER.slice(24, 80), ANSWER.slice(80)];
|
||||
let index = 0;
|
||||
const writeChunk = () => {
|
||||
response.write(JSON.stringify({ message: { content: chunks[index] }, done: false }) + "\n");
|
||||
index += 1;
|
||||
};
|
||||
// Slow mode: the first chunk lands at once so a stop test has content.
|
||||
if (mode === "slow") writeChunk();
|
||||
const timer = setInterval(
|
||||
() => {
|
||||
if (index < chunks.length) {
|
||||
if (index === 0 && mode === "thinking") {
|
||||
response.write(JSON.stringify({ message: { thinking: "REASONING-MARKER let me think…" }, done: false }) + "\n");
|
||||
}
|
||||
writeChunk();
|
||||
} else {
|
||||
response.write(JSON.stringify({ message: { content: "" }, done: true }) + "\n");
|
||||
response.end();
|
||||
clearInterval(timer);
|
||||
}
|
||||
},
|
||||
mode === "slow" ? 700 : 30,
|
||||
);
|
||||
// `res` close fires when the client aborts or the response ends; `req`
|
||||
// close would fire immediately after the request body.
|
||||
response.on("close", () => clearInterval(timer));
|
||||
return;
|
||||
}
|
||||
response.writeHead(404);
|
||||
response.end();
|
||||
});
|
||||
});
|
||||
await new Promise((resolve) => stub.listen(STUB_PORT, "127.0.0.1", resolve));
|
||||
|
||||
check("stub app port is free", !(await portInUse(APP_PORT)));
|
||||
const app = spawnApp(APP_PORT, {
|
||||
OLLAMA_BASE_URL: `http://127.0.0.1:${STUB_PORT}`,
|
||||
OLLAMA_AUTH: "local",
|
||||
OLLAMA_VERIFY_KEY: "false",
|
||||
CHAT_MODELS: "stub-text,stub-vision",
|
||||
});
|
||||
|
||||
const DECK_NOTES = {
|
||||
...DECK,
|
||||
sourceText: "FOTOSYNTEZA-MARKER: plants convert light, water and CO2 into sugar. Chlorophyll absorbs red and blue light.",
|
||||
};
|
||||
|
||||
async function chatPost(payload) {
|
||||
const response = await fetch(`${APP}/api/chat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const events = response.headers.get("content-type")?.includes("ndjson")
|
||||
? (await response.text())
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line))
|
||||
: null;
|
||||
const body = events ? null : await response.json().catch(() => null);
|
||||
return { status: response.status, events, body };
|
||||
}
|
||||
|
||||
try {
|
||||
const up = await waitForHttp(`${APP}/api/health`);
|
||||
check("stub app instance is up", up);
|
||||
|
||||
if (up) {
|
||||
/* 1. API: validation + allowlist ---------------------------------- */
|
||||
const badModel = await chatPost({ model: "evil-model", messages: [{ role: "user", content: "hi" }] });
|
||||
check("api: model outside the allowlist → 400 bad_model", badModel.status === 400 && badModel.body?.kind === "bad_model", JSON.stringify(badModel.body));
|
||||
|
||||
const empty = await chatPost({ messages: [] });
|
||||
check("api: empty messages → 400", empty.status === 400);
|
||||
|
||||
const wrongLast = await chatPost({ messages: [{ role: "user", content: "hi" }, { role: "assistant", content: "hello" }] });
|
||||
check("api: last message must be from the user → 400", wrongLast.status === 400);
|
||||
|
||||
/* 2. API: streaming + context travel + thinking -------------------- */
|
||||
const ok = await chatPost({
|
||||
messages: [{ role: "user", content: "Why is the sky blue?" }],
|
||||
context: [{ deckId: "seed_deck", deckTitle: "Sky deck", section: "Notes", text: "RAYLEIGH-CONTEXT-MARKER scattering notes." }],
|
||||
});
|
||||
const tokens = (ok.events ?? []).filter((event) => event.type === "token").map((event) => event.text).join("");
|
||||
const done = (ok.events ?? []).find((event) => event.type === "done");
|
||||
check("api: streams tokens and a done event", ok.status === 200 && tokens.includes("Rayleigh") && done?.model === "stub-text", done?.model);
|
||||
check("api: thinking never mixes into the answer", !tokens.includes("REASONING-MARKER"));
|
||||
check(
|
||||
"api: system prompt carries the chosen context + the rules",
|
||||
lastChat?.messages?.[0]?.content?.includes("RAYLEIGH-CONTEXT-MARKER") &&
|
||||
lastChat?.messages?.[0]?.content?.includes("primary source"),
|
||||
);
|
||||
check("api: the conversation window follows the system prompt", lastChat?.messages?.[1]?.role === "user");
|
||||
|
||||
mode = "thinking";
|
||||
const thinking = await chatPost({ messages: [{ role: "user", content: "Think first" }] });
|
||||
const thinkingEvents = (thinking.events ?? []).filter((event) => event.type === "thinking").map((event) => event.text).join("");
|
||||
const thinkingTokens = (thinking.events ?? []).filter((event) => event.type === "token").map((event) => event.text).join("");
|
||||
check("api: reasoning streams separately", thinkingEvents.includes("REASONING-MARKER") && !thinkingTokens.includes("REASONING-MARKER"));
|
||||
mode = "ok";
|
||||
|
||||
/* 3. API: history window + context cap ----------------------------- */
|
||||
const longHistory = Array.from({ length: 60 }, (_, index) => ({
|
||||
role: index % 2 === 0 ? "user" : "assistant",
|
||||
content: `Message ${index}: ${"x".repeat(600)}`,
|
||||
}));
|
||||
longHistory[59] = { role: "user", content: `Last question ${"y".repeat(600)}` };
|
||||
await chatPost({ messages: longHistory });
|
||||
const sentCount = lastChat?.messages?.length ?? 0;
|
||||
check("api: sliding window trims the history", sentCount > 1 && sentCount < 61, `${sentCount} messages reached the model`);
|
||||
|
||||
const manyChunks = Array.from({ length: 40 }, (_, index) => ({
|
||||
deckId: `deck_${index}`,
|
||||
deckTitle: `Deck ${index}`,
|
||||
section: "Notes",
|
||||
text: `CHUNK-${index} ${"z".repeat(11_500)}`,
|
||||
}));
|
||||
await chatPost({ messages: [{ role: "user", content: "question" }], context: manyChunks });
|
||||
const systemLength = String(lastChat?.messages?.[0]?.content ?? "").length;
|
||||
check("api: context is capped well below what was sent", systemLength > 1000 && systemLength < 60_000, `${systemLength} chars`);
|
||||
check("api: the first (best) chunk survives the cap", String(lastChat?.messages?.[0]?.content ?? "").includes("CHUNK-0"));
|
||||
|
||||
/* 4. API: general chat + usage limit ------------------------------- */
|
||||
const general = await chatPost({ messages: [{ role: "user", content: "hello" }], context: [] });
|
||||
check("api: no context → general-chat system prompt", String(lastChat?.messages?.[0]?.content ?? "").includes("No notes are attached") && general.status === 200);
|
||||
|
||||
mode = "limit";
|
||||
const limited = await chatPost({ messages: [{ role: "user", content: "hi" }] });
|
||||
const limitEvent = (limited.events ?? []).find((event) => event.type === "error");
|
||||
check("api: monthly usage 429 → usage_limit error event", limitEvent?.kind === "usage_limit", JSON.stringify(limitEvent).slice(0, 90));
|
||||
mode = "ok";
|
||||
|
||||
/* 5. API: attachment extraction ------------------------------------ */
|
||||
const extracted = await fetch(`${APP}/api/chat/extract`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ images: ["data:image/png;base64," + "A".repeat(64)] }),
|
||||
});
|
||||
const extractedBody = await extracted.json();
|
||||
check("extract: photo → vision text", extracted.status === 200 && extractedBody.text?.includes("EXTRACTED-PHOTO-MARKER"));
|
||||
|
||||
const plain = await fetch(`${APP}/api/chat/extract`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ text: "pasted text stays as is" }),
|
||||
});
|
||||
const plainBody = await plain.json();
|
||||
check("extract: plain text needs no model call", plain.status === 200 && plainBody.text === "pasted text stays as is");
|
||||
|
||||
const emptyExtract = await fetch(`${APP}/api/chat/extract`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
check("extract: nothing attached → 400", emptyExtract.status === 400);
|
||||
|
||||
/* 6. UI: entry points ---------------------------------------------- */
|
||||
const desktop = await makePage(browser, { width: 1280, height: 900, deviceScaleFactor: 1 });
|
||||
await seed(desktop.page, { decks: [DECK_NOTES] });
|
||||
await desktop.page.goto(APP, { waitUntil: "networkidle" });
|
||||
await desktop.page.waitForTimeout(900);
|
||||
const askLink = desktop.page.getByRole("link", { name: "Ask", exact: true });
|
||||
check("desktop: Library has an Ask link", (await askLink.count()) === 1 && (await askLink.getAttribute("href")) === "/chat");
|
||||
check("desktop: sidebar has a Chat row", (await desktop.page.locator('aside a[href="/chat"]').count()) === 1);
|
||||
await askLink.click();
|
||||
await desktop.page.waitForTimeout(1200);
|
||||
check("desktop: chat route opens with the sidebar", desktop.page.url().endsWith("/chat") && (await desktop.page.locator("aside").count()) >= 1);
|
||||
check("desktop: no tab bar on chat", (await desktop.page.locator("nav.tabbar").count()) === 0);
|
||||
await desktop.context.close();
|
||||
|
||||
const phone = await makePage(browser);
|
||||
await seed(phone.page, { decks: [DECK_NOTES] });
|
||||
await phone.page.goto(APP, { waitUntil: "networkidle" });
|
||||
await phone.page.waitForTimeout(900);
|
||||
const askButton = phone.page.getByRole("link", { name: "Ask", exact: true });
|
||||
check("phone: Library has an Ask button", (await askButton.count()) === 1 && (await askButton.getAttribute("href")) === "/chat");
|
||||
await phone.page.getByRole("button", { name: "Deck actions" }).click();
|
||||
await phone.page.waitForTimeout(300);
|
||||
const askDeck = phone.page.getByRole("link", { name: "Ask about this deck" });
|
||||
check("phone: deck menu has Ask about this deck", (await askDeck.count()) === 1 && (await askDeck.getAttribute("href"))?.includes("/chat?deck=seed_deck"));
|
||||
await askDeck.click();
|
||||
await phone.page.waitForTimeout(1400);
|
||||
check("phone: chat opens scoped to the deck", phone.page.url().includes("deck=seed_deck") && (await phone.page.getByText("1 deck").count()) >= 1);
|
||||
check("phone: no tab bar on chat", (await phone.page.locator("nav.tabbar").count()) === 0);
|
||||
check("phone: back button present", (await phone.page.getByRole("button", { name: "Back" }).count()) === 1);
|
||||
|
||||
/* 7. UI: streaming + markdown + math + sources ---------------------- */
|
||||
check("chat: empty state with suggestions", await phone.page.getByText("Ask your notes anything").isVisible());
|
||||
check("chat: deck suggestion present", (await phone.page.getByRole("button", { name: /Summarize my notes/ }).count()) === 1);
|
||||
const context = phone.page.locator(".chat-md").first();
|
||||
await phone.page.getByRole("button", { name: /Summarize my notes/ }).click();
|
||||
await phone.page.waitForTimeout(2500);
|
||||
check("chat: user bubble appears", (await phone.page.getByText("Summarize my notes").count()) >= 1);
|
||||
check("chat: assistant answer streams in", await context.getByText("scattering makes the sky blue").isVisible());
|
||||
check("chat: markdown bold + list render", (await context.locator("strong").count()) >= 1 && (await context.locator("li").count()) >= 2);
|
||||
check("chat: table renders", (await context.locator("table").count()) === 1);
|
||||
check(
|
||||
"chat: code block + copy button",
|
||||
(await context.locator("pre").count()) === 1 && (await context.getByRole("button", { name: "Copy" }).count()) >= 1,
|
||||
);
|
||||
check("chat: math renders through KaTeX", (await phone.page.locator(".katex").count()) >= 1);
|
||||
check("chat: sources chip links to the deck", (await phone.page.locator('a[href="/deck/seed_deck"]').count()) >= 1);
|
||||
check("chat: context reached the model", String(lastChat?.messages?.[0]?.content ?? "").includes("FOTOSYNTEZA-MARKER"));
|
||||
|
||||
/* 8. UI: model picker + context picker ------------------------------ */
|
||||
await phone.page.getByRole("button", { name: "stub-text" }).click();
|
||||
await phone.page.waitForTimeout(400);
|
||||
check("model: picker lists both models with availability", (await phone.page.getByText("stub-vision").count()) >= 1 && (await phone.page.getByText("available").count()) >= 1);
|
||||
await phone.page.getByRole("button", { name: /stub-vision/ }).click();
|
||||
await phone.page.waitForTimeout(400);
|
||||
check("model: choice updates the chip", (await phone.page.getByRole("button", { name: "stub-vision" }).count()) === 1);
|
||||
check(
|
||||
"model: choice is remembered",
|
||||
(await phone.page.evaluate(() => localStorage.getItem("freezmo.chat.model.v1"))) === "stub-vision",
|
||||
);
|
||||
await phone.page.reload({ waitUntil: "networkidle" });
|
||||
await phone.page.waitForTimeout(1200);
|
||||
check("model: remembered across reloads", (await phone.page.getByRole("button", { name: "stub-vision" }).count()) === 1);
|
||||
|
||||
await phone.page.getByRole("button", { name: "1 deck" }).click();
|
||||
await phone.page.waitForTimeout(400);
|
||||
check("scope: picker opens with the modes", (await phone.page.getByText("All notes").count()) >= 1 && (await phone.page.getByText("No notes").count()) >= 1);
|
||||
await phone.page.getByRole("button", { name: /No notes/ }).first().click();
|
||||
await phone.page.waitForTimeout(400);
|
||||
check("scope: chip follows the choice", (await phone.page.getByRole("button", { name: "No notes" }).count()) === 1);
|
||||
|
||||
/* 9. UI: thinking row ----------------------------------------------- */
|
||||
mode = "thinking";
|
||||
await phone.page.getByRole("textbox").fill("Think about it");
|
||||
await phone.page.getByRole("button", { name: "Send", exact: true }).click();
|
||||
await phone.page.waitForTimeout(2500);
|
||||
const thinkingRow = phone.page.getByRole("button", { name: /Thinking/ }).last();
|
||||
check("thinking: row appears", (await thinkingRow.count()) >= 1);
|
||||
check("thinking: reasoning hidden by default", (await phone.page.getByText("REASONING-MARKER").count()) === 0);
|
||||
await thinkingRow.click();
|
||||
await phone.page.waitForTimeout(300);
|
||||
check("thinking: expanding reveals it", (await phone.page.getByText(/REASONING-MARKER/).count()) >= 1);
|
||||
mode = "ok";
|
||||
|
||||
/* 10. UI: stop mid-stream ------------------------------------------- */
|
||||
mode = "slow";
|
||||
await phone.page.getByRole("textbox").fill("Slow one");
|
||||
await phone.page.getByRole("button", { name: "Send", exact: true }).click();
|
||||
await phone.page.waitForTimeout(1700);
|
||||
check("stop: stop button appears while streaming", (await phone.page.getByRole("button", { name: "Stop" }).count()) === 1);
|
||||
await phone.page.getByRole("button", { name: "Stop" }).click();
|
||||
await phone.page.waitForTimeout(700);
|
||||
check("stop: send button returns", (await phone.page.getByRole("button", { name: "Send", exact: true }).count()) === 1);
|
||||
const partial = await phone.page.locator(".chat-md").last().innerText().catch(() => "");
|
||||
// Next's route announcer is also role=alert — only real error cards count.
|
||||
const alerts = await phone.page.locator('[role="alert"]:not(#__next-route-announcer__)').count();
|
||||
check("stop: partial answer kept, no error card", partial.includes("Slow") && alerts === 0, JSON.stringify(partial.slice(0, 30)));
|
||||
mode = "ok";
|
||||
|
||||
/* 11. UI: regenerate + edit ----------------------------------------- */
|
||||
const lastAssistantActions = phone.page.locator("div").filter({ has: phone.page.getByRole("button", { name: "Regenerate" }) });
|
||||
check("actions: regenerate offered on the last answer", (await phone.page.getByRole("button", { name: "Regenerate" }).count()) >= 1);
|
||||
const before = await phone.page.locator(".chat-md").count();
|
||||
await phone.page.getByRole("button", { name: "Regenerate" }).last().click();
|
||||
await phone.page.waitForTimeout(2200);
|
||||
check("actions: regenerate streams a fresh answer", (await phone.page.locator(".chat-md").count()) === before);
|
||||
void lastAssistantActions;
|
||||
check("actions: edit offered on the last user message", (await phone.page.getByRole("button", { name: "Edit and resend" }).count()) === 1);
|
||||
await phone.page.getByRole("button", { name: "Edit and resend" }).click();
|
||||
await phone.page.waitForTimeout(400);
|
||||
check("actions: edit restores the text into the composer", (await phone.page.getByRole("textbox").inputValue()) === "Slow one");
|
||||
|
||||
/* 12. UI: usage limit + offline ------------------------------------- */
|
||||
mode = "limit";
|
||||
await phone.page.getByRole("textbox").fill("Will hit the limit");
|
||||
await phone.page.getByRole("button", { name: "Send", exact: true }).click();
|
||||
await phone.page.waitForTimeout(2000);
|
||||
check("limit: error card shown", (await phone.page.getByRole("alert").count()) >= 1 && (await phone.page.getByText(/free monthly Ollama usage/i).count()) >= 1);
|
||||
check("limit: Settings + Ollama actions offered", (await phone.page.getByRole("button", { name: /Open settings/ }).count()) >= 1 && (await phone.page.getByRole("button", { name: /Ollama/ }).count()) >= 1);
|
||||
check("limit: the draft is kept for a retry", (await phone.page.getByRole("textbox").inputValue()) === "Will hit the limit");
|
||||
mode = "ok";
|
||||
|
||||
await phone.context.setOffline(true);
|
||||
await phone.page.waitForTimeout(500);
|
||||
check("offline: send disabled with a reason", (await phone.page.getByRole("button", { name: "Send", exact: true }).isDisabled()) && (await phone.page.getByText(/Offline — sending is paused/).count()) === 1);
|
||||
await phone.context.setOffline(false);
|
||||
await phone.page.waitForTimeout(400);
|
||||
|
||||
/* 13. UI: attachment ------------------------------------------------- */
|
||||
await phone.page.getByRole("button", { name: "Attach photos or documents" }).click();
|
||||
await phone.page.locator('input[type="file"]').setInputFiles({
|
||||
name: "notes.png",
|
||||
mimeType: "image/png",
|
||||
buffer: Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
),
|
||||
});
|
||||
await phone.page.waitForTimeout(1800);
|
||||
check("attach: chip appears after extraction", (await phone.page.getByText("notes.png").count()) >= 1);
|
||||
await phone.page.getByRole("textbox").fill("What does the photo say?");
|
||||
await phone.page.getByRole("button", { name: "Send", exact: true }).click();
|
||||
await phone.page.waitForTimeout(2200);
|
||||
check("attach: extracted text travels as context", String(lastChat?.messages?.[0]?.content ?? "").includes("EXTRACTED-PHOTO-MARKER"));
|
||||
|
||||
/* 14. UI: new chat + old-decks note --------------------------------- */
|
||||
await phone.page.getByRole("button", { name: "New chat" }).click();
|
||||
await phone.page.waitForTimeout(500);
|
||||
check("new chat: clears the conversation", (await phone.page.locator(".chat-md").count()) === 0 && (await phone.page.getByText("Ask your notes anything").count()) === 1);
|
||||
|
||||
const oldDeck = { ...DECK, id: "old_deck", title: "Imported deck", sourceText: "" };
|
||||
const oldPage = await makePage(browser);
|
||||
await seed(oldPage.page, { decks: [oldDeck] });
|
||||
await oldPage.page.goto(`${APP}/chat?deck=old_deck`, { waitUntil: "networkidle" });
|
||||
await oldPage.page.waitForTimeout(1200);
|
||||
check("old decks: the fallback notice is shown", (await oldPage.page.getByText(/Older decks only have the generated study content/).count()) === 1);
|
||||
check("old decks: scope chip scoped to it", (await oldPage.page.getByText("1 deck").count()) >= 1);
|
||||
await oldPage.context.close();
|
||||
|
||||
/* 15. reduced motion still works ------------------------------------ */
|
||||
const reduced = await makePage(browser, { reducedMotion: "reduce" });
|
||||
await seed(reduced.page, { decks: [DECK_NOTES] });
|
||||
await reduced.page.goto(`${APP}/chat`, { waitUntil: "networkidle" });
|
||||
await reduced.page.waitForTimeout(1000);
|
||||
await reduced.page.getByRole("textbox").fill("Reduced motion question");
|
||||
await reduced.page.getByRole("button", { name: "Send", exact: true }).click();
|
||||
await reduced.page.waitForTimeout(2500);
|
||||
check("reduced motion: chat still answers", (await reduced.page.locator(".chat-md").count()) >= 1);
|
||||
await reduced.context.close();
|
||||
|
||||
await phone.context.close();
|
||||
}
|
||||
} finally {
|
||||
app.stop();
|
||||
stub.close();
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
finish();
|
||||
141
tests/model-compare.mjs
Normal file
141
tests/model-compare.mjs
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
/**
|
||||
* Model A/B benchmark (diagnostic, not part of `npm run test:e2e`).
|
||||
*
|
||||
* Answers "is model X a better fit than model Y for Freezmo's strict-JSON
|
||||
* pipeline?" with numbers: it runs the same notes through two throwaway
|
||||
* production instances (one per model), times them and reports how often the
|
||||
* model needed the JSON repair round.
|
||||
*
|
||||
* OLLAMA_API_KEY=... node tests/model-compare.mjs
|
||||
* OLLAMA_API_KEY=... node tests/model-compare.mjs gemma4:31b-cloud gpt-oss:120b-cloud
|
||||
*
|
||||
* The key travels only to the spawned local instances (server auth mode) and
|
||||
* is never printed, stored or committed. Uses your Ollama quota: 2 generate
|
||||
* calls + 1 grade call per model.
|
||||
*/
|
||||
import { spawnApp, waitForHttp, portInUse } from "./lib.mjs";
|
||||
|
||||
const MODELS = process.argv.slice(2);
|
||||
const [MODEL_A, MODEL_B] = MODELS.length >= 2 ? MODELS : ["gpt-oss:120b-cloud", "gemma4:31b-cloud"];
|
||||
const API_KEY = process.env.OLLAMA_API_KEY?.trim();
|
||||
const BASE_URL = process.env.OLLAMA_BASE_URL?.trim() || "https://ollama.com";
|
||||
|
||||
if (!API_KEY) {
|
||||
console.error("Set OLLAMA_API_KEY (your Ollama key) for this command — it is never printed.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const NOTES = `Rayleigh scattering
|
||||
Light from the sun hits molecules in the atmosphere. Short wavelengths (blue and violet) scatter much more strongly than long ones (red). This is why a clear daytime sky is blue.
|
||||
Sunsets
|
||||
At sunset the light travels through much more atmosphere, so almost all the blue light is scattered away and only the red end of the spectrum reaches our eyes.
|
||||
Why not violet?
|
||||
Violet scatters even more than blue, but our eyes are far less sensitive to violet, and some of it is absorbed in the upper atmosphere.
|
||||
Interesting: the sky on the Moon is black because there is no atmosphere to scatter light.`;
|
||||
|
||||
const GRADE_INPUT = {
|
||||
question: "Why is a clear daytime sky blue?",
|
||||
referenceAnswer: "Short wavelengths scatter more strongly than long ones.",
|
||||
keyPoints: ["Rayleigh scattering", "short wavelengths scatter more", "molecules in the atmosphere"],
|
||||
userAnswer: "Because blue light scatters more than red in the atmosphere.",
|
||||
};
|
||||
|
||||
async function timed(label, run) {
|
||||
const started = Date.now();
|
||||
try {
|
||||
const value = await run();
|
||||
return { label, ms: Date.now() - started, value };
|
||||
} catch (error) {
|
||||
return { label, ms: Date.now() - started, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function generateOnce(port, index) {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/generate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ text: NOTES, difficulty: "medium", count: 8 }),
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}: ${(await response.text()).slice(0, 120)}`);
|
||||
const events = (await response.text())
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line));
|
||||
const deckEvent = events.find((event) => event.type === "deck");
|
||||
const errorEvent = events.find((event) => event.type === "error");
|
||||
if (errorEvent) throw new Error(errorEvent.error);
|
||||
if (!deckEvent) throw new Error("no deck event");
|
||||
return {
|
||||
run: index,
|
||||
activities: deckEvent.deck.activities.length,
|
||||
repaired: Boolean(deckEvent.meta?.repaired),
|
||||
requested: deckEvent.meta?.requested ?? null,
|
||||
types: deckEvent.deck.activities.map((activity) => activity.type),
|
||||
sample: deckEvent.deck.activities[0]?.question ?? deckEvent.deck.activities[0]?.front ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
async function gradeOnce(port) {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/grade`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(GRADE_INPUT),
|
||||
});
|
||||
const body = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(body?.error ?? `HTTP ${response.status}`);
|
||||
return { correct: body.correct, score: body.score, feedback: String(body.feedback ?? "").slice(0, 70) };
|
||||
}
|
||||
|
||||
async function bench(model, port) {
|
||||
if (await portInUse(port)) throw new Error(`port ${port} is busy`);
|
||||
const app = spawnApp(port, {
|
||||
OLLAMA_AUTH: "server",
|
||||
OLLAMA_API_KEY: API_KEY,
|
||||
OLLAMA_BASE_URL: BASE_URL,
|
||||
OLLAMA_VERIFY_KEY: "false",
|
||||
TEXT_MODEL: model,
|
||||
VISION_MODEL: model,
|
||||
});
|
||||
try {
|
||||
const up = await waitForHttp(`http://127.0.0.1:${port}/api/health`);
|
||||
if (!up) throw new Error("instance did not start");
|
||||
const rows = [];
|
||||
for (const index of [1, 2]) rows.push(await timed(`generate #${index}`, () => generateOnce(port, index)));
|
||||
rows.push(await timed("grade", () => gradeOnce(port)));
|
||||
return { model, rows };
|
||||
} finally {
|
||||
app.stop();
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Comparing ${MODEL_A} vs ${MODEL_B} against ${BASE_URL}\n`);
|
||||
const results = [];
|
||||
for (const [model, port] of [
|
||||
[MODEL_A, 3210],
|
||||
[MODEL_B, 3211],
|
||||
]) {
|
||||
console.log(`--- ${model} ---`);
|
||||
const result = await bench(model, port);
|
||||
for (const row of result.rows) {
|
||||
if (row.error) console.log(` ${row.label}: ERROR after ${row.ms} ms — ${row.error}`);
|
||||
else if (row.label.startsWith("generate")) {
|
||||
console.log(
|
||||
` ${row.label}: ${row.ms} ms | ${row.value.activities} activities | repaired: ${row.value.repaired ? "YES" : "no"} | types: ${row.value.types.join(", ")}`,
|
||||
);
|
||||
console.log(` first: ${row.value.sample.slice(0, 90)}`);
|
||||
} else {
|
||||
console.log(` ${row.label}: ${row.ms} ms | correct: ${row.value.correct} | score: ${row.value.score} | ${row.value.feedback}`);
|
||||
}
|
||||
}
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
console.log("\nSummary");
|
||||
for (const result of results) {
|
||||
const generates = result.rows.filter((row) => row.label.startsWith("generate") && !row.error);
|
||||
const repaired = generates.filter((row) => row.value.repaired).length;
|
||||
const times = generates.map((row) => row.ms);
|
||||
const average = times.length ? Math.round(times.reduce((sum, value) => sum + value, 0) / times.length) : 0;
|
||||
console.log(` ${result.model}: avg generate ${average} ms | repairs ${repaired}/${generates.length} | errors ${result.rows.filter((row) => row.error).length}`);
|
||||
}
|
||||
console.log("\nRead it as: fewer repairs + sensible activity types + acceptable latency = the better pipeline fit.");
|
||||
|
|
@ -22,6 +22,7 @@ const SUITES = [
|
|||
"phase1-test",
|
||||
"phase2-test",
|
||||
"phase3-test",
|
||||
"chat-test",
|
||||
"review-test",
|
||||
];
|
||||
|
||||
|
|
|
|||
6
types/globals.d.ts
vendored
Normal file
6
types/globals.d.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/**
|
||||
* Ambient declarations for assets Next.js bundles but TypeScript doesn't know
|
||||
* about. KaTeX's stylesheet is imported dynamically next to the library so it
|
||||
* only loads when a message actually contains math.
|
||||
*/
|
||||
declare module "*.css";
|
||||
Loading…
Add table
Add a link
Reference in a new issue