6aecbdc9b7
The support chat showed 'Tänker' and then went quiet with no answer and no error. Root cause: the 2026-08-21 RIP-3 cutover moved general.help to the single-call POST /api/agent/ask, whose 1500-token default cap made stop_reason max_tokens routine on tool-loop turns. The empty answer then passed unlogged through the service, the route answered 200 with an empty string, and the console appended an invisible empty bubble. Fixes, single-call path: - ask-service: default maxTokens 1500 -> 5400 (the streaming chat's reply ceiling); an empty final answer now logs model + usage and throws the typed EmptyModelAnswerError instead of passing through. - /api/agent/ask: maxDuration 300, logger, empty answer maps to 502 with 'Assistenten gav inget svar. Försök igen.'; an empty assistant turn is never persisted (the question stays, so retry works). - AskConsole: a 200 with an empty answer shows the error box instead of appending an invisible bubble. - anthropic-family: serialized tool results are bounded at 40000 chars (mirrors run-turn) so one big read cannot eat the output budget; the step-exhausted fallback keeps tools declared with tool_choice none, because replaying tool_use/tool_result without tools is an API 400. Fixes, streaming path (same silent class): - /api/agent/invoke: maxDuration 300 so deep thinking turns are not killed mid-stream at the platform default cap. - run-turn: stop_reason max_tokens with no visible text emits an error event, not a bare turn_complete. - AgentChat: an NDJSON stream that ends without turn_complete or error (and was not aborted) shows 'Anslutningen bröts innan svaret blev klart. Försök igen.' The lib/ai request-shape tests were updated deliberately for the fallback change; general.help stays on the single-call runtime (founder decision, not reverted). Claude-Session: https://claude.ai/code/session_01SyDuePXxUFowaPBKpAv8SF Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
378 lines
15 KiB
TypeScript
378 lines
15 KiB
TypeScript
'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)
|
|
}
|
|
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 (
|
|
<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>
|
|
)
|
|
}
|