Files
9cb1d105e3 fix(agent): hide Anthropic-only assistant surfaces where the provider cannot run them (#2204) (#2343)
* fix(agent): hide Anthropic-only assistant surfaces where the provider cannot run them

Self-hosted deployments on an OpenAI-compatible provider (or with no AI
configured) still showed every entry point into the tool-loop runtime
behind /api/agent/invoke, which answers 503 there. The capability lived
server-side only (getAiStatus().assistantAvailable); no UI could read it.

Hand the flag to the client through CompanyContext (useAssistantAvailable,
beside the paid-capability gate) and gate each entry point that opens
AgentChat: the bookkeeping page's "Skapa med assistent" and "Med
assistenten", the inbox workspace's "Fråga assistenten" doors, /chat/intake
and /chat/new?intent=. The floating trigger falls back to general help (the
single-call console runs on any provider) instead of hiding, and AgentChat
itself never fires an invoke without the runtime, so a resumed thread or a
forgotten entry point shows a notice instead of a 503. The Hem checklist's
"Anslut till Claude" step renders only where the assistant runs on Claude
and the mcp-server extension is on. Provider-agnostic AI (ask console,
categorization, extraction) and the server-side 503 are unchanged; on
hosted the flag is true and nothing changes.

Closes #2204

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6

* test(ai): use a placeholder that cannot match an Anthropic key shape

The new direct-Anthropic status test assigned a string in the exact
format of a live API key, which trips secret scanners on every run. The
config only reads presence, so any non-empty string exercises the path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:54:47 +02:00

184 lines
7.0 KiB
TypeScript

// Route → intent dispatch for the floating "Fråga [namn]" trigger.
//
// The page-specific buttons ("Granska med assistent" on a supplier invoice
// page, "Fråga om bokslutet" in the year-end wizard) already open the right
// intent because they know what they're attached to. The floating FAB
// previously always opened general.help with just the URL string, so clicking
// it on /invoices/abc-123 gave the agent zero context about that invoice.
//
// This module gives the FAB the same situational awareness: it inspects the
// pathname and picks the intent + intentArgs that the equivalent on-page
// button would have used.
//
// Pure function, no React deps: easy to test, easy to extend with new
// routes as more intents land.
export interface RouteIntent {
intentId: string
intentArgs: Record<string, unknown>
// Persisted on agent_conversations.context_ref so /chat can back-link.
contextRef?: string
// Short suffix appended to the FAB label ("Fråga [namn] om denna faktura").
// null → just "Fråga [namn]".
labelSuffix: string | null
}
const GENERAL_HELP = (route: string | null): RouteIntent => ({
intentId: 'general.help',
intentArgs: { route: route ?? undefined },
labelSuffix: null,
})
export interface RouteIntentOptions {
/**
* Whether the deployment can run the tool-loop runtime (/api/agent/invoke,
* getAiStatus().assistantAvailable). Every specialized intent below runs on
* it; general.help runs on the single-call console, which any provider can
* serve. When false, every route dispatches to general.help so the trigger
* keeps working instead of opening a chat that 503s (#2204). Omitted = true.
*/
assistantAvailable?: boolean
}
export function routeToIntent(
pathname: string | null | undefined,
options: RouteIntentOptions = {},
): RouteIntent {
if (!pathname) return GENERAL_HELP(null)
if (options.assistantAvailable === false) return GENERAL_HELP(pathname)
const segments = pathname.split('/').filter(Boolean)
const [first, second] = segments
// /invoices/new: drafting a brand-new invoice (no entity id yet).
if (first === 'invoices' && second === 'new') {
return {
intentId: 'invoice.draft',
intentArgs: {},
labelSuffix: 'om denna faktura',
}
}
// /invoices/[id] and /invoices/[id]/credit: entity in focus.
if (first === 'invoices' && second && second !== 'new') {
return {
intentId: 'invoice.draft',
intentArgs: { invoice_id: second },
contextRef: `invoice:${second}`,
labelSuffix: 'om denna faktura',
}
}
// /supplier-invoices/[id]: review/attest flow.
// /supplier-invoices/new has no entity to review yet: fall through to
// general.help so the agent doesn't load a heavy Opus intent on an empty
// capture.
if (first === 'supplier-invoices' && second && second !== 'new') {
return {
intentId: 'supplier_invoice.review',
intentArgs: { supplier_invoice_id: second },
contextRef: `supplier_invoice:${second}`,
labelSuffix: 'om denna leverantörsfaktura',
}
}
// /bookkeeping/year-end: the bokslut wizard. Match the page's "Fråga om
// bokslutet" button (bokslut.step) instead of general.help, so the FAB and the
// page button open the SAME assistant here rather than two different ones.
if (first === 'bookkeeping' && second === 'year-end') {
return {
intentId: 'bokslut.step',
intentArgs: { step_id: null },
contextRef: 'bokslut:overview',
labelSuffix: 'om bokslutet',
}
}
// /bookkeeping/[id] (single verifikation) is intentionally NOT mapped
// here: AgentTrigger suppresses the FAB on that route entirely. The
// verifikation editor is a dense regulatory surface and the floating
// pill earned its way off the page.
// /kpi: nyckeltal dashboard. Match the page's "Fråga om nyckeltalen" button
// (kpi.explain) so the FAB and the page button agree on this page.
if (first === 'kpi') {
return {
intentId: 'kpi.explain',
intentArgs: { kpi_key: 'översikt' },
contextRef: 'kpi:översikt',
labelSuffix: 'om nyckeltalen',
}
}
// Note: /transactions and /reports intentionally fall through to general.help.
// Their on-page triggers are entity/view-specific (a transaction row needs a
// transaction_id; the VAT report button needs the selected period/view): the
// FAB only knows the pathname, so page-level help is the honest default there.
// /settings/<panel>[/...]: settings.help captures which panel is active.
// Uses the second segment as panel slug so /settings/invoicing/templates
// still surfaces panel=invoicing.
if (first === 'settings' && second) {
return {
intentId: 'settings.help',
intentArgs: { panel: second },
labelSuffix: null,
}
}
return GENERAL_HELP(pathname)
}
/**
* The reverse direction: what a stored `context_ref` was about.
*
* `agent_conversations.context_ref` has been written since the first intents
* landed and has never been read by anything. Resuming a thread from three days
* ago therefore showed the messages with no indication of which invoice, which
* verifikat, which bokslut it concerned, even though the row knew. That matters
* more now the panel docks beside the page: "what is this conversation anchored
* to" is a question the surface should answer, not the user's memory.
*
* A data map rather than a switch in a component (plan seam 8.5), so flows can
* add their own ref kinds here and every surface picks them up at once.
*/
export interface ContextRefTarget {
/** Human noun for the thing, already in Swedish. */
label: string
/** Where to go to look at it, or null when there is no stable page. */
href: string | null
}
export function contextRefToTarget(ref: string | null | undefined): ContextRefTarget | null {
if (!ref) return null
const separator = ref.indexOf(':')
if (separator <= 0) return null
const kind = ref.slice(0, separator)
const id = ref.slice(separator + 1)
if (!id) return null
switch (kind) {
case 'invoice':
return { label: 'Faktura', href: `/invoices/${encodeURIComponent(id)}` }
case 'supplier_invoice':
return { label: 'Leverantörsfaktura', href: `/supplier-invoices/${encodeURIComponent(id)}` }
// No /transactions/[id] route exists: the list is the only page that can
// show it, so that is where the chip goes rather than a link that 404s.
case 'transaction':
return { label: 'Transaktion', href: '/transactions' }
case 'verifikation':
return { label: 'Verifikation', href: '/bookkeeping' }
case 'bokslut':
return { label: 'Bokslut', href: '/bookkeeping/year-end' }
case 'kpi':
return { label: 'Nyckeltal', href: '/kpi' }
// The document inbox is an extension, mounted under /e/[sector]. Core must
// not import from @/extensions or hardcode a route that only exists when
// the extension is enabled, so this names the context without linking it.
case 'inbox':
return { label: 'Dokumentinkorgen', href: null }
default:
return null
}
}