diff --git a/packages/chat-ui/src/api.ts b/packages/chat-ui/src/api.ts index 2e7c5b21a..bb1f42b2f 100644 --- a/packages/chat-ui/src/api.ts +++ b/packages/chat-ui/src/api.ts @@ -15,6 +15,7 @@ import { parseParticipants } from "@corbits/chat/participants"; import type { ParticipantRecord } from "@corbits/chat/participants"; import type { WorkbenchOnboardingStep } from "@corbits/chat/blocks"; import { UnauthenticatedError } from "@corbits/api-query"; +import { InferenceSettingsApiError } from "@corbits/inference-settings"; import { jimmyAgentRequest } from "@workbench/templates"; import { CHAT_STRINGS } from "./strings"; @@ -215,25 +216,38 @@ export class ChatApiError extends Error { /** * Plain-language copy for a failed chat request — never `error.message` * or `String(error)` verbatim, which for a `ChatApiError` embeds the raw - * request path (see `request()` below). Every chat-ui/chat-adjacent - * catch block that surfaces an error to the user should call this - * rather than format one of its own, so the class of leak (a raw - * `/api/...` URL or bare status code in user-facing copy) has exactly - * one place to fix. + * request path (see `request()` below). `InferenceSettingsApiError` + * carries the envelope `userMessage` (safe to show) rather than a path, + * so a 500 from `getResolvedCatalog` surfaces that sentence instead of + * the generic fallback. Every chat-ui/chat-adjacent catch block that + * surfaces an error to the user should call this rather than format one + * of its own, so the class of leak (a raw `/api/...` URL or bare status + * code in user-facing copy) has exactly one place to fix. */ export function describeChatError(cause: unknown, fallback: string): string { - if (!(cause instanceof ChatApiError)) return fallback; - switch (cause.status) { + const statused = + cause instanceof ChatApiError || cause instanceof InferenceSettingsApiError + ? cause + : null; + if (statused === null) return fallback; + switch (statused.status) { case 401: return "You're signed out. Sign in again to continue."; case 403: return "You don't have access to this."; case undefined: return "Couldn't reach the server. Check your connection and try again."; - default: - return cause.status >= 500 + default: { + if ( + cause instanceof InferenceSettingsApiError && + statused.message.trim() !== "" + ) { + return statused.message; + } + return statused.status >= 500 ? "Something went wrong on our end. Try again in a moment." : fallback; + } } } diff --git a/packages/chat-ui/src/strings.ts b/packages/chat-ui/src/strings.ts index 4a9790311..4017e1361 100644 --- a/packages/chat-ui/src/strings.ts +++ b/packages/chat-ui/src/strings.ts @@ -432,6 +432,9 @@ export const CHAT_STRINGS = { ) => `${canonicalName} · ${providerName}`, workbenchSettingsAgentDetailNoConnectedModels: "No connected providers yet — connect one in Shared Settings.", + workbenchSettingsAgentDetailCatalogError: "Couldn't load the models.", + workbenchSettingsAgentDetailCatalogRetryAction: "Retry", + workbenchSettingsAgentDetailCatalogSettingsAction: "Shared Settings", workbenchSettingsAgentDetailHistoryTitle: "History", workbenchSettingsAgentDetailHistoryHint: "Every change to this agent's instructions and capabilities, oldest actions first.", diff --git a/packages/chat-ui/src/workbench-settings/agents-section.tsx b/packages/chat-ui/src/workbench-settings/agents-section.tsx index ee45f8040..d5f8e2ee1 100644 --- a/packages/chat-ui/src/workbench-settings/agents-section.tsx +++ b/packages/chat-ui/src/workbench-settings/agents-section.tsx @@ -238,20 +238,29 @@ function AgentDetailEditor({ const [catalogState, setCatalogState] = useState({ kind: "loading", }); + const [catalogReloadKey, setCatalogReloadKey] = useState(0); useEffect(() => { let cancelled = false; + setCatalogState({ kind: "loading" }); getResolvedCatalog(tenantId) .then((models) => { if (!cancelled) setCatalogState({ kind: "ready", models }); }) - .catch(() => { - if (!cancelled) setCatalogState({ kind: "error" }); + .catch((cause: unknown) => { + if (cancelled) return; + setCatalogState({ + kind: "error", + message: describeChatError( + cause, + CHAT_STRINGS.workbenchSettingsAgentDetailCatalogError, + ), + }); }); return () => { cancelled = true; }; - }, [tenantId]); + }, [tenantId, catalogReloadKey]); useEffect(() => { let cancelled = false; @@ -345,6 +354,7 @@ function AgentDetailEditor({ agent={agent} detail={state.detail} catalogState={catalogState} + onRetryCatalog={() => setCatalogReloadKey((value) => value + 1)} onChanged={(detail) => setState({ kind: "ready", detail })} /> @@ -430,7 +440,7 @@ type ModelOption = { readonly canonicalName: string; readonly label: string }; type CatalogState = | { readonly kind: "loading" } - | { readonly kind: "error" } + | { readonly kind: "error"; readonly message: string } | { readonly kind: "ready"; readonly models: readonly ModelInfo[] }; /** Only a model with at least one offering is actually launchable for this @@ -479,6 +489,7 @@ function ModelSelect({ agent, detail, catalogState, + onRetryCatalog, onChanged, }: { readonly tenantId: string; @@ -486,6 +497,7 @@ function ModelSelect({ readonly agent: WorkbenchAgent; readonly detail: AgentDetail; readonly catalogState: CatalogState; + readonly onRetryCatalog: () => void; readonly onChanged: (detail: AgentDetail) => void; }) { const [saving, setSaving] = useState(false); @@ -534,7 +546,11 @@ function ModelSelect({