diff --git a/app/(dashboard)/bookkeeping/page.tsx b/app/(dashboard)/bookkeeping/page.tsx
index f92bd111..f6ded70e 100644
--- a/app/(dashboard)/bookkeeping/page.tsx
+++ b/app/(dashboard)/bookkeeping/page.tsx
@@ -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. */}
-
+ {/* 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. */}
+
{(
[
['mall', () => setShowTemplateDialog(true)],
@@ -307,7 +320,9 @@ export default function BookkeepingPage() {
},
],
] as const
- ).map(([mode, onClick]) => (
+ )
+ .filter(([mode]) => createModes.includes(mode))
+ .map(([mode, onClick]) => (
}
diff --git a/app/(dashboard)/chat/new/page.tsx b/app/(dashboard)/chat/new/page.tsx
index c27f78ec..2ef14f74 100644
--- a/app/(dashboard)/chat/new/page.tsx
+++ b/app/(dashboard)/chat/new/page.tsx
@@ -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
diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx
index b1249850..9c50224f 100644
--- a/app/(dashboard)/layout.tsx
+++ b/app/(dashboard)/layout.tsx
@@ -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,
diff --git a/components/agent/AgentChat.tsx b/components/agent/AgentChat.tsx
index 9b62ff08..4493837e 100644
--- a/components/agent/AgentChat.tsx
+++ b/components/agent/AgentChat.tsx
@@ -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({
AI-assistenten kräver ett abonnemang.
+ ) : !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. */
+