Files
accounted/lib/agent/ask/persist.ts
T
3f6f1ab06e 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>
2026-08-20 20:35:16 +02:00

138 lines
4.9 KiB
TypeScript

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)
}