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>
This commit is contained in:
@@ -5,6 +5,8 @@ import { useState, useEffect, useMemo } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAssistantAvailable } from '@/contexts/CompanyContext'
|
||||
import JournalEntryList from '@/components/bookkeeping/JournalEntryList'
|
||||
import { StartCard } from '@/components/dashboard/StartCard'
|
||||
import { type FormLine } from '@/components/bookkeeping/JournalEntryForm'
|
||||
@@ -39,6 +41,7 @@ const TemplateBookDialog = dynamic(
|
||||
// SplitButton modes for "Nytt verifikat" (concept scene 9). The last-used
|
||||
// mode persists per user in ui_state.create_mode.bookkeeping.
|
||||
const CREATE_MODES = ['tomt', 'mall', 'assistent'] as const
|
||||
type CreateMode = (typeof CREATE_MODES)[number]
|
||||
|
||||
interface NextVoucher {
|
||||
next: number
|
||||
@@ -75,6 +78,16 @@ export default function BookkeepingPage() {
|
||||
const tStart = useTranslations('start_cards')
|
||||
const { openAgentSheet } = useAgentSheet()
|
||||
const { uiState, loaded: uiStateLoaded } = useUiState()
|
||||
// The 'assistent' mode opens the tool-loop runtime (verifikation.draft via
|
||||
// /api/agent/invoke), which an OpenAI-compatible or unconfigured deployment
|
||||
// cannot run (#2204): drop it from the split button and the pristine cards
|
||||
// rather than offer a door into a 503. One list feeds both surfaces, and a
|
||||
// persisted last-used 'assistent' falls back to 'tomt' through
|
||||
// resolveInitialMode's validity check.
|
||||
const assistantAvailable = useAssistantAvailable()
|
||||
const createModes: readonly CreateMode[] = assistantAvailable
|
||||
? CREATE_MODES
|
||||
: CREATE_MODES.filter((mode) => mode !== 'assistent')
|
||||
|
||||
// React to copy_from in URL: switch tab, fetch source entry, then clean URL.
|
||||
// useSearchParams keeps this reactive even when navigation happens within the
|
||||
@@ -234,7 +247,7 @@ export default function BookkeepingPage() {
|
||||
// to the persisted last-used mode.
|
||||
key={uiStateLoaded ? 'loaded' : 'initial'}
|
||||
persistKey="bookkeeping"
|
||||
initialModeKey={resolveInitialMode(uiState, 'bookkeeping', CREATE_MODES, 'tomt')}
|
||||
initialModeKey={resolveInitialMode(uiState, 'bookkeeping', createModes, 'tomt')}
|
||||
options={[
|
||||
{
|
||||
key: 'tomt',
|
||||
@@ -267,7 +280,7 @@ export default function BookkeepingPage() {
|
||||
contextRef: 'verifikation:new',
|
||||
}),
|
||||
},
|
||||
]}
|
||||
].filter((option) => (createModes as readonly string[]).includes(option.key))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -288,9 +301,9 @@ export default function BookkeepingPage() {
|
||||
primary={{ label: tStart('bookkeeping_primary'), href: '/import?mode=migration' }}
|
||||
secondary={{ label: tStart('bookkeeping_secondary'), href: '/import?mode=sie' }}
|
||||
/>
|
||||
{/* The split button's three create modes, laid out as cards so the
|
||||
pristine page shows what the ledger can do instead of a bare table. */}
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{/* The split button's create modes, laid out as cards so the pristine
|
||||
page shows what the ledger can do instead of a bare table. */}
|
||||
<div className={cn('grid gap-4', createModes.length === 3 ? 'sm:grid-cols-3' : 'sm:grid-cols-2')}>
|
||||
{(
|
||||
[
|
||||
['mall', () => setShowTemplateDialog(true)],
|
||||
@@ -307,7 +320,9 @@ export default function BookkeepingPage() {
|
||||
},
|
||||
],
|
||||
] as const
|
||||
).map(([mode, onClick]) => (
|
||||
)
|
||||
.filter(([mode]) => createModes.includes(mode))
|
||||
.map(([mode, onClick]) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import ChatIntakeStarter from '@/components/agent/ChatIntakeStarter'
|
||||
import { getAiStatus } from '@/lib/ai'
|
||||
import { getDashboardAuthContext, getDashboardCompanyId } from '../../request-context'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -18,6 +19,10 @@ export default async function ChatIntakePage() {
|
||||
])
|
||||
if (!user) redirect('/login')
|
||||
if (!companyId) redirect('/onboarding')
|
||||
// onboarding.intake runs on the tool-loop runtime; without it (#2204) the
|
||||
// starter would fire an invoke that answers 503. The agent is built by now,
|
||||
// so the general-help console on /chat is the working next step.
|
||||
if (!getAiStatus().assistantAvailable) redirect('/chat')
|
||||
|
||||
return <ChatIntakeStarter />
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import ChatNewStarter from '@/components/agent/ChatNewStarter'
|
||||
import { getIntent } from '@/lib/agent/intents/registry'
|
||||
import { CHAT_INTENT_ID } from '@/lib/agent/ask/persist'
|
||||
import { getAiStatus } from '@/lib/ai'
|
||||
import { getDashboardAuthContext, getDashboardCompanyId } from '../../request-context'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -28,7 +30,13 @@ export default async function ChatNewPage({ searchParams }: PageProps) {
|
||||
// Validate against the registry: a bogus ?intent= would otherwise render the
|
||||
// chat shell and then fail at invoke with a 400, which reads as "Anna is broken"
|
||||
// rather than "bad link". Fall back to general help instead.
|
||||
const intent = getIntent(requested) ? requested : 'general.help'
|
||||
// Specialized intents run on the tool-loop runtime; where the deployment
|
||||
// cannot run it (#2204) the single-call console takes the same prompt as
|
||||
// general help instead of a chat that fails at invoke.
|
||||
const intent =
|
||||
getIntent(requested) && (requested === CHAT_INTENT_ID || getAiStatus().assistantAvailable)
|
||||
? requested
|
||||
: CHAT_INTENT_ID
|
||||
const prompt = typeof sp.prompt === 'string' ? sp.prompt : ''
|
||||
|
||||
return <ChatNewStarter intentId={intent} seedUserMessage={prompt} />
|
||||
|
||||
@@ -20,6 +20,7 @@ import { getExtensionNavItems } from '@/lib/extensions/sectors'
|
||||
import { CompanyProvider, type ByraTeamRef } from '@/contexts/CompanyContext'
|
||||
import { ReferenceDataSeed } from '@/components/providers/ReferenceDataSeed'
|
||||
import { getCompanyEntitlements } from '@/lib/entitlements/has-capability'
|
||||
import { getAiStatus } from '@/lib/ai'
|
||||
import { getDashboardNavFlags } from '@/lib/dashboard/nav-flags'
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
import { resolveBrandByHost } from '@/lib/branding/resolve'
|
||||
@@ -77,6 +78,12 @@ export default async function DashboardLayout({
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
// Deployment-level: can the in-app assistant (the tool-loop runtime behind
|
||||
// /api/agent/invoke) run here at all? Env reads only. Handed to every
|
||||
// CompanyProvider below so the entry points that open that runtime can hide
|
||||
// instead of leading the user into its 503 (#2204).
|
||||
const assistantAvailable = getAiStatus().assistantAvailable
|
||||
|
||||
// Resolve active company from user_preferences (authoritative). The
|
||||
// `gnubok-company-id` cookie is intentionally no longer consulted here:
|
||||
// `getActiveCompanyId` reads from user_preferences, matching what RLS
|
||||
@@ -196,6 +203,7 @@ export default async function DashboardLayout({
|
||||
foreignCompanies: [],
|
||||
isSandbox: false,
|
||||
capabilities: [],
|
||||
assistantAvailable,
|
||||
trialEndsAt: null,
|
||||
entitlementState: 'none' as const,
|
||||
trialExpiredAt: null,
|
||||
@@ -351,6 +359,7 @@ export default async function DashboardLayout({
|
||||
foreignCompanies: [],
|
||||
isSandbox: false,
|
||||
capabilities: [],
|
||||
assistantAvailable,
|
||||
trialEndsAt: null,
|
||||
entitlementState: 'none' as const,
|
||||
trialExpiredAt: null,
|
||||
@@ -518,6 +527,7 @@ export default async function DashboardLayout({
|
||||
foreignCompanies,
|
||||
isSandbox,
|
||||
capabilities: entitlements.capabilities,
|
||||
assistantAvailable,
|
||||
trialEndsAt: entitlements.trialEndsAt,
|
||||
entitlementState: entitlements.entitlementState,
|
||||
trialExpiredAt: entitlements.trialExpiredAt,
|
||||
|
||||
@@ -18,7 +18,8 @@ import {
|
||||
import dynamic from 'next/dynamic'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useCapability } from '@/contexts/CompanyContext'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useAssistantAvailable, useCapability } from '@/contexts/CompanyContext'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import { UpgradeNote } from '@/components/billing/UpgradeNote'
|
||||
import ApprovalCard from './ApprovalCard'
|
||||
@@ -251,6 +252,11 @@ export default function AgentChat({
|
||||
// though it were the new one.
|
||||
const turnStartRef = useRef(0)
|
||||
const hasAi = useCapability(CAPABILITY.ai)
|
||||
// Whether the deployment can run this runtime at all (#2204). Every entry
|
||||
// point hides itself when it cannot; this is the last line of defense for a
|
||||
// resumed thread or a deep link: never fire an invoke that answers 503.
|
||||
const assistantAvailable = useAssistantAvailable()
|
||||
const tChat = useTranslations('agent_chat')
|
||||
const [input, setInput] = useState('')
|
||||
const [streaming, setStreaming] = useState(false)
|
||||
// Turn boundaries for the status channel are derived from the streaming flag
|
||||
@@ -312,9 +318,10 @@ export default function AgentChat({
|
||||
const hasResumeState = !!initialConversationId
|
||||
if (hasResumeState) return
|
||||
|
||||
// Paywall: never auto-fire the first invoke without the ai capability;
|
||||
// the composer is already replaced by the upgrade note.
|
||||
if (!hasAi) return
|
||||
// Paywall / no runtime: never auto-fire the first invoke without the ai
|
||||
// capability or the tool-loop runtime; the composer is already replaced
|
||||
// by the upgrade note or the unavailable notice.
|
||||
if (!hasAi || !assistantAvailable) return
|
||||
|
||||
// Seed-message path: render the user's pre-baked starter in the timeline
|
||||
// and send it as the first turn's user_message (skips intent.capture +
|
||||
@@ -543,7 +550,7 @@ export default function AgentChat({
|
||||
}
|
||||
|
||||
function handleRegenerate() {
|
||||
if (!hasAi) return
|
||||
if (!hasAi || !assistantAvailable) return
|
||||
// Re-run the last user message and let the agent produce a fresh
|
||||
// response. UI truncates back to the last user message; DB rows are
|
||||
// append-only, so the previous assistant turn stays in agent_messages
|
||||
@@ -611,7 +618,7 @@ export default function AgentChat({
|
||||
// user turn so the agent re-proposes inline: no synthetic user bubble (we
|
||||
// don't add a user row, and the turn is persisted hidden).
|
||||
function handleCorrection(correctionMessage: string) {
|
||||
if (!hasAi) return
|
||||
if (!hasAi || !assistantAvailable) return
|
||||
void startTurn({ conversationId, userMessage: correctionMessage, hidden: true })
|
||||
}
|
||||
|
||||
@@ -865,6 +872,7 @@ export default function AgentChat({
|
||||
showRegenerate={
|
||||
!streaming &&
|
||||
hasAi &&
|
||||
assistantAvailable &&
|
||||
i === lastAssistantIdx &&
|
||||
m.role === 'assistant' &&
|
||||
m.text.length > 0
|
||||
@@ -924,6 +932,15 @@ export default function AgentChat({
|
||||
<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>
|
||||
) : !assistantAvailable ? (
|
||||
/* No tool-loop runtime on this deployment (#2204): same swap as the
|
||||
paywall, so a resumed or deep-linked thread never offers a send
|
||||
that answers 503. */
|
||||
<div className="border-t border-border px-5 pt-4 pb-[calc(env(safe-area-inset-bottom,0px)+1rem)]">
|
||||
<p className="text-sm text-muted-foreground" role="status">
|
||||
{tChat('assistant_unavailable')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
// padding-bottom = base 1rem + safe-area-inset-bottom on phones so
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Loader2, X } from 'lucide-react'
|
||||
import AgentAvatar from './AgentAvatar'
|
||||
import { collapsedStatusLabel } from './agent-status'
|
||||
import { routeToIntent } from '@/lib/agent/intents/route-mapping'
|
||||
import { useCapability } from '@/contexts/CompanyContext'
|
||||
import { useAssistantAvailable, useCapability } from '@/contexts/CompanyContext'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
|
||||
// Floating trigger sits above the page bottom-right, opens the AgentSheet when
|
||||
@@ -48,6 +48,7 @@ export default function AgentTrigger({ hidden = false }: { hidden?: boolean }) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const hasAi = useCapability(CAPABILITY.ai)
|
||||
const assistantAvailable = useAssistantAvailable()
|
||||
|
||||
// Read the dismissal AFTER hydration (effect, not state initializer): the
|
||||
// server always renders the pill, so an initializer that reads
|
||||
@@ -147,7 +148,10 @@ export default function AgentTrigger({ hidden = false }: { hidden?: boolean }) {
|
||||
if (!identity.isVerified) return null
|
||||
|
||||
const name = identity.displayName?.trim() || 'min assistent'
|
||||
const dispatch = routeToIntent(pathname)
|
||||
// Without the tool-loop runtime (OpenAI-compatible or unconfigured AI, #2204)
|
||||
// every route dispatches to general.help: the single-call console runs on
|
||||
// any provider, so the pill stays but never opens a chat that would 503.
|
||||
const dispatch = routeToIntent(pathname, { assistantAvailable })
|
||||
// AI assistant runs on a paid cloud service. Without the capability, opening
|
||||
// the sheet would land the user in a chat whose send is dead. Keep the FAB
|
||||
// visible (it's the conversion surface) but route it to billing instead.
|
||||
|
||||
@@ -61,7 +61,7 @@ import { useReceiptHunt } from '@/components/extensions/general/use-receipt-hunt
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { fetchWithTimeout } from '@/lib/http/fetch-with-timeout'
|
||||
import { copyInboxAddress, type AddressCopyState } from '@/components/extensions/general/inbox-address-copy'
|
||||
import { useCapability, useCompanyOptional } from '@/contexts/CompanyContext'
|
||||
import { useAssistantAvailable, useCapability, useCompanyOptional } from '@/contexts/CompanyContext'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import { useBranding } from '@/lib/branding/brand-context'
|
||||
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||||
@@ -440,6 +440,9 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
// Its own input: sharing the header's would upload without the purchase.
|
||||
const purchaseFileInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const { openAgentSheet, identity } = useAgentSheet()
|
||||
// Both "Fråga assistenten" doors below open the tool-loop runtime
|
||||
// (/api/agent/invoke); hide them where the deployment cannot run it (#2204).
|
||||
const assistantAvailable = useAssistantAvailable()
|
||||
|
||||
const [items, setItems] = useState<InboxItem[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
@@ -1869,7 +1872,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
</Button>
|
||||
{/* Secondary actions: outlined, so they read as buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
{identity.isVerified && (
|
||||
{identity.isVerified && assistantAvailable && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -2197,7 +2200,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
await Promise.all([fetchItems(), handleSelect(targetId)])
|
||||
}}
|
||||
onAskAssistant={
|
||||
identity.isVerified
|
||||
identity.isVerified && assistantAvailable
|
||||
? (transactionId) => {
|
||||
openAgentSheet({
|
||||
intentId: 'transaction.categorization',
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
type VatDeadlineLine,
|
||||
} from '@/lib/onboarding/checklist'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
import { useCapability } from '@/contexts/CompanyContext'
|
||||
import { useAssistantAvailable, useCapability } from '@/contexts/CompanyContext'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import type { InitialSetupPath, InitialSetupState } from '@/types'
|
||||
import { useBranding } from '@/lib/branding/brand-context'
|
||||
@@ -97,6 +97,7 @@ export default function NewUserChecklist({
|
||||
const showError = useErrorToast()
|
||||
const { formatDateLong } = useFormat()
|
||||
const hasAi = useCapability(CAPABILITY.ai)
|
||||
const assistantAvailable = useAssistantAvailable()
|
||||
const [state, setState] = useState(initialState)
|
||||
const [saving, setSaving] = useState<InitialSetupPath | 'dismiss' | 'complete' | null>(null)
|
||||
// The completion signature: 'verdict' shows the orb check-morph and the
|
||||
@@ -121,6 +122,21 @@ export default function NewUserChecklist({
|
||||
const hasSkatteverket = ENABLED_EXTENSION_IDS.has('skatteverket')
|
||||
const hasInbox = ENABLED_EXTENSION_IDS.has('invoice-inbox')
|
||||
const hasWhatsApp = ENABLED_EXTENSION_IDS.has('whatsapp-inbox')
|
||||
// The Claude step is a pitch for running the books with Claude over the MCP
|
||||
// server. It goes when either half is missing: no mcp-server extension (the
|
||||
// connector URL would 404) or a deployment whose AI runs on another provider
|
||||
// (getAiStatus().assistantAvailable false, #2204), where steering every new
|
||||
// user to claude.ai contradicts the operator's own choice. Settings → API &
|
||||
// MCP keeps the connector for anyone who wants it anyway.
|
||||
const hasClaudeStep = assistantAvailable && ENABLED_EXTENSION_IDS.has('mcp-server')
|
||||
// Which step closes the thread (carries no spine below it).
|
||||
const lastStep = hasClaudeStep
|
||||
? 'assistant'
|
||||
: hasInbox
|
||||
? 'receipts'
|
||||
: hasSkatteverket
|
||||
? 'skv'
|
||||
: 'bank'
|
||||
|
||||
const persist = async (
|
||||
body: Record<string, unknown>,
|
||||
@@ -150,10 +166,11 @@ export default function NewUserChecklist({
|
||||
|
||||
const step1Done = hasBookkeepingImported || state.path === 'fresh'
|
||||
const step2Done = hasBankConnected
|
||||
// Companies built without the skatteverket/inbox extensions skip those steps.
|
||||
// Companies built without the skatteverket/inbox extensions skip those
|
||||
// steps; so does the Claude step where it does not render.
|
||||
const step3Done = !hasSkatteverket || hasSkatteverketConnected
|
||||
const step4Done = !hasInbox || hasInboxItems
|
||||
const step5Done = hasMcpKey
|
||||
const step5Done = !hasClaudeStep || hasMcpKey
|
||||
|
||||
useEffect(() => {
|
||||
// The block retires itself once every step is done; Dölj remains the
|
||||
@@ -192,7 +209,7 @@ export default function NewUserChecklist({
|
||||
}
|
||||
}, [retiring])
|
||||
|
||||
const numbers = checklistNumbers({ hasSkatteverket, hasInbox })
|
||||
const numbers = checklistNumbers({ hasSkatteverket, hasInbox, hasAssistant: hasClaudeStep })
|
||||
const stepCount = numbers.count
|
||||
|
||||
if (state.dismissedAt) return null
|
||||
@@ -346,6 +363,7 @@ export default function NewUserChecklist({
|
||||
done={step2Done}
|
||||
active={activeStep === 2}
|
||||
title={t('step_bank_title')}
|
||||
last={lastStep === 'bank'}
|
||||
action={(variant) => (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -387,6 +405,7 @@ export default function NewUserChecklist({
|
||||
done={step3Done}
|
||||
active={activeStep === 3}
|
||||
title={t('step_skv_title')}
|
||||
last={lastStep === 'skv'}
|
||||
action={(variant) => (
|
||||
<Button size="sm" variant={variant} asChild>
|
||||
{/* The authorize endpoint redirects off-site to Skatteverket. */}
|
||||
@@ -430,6 +449,7 @@ export default function NewUserChecklist({
|
||||
done={step4Done}
|
||||
active={activeStep === 4}
|
||||
title={t('step_receipts_title')}
|
||||
last={lastStep === 'receipts'}
|
||||
action={(variant) => (
|
||||
<Button size="sm" variant={variant} onClick={goReceipts}>
|
||||
{t('step_receipts_action')}
|
||||
@@ -445,6 +465,7 @@ export default function NewUserChecklist({
|
||||
</Step>
|
||||
)}
|
||||
|
||||
{hasClaudeStep && (
|
||||
<Step
|
||||
number={numbers.assistant}
|
||||
done={step5Done}
|
||||
@@ -504,6 +525,7 @@ export default function NewUserChecklist({
|
||||
>
|
||||
{t('step_claude_description')}
|
||||
</Step>
|
||||
)}
|
||||
</ol>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -49,6 +49,16 @@ interface CompanyContextValue {
|
||||
isSandbox: boolean
|
||||
/** PAID capability keys the active company currently holds (entitled + enabled). */
|
||||
capabilities: CapabilityKey[]
|
||||
/**
|
||||
* Whether this deployment can run the in-app assistant, i.e. the tool-loop
|
||||
* runtime behind /api/agent/invoke: getAiStatus().assistantAvailable, false
|
||||
* without AI credentials or on an OpenAI-compatible endpoint (#2204).
|
||||
* Deployment-level, not per company. Gates ONLY the surfaces that open
|
||||
* that runtime; provider-agnostic AI (the single-call ask console,
|
||||
* categorization, document extraction) never reads it. The route's 503
|
||||
* stays the real enforcement.
|
||||
*/
|
||||
assistantAvailable: boolean
|
||||
/**
|
||||
* Trial expiry while the trial is the company's only source of paid access;
|
||||
* null when paying/comped or after the trial lapsed. Drives the countdown
|
||||
@@ -111,3 +121,16 @@ export function useCapability(key: CapabilityKey): boolean {
|
||||
if (!ctx) return true
|
||||
return ctx.capabilities.includes(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the deployment can run the in-app assistant (see
|
||||
* CompanyContextValue.assistantAvailable). Read it at every entry point that
|
||||
* opens AgentChat / /api/agent/invoke; the single-call console (general.help)
|
||||
* runs on any provider and does not need it. Fail-open outside a
|
||||
* CompanyProvider for the same reason as useCapability: the server answers 503.
|
||||
*/
|
||||
export function useAssistantAvailable(): boolean {
|
||||
const ctx = useContext(CompanyContext)
|
||||
if (!ctx) return true
|
||||
return ctx.assistantAvailable
|
||||
}
|
||||
|
||||
@@ -1,6 +1,33 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { routeToIntent, contextRefToTarget } from '../route-mapping'
|
||||
|
||||
describe('routeToIntent without the tool-loop runtime', () => {
|
||||
// getAiStatus().assistantAvailable is false on an OpenAI-compatible or
|
||||
// unconfigured deployment (#2204): the trigger keeps working on the
|
||||
// single-call console, so every route collapses onto general.help.
|
||||
it('dispatches every specialized route to general.help', () => {
|
||||
for (const route of [
|
||||
'/invoices/new',
|
||||
'/invoices/abc-123',
|
||||
'/supplier-invoices/sup-1',
|
||||
'/bookkeeping/year-end',
|
||||
'/kpi',
|
||||
'/settings/invoicing',
|
||||
]) {
|
||||
const out = routeToIntent(route, { assistantAvailable: false })
|
||||
expect(out.intentId).toBe('general.help')
|
||||
expect(out.intentArgs).toEqual({ route })
|
||||
expect(out.contextRef).toBeUndefined()
|
||||
expect(out.labelSuffix).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves the specialized dispatch alone when the option is omitted or true', () => {
|
||||
expect(routeToIntent('/kpi').intentId).toBe('kpi.explain')
|
||||
expect(routeToIntent('/kpi', { assistantAvailable: true }).intentId).toBe('kpi.explain')
|
||||
})
|
||||
})
|
||||
|
||||
describe('routeToIntent', () => {
|
||||
it('falls back to general.help when pathname is null/undefined/empty', () => {
|
||||
for (const input of [null, undefined, '']) {
|
||||
|
||||
@@ -29,8 +29,23 @@ const GENERAL_HELP = (route: string | null): RouteIntent => ({
|
||||
labelSuffix: null,
|
||||
})
|
||||
|
||||
export function routeToIntent(pathname: string | null | undefined): RouteIntent {
|
||||
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
|
||||
|
||||
@@ -199,6 +199,16 @@ describe('getAiStatus', () => {
|
||||
expect(s.models.extraction).toBe('eu.anthropic.claude-sonnet-5')
|
||||
})
|
||||
|
||||
it('is configured and assistant-capable on a direct Anthropic key', () => {
|
||||
// Presence is all the config reads (no format check), so the placeholder
|
||||
// deliberately cannot match a real key's shape and trip secret scanners.
|
||||
process.env.ANTHROPIC_API_KEY = 'test-not-a-real-key'
|
||||
const s = getAiStatus()
|
||||
expect(s.provider).toBe('anthropic')
|
||||
expect(s.configured).toBe(true)
|
||||
expect(s.assistantAvailable).toBe(true)
|
||||
})
|
||||
|
||||
it('needs a model id on an OpenAI-compatible endpoint before it counts as configured', () => {
|
||||
byo('')
|
||||
const s = getAiStatus()
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('vatDeadlineLine', () => {
|
||||
|
||||
describe('checklistNumbers', () => {
|
||||
it('numbers all five steps when both extensions are on', () => {
|
||||
expect(checklistNumbers({ hasSkatteverket: true, hasInbox: true })).toEqual({
|
||||
expect(checklistNumbers({ hasSkatteverket: true, hasInbox: true, hasAssistant: true })).toEqual({
|
||||
count: 5,
|
||||
skv: 3,
|
||||
receipts: 4,
|
||||
@@ -54,7 +54,7 @@ describe('checklistNumbers', () => {
|
||||
})
|
||||
|
||||
it('collapses to four steps without the inbox extension', () => {
|
||||
expect(checklistNumbers({ hasSkatteverket: true, hasInbox: false })).toEqual({
|
||||
expect(checklistNumbers({ hasSkatteverket: true, hasInbox: false, hasAssistant: true })).toEqual({
|
||||
count: 4,
|
||||
skv: 3,
|
||||
receipts: 4,
|
||||
@@ -63,7 +63,7 @@ describe('checklistNumbers', () => {
|
||||
})
|
||||
|
||||
it('collapses to four steps without the skatteverket extension', () => {
|
||||
expect(checklistNumbers({ hasSkatteverket: false, hasInbox: true })).toEqual({
|
||||
expect(checklistNumbers({ hasSkatteverket: false, hasInbox: true, hasAssistant: true })).toEqual({
|
||||
count: 4,
|
||||
skv: 3,
|
||||
receipts: 3,
|
||||
@@ -72,13 +72,30 @@ describe('checklistNumbers', () => {
|
||||
})
|
||||
|
||||
it('collapses to three steps with neither extension', () => {
|
||||
expect(checklistNumbers({ hasSkatteverket: false, hasInbox: false })).toEqual({
|
||||
expect(checklistNumbers({ hasSkatteverket: false, hasInbox: false, hasAssistant: true })).toEqual({
|
||||
count: 3,
|
||||
skv: 3,
|
||||
receipts: 3,
|
||||
assistant: 3,
|
||||
})
|
||||
})
|
||||
|
||||
// A deployment that cannot run the assistant (#2204) drops the Claude step:
|
||||
// the title counts one step fewer, the other ordinals stay put.
|
||||
it('drops the assistant step from the count without moving the other ordinals', () => {
|
||||
expect(checklistNumbers({ hasSkatteverket: true, hasInbox: true, hasAssistant: false })).toEqual({
|
||||
count: 4,
|
||||
skv: 3,
|
||||
receipts: 4,
|
||||
assistant: 5,
|
||||
})
|
||||
expect(checklistNumbers({ hasSkatteverket: false, hasInbox: false, hasAssistant: false })).toEqual({
|
||||
count: 2,
|
||||
skv: 3,
|
||||
receipts: 3,
|
||||
assistant: 3,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('completionPatchBody', () => {
|
||||
|
||||
@@ -33,10 +33,15 @@ export function vatDeadlineLine(input: {
|
||||
/**
|
||||
* Display ordinals for the setup checklist steps. Books and bank are always
|
||||
* present; Skatteverket and the receipts/inbox step render only when their
|
||||
* extensions are enabled; the assistant step is always last. `count` drives
|
||||
* extensions are enabled; the assistant (Claude) step renders only where the
|
||||
* deployment can run the assistant and, when it does, is last. `count` drives
|
||||
* the "{count} steg så är bokföringen igång" title.
|
||||
*/
|
||||
export function checklistNumbers(gates: { hasSkatteverket: boolean; hasInbox: boolean }): {
|
||||
export function checklistNumbers(gates: {
|
||||
hasSkatteverket: boolean
|
||||
hasInbox: boolean
|
||||
hasAssistant: boolean
|
||||
}): {
|
||||
count: number
|
||||
skv: number
|
||||
receipts: number
|
||||
@@ -45,7 +50,10 @@ export function checklistNumbers(gates: { hasSkatteverket: boolean; hasInbox: bo
|
||||
const skv = 3
|
||||
const receipts = 3 + (gates.hasSkatteverket ? 1 : 0)
|
||||
const assistant = receipts + (gates.hasInbox ? 1 : 0)
|
||||
return { count: assistant, skv, receipts, assistant }
|
||||
// Without the assistant step the thread ends one step earlier; the other
|
||||
// ordinals do not move.
|
||||
const count = gates.hasAssistant ? assistant : assistant - 1
|
||||
return { count, skv, receipts, assistant }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -122,6 +122,9 @@
|
||||
"cta_primary": "View subscription",
|
||||
"cta_secondary": "Continue without"
|
||||
},
|
||||
"agent_chat": {
|
||||
"assistant_unavailable": "The assistant cannot run on the AI provider configured for this installation."
|
||||
},
|
||||
"agentKnowledge": {
|
||||
"load_error_title": "Couldn't load the knowledge profile",
|
||||
"load_error_description": "Something went wrong loading what your agent knows. Try reopening this tab.",
|
||||
|
||||
@@ -122,6 +122,9 @@
|
||||
"cta_primary": "Se abonnemang",
|
||||
"cta_secondary": "Fortsätt utan"
|
||||
},
|
||||
"agent_chat": {
|
||||
"assistant_unavailable": "Assistenten kan inte köras med den AI-leverantör som är konfigurerad på den här installationen."
|
||||
},
|
||||
"agentKnowledge": {
|
||||
"load_error_title": "Kunde inte läsa in kunskapsprofilen",
|
||||
"load_error_description": "Något gick fel när det din agent vet skulle läsas in. Försök öppna fliken igen.",
|
||||
|
||||
Reference in New Issue
Block a user