feat(ai-settings): rebind endpoint status dot to configured x enabled

The header dot on each AI endpoint card (Chat / LLM, Embeddings, Voice /
STT) used to reflect the last 'Test endpoint' probe result - green/red/
gray. That was misleading: a configured-and-enabled endpoint showed GRAY
until someone manually clicked 'Test endpoint'. The dot now reads as the
endpoint's health at a glance, derived synchronously from the live form
values + the workspace feature toggle - never from a network probe.

Four-state model (resolveCardStatus):
  ready      (green)  - configured AND enabled
  configured (yellow) - configured but the feature toggle is OFF
  off        (gray)   - not configured (nothing to enable)
  warning    (orange) - enabled but not configured (a real misconfig:
                        the feature is on but will not work; surfaced
                        instead of hidden under gray)

'configured' = model field non-empty AND a base URL available (own OR
inherited from chat for embeddings/STT). The API key is optional - local
servers (Ollama, speaches) work without one. Source of truth is the live
form.values so the dot reacts as the admin types; the persistent feature
toggles drive the enabled axis. The 'Test endpoint' probe result stays
as text under the button - it just no longer paints the dot.

A Tooltip with a human-readable label wraps the dot so the state is not
color-only (colorblind-friendly). resolveCardStatus is exported and
covered by a Vitest spec (4 cases, including the misconfig branch).
This commit is contained in:
glm5.2 agent 180
2026-06-20 13:48:15 +03:00
parent c8af637654
commit 394d3e58fc
3 changed files with 97 additions and 31 deletions

View File

@@ -1162,6 +1162,10 @@
"Voice dictation is not available yet.": "Voice dictation is not available yet.", "Voice dictation is not available yet.": "Voice dictation is not available yet.",
"Test endpoint": "Test endpoint", "Test endpoint": "Test endpoint",
"Save endpoints": "Save endpoints", "Save endpoints": "Save endpoints",
"Configured and enabled": "Configured and enabled",
"Configured but disabled": "Configured but disabled",
"Enabled but not configured": "Enabled but not configured",
"Not configured": "Not configured",
"External tools": "External tools", "External tools": "External tools",
"Gitmost as MCP client": "Gitmost as MCP client", "Gitmost as MCP client": "Gitmost as MCP client",
"Servers the agent calls out to.": "Servers the agent calls out to.", "Servers the agent calls out to.": "Servers the agent calls out to.",

View File

@@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest';
import { resolveCardStatus } from './ai-provider-settings';
describe('resolveCardStatus', () => {
it('returns "off" when not configured and not enabled', () => {
expect(resolveCardStatus(false, false)).toBe('off');
});
it('returns "warning" when enabled but not configured (misconfig, not silent "off")', () => {
expect(resolveCardStatus(false, true)).toBe('warning');
});
it('returns "configured" when configured but disabled', () => {
expect(resolveCardStatus(true, false)).toBe('configured');
});
it('returns "ready" when configured and enabled', () => {
expect(resolveCardStatus(true, true)).toBe('ready');
});
});

View File

@@ -15,6 +15,7 @@ import {
Text, Text,
Textarea, Textarea,
TextInput, TextInput,
Tooltip,
useMantineTheme, useMantineTheme,
} from "@mantine/core"; } from "@mantine/core";
import { useForm } from "@mantine/form"; import { useForm } from "@mantine/form";
@@ -60,8 +61,15 @@ const formSchema = z.object({
type FormValues = z.infer<typeof formSchema>; type FormValues = z.infer<typeof formSchema>;
// Status of an endpoint card, drives the little status dot color. // Four-state endpoint health shown by the header dot. Derived synchronously
type CardStatus = "ok" | "error" | "idle"; // from the form values + feature toggle — never from a network probe (the
// "Test endpoint" button still surfaces the live probe result as text).
// "ready" (green) — required fields filled AND the feature is ON
// "configured"(yellow) — required fields filled but the feature is OFF
// "off" (gray) — required fields missing (nothing to enable)
// "warning" (orange) — feature is ON but required fields are missing
// (a real misconfiguration: it won't work as-is)
type CardStatus = "ready" | "configured" | "off" | "warning";
// Resolve a "Base URL + path" hint defensively: trim a single trailing slash // Resolve a "Base URL + path" hint defensively: trim a single trailing slash
// off the base, then append the path. Empty base falls back to `fallback` // off the base, then append the path. Empty base falls back to `fallback`
@@ -71,21 +79,53 @@ function resolveUrl(base: string, path: string, fallback = ""): string {
return `${trimmed}${path}`; return `${trimmed}${path}`;
} }
// Small colored dot used in each card header. // Pure + unit-testable. `configured` = the endpoint has the fields it needs
function StatusDot({ status }: { status: CardStatus }) { // to work; `enabled` = the workspace feature toggle for this endpoint is ON.
// The "enabled && !configured" case is surfaced as "warning" instead of "off"
// so a misconfiguration (feature on, endpoint not filled) is not hidden.
export function resolveCardStatus(
configured: boolean,
enabled: boolean,
): CardStatus {
if (configured) return enabled ? "ready" : "configured";
return enabled ? "warning" : "off";
}
// Translate the dot's tooltip label. Kept in one place so all three endpoint
// cards share identical wording.
function cardStatusLabel(status: CardStatus, t: (k: string) => string): string {
switch (status) {
case "ready":
return t("Configured and enabled");
case "configured":
return t("Configured but disabled");
case "warning":
return t("Enabled but not configured");
default:
return t("Not configured");
}
}
// Small colored dot used in each card header, with a tooltip label so the
// state is readable without relying on color alone (colorblind access).
function StatusDot({ status, label }: { status: CardStatus; label: string }) {
const theme = useMantineTheme(); const theme = useMantineTheme();
const color = const color =
status === "ok" status === "ready"
? theme.colors.green[6] ? theme.colors.green[6]
: status === "error" : status === "configured"
? theme.colors.red[6] ? theme.colors.yellow[6]
: theme.colors.gray[5]; : status === "warning"
? theme.colors.orange[6]
: theme.colors.gray[5];
return ( return (
<Box <Tooltip label={label} position="top" withArrow>
w={9} <Box
h={9} w={9}
style={{ borderRadius: "50%", background: color, flex: "none" }} h={9}
/> style={{ borderRadius: "50%", background: color, flex: "none" }}
/>
</Tooltip>
); );
} }
@@ -353,21 +393,23 @@ export default function AiProviderSettings() {
); );
} }
const chatStatus: CardStatus = chatTest.data // Per-endpoint "configured" predicate, derived from the LIVE form values
? chatTest.data.ok // (the dot reacts as the admin types). A key is NOT required — local
? "ok" // servers (Ollama, speaches) work without one. Embeddings and Voice
: "error" // inherit the chat base URL when their own is empty (see resolveUrl).
: "idle"; const v = form.values;
const embedStatus: CardStatus = embedTest.data const chatBase = v.baseUrl.trim();
? embedTest.data.ok const chatConfigured = v.chatModel.trim() !== "" && chatBase !== "";
? "ok" const embedConfigured =
: "error" v.embeddingModel.trim() !== "" &&
: "idle"; (v.embeddingBaseUrl.trim() !== "" || chatBase !== "");
const sttStatus: CardStatus = sttTest.data const sttConfigured =
? sttTest.data.ok v.sttModel.trim() !== "" &&
? "ok" (v.sttBaseUrl.trim() !== "" || chatBase !== "");
: "error"
: "idle"; const chatStatus = resolveCardStatus(chatConfigured, chatEnabled);
const embedStatus = resolveCardStatus(embedConfigured, searchEnabled);
const sttStatus = resolveCardStatus(sttConfigured, dictationEnabled);
const chatResolved = resolveUrl(form.values.baseUrl, "/chat/completions"); const chatResolved = resolveUrl(form.values.baseUrl, "/chat/completions");
const embedResolved = resolveUrl( const embedResolved = resolveUrl(
@@ -404,7 +446,7 @@ export default function AiProviderSettings() {
<Paper withBorder radius="md" p="lg"> <Paper withBorder radius="md" p="lg">
<Group justify="space-between" align="center" wrap="nowrap"> <Group justify="space-between" align="center" wrap="nowrap">
<Group gap="xs" align="center" wrap="nowrap"> <Group gap="xs" align="center" wrap="nowrap">
<StatusDot status={chatStatus} /> <StatusDot status={chatStatus} label={cardStatusLabel(chatStatus, t)} />
<Text fw={600}>{t("Chat / LLM")}</Text> <Text fw={600}>{t("Chat / LLM")}</Text>
<Badge size="sm" variant="light" color="gray"> <Badge size="sm" variant="light" color="gray">
{t("root")} {t("root")}
@@ -514,7 +556,7 @@ export default function AiProviderSettings() {
<Paper withBorder radius="md" p="lg"> <Paper withBorder radius="md" p="lg">
<Group justify="space-between" align="center" wrap="nowrap"> <Group justify="space-between" align="center" wrap="nowrap">
<Group gap="xs" align="center" wrap="nowrap"> <Group gap="xs" align="center" wrap="nowrap">
<StatusDot status={embedStatus} /> <StatusDot status={embedStatus} label={cardStatusLabel(embedStatus, t)} />
<Text fw={600}>{t("Embeddings")}</Text> <Text fw={600}>{t("Embeddings")}</Text>
</Group> </Group>
<Switch <Switch
@@ -631,7 +673,7 @@ export default function AiProviderSettings() {
<Paper withBorder radius="md" p="lg"> <Paper withBorder radius="md" p="lg">
<Group justify="space-between" align="center" wrap="nowrap"> <Group justify="space-between" align="center" wrap="nowrap">
<Group gap="xs" align="center" wrap="nowrap"> <Group gap="xs" align="center" wrap="nowrap">
<StatusDot status={sttStatus} /> <StatusDot status={sttStatus} label={cardStatusLabel(sttStatus, t)} />
<Text fw={600}>{t("Voice / STT")}</Text> <Text fw={600}>{t("Voice / STT")}</Text>
</Group> </Group>
<Switch <Switch