* 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>
184 lines
7.0 KiB
TypeScript
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
|
|
}
|
|
}
|