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:
co-authored by
Jakob Wennberg
Claude Opus 4.8
parent
a071a0220b
commit
3f6f1ab06e
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user