'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) } if (answer.trim().length === 0) { // Belt and braces with the server's 502 guard: a 200 whose answer is // empty must not append an invisible bubble ("Tänker" collapses and // nothing appears). Same message the server sends on 502; the thread // id (if one was created) is kept above so a retry lands in it. setError('Assistenten gav inget svar. Försök igen.') return } 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) }} >