f0f3050f54
* fix(assistant): stop the chat loading in stages PR2 of the assistant UI makeover (dev_docs/assistant_redesign_plan.md section 7). No redesign; this is the "it loads in different stages" complaint, traced to four separate staging points and one dead link. Resumed conversations rendered a column of EMPTY bordered cards until the markdown chunk arrived, then filled in all at once and reflowed the thread. The chunk was deferred with a null fallback, which is invisible while a reply streams (nobody reads that fast) but very visible on hydrate, where every assistant bubble is already text. The chunk is now prefetched as soon as any chat surface mounts, and until it resolves the raw text renders instead of nothing, so a bubble is never blank. Clicking the assistant launcher showed NOTHING until the sheet chunk loaded: the dynamic import had no loading state at all. It now renders a skeleton in the same geometry, and the chunk is warmed on idle so the click usually hits an already-loaded module. /chat's route skeleton drew a 320px sidebar while ChatSidebar mounts collapsed as a 48px rail, so every load snapped one to the other. The skeleton now matches what actually mounts, per breakpoint. The first turn read agent_profiles twice: once in the route to build the intent's prompt template, once again in run-turn for the system prompt. The route now hands its result over. Ranked memory is deliberately NOT shared: the two queries differ (the route's selects fewer columns and orders without is_pinned, and run-turn needs ids to stamp last_accessed_at), so reusing it would silently change both the prompt and memory touch. Command palette's "Fråga Anna: ..." pointed at /chat?prompt=, but only /chat/new reads ?prompt=, so the typed question was silently dropped and the user landed on an empty state. Verified: 9526 unit tests pass, lint clean and tsc clean on every touched file, guards pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(assistant): make the prefetches fail-safe and bounded Review follow-ups on the staged-loading batch. A rejected markdown import left the cached promise permanently rejected, so every bubble for the rest of the session stayed on the plain-text fallback and the rejection went unhandled. The cache is now cleared on failure so a later surface retries, and the rejection is swallowed. requestIdleCallback can defer indefinitely on a page that never goes idle; the 2s fallback only applied where the API is missing. The idle request now carries a 2s timeout, and the warm import cannot produce an unhandled rejection either. Adds the first-turn test for the profile-summary handover: it asserts the value read for the prompt template is what reaches the turn, so a regression that re-introduces the second read (or drops the template) fails. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
349 lines
13 KiB
TypeScript
349 lines
13 KiB
TypeScript
import { createClient } from '@/lib/supabase/server'
|
|
import { NextResponse } from 'next/server'
|
|
import { z } from 'zod'
|
|
import { ensureInitialized } from '@/lib/init'
|
|
import { requireAuth } from '@/lib/auth/require-auth'
|
|
import { getActiveCompanyId } from '@/lib/company/context'
|
|
import { getIntent } from '@/lib/agent/intents/registry'
|
|
import { checkAgentRateLimit, agentRateLimitResponseBody } from '@/lib/rate-limits/agent'
|
|
import { runChatTurn, friendlyModelError } from '@/lib/agent/chat/run-turn'
|
|
import { guardSandbox } from '@/lib/sandbox/guard'
|
|
import { requireCapability } from '@/lib/entitlements/has-capability'
|
|
import { CAPABILITY } from '@/lib/entitlements/keys'
|
|
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
|
|
|
// Make sure extensions are loaded: the chat loop dispatches against the
|
|
// agent tool registry which is populated by the mcp-server extension at load.
|
|
ensureInitialized()
|
|
|
|
// Hard cap on the per-turn user input. Generous for a chat composer (about
|
|
// 5k words / 20 pages) but bounds Bedrock token cost if the rate limiter is
|
|
// ever fail-open and a client floods large payloads.
|
|
const MAX_USER_MESSAGE_LEN = 20_000
|
|
|
|
const BodySchema = z.object({
|
|
intent_id: z.string().min(1).max(200),
|
|
// Existing conversation to resume; if omitted, the route creates one. The
|
|
// chat sheet's React state holds the conversation id as `string | null`
|
|
// and serializes `null` on the first turn, so accept null alongside
|
|
// undefined and treat both as "no existing conversation".
|
|
conversation_id: z.string().uuid().nullable().optional(),
|
|
// Optional company override; defaults to active_company_id.
|
|
company_id: z.string().uuid().nullable().optional(),
|
|
// The user's message (or, on the first turn, this is empty and we send the
|
|
// intent's prompt template instead). Capped to bound LLM cost.
|
|
user_message: z.string().max(MAX_USER_MESSAGE_LEN).nullable().optional(),
|
|
// Intent-specific capture args (e.g. { transaction_id: '...' } for
|
|
// transaction.categorization). Used only on the first turn to build the
|
|
// prompt template. Each value is bounded so capture inputs can't be a
|
|
// megabyte each; the dispatcher rejects oversize values upfront.
|
|
intent_args: z
|
|
.record(z.string().max(120), z.unknown())
|
|
.nullable()
|
|
.optional()
|
|
.refine(
|
|
(v) => {
|
|
if (!v) return true
|
|
try {
|
|
return JSON.stringify(v).length <= MAX_USER_MESSAGE_LEN
|
|
} catch {
|
|
return false
|
|
}
|
|
},
|
|
{ message: 'intent_args too large' },
|
|
),
|
|
// Optional context_ref for the conversation row, e.g. 'transaction:<id>'.
|
|
context_ref: z.string().max(200).nullable().optional(),
|
|
// When true (and user_message is provided), persist the turn but flag it
|
|
// hidden so it doesn't render as a user bubble on resume. Used by the chat's
|
|
// rejection-correction flow (ApprovalCard → AgentChat) to feed the agent a
|
|
// synthetic correction without showing it as something the user typed.
|
|
user_message_hidden: z.boolean().nullable().optional(),
|
|
})
|
|
|
|
// POST /api/agent/invoke
|
|
//
|
|
// Streams NDJSON events from the chat loop. Each line is a JSON object whose
|
|
// `kind` identifies the event type: see lib/agent/chat/run-turn.ts StreamEvent.
|
|
//
|
|
// Auth: the user must be a member of the resolved company.
|
|
//
|
|
// Plan ref: dev_docs/specialized-agent-plan.md §9 (chat loop).
|
|
export async function POST(request: Request) {
|
|
const { user, supabase, error } = await requireAuth()
|
|
if (error) return error
|
|
|
|
// Generous per-user rate limit: bounds runaway Bedrock spend (loop-firing
|
|
// sessions). Fails open on infra error.
|
|
const rate = await checkAgentRateLimit(supabase, user.id)
|
|
if (!rate.ok) {
|
|
return NextResponse.json(agentRateLimitResponseBody(rate), {
|
|
status: 429,
|
|
headers: rate.retryAfterSec ? { 'Retry-After': String(rate.retryAfterSec) } : undefined,
|
|
})
|
|
}
|
|
|
|
let body: z.infer<typeof BodySchema>
|
|
try {
|
|
body = BodySchema.parse(await request.json())
|
|
} catch (err) {
|
|
return NextResponse.json(
|
|
{ error: err instanceof Error ? getUserErrorMessage(err) : 'Invalid body' },
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
|
|
const intent = getIntent(body.intent_id)
|
|
if (!intent) {
|
|
return NextResponse.json({ error: `Unknown intent: ${body.intent_id}` }, { status: 400 })
|
|
}
|
|
|
|
const companyId = body.company_id ?? (await getActiveCompanyId(supabase, user.id))
|
|
if (!companyId) return NextResponse.json({ error: 'No active company' }, { status: 400 })
|
|
|
|
const { data: membership } = await supabase
|
|
.from('company_members')
|
|
.select('role')
|
|
.eq('company_id', companyId)
|
|
.eq('user_id', user.id)
|
|
.maybeSingle()
|
|
if (!membership) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
|
|
|
// No Anthropic Bedrock calls in the sandbox: the demo runs entirely on
|
|
// seed data and the assistant is gated to a "look, don't touch" preview.
|
|
const blocked = await guardSandbox(supabase, companyId)
|
|
if (blocked) return blocked
|
|
|
|
const capBlocked = await requireCapability(supabase, companyId, CAPABILITY.ai)
|
|
if (capBlocked) return capBlocked
|
|
|
|
// Resolve the conversation BEFORE any side effect below (the onboarding
|
|
// intake stamp): a request that is about to be rejected must not write.
|
|
let conversationId = body.conversation_id ?? null
|
|
if (conversationId) {
|
|
// A resumed conversation id comes straight from the client, so ownership
|
|
// has to be proven here. RLS on agent_conversations/agent_messages is
|
|
// COMPANY-scoped (migration 20260517204000), not user-scoped, so RLS alone
|
|
// would happily load a colleague's thread into the prompt and append this
|
|
// user's turns to it. The conversations list route filters on user_id for
|
|
// exactly this reason; the same rule applies to the turn itself.
|
|
//
|
|
// The company check matters too: a user who belongs to several companies
|
|
// must not resume a thread from company B while the turn runs with company
|
|
// A's ledger, tools and staged operations.
|
|
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 !== user.id || conv.company_id !== companyId) {
|
|
// Same response for "doesn't exist" and "isn't yours": a 403 here would
|
|
// confirm that someone else's conversation id is real.
|
|
return NextResponse.json({ error: 'Konversationen hittades inte.' }, { status: 404 })
|
|
}
|
|
|
|
// The intent decides the tool loadout and the system prompt. Letting a
|
|
// resumed thread switch intent mid-conversation would swap the tool
|
|
// whitelist under history the model has already been shown.
|
|
if (conv.intent_id !== body.intent_id) {
|
|
return NextResponse.json(
|
|
{ error: 'Konversationen hör till ett annat sammanhang.' },
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
}
|
|
|
|
// onboarding.intake completion signal: once the user has actually
|
|
// engaged (typed a real reply, not the auto-fired greeting prompt that
|
|
// mounts the chat), stamp intake_completed_at on the profile so re-entry
|
|
// logic and opportunistic follow-up logic in other intents can tell the
|
|
// intake happened. Idempotent: the IS NULL guard ensures we never
|
|
// overwrite the first engagement timestamp. Best-effort: failure here
|
|
// doesn't break the chat; the next user turn retries.
|
|
if (
|
|
body.intent_id === 'onboarding.intake' &&
|
|
typeof body.user_message === 'string' &&
|
|
body.user_message.trim().length > 0 &&
|
|
body.user_message_hidden !== true
|
|
) {
|
|
try {
|
|
await supabase
|
|
.from('agent_profiles')
|
|
.update({ intake_completed_at: new Date().toISOString() })
|
|
.eq('company_id', companyId)
|
|
.is('intake_completed_at', null)
|
|
} catch {
|
|
// ignored: see comment above
|
|
}
|
|
}
|
|
|
|
// Load lightweight company + user signals for the system prompt.
|
|
const [{ data: company }, { data: profile }] = await Promise.all([
|
|
supabase.from('companies').select('name').eq('id', companyId).single(),
|
|
supabase.from('profiles').select('full_name').eq('id', user.id).single(),
|
|
])
|
|
const companyName = company?.name ?? ''
|
|
const firstName = profile?.full_name?.split(' ')[0] ?? null
|
|
|
|
// Create the conversation row when this is a fresh thread.
|
|
if (!conversationId) {
|
|
const { data: newConv, error: convErr } = await supabase
|
|
.from('agent_conversations')
|
|
.insert({
|
|
company_id: companyId,
|
|
user_id: user.id,
|
|
intent_id: body.intent_id,
|
|
context_ref: body.context_ref ?? null,
|
|
title: intent.sheetTitle,
|
|
})
|
|
.select('id')
|
|
.single()
|
|
if (convErr || !newConv) {
|
|
return NextResponse.json(
|
|
{ error: getUserErrorMessage(convErr) ?? 'Failed to create conversation' },
|
|
{ status: 500 },
|
|
)
|
|
}
|
|
conversationId = newConv.id as string
|
|
}
|
|
|
|
// Compute the user message to send to Anthropic. On the first turn (no
|
|
// user_message provided), we run the intent's capture + promptTemplate
|
|
// pipeline so the prompt is anchored on the page context the user
|
|
// clicked from.
|
|
let effectiveUserMessage = body.user_message ?? ''
|
|
// When the caller didn't supply a user_message, we synthesize one from the
|
|
// intent's promptTemplate. Mark that synthetic turn hidden so the UI
|
|
// doesn't render the template scaffolding as a user bubble on resume. The
|
|
// client can also explicitly request a hidden turn (rejection correction)
|
|
// even when it DID supply a user_message.
|
|
let userMessageHidden = body.user_message_hidden === true
|
|
// Set on a first turn, where the prompt template needs it anyway; handed to
|
|
// runChatTurn so the system prompt reuses it instead of re-reading.
|
|
let preloadedProfileSummary: string | null | undefined
|
|
if (!effectiveUserMessage) {
|
|
try {
|
|
const captured = await intent.capture(body.intent_args ?? {}, {
|
|
supabase,
|
|
userId: user.id,
|
|
companyId,
|
|
})
|
|
const [profileSummary, memory] = await Promise.all([
|
|
loadProfileSummary(supabase, companyId),
|
|
loadRankedMemory(supabase, companyId, 30),
|
|
])
|
|
effectiveUserMessage = intent.promptTemplate({
|
|
captured,
|
|
profileSummary,
|
|
activeMemory: memory,
|
|
})
|
|
// Hand it to the turn so it doesn't re-read it for the system prompt.
|
|
preloadedProfileSummary = profileSummary
|
|
userMessageHidden = true
|
|
} catch (err) {
|
|
return NextResponse.json(
|
|
{
|
|
error:
|
|
err instanceof Error
|
|
? `Capture failed: ${getUserErrorMessage(err)}`
|
|
: 'Capture failed',
|
|
},
|
|
{ status: 500 },
|
|
)
|
|
}
|
|
}
|
|
|
|
// Stream: NDJSON events from the chat loop.
|
|
const encoder = new TextEncoder()
|
|
// Conversation id is set above; capture into a non-null local for the
|
|
// streaming closure's first emission.
|
|
const convId: string = conversationId
|
|
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
async start(controller) {
|
|
const emit = (event: unknown): boolean => {
|
|
try {
|
|
controller.enqueue(encoder.encode(JSON.stringify(event) + '\n'))
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Surface the conversation id so the client can resume with it.
|
|
emit({ kind: 'conversation', conversation_id: convId })
|
|
|
|
try {
|
|
await runChatTurn({
|
|
supabase,
|
|
userId: user.id,
|
|
companyId,
|
|
companyName,
|
|
firstName,
|
|
intent,
|
|
conversationId: convId,
|
|
userMessage: effectiveUserMessage,
|
|
userMessageHidden,
|
|
persist: true,
|
|
preloadedProfileSummary,
|
|
emit: (event) => emit(event),
|
|
})
|
|
} catch (err) {
|
|
// run-turn already emitted a friendly error before re-throwing; emit a
|
|
// normalized one here too so this outer catch never overwrites it with a
|
|
// raw AWS SDK string.
|
|
emit({
|
|
kind: 'error',
|
|
message: friendlyModelError(err),
|
|
})
|
|
} finally {
|
|
try {
|
|
controller.close()
|
|
} catch {
|
|
// Already closed
|
|
}
|
|
}
|
|
},
|
|
})
|
|
|
|
return new Response(stream, {
|
|
headers: {
|
|
'Content-Type': 'application/x-ndjson; charset=utf-8',
|
|
'Cache-Control': 'no-store',
|
|
'X-Accel-Buffering': 'no',
|
|
},
|
|
})
|
|
}
|
|
|
|
async function loadProfileSummary(
|
|
supabase: Awaited<ReturnType<typeof createClient>>,
|
|
companyId: string,
|
|
): Promise<string | null> {
|
|
const { data } = await supabase
|
|
.from('agent_profiles')
|
|
.select('profile_summary')
|
|
.eq('company_id', companyId)
|
|
.maybeSingle()
|
|
return (data?.profile_summary as string | null) ?? null
|
|
}
|
|
|
|
async function loadRankedMemory(
|
|
supabase: Awaited<ReturnType<typeof createClient>>,
|
|
companyId: string,
|
|
cap: number,
|
|
): Promise<{ content: string; kind: string }[]> {
|
|
const { data } = await supabase
|
|
.from('agent_memory')
|
|
.select('content, kind, relevance_score, last_accessed_at')
|
|
.eq('company_id', companyId)
|
|
.eq('is_active', true)
|
|
.order('relevance_score', { ascending: false })
|
|
.order('last_accessed_at', { ascending: false, nullsFirst: false })
|
|
.limit(cap)
|
|
return (data ?? []).map((r: { content: string; kind: string }) => ({
|
|
content: r.content,
|
|
kind: r.kind,
|
|
}))
|
|
}
|