From 3f6f1ab06e608050727fd4904f5beaa70063c4b8 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Thu, 20 Aug 2026 20:35:16 +0200 Subject: [PATCH] feat(chat): single-call console for general.help, persisted, runs on a local model (#1762) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RIP-3 cutover. The free-form /chat panel (general.help) now answers through a page-scoped single-call console (AskConsole → POST /api/agent/ask) instead of the streaming Anthropic runtime, so the in-app assistant runs on ANY configured backend, including a local OpenAI-compatible model (Qwen behind llama.cpp/Ollama/vLLM). No tool loop, no NDJSON stream, no Anthropic wire format. Threads still persist: the ask route gains an opt-in persist branch that writes both turns to agent_conversations/agent_messages as canonical Anthropic text blocks, so the /chat sidebar and "resume a thread" keep working across old streaming threads and new single-call ones. Page-scoped one-off asks (a report page) omit persist and stay stateless. Scope: only general.help is wired to the console. The tool-loop intents (transaction.categorization, invoice.draft, supplier_invoice.review) and the docked AgentSheet still use AgentChat + run-turn.ts because they stage operations and need the tool loop, so run-turn.ts is intentionally NOT deleted here (the plan gates its deletion on "once nothing calls them"; RIP-4 migrates the rest). - lib/agent/ask/persist.ts: resolveChatConversation (create/resume, ownership), persistUserTurn, persistAssistantTurn (append + roll last_message_* forward) - app/api/agent/ask/route.ts: persist branch (resolve → user turn → answer → assistant turn), returns conversation_id; 404 on a foreign conversation - components/agent/AskConsole.tsx: the console UI (approved sign-off design): user bubble + bare-prose answer, thinking indicator, empty/503/paywall states - ChatConversationView / ChatNewStarter: branch general.help → AskConsole, every other intent keeps AgentChat unchanged Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 --- DECISIONS.md | 1 + app/api/agent/ask/__tests__/route.test.ts | 90 ++++++ app/api/agent/ask/route.ts | 55 +++- components/agent/AskConsole.tsx | 369 ++++++++++++++++++++++ components/agent/ChatConversationView.tsx | 20 ++ components/agent/ChatNewStarter.tsx | 23 +- lib/agent/ask/__tests__/persist.test.ts | 181 +++++++++++ lib/agent/ask/persist.ts | 137 ++++++++ 8 files changed, 872 insertions(+), 4 deletions(-) create mode 100644 components/agent/AskConsole.tsx create mode 100644 lib/agent/ask/__tests__/persist.test.ts create mode 100644 lib/agent/ask/persist.ts diff --git a/DECISIONS.md b/DECISIONS.md index 904f797b..d80ac77b 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1126,3 +1126,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-20] Strict JSON on OpenAI-compatible endpoints uses a hand-maintained JSON-schema mirror of the extraction Zod schema, opt-in via AI_STRICT_JSON, never an automatic Zod-to-JSON-schema conversion: the Zod schema carries .catch()/.transform() that have no schema equivalent and a generated schema would drift silently. Zod stays the validator either way; JSON-in-prose + extractJsonObject remains the default everywhere because it works on every model and is what hosted runs. [2026-08-20] AI_API_KEY made optional for the OpenAI-compatible backend: a base URL alone now counts as configured (hasAiCredentials / resolveAiProvider), so a local model server (llama.cpp/Ollama/LM Studio/vLLM), which usually has no auth, works with just AI_BASE_URL + AI_MODEL. The openai-compatible service only sends an Authorization: Bearer when AI_API_KEY is set, so a keyless local server is never handed an empty bearer. Hosted providers that require a key still set AI_API_KEY. Bedrock/Anthropic credential logic unchanged. [2026-08-20] poppler-utils is the one system package added to the self-host runner image (Sovereign plan WS1 PR2): pdftoppm renders the first pages of a PDF for AI backends without native PDF input (an OpenAI-compatible Swedish endpoint), measured at ~4 MB plus shared libs on node:22-alpine (pdftoppm 25.12), written to /tmp which docker-compose.yml already mounts as tmpfs under the read-only root. Hosted never calls it (Bedrock reads PDFs natively) and the cron image is untouched. pdfjs-dist + @napi-rs/canvas were rejected earlier (two npm deps, memory spikes, dead weight on hosted). scripts/smoke-ai-provider.ts is the backend-agnostic "is AI wired up" check; verified live against hosted Bedrock and against a local OpenAI-compatible mock (the mock received Bearer auth, per-tier model ids and one image_url part per rasterized page). +[2026-08-20] RIP-3 chat cutover is scoped to general.help only: the free-form Q&A /chat panel now runs on a page-scoped single-call console (AskConsole → POST /api/agent/ask, persist:true), so it works on any configured backend incl. a local OpenAI-compatible model. The tool-loop intents (transaction.categorization, invoice.draft, supplier_invoice.review) and the docked AgentSheet still use AgentChat + run-turn.ts because they stage operations and need the tool loop, so run-turn.ts is NOT deleted here (the plan gates its deletion on "once nothing calls them"; RIP-4 migrates the rest). Persistence is an opt-in branch on the existing /api/agent/ask route rather than a new endpoint, so page-scoped one-off asks (a report page) stay stateless; the console writes both turns to agent_conversations/agent_messages as canonical Anthropic text blocks so the /chat sidebar and resume keep working across old streaming threads and new single-call ones. diff --git a/app/api/agent/ask/__tests__/route.test.ts b/app/api/agent/ask/__tests__/route.test.ts index 2606086c..8de8c7b5 100644 --- a/app/api/agent/ask/__tests__/route.test.ts +++ b/app/api/agent/ask/__tests__/route.test.ts @@ -18,6 +18,14 @@ const aiStatus = vi.fn() vi.mock('@/lib/ai', () => ({ getAiStatus: () => aiStatus() })) const answer = vi.fn() vi.mock('@/lib/agent/ask/ask-service', () => ({ answerAssistantQuestion: (...a: unknown[]) => answer(...a) })) +const resolveConv = vi.fn() +const persistUser = vi.fn() +const persistAssistant = vi.fn() +vi.mock('@/lib/agent/ask/persist', () => ({ + resolveChatConversation: (...a: unknown[]) => resolveConv(...a), + persistUserTurn: (...a: unknown[]) => persistUser(...a), + persistAssistantTurn: (...a: unknown[]) => persistAssistant(...a), +})) import { POST } from '../route' @@ -31,6 +39,9 @@ beforeEach(() => { requireCapability.mockResolvedValue(null) aiStatus.mockReturnValue({ configured: true, assistantAvailable: false, provider: 'openai-compatible' }) answer.mockResolvedValue({ answer: 'Svar', model: 'qwen3.8' }) + resolveConv.mockResolvedValue({ ok: true, conversationId: 'conv-9', created: true }) + persistUser.mockResolvedValue(undefined) + persistAssistant.mockResolvedValue(undefined) }) const body = (o: Record = {}) => ({ question: 'Hur gick juli?', ...o }) @@ -71,4 +82,83 @@ describe('POST /api/agent/ask', () => { expect(b.code).toBe('ai_unconfigured') expect(answer).not.toHaveBeenCalled() }) + + it('stateless (no persist): never touches the conversation tables', async () => { + await POST(createMockRequest('/x', { method: 'POST', body: body() })) + expect(resolveConv).not.toHaveBeenCalled() + expect(persistUser).not.toHaveBeenCalled() + expect(persistAssistant).not.toHaveBeenCalled() + }) + + describe('persist: true (chat console)', () => { + it('creates/resumes the thread, writes both turns, returns the conversation id', async () => { + const res = await POST( + createMockRequest('/x', { + method: 'POST', + body: body({ persist: true, context_ref: 'report:vat:2026-07' }), + }), + ) + const { status, body: b } = await parseJsonResponse<{ + data: { answer: string; model: string; conversation_id: string } + }>(res) + expect(status).toBe(200) + expect(b.data.conversation_id).toBe('conv-9') + expect(b.data.answer).toBe('Svar') + // Order matters: resolve → user turn → answer → assistant turn. + expect(resolveConv).toHaveBeenCalledWith( + supabase, + 'user-1', + 'company-1', + undefined, + 'Hur gick juli?', + 'report:vat:2026-07', + ) + expect(persistUser).toHaveBeenCalledWith(supabase, 'conv-9', 'Hur gick juli?') + expect(answer).toHaveBeenCalled() + expect(persistAssistant).toHaveBeenCalledWith(supabase, 'conv-9', 'Svar') + }) + + it('resumes with a supplied conversation_id', async () => { + resolveConv.mockResolvedValue({ ok: true, conversationId: 'conv-7', created: false }) + const res = await POST( + createMockRequest('/x', { + method: 'POST', + body: body({ persist: true, conversation_id: '11111111-1111-4111-8111-111111111111' }), + }), + ) + const { status, body: b } = await parseJsonResponse<{ data: { conversation_id: string } }>(res) + expect(status).toBe(200) + expect(b.data.conversation_id).toBe('conv-7') + expect(resolveConv).toHaveBeenCalledWith( + supabase, + 'user-1', + 'company-1', + '11111111-1111-4111-8111-111111111111', + 'Hur gick juli?', + undefined, + ) + }) + + it("404s on a conversation that isn't the user's, without answering or persisting", async () => { + resolveConv.mockResolvedValue({ ok: false, reason: 'not_found' }) + const res = await POST( + createMockRequest('/x', { + method: 'POST', + body: body({ persist: true, conversation_id: '22222222-2222-4222-8222-222222222222' }), + }), + ) + expect(res.status).toBe(404) + expect(persistUser).not.toHaveBeenCalled() + expect(answer).not.toHaveBeenCalled() + expect(persistAssistant).not.toHaveBeenCalled() + }) + + it('still 503s (no write) when no backend is configured', async () => { + aiStatus.mockReturnValue({ configured: false }) + const res = await POST(createMockRequest('/x', { method: 'POST', body: body({ persist: true }) })) + expect(res.status).toBe(503) + expect(resolveConv).not.toHaveBeenCalled() + expect(persistUser).not.toHaveBeenCalled() + }) + }) }) diff --git a/app/api/agent/ask/route.ts b/app/api/agent/ask/route.ts index 60531887..cc642986 100644 --- a/app/api/agent/ask/route.ts +++ b/app/api/agent/ask/route.ts @@ -8,6 +8,11 @@ import { requireCapability } from '@/lib/entitlements/has-capability' import { CAPABILITY } from '@/lib/entitlements/keys' import { getAiStatus } from '@/lib/ai' import { answerAssistantQuestion } from '@/lib/agent/ask/ask-service' +import { + resolveChatConversation, + persistUserTurn, + persistAssistantTurn, +} from '@/lib/agent/ask/persist' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' /** @@ -27,6 +32,15 @@ const Schema = z.object({ context: z.string().max(24_000).optional(), tier: z.enum(['assistant', 'heavy']).optional(), company_id: z.string().uuid().optional(), + // Chat-console persistence (opt-in). When `persist` is true, the turn is + // written to agent_conversations/agent_messages so the /chat sidebar keeps + // working. Page-scoped one-off actions (a report page asking a question) + // omit it and stay stateless. `conversation_id` resumes an existing + // general.help thread; omitted means "create one". `context_ref` binds a + // fresh thread to a page ("report:vat:2026-07") for the context chip. + persist: z.boolean().optional(), + conversation_id: z.string().uuid().nullable().optional(), + context_ref: z.string().max(200).nullable().optional(), }) export async function POST(request: Request): Promise { @@ -73,7 +87,43 @@ export async function POST(request: Request): Promise { ) } + // Stateless page-scoped ask: one answer, nothing written. + if (parsed.data.persist !== true) { + try { + const result = await answerAssistantQuestion({ + supabase, + companyId, + question: parsed.data.question, + pageContext: parsed.data.context, + tier: parsed.data.tier, + }) + return NextResponse.json({ data: result }) + } catch (err) { + return NextResponse.json({ error: getUserErrorMessage(err) }, { status: 500 }) + } + } + + // Persisted chat-console turn: resolve/create the thread, write the question, + // answer once, write the answer. Resolve BEFORE the model call so a bad + // conversation id 404s without spending a request; the user turn is written + // before the answer so a mid-call failure still leaves the question in the + // thread (the user can retry), matching the streaming runtime's semantics. try { + const resolved = await resolveChatConversation( + supabase, + user.id, + companyId, + parsed.data.conversation_id, + parsed.data.question, + parsed.data.context_ref, + ) + if (!resolved.ok) { + return NextResponse.json({ error: 'Konversationen hittades inte.' }, { status: 404 }) + } + const { conversationId } = resolved + + await persistUserTurn(supabase, conversationId, parsed.data.question) + const result = await answerAssistantQuestion({ supabase, companyId, @@ -81,7 +131,10 @@ export async function POST(request: Request): Promise { pageContext: parsed.data.context, tier: parsed.data.tier, }) - return NextResponse.json({ data: result }) + + await persistAssistantTurn(supabase, conversationId, result.answer) + + return NextResponse.json({ data: { ...result, conversation_id: conversationId } }) } catch (err) { return NextResponse.json({ error: getUserErrorMessage(err) }, { status: 500 }) } diff --git a/components/agent/AskConsole.tsx b/components/agent/AskConsole.tsx new file mode 100644 index 00000000..bfb95790 --- /dev/null +++ b/components/agent/AskConsole.tsx @@ -0,0 +1,369 @@ +'use client' + +import { useCallback, useEffect, useRef, useState } from 'react' +import dynamic from 'next/dynamic' +import { AlertTriangle, MessageSquare, Send } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { UpgradeNote } from '@/components/billing/UpgradeNote' +import { useCapability } from '@/contexts/CompanyContext' +import { CAPABILITY } from '@/lib/entitlements/keys' +import { cn } from '@/lib/utils' + +/** + * The single-call chat console for general.help (audit Option A / rip). + * + * Replaces the streaming AgentChat runtime for free-form questions: one POST + * to /api/agent/ask, one answer, no tool loop and no NDJSON stream. That is + * exactly what lets it run on a local model (the endpoint is gated on + * `configured`, any provider, not `assistantAvailable`). The tool-loop intents + * (categorization, invoice draft, supplier review) still use AgentChat + + * run-turn.ts because they stage operations; this console is only wired where + * the intent is general.help. + * + * The surrounding view (ChatConversationView / ChatNewStarter) renders the + * header, the agent avatar and the context chip, so this component is just the + * thread + composer, the same division of labour AgentChat had. + * + * Threads persist: every turn is written server-side to + * agent_conversations/agent_messages (persist:true), so the /chat sidebar and + * "resume a conversation" keep working across old streaming threads and new + * single-call ones. + */ + +// Same lazy markdown chunk AgentChat uses, so a resumed thread renders links, +// lists and tables. Loaded on demand; a plain-text fallback covers the frame +// before the chunk resolves. +const MarkdownMessage = dynamic(() => import('./MarkdownMessage'), { + ssr: false, + loading: () => null, +}) + +let markdownReady = false +let markdownPromise: Promise | null = null +function prefetchMarkdown(): Promise { + if (!markdownPromise) { + markdownPromise = import('./MarkdownMessage') + .then((mod) => { + markdownReady = true + return mod + }) + .catch(() => { + // Keep the plain-text fallback; a failed chunk load must not blank the answer. + }) + } + return markdownPromise +} +function useMarkdownReady(): boolean { + const [ready, setReady] = useState(markdownReady) + useEffect(() => { + if (ready) return + let alive = true + void prefetchMarkdown().then(() => { + if (alive) setReady(true) + }) + return () => { + alive = false + } + }, [ready]) + return ready +} + +const ANSWER_PROSE = + 'prose prose-sm max-w-none text-foreground [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 prose-headings:font-display prose-headings:font-normal prose-headings:tracking-tight prose-h2:text-base prose-h2:mt-3 prose-h2:mb-2 prose-h3:text-sm prose-h3:mt-3 prose-h3:mb-1 prose-p:my-2 prose-p:leading-6 prose-strong:font-semibold prose-strong:text-foreground prose-ul:my-2 prose-li:my-0.5 prose-blockquote:border-l-2 prose-blockquote:border-foreground/30 prose-blockquote:not-italic prose-blockquote:text-muted-foreground prose-blockquote:pl-3 prose-blockquote:my-2 prose-code:bg-secondary prose-code:rounded-sm prose-code:px-1 prose-code:py-0.5 prose-code:text-xs prose-code:before:content-none prose-code:after:content-none prose-a:text-foreground prose-a:underline prose-a:underline-offset-2 prose-pre:bg-secondary prose-pre:text-foreground prose-pre:border prose-pre:border-border prose-pre:rounded-lg prose-pre:my-2 prose-pre:p-3 prose-pre:text-xs prose-pre:leading-relaxed prose-pre:overflow-x-auto [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-foreground [&_pre_code]:text-xs prose-table:my-2 prose-table:text-xs prose-table:border-collapse [&_table]:w-full [&_th]:border-b [&_th]:border-border [&_th]:py-1.5 [&_th]:px-2 [&_th]:text-left [&_th]:font-medium [&_th]:text-muted-foreground [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-[10px] [&_td]:border-b [&_td]:border-border [&_td]:py-1.5 [&_td]:px-2 [&_td]:align-top [&_tbody_tr:last-child_td]:border-b-0' + +// Mirrors ChatEmptyState's three so the in-console empty state and the /chat +// index offer the same way in. +const SUGGESTIONS: { label: string; prompt: string }[] = [ + { + label: 'Vad är min största utgiftspost den här månaden?', + prompt: 'Vad är min största utgiftspost den här månaden? Visa de fem största kategorierna.', + }, + { + label: 'Hur ser min momsrapport ut för senaste perioden?', + prompt: + 'Hur ser min momsrapport ut för den senaste perioden? Vad blir moms att betala eller få tillbaka, och ser något ovanligt ut?', + }, + { + label: 'När är min nästa skatte- eller momsdeadline?', + prompt: 'När är min nästa skatte- eller momsdeadline, och vad behöver jag göra inför den?', + }, +] + +export interface AskConsoleMessage { + role: 'user' | 'assistant' + text: string +} + +interface AskConsoleProps { + /** Existing general.help thread to resume; null/undefined starts a fresh one on first send. */ + initialConversationId?: string | null + /** Already-persisted turns to hydrate (from normalizeStoredMessages, text only). */ + initialMessages?: AskConsoleMessage[] + /** Bound page ref ("report:vat:2026-07"); stored on a fresh thread. The header renders the chip. */ + contextRef?: string | null + /** Fire this question once on mount (suggestion chips / ⌘K deep link). */ + seedUserMessage?: string + /** Called with the id once a fresh thread is created, so the starter can swap the URL to /chat/[id]. */ + onConversationCreated?: (id: string) => void + /** Vertical padding override for the scroller (the full-page chat uses px-6 py-8). */ + scrollerClassName?: string +} + +export default function AskConsole({ + initialConversationId, + initialMessages, + contextRef, + seedUserMessage, + onConversationCreated, + scrollerClassName, +}: AskConsoleProps) { + const hasAi = useCapability(CAPABILITY.ai) + const [messages, setMessages] = useState(initialMessages ?? []) + const [input, setInput] = useState('') + const [pending, setPending] = useState(false) + const [error, setError] = useState(null) + // Set when the endpoint 503s because no AI backend is configured (self-host + // without AI_BASE_URL). Distinct from the paywall (hasAi handles that). + const [unconfigured, setUnconfigured] = useState(false) + const conversationIdRef = useRef(initialConversationId ?? null) + const scrollerRef = useRef(null) + const seedFiredRef = useRef(false) + + // Keep the newest turn in view. A resumed thread lands at the bottom too, + // which is where the composer is. + useEffect(() => { + const el = scrollerRef.current + if (el) el.scrollTop = el.scrollHeight + }, [messages, pending]) + + const send = useCallback( + async (raw: string) => { + const question = raw.trim() + if (!question || pending) return + + setInput('') + setError(null) + setUnconfigured(false) + setMessages((prev) => [...prev, { role: 'user', text: question }]) + setPending(true) + + try { + const res = await fetch('/api/agent/ask', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + question, + persist: true, + conversation_id: conversationIdRef.current, + // context_ref only binds a FRESH thread; a resumed one already has it. + context_ref: conversationIdRef.current ? undefined : (contextRef ?? undefined), + }), + }) + + if (res.status === 503) { + setUnconfigured(true) + return + } + if (!res.ok) { + let message = 'Något gick fel. Försök igen.' + try { + const b = (await res.json()) as { error?: unknown } + if (typeof b?.error === 'string') message = b.error + } catch { + // keep the default + } + setError(message) + return + } + + const body = (await res.json()) as { + data?: { answer?: string; conversation_id?: string } + } + const answer = body?.data?.answer ?? '' + const convId = body?.data?.conversation_id + if (convId && !conversationIdRef.current) { + conversationIdRef.current = convId + onConversationCreated?.(convId) + } + setMessages((prev) => [...prev, { role: 'assistant', text: answer }]) + } catch { + setError('Kunde inte nå assistenten. Kontrollera anslutningen och försök igen.') + } finally { + setPending(false) + } + }, + [pending, contextRef, onConversationCreated], + ) + + // Auto-fire a seeded question exactly once (a suggestion chip the user + // actually clicked, not an unprompted greeting: RIP-1 removed that). + useEffect(() => { + if (seedFiredRef.current) return + const seed = seedUserMessage?.trim() + if (!seed) return + seedFiredRef.current = true + void send(seed) + }, [seedUserMessage, send]) + + const showEmpty = messages.length === 0 && !pending && !unconfigured + + return ( +
+
+ {showEmpty ? ( + void send(p)} canSend={hasAi} /> + ) : ( + <> + {messages.map((m, i) => ( + + ))} + {pending && } + + )} + + {unconfigured && } + + {error && ( +
+ {error} +
+ )} +
+ + {/* Paywall parity with AgentChat: the ask endpoint 403s without CAPABILITY.ai, + so replace the composer with an upsell rather than offer an input that can't send. */} + {!hasAi ? ( +
+ AI-assistenten kräver ett abonnemang. +
+ ) : ( +
{ + e.preventDefault() + void send(input) + }} + > +
+