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
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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue