diff --git a/docs/architecture/chat.md b/docs/architecture/chat.md index dc76725df..1078109bd 100644 --- a/docs/architecture/chat.md +++ b/docs/architecture/chat.md @@ -101,7 +101,7 @@ The list handed to the generator is `[system, *history within budget, user]`. - The runtime is scoped to the active thread; thread lists and workspaces stay application state. Sending from a new chat first creates a thread titled "New chat". The last open thread of each workspace is remembered in `localStorage`. - [`sse.ts`](../../surfsense_local/frontend/src/features/chat/sse.ts) parses the stream chunk-safely: frames split on blank lines, and a partial frame waits for the next read. This token stream is separate from the `/events` channel. - From send until the first token, the assistant turn shows a shimmering "Thinking" beside an orbiting-dots indicator ported from surfsense_web ([`thinking-indicator.tsx`](../../surfsense_local/frontend/src/features/chat/thinking-indicator.tsx)), so a model load or a long prompt is never a blank reply. It is one header from send to answer, whose state moves from waiting to thinking to done, so the indicator and shimmer never restart when the trace arrives. A thinking model's trace then streams open below it, above the answer, in a box 13rem tall that fades at its scrolled edges and follows the newest line until the reader scrolls up, and folds to "Thought for N seconds" when the answer starts, the indicator sliding away; the person's own open or close wins until the next reply, and holds the chat's scroll still while the trace slides, with assistant-ui's `useScrollLock` ([`reply-thinking.tsx`](../../surfsense_local/frontend/src/features/chat/reply-thinking.tsx)). The trace rides in the message's `metadata.custom`, beside the citations, not as a content part, so Copy takes the answer alone. -- Sending is disabled without a usable model or while threads or messages load, and Stop aborts the request. A `409` "no chat model selected" opens model setup. An `error` frame attaches to its assistant turn: auth and not-found errors, and network errors from a remote endpoint, offer Model setup; a network error from the local runtime offers nothing, since no setting restarts it; the rest offer Retry, which resends the same text. +- Sending is disabled without a usable model or while threads or messages load, and Stop aborts the request. A `409` "no chat model selected" opens model setup. An `error` frame attaches to its assistant turn: auth, not-found and model-cannot-run errors, and network errors from a remote endpoint, offer Model setup; a network error from the local runtime and a context-too-long error offer nothing, since no button fixes either; the rest offer Retry, which resends the same text. - Every turn sends the ids of the ready sources the user left included in the sources panel, so unticking a source takes it out of retrieval, and unticking all of them leaves the model with no context. ## Citation panel @@ -128,7 +128,6 @@ The list handed to the generator is `[system, *history within budget, user]`. - A stream that ends without an error but yields no text still renames the thread and stores an empty assistant turn; the discard guard is `failed and not parts`. - `budget.py` prices the question at 1,024 tokens and says `MessageText` enforces that, but `MessageText` has no length limit, so a long question can push a turn past the model's window. -- `model_cannot_run` and `context_too_long` offer Retry in `chat-error-notice.tsx`, which cannot fix either. - No live region announces streamed text, and focus does not move to the conversation heading after a thread switch; the dashboard design asks for both. - No test covers a client disconnecting mid-reply. The assistant's text is written only when generation ends, inside the stream, so whether a disconnected reply is kept is unverified. - With no hits, the system message is the instructions alone, which still ask for `[n]` labels. In the chat eval, Qwen3 1.7B wrote a `[1]` in all three such answers, which chat drops, and one of them never said the sources did not cover the question. diff --git a/surfsense_local/frontend/src/features/chat/chat-error-notice.tsx b/surfsense_local/frontend/src/features/chat/chat-error-notice.tsx index 02f02ae7e..1936b3d8f 100644 --- a/surfsense_local/frontend/src/features/chat/chat-error-notice.tsx +++ b/surfsense_local/frontend/src/features/chat/chat-error-notice.tsx @@ -12,11 +12,16 @@ function actionFor(error: ChatTurnError): Action { switch (error.kind) { case "provider_auth": case "provider_not_found": + case "model_cannot_run": return "model-setup" case "network": // A bad base URL is a Model setup fix; a local runtime that isn't // running isn't — there's no settings action that starts it. return error.provider === "llamacpp" ? "none" : "model-setup" + case "context_too_long": + // The notice text already says to start a new chat or pick a model + // with a larger window — Retry would resend the same overlong turn. + return "none" default: return "retry" } diff --git a/surfsense_local/frontend/src/features/dashboard/dashboard-page.test.tsx b/surfsense_local/frontend/src/features/dashboard/dashboard-page.test.tsx index e3edb1bd7..5933a8e1e 100644 --- a/surfsense_local/frontend/src/features/dashboard/dashboard-page.test.tsx +++ b/surfsense_local/frontend/src/features/dashboard/dashboard-page.test.tsx @@ -891,6 +891,85 @@ describe("dashboard chat", () => { expect(screen.queryByRole("button", { name: "Model setup" })).toBeNull() }) + it("offers no Retry for a context-too-long failure it cannot fix", async () => { + const fetchMock = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + const path = String(input) + if (path === "/llm/providers") { + return Response.json([ + { name: "llamacpp", healthy: true, can_download: true }, + ]) + } + if ( + path === "/workspaces/1/documents?document_type=FILE&document_type=NOTE" + ) { + return Response.json([]) + } + if (path === "/workspaces/1/chat/threads" && !init?.method) { + return Response.json([]) + } + if (path === "/workspaces/1/chat/threads" && init?.method === "POST") { + return Response.json( + { + id: 10, + workspace_id: 1, + title: "Too long", + created_at: "2026-09-05T00:00:00Z", + updated_at: "2026-09-05T00:00:00Z", + }, + { status: 201 } + ) + } + if (path === "/chat/threads/10/messages" && init?.method === "POST") { + return new Response( + 'data: {"type":"accepted","user_message_id":100,"assistant_message_id":101,"user_created_at":"2026-09-05T00:00:00Z"}\n\ndata: {"type":"error","kind":"context_too_long","message":"context window exceeded","provider":"llamacpp"}\n\ndata: [DONE]\n\n', + { headers: { "Content-Type": "text/event-stream" } } + ) + } + if (path === "/chat/threads/10/messages") { + return Response.json([]) + } + return Response.json({ detail: "not found" }, { status: 404 }) + } + ) + vi.stubGlobal("fetch", fetchMock) + const user = userEvent.setup() + + render( + + + + ) + + await screen.findByRole("textbox", { name: "Message" }) + await user.type( + screen.getByRole("textbox", { name: "Message" }), + "Too long" + ) + await user.click(screen.getByRole("button", { name: "Send message" })) + + expect( + await screen.findByText( + "This conversation is too long for the model’s context window. Start a new chat or pick a model with a larger window." + ) + ).toBeTruthy() + // Retry resends the identical message to the identical model and fails + // identically — it cannot fix an oversized context window. + const retryButton = screen.queryByRole("button", { name: "Retry" }) + expect(retryButton, "context_too_long must not offer Retry").toBeNull() + }) + it("aborts the active stream when stop is pressed", async () => { const captured: { signal: AbortSignal | null } = { signal: null } const fetchMock = vi.fn(