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:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
272d19b287
commit
9cb1d105e3
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user