feat(chat): single-call console for general.help, persisted, runs on a local model (#1762)

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 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-20 20:35:16 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Opus 4.8
parent a071a0220b
commit 3f6f1ab06e
8 changed files with 872 additions and 4 deletions
+1
View File
@@ -1126,3 +1126,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
+90
View File
@@ -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<string, unknown> = {}) => ({ 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()
})
})
})
+54 -1
View File
@@ -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<Response> {
@@ -73,7 +87,43 @@ export async function POST(request: Request): Promise<Response> {
)
}
// 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<Response> {
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 })
}
+369
View File
@@ -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<unknown> | null = null
function prefetchMarkdown(): Promise<unknown> {
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<AskConsoleMessage[]>(initialMessages ?? [])
const [input, setInput] = useState('')
const [pending, setPending] = useState(false)
const [error, setError] = useState<string | null>(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<string | null>(initialConversationId ?? null)
const scrollerRef = useRef<HTMLDivElement>(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 (
<div className="flex flex-1 min-h-0 flex-col">
<div
ref={scrollerRef}
className={cn('flex-1 overflow-y-auto px-5 py-6 space-y-6', scrollerClassName)}
>
{showEmpty ? (
<EmptyState onPick={(p) => void send(p)} canSend={hasAi} />
) : (
<>
{messages.map((m, i) => (
<MessageRow key={i} message={m} />
))}
{pending && <ThinkingRow />}
</>
)}
{unconfigured && <UnconfiguredNotice />}
{error && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error}
</div>
)}
</div>
{/* 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 ? (
<div className="border-t border-border px-5 pt-4 pb-[calc(env(safe-area-inset-bottom,0px)+1rem)]">
<UpgradeNote>AI-assistenten kräver ett abonnemang.</UpgradeNote>
</div>
) : (
<form
className="border-t border-border px-5 pt-4 pb-[calc(env(safe-area-inset-bottom,0px)+1rem)]"
onSubmit={(e) => {
e.preventDefault()
void send(input)
}}
>
<div className="flex items-end gap-2">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Skriv din fråga…"
rows={1}
disabled={pending}
className="flex-1 resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring max-h-32 overflow-y-auto disabled:opacity-60"
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
void send(input)
}
}}
/>
<Button
type="submit"
size="icon"
disabled={pending || input.trim().length === 0}
aria-label="Skicka"
>
<Send className="h-4 w-4" />
</Button>
</div>
<p className="mt-2 text-[11px] text-muted-foreground">
Enter att skicka · Shift+Enter för ny rad
</p>
</form>
)}
</div>
)
}
function MessageRow({ message }: { message: AskConsoleMessage }) {
const isUser = message.role === 'user'
const markdownLoaded = useMarkdownReady()
if (isUser) {
return (
<div className="flex justify-end">
<div className="max-w-[85%] rounded-lg bg-secondary px-4 py-3 text-sm leading-6 text-foreground whitespace-pre-wrap">
{message.text}
</div>
</div>
)
}
// Assistant answer: bare prose, no card (the sign-off design).
return (
<div className="flex">
<div className={cn('max-w-[88%]', ANSWER_PROSE)}>
{markdownLoaded ? (
<MarkdownMessage text={message.text} />
) : (
<p className="whitespace-pre-wrap">{message.text}</p>
)}
</div>
</div>
)
}
function ThinkingRow() {
return (
<div className="flex" aria-live="polite">
<div className="inline-flex items-center gap-2 text-sm text-muted-foreground">
<span className="inline-flex gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-muted-foreground/70 animate-bounce [animation-delay:-0.3s]" />
<span className="h-1.5 w-1.5 rounded-full bg-muted-foreground/70 animate-bounce [animation-delay:-0.15s]" />
<span className="h-1.5 w-1.5 rounded-full bg-muted-foreground/70 animate-bounce" />
</span>
Tänker
</div>
</div>
)
}
function EmptyState({ onPick, canSend }: { onPick: (prompt: string) => void; canSend: boolean }) {
return (
<div className="flex flex-1 flex-col items-center justify-center py-10 text-center">
<div className="mb-3.5 grid h-9 w-9 place-items-center rounded-lg bg-secondary text-foreground">
<MessageSquare className="h-[18px] w-[18px]" />
</div>
<h3 className="mb-1.5 text-[15px] font-medium text-foreground">Fråga om det du ser</h3>
<p className="mx-auto max-w-[34ch] text-sm text-muted-foreground">
Assistenten svarar utifrån den här sidan och din bokföring.
</p>
{canSend && (
<div className="mt-5 flex flex-col gap-2 w-full max-w-md">
{SUGGESTIONS.map((s) => (
<button
key={s.label}
type="button"
onClick={() => onPick(s.prompt)}
className="rounded-lg border border-border bg-card px-4 py-3 text-left text-sm text-muted-foreground transition-colors hover:border-foreground/30 hover:bg-secondary/30 hover:text-foreground"
>
{s.label}
</button>
))}
</div>
)}
</div>
)
}
function UnconfiguredNotice() {
return (
<div className="flex items-start gap-3 rounded-lg border border-border bg-secondary/40 px-4 py-3 text-sm">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
<div>
<strong className="font-medium">Assistenten är inte konfigurerad</strong>
<p className="mt-1 text-muted-foreground">
Den här installationen har ingen AI-modell inställd. Sätt{' '}
<code className="rounded-sm bg-secondary px-1 py-0.5 text-xs">AI_BASE_URL</code> och{' '}
<code className="rounded-sm bg-secondary px-1 py-0.5 text-xs">AI_MODEL</code> (t.ex. en
lokal modell) så svarar assistenten. Bokföringen och underlagstolkningen påverkas inte.
</p>
</div>
</div>
)
}
+20
View File
@@ -4,6 +4,8 @@ import { useMemo } from 'react'
import Link from 'next/link'
import { ArrowLeft } from 'lucide-react'
import AgentChat, { attachStagedOperations, normalizeStoredMessages } from './AgentChat'
import AskConsole, { type AskConsoleMessage } from './AskConsole'
import { CHAT_INTENT_ID } from '@/lib/agent/ask/persist'
import type { StoredStagedOperation } from '@/types'
import AgentAvatar from './AgentAvatar'
import ContextChip from './ContextChip'
@@ -37,6 +39,17 @@ export default function ChatConversationView({
() => attachStagedOperations(normalizeStoredMessages(rawMessages), stagedOperations ?? []),
[rawMessages, stagedOperations],
)
// general.help runs on the single-call console; it never stages operations,
// so the thread is text-only. Empty turns (a historical pure-tool_use row
// from the old runtime) are dropped rather than rendered as blank rows.
const isSingleCall = intentId === CHAT_INTENT_ID
const consoleMessages = useMemo<AskConsoleMessage[]>(
() =>
normalizeStoredMessages(rawMessages)
.filter((m) => m.text.trim().length > 0)
.map((m) => ({ role: m.role, text: m.text })),
[rawMessages],
)
const { identity } = useAgentSheet()
const companyCtx = useCompanyOptional()
const isSandbox = companyCtx?.isSandbox ?? false
@@ -71,6 +84,13 @@ export default function ChatConversationView({
<div className="flex-1 min-h-0 flex flex-col">
{isSandbox ? (
<SandboxAgentPreview agentName={agentName} />
) : isSingleCall ? (
<AskConsole
initialConversationId={conversationId}
initialMessages={consoleMessages}
contextRef={contextRef}
scrollerClassName="px-6 py-8"
/>
) : (
<AgentChat
intentId={intentId}
+20 -3
View File
@@ -3,6 +3,8 @@
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import AgentChat from './AgentChat'
import AskConsole from './AskConsole'
import { CHAT_INTENT_ID } from '@/lib/agent/ask/persist'
import AgentAvatar from './AgentAvatar'
import SandboxAgentPreview from './SandboxAgentPreview'
import { useAgentSheet } from './AgentSheetProvider'
@@ -25,6 +27,16 @@ export default function ChatNewStarter({
const isSandbox = companyCtx?.isSandbox ?? false
const agentName = identity.displayName?.trim() || 'Din assistent'
const [swapped, setSwapped] = useState(false)
const isSingleCall = intentId === CHAT_INTENT_ID
const swapToConversation = (id: string) => {
// Swap once, after the turn is created. For the single-call console both
// the question and the answer are already persisted by the time we get the
// id back, so /chat/[id] hydrates the full thread.
if (swapped) return
setSwapped(true)
router.replace(`/chat/${id}`)
}
return (
<>
@@ -41,6 +53,13 @@ export default function ChatNewStarter({
<div className="flex-1 min-h-0 flex flex-col">
{isSandbox ? (
<SandboxAgentPreview agentName={agentName} />
) : isSingleCall ? (
<AskConsole
seedUserMessage={seedUserMessage}
initialConversationId={null}
onConversationCreated={swapToConversation}
scrollerClassName="px-6 py-8"
/>
) : (
<AgentChat
intentId={intentId}
@@ -51,9 +70,7 @@ export default function ChatNewStarter({
// Wait for the first turn to finish before swapping the URL:
// otherwise the unmount aborts the in-flight stream and
// /chat/[id] hydrates with only the user message.
if (swapped) return
setSwapped(true)
router.replace(`/chat/${id}`)
swapToConversation(id)
}}
scrollerClassName="px-6 py-8"
/>
+181
View File
@@ -0,0 +1,181 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import {
resolveChatConversation,
persistUserTurn,
persistAssistantTurn,
CHAT_INTENT_ID,
} from '../persist'
interface Recorded {
inserts: { table: string; payload: Record<string, unknown> }[]
updates: { table: string; payload: Record<string, unknown>; id?: string }[]
}
/**
* Hand-rolled supabase double. agent_messages.insert() is awaited directly
* ({ error }); agent_conversations.insert() chains .select('id').single().
* agent_conversations.update() chains .eq('id', ...). The mock branches on the
* table so both shapes resolve.
*/
function makeSupabase(opts: {
conv?: { id: string; user_id: string; company_id: string; intent_id: string } | null
newId?: string
msgInsertError?: unknown
} = {}) {
const rec: Recorded = { inserts: [], updates: [] }
const api = {
from(table: string) {
const chain = {
select: () => chain,
eq: (_col: string, _val: string) => chain,
maybeSingle: async () => ({ data: opts.conv ?? null, error: null }),
insert(payload: Record<string, unknown>) {
rec.inserts.push({ table, payload })
if (table === 'agent_messages') {
return Promise.resolve({ error: opts.msgInsertError ?? null })
}
return {
select: () => ({
single: async () => ({ data: { id: opts.newId ?? 'new-conv' }, error: null }),
}),
}
},
update(payload: Record<string, unknown>) {
return {
eq: async (_col: string, id: string) => {
rec.updates.push({ table, payload, id })
return { error: null }
},
}
},
}
return chain
},
}
return { supabase: api as unknown as SupabaseClient, rec }
}
beforeEach(() => vi.clearAllMocks())
describe('resolveChatConversation', () => {
it('creates a general.help conversation titled from the first question', async () => {
const { supabase, rec } = makeSupabase({ newId: 'conv-new' })
const res = await resolveChatConversation(
supabase,
'user-1',
'company-1',
null,
' Hur bokför jag en lunch med en kund? ',
'report:vat:2026-07',
)
expect(res).toEqual({ ok: true, conversationId: 'conv-new', created: true })
const insert = rec.inserts.find((i) => i.table === 'agent_conversations')!
expect(insert.payload).toMatchObject({
company_id: 'company-1',
user_id: 'user-1',
intent_id: CHAT_INTENT_ID,
context_ref: 'report:vat:2026-07',
})
expect(insert.payload.title).toBe('Hur bokför jag en lunch med en kund?')
})
it('resumes an owned general.help thread', async () => {
const { supabase } = makeSupabase({
conv: {
id: 'conv-9',
user_id: 'user-1',
company_id: 'company-1',
intent_id: CHAT_INTENT_ID,
},
})
const res = await resolveChatConversation(supabase, 'user-1', 'company-1', 'conv-9', 'q')
expect(res).toEqual({ ok: true, conversationId: 'conv-9', created: false })
})
it("refuses a colleague's thread (not_found, not 403)", async () => {
const { supabase } = makeSupabase({
conv: {
id: 'conv-9',
user_id: 'other-user',
company_id: 'company-1',
intent_id: CHAT_INTENT_ID,
},
})
const res = await resolveChatConversation(supabase, 'user-1', 'company-1', 'conv-9', 'q')
expect(res).toEqual({ ok: false, reason: 'not_found' })
})
it('refuses a thread from another company', async () => {
const { supabase } = makeSupabase({
conv: { id: 'conv-9', user_id: 'user-1', company_id: 'company-2', intent_id: CHAT_INTENT_ID },
})
const res = await resolveChatConversation(supabase, 'user-1', 'company-1', 'conv-9', 'q')
expect(res).toEqual({ ok: false, reason: 'not_found' })
})
it('refuses a tool-loop thread (wrong intent)', async () => {
const { supabase } = makeSupabase({
conv: {
id: 'conv-9',
user_id: 'user-1',
company_id: 'company-1',
intent_id: 'transaction.categorization',
},
})
const res = await resolveChatConversation(supabase, 'user-1', 'company-1', 'conv-9', 'q')
expect(res).toEqual({ ok: false, reason: 'not_found' })
})
it('falls back to a default title when the first question is blank', async () => {
const { supabase, rec } = makeSupabase({ newId: 'conv-new' })
await resolveChatConversation(supabase, 'user-1', 'company-1', null, ' ')
const insert = rec.inserts.find((i) => i.table === 'agent_conversations')!
expect(insert.payload.title).toBe('Fråga din assistent')
expect(insert.payload.context_ref).toBeNull()
})
})
describe('persistUserTurn', () => {
it('appends the question as a text-block user message', async () => {
const { supabase, rec } = makeSupabase()
await persistUserTurn(supabase, 'conv-9', 'Stämmer momsen?')
const insert = rec.inserts.find((i) => i.table === 'agent_messages')!
expect(insert.payload).toEqual({
conversation_id: 'conv-9',
role: 'user',
content: [{ type: 'text', text: 'Stämmer momsen?' }],
})
})
it('throws if the insert fails (append-only audit must not silently drop turns)', async () => {
const { supabase } = makeSupabase({ msgInsertError: new Error('rls') })
await expect(persistUserTurn(supabase, 'conv-9', 'q')).rejects.toThrow('rls')
})
})
describe('persistAssistantTurn', () => {
it('appends the answer and rolls the conversation row forward with a preview', async () => {
const { supabase, rec } = makeSupabase()
await persistAssistantTurn(supabase, 'conv-9', 'Ja, momsen stämmer.')
const insert = rec.inserts.find((i) => i.table === 'agent_messages')!
expect(insert.payload).toEqual({
conversation_id: 'conv-9',
role: 'assistant',
content: [{ type: 'text', text: 'Ja, momsen stämmer.' }],
})
const update = rec.updates.find((u) => u.table === 'agent_conversations')!
expect(update.id).toBe('conv-9')
expect(update.payload.last_message_preview).toBe('Ja, momsen stämmer.')
expect(update.payload.last_message_at).toEqual(expect.any(String))
})
it('truncates a long preview to 200 chars with an ellipsis', async () => {
const { supabase, rec } = makeSupabase()
await persistAssistantTurn(supabase, 'conv-9', 'x'.repeat(500))
const preview = rec.updates.find((u) => u.table === 'agent_conversations')!.payload
.last_message_preview as string
expect(preview.length).toBe(200)
expect(preview.endsWith('…')).toBe(true)
})
})
+137
View File
@@ -0,0 +1,137 @@
import type { SupabaseClient } from '@supabase/supabase-js'
/**
* Thread persistence for the single-call chat console (/chat).
*
* The console answers in one call via answerAssistantQuestion (no tool loop,
* no streaming: runs on a local model). But the founder wants the /chat
* sidebar and "resume a thread" to keep working, so each console turn is
* written to the SAME durable tables the streaming runtime uses:
* agent_conversations + agent_messages. That way old and new threads live in
* one list and one schema.
*
* Content is stored as the canonical Anthropic content array
* ([{ type: 'text', text }]) so normalizeStoredMessages() renders these rows
* identically to run-turn's, and the BFL append-only audit invariant on
* agent_messages holds (no UPDATE/DELETE policy exists on that table).
*
* Only the general.help intent uses this path. Tool-loop intents
* (categorization, invoice draft, supplier review) still go through
* run-turn.ts because they stage operations and need the tool loop.
*/
/** The only intent the single-call console persists under. */
export const CHAT_INTENT_ID = 'general.help'
// The sidebar caches a one-line preview per row; the title is the sidebar
// label. Both are bounded so a long first question can't bloat the row.
const PREVIEW_MAX = 200
const TITLE_MAX = 80
/** Truncate on a whole-grapheme boundary is overkill here; a hard slice with an ellipsis is fine for a preview. */
function clamp(text: string, max: number): string {
const t = text.trim().replace(/\s+/g, ' ')
if (t.length <= max) return t
return `${t.slice(0, max - 1).trimEnd()}…`
}
function textBlocks(text: string): { type: 'text'; text: string }[] {
return [{ type: 'text', text }]
}
export type ResolveConversationResult =
| { ok: true; conversationId: string; created: boolean }
// The client passed a conversation id that isn't this user's general.help
// thread in this company. Same outcome as "doesn't exist": a 404, never a
// 403 that would confirm someone else's id is real.
| { ok: false; reason: 'not_found' }
/**
* Resolve the conversation to append this turn to.
*
* With no id, create a fresh general.help conversation titled from the first
* question. With an id, verify ownership the same way /api/agent/invoke does:
* RLS on agent_conversations is COMPANY-scoped, so a colleague's thread would
* otherwise load; the user_id + company_id + intent_id checks close that.
*/
export async function resolveChatConversation(
supabase: SupabaseClient,
userId: string,
companyId: string,
conversationId: string | null | undefined,
firstQuestion: string,
contextRef?: string | null,
): Promise<ResolveConversationResult> {
if (conversationId) {
const { data: conv } = await supabase
.from('agent_conversations')
.select('id, user_id, company_id, intent_id')
.eq('id', conversationId)
.maybeSingle()
if (
!conv ||
conv.user_id !== userId ||
conv.company_id !== companyId ||
conv.intent_id !== CHAT_INTENT_ID
) {
return { ok: false, reason: 'not_found' }
}
return { ok: true, conversationId: conv.id as string, created: false }
}
const { data: created, error } = await supabase
.from('agent_conversations')
.insert({
company_id: companyId,
user_id: userId,
intent_id: CHAT_INTENT_ID,
context_ref: contextRef ?? null,
title: clamp(firstQuestion, TITLE_MAX) || 'Fråga din assistent',
})
.select('id')
.single()
if (error || !created) throw error ?? new Error('Failed to create conversation')
return { ok: true, conversationId: created.id as string, created: true }
}
/** Append the user's question as a persisted turn (append-only). */
export async function persistUserTurn(
supabase: SupabaseClient,
conversationId: string,
question: string,
): Promise<void> {
const { error } = await supabase.from('agent_messages').insert({
conversation_id: conversationId,
role: 'user',
content: textBlocks(question),
})
if (error) throw error
}
/**
* Append the assistant's answer and roll the conversation row forward so the
* sidebar shows this thread at the top with a fresh preview. run-turn updates
* the same two columns on every assistant turn; we mirror that exactly.
*/
export async function persistAssistantTurn(
supabase: SupabaseClient,
conversationId: string,
answer: string,
): Promise<void> {
const { error: msgErr } = await supabase.from('agent_messages').insert({
conversation_id: conversationId,
role: 'assistant',
content: textBlocks(answer),
})
if (msgErr) throw msgErr
// Best-effort: a failed roll-forward only mis-sorts the sidebar row, it does
// not lose the answer (already persisted above). Do not fail the request on it.
await supabase
.from('agent_conversations')
.update({
last_message_at: new Date().toISOString(),
last_message_preview: clamp(answer, PREVIEW_MAX),
})
.eq('id', conversationId)
}