From 394d3e58fcc7a2f8aad40302d8ccd010709300df Mon Sep 17 00:00:00 2001 From: "glm5.2 agent 180" Date: Sat, 20 Jun 2026 13:48:15 +0300 Subject: [PATCH 1/2] 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). --- .../public/locales/en-US/translation.json | 4 + .../components/ai-provider-settings.spec.tsx | 20 ++++ .../components/ai-provider-settings.tsx | 104 ++++++++++++------ 3 files changed, 97 insertions(+), 31 deletions(-) create mode 100644 apps/client/src/features/workspace/components/settings/components/ai-provider-settings.spec.tsx diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 21f7c5f7..4405032f 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1162,6 +1162,10 @@ "Voice dictation is not available yet.": "Voice dictation is not available yet.", "Test endpoint": "Test endpoint", "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", "Gitmost as MCP client": "Gitmost as MCP client", "Servers the agent calls out to.": "Servers the agent calls out to.", diff --git a/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.spec.tsx b/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.spec.tsx new file mode 100644 index 00000000..4bc479bc --- /dev/null +++ b/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.spec.tsx @@ -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'); + }); +}); diff --git a/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx b/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx index 78727bda..de3379f8 100644 --- a/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx +++ b/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx @@ -15,6 +15,7 @@ import { Text, Textarea, TextInput, + Tooltip, useMantineTheme, } from "@mantine/core"; import { useForm } from "@mantine/form"; @@ -60,8 +61,15 @@ const formSchema = z.object({ type FormValues = z.infer; -// Status of an endpoint card, drives the little status dot color. -type CardStatus = "ok" | "error" | "idle"; +// Four-state endpoint health shown by the header dot. Derived synchronously +// 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 // 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}`; } -// Small colored dot used in each card header. -function StatusDot({ status }: { status: CardStatus }) { +// Pure + unit-testable. `configured` = the endpoint has the fields it needs +// 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 color = - status === "ok" + status === "ready" ? theme.colors.green[6] - : status === "error" - ? theme.colors.red[6] - : theme.colors.gray[5]; + : status === "configured" + ? theme.colors.yellow[6] + : status === "warning" + ? theme.colors.orange[6] + : theme.colors.gray[5]; return ( - + + + ); } @@ -353,21 +393,23 @@ export default function AiProviderSettings() { ); } - const chatStatus: CardStatus = chatTest.data - ? chatTest.data.ok - ? "ok" - : "error" - : "idle"; - const embedStatus: CardStatus = embedTest.data - ? embedTest.data.ok - ? "ok" - : "error" - : "idle"; - const sttStatus: CardStatus = sttTest.data - ? sttTest.data.ok - ? "ok" - : "error" - : "idle"; + // Per-endpoint "configured" predicate, derived from the LIVE form values + // (the dot reacts as the admin types). A key is NOT required — local + // servers (Ollama, speaches) work without one. Embeddings and Voice + // inherit the chat base URL when their own is empty (see resolveUrl). + const v = form.values; + const chatBase = v.baseUrl.trim(); + const chatConfigured = v.chatModel.trim() !== "" && chatBase !== ""; + const embedConfigured = + v.embeddingModel.trim() !== "" && + (v.embeddingBaseUrl.trim() !== "" || chatBase !== ""); + const sttConfigured = + v.sttModel.trim() !== "" && + (v.sttBaseUrl.trim() !== "" || chatBase !== ""); + + const chatStatus = resolveCardStatus(chatConfigured, chatEnabled); + const embedStatus = resolveCardStatus(embedConfigured, searchEnabled); + const sttStatus = resolveCardStatus(sttConfigured, dictationEnabled); const chatResolved = resolveUrl(form.values.baseUrl, "/chat/completions"); const embedResolved = resolveUrl( @@ -404,7 +446,7 @@ export default function AiProviderSettings() { - + {t("Chat / LLM")} {t("root")} @@ -514,7 +556,7 @@ export default function AiProviderSettings() { - + {t("Embeddings")} - + {t("Voice / STT")} Date: Sat, 20 Jun 2026 17:22:03 +0300 Subject: [PATCH 2/2] docs: remove implemented ai-endpoint-status-dot backlog plan The configured x enabled status dot is implemented and merged via this branch, so the backlog plan is no longer needed. Co-Authored-By: Claude Opus 4.8 --- .../ai-endpoint-status-dot-config-enabled.md | 224 ------------------ 1 file changed, 224 deletions(-) delete mode 100644 docs/backlog/ai-endpoint-status-dot-config-enabled.md diff --git a/docs/backlog/ai-endpoint-status-dot-config-enabled.md b/docs/backlog/ai-endpoint-status-dot-config-enabled.md deleted file mode 100644 index e7375524..00000000 --- a/docs/backlog/ai-endpoint-status-dot-config-enabled.md +++ /dev/null @@ -1,224 +0,0 @@ -# Индикатор-точка эндпоинта AI: «настроено / включено» вместо «результат теста» - -## Контекст (симптом) - -В админских настройках AI (Workspace settings → AI) у каждой карточки-эндпоинта -(«Chat / LLM», «Embeddings», «Voice / STT») слева от заголовка есть маленькая -цветная точка. Сейчас её цвет означает **результат последнего ручного теста** -кнопкой «Test endpoint», а не состояние настройки: - -- зелёная — тест «Test endpoint» прошёл (`ok`); -- красная — тест упал (`error`); -- серая — тест ещё **не запускали** (`idle`). - -Поэтому на текущем экране у «Embeddings» точка зелёная (по карточке нажимали -«Test endpoint» → «Connection successful»), а у «Voice / STT» — серая, **хотя -тумблер «Voice dictation» включён и эндпоинт настроен**. Тумблеры фич -(`chat` / `search` / `dictation`) и сам факт заполненности полей (модель + -Base URL) на цвет точки сейчас никак не влияют. - -Хотим, чтобы точка читалась с одного взгляда как состояние эндпоинта, без -ручного теста: - -- **зелёная** — корректно настроено **и** включено; -- **жёлтая** — настроено, но **не** включено; -- **серая** — выключено / не настроено (нечего включать). - -## Как сейчас устроено (цепочка) - -Всё в одном файле клиента: -`apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx`. - -- Тип состояния точки: `type CardStatus = "ok" | "error" | "idle";` - — строка ~64. -- Компонент `StatusDot` (строки ~75-90) красит круг: `ok` → `green[6]`, - `error` → `red[6]`, иначе → `gray[5]`. -- Источник статуса — **только** мутации теста (строки ~356-370): - - ```ts - const chatStatus: CardStatus = chatTest.data - ? (chatTest.data.ok ? "ok" : "error") - : "idle"; - // аналогично embedStatus (embedTest), sttStatus (sttTest) - ``` - - `chatTest` / `embedTest` / `sttTest` — это `useTestAiConnectionMutation()` - (строки ~101-104); их `data` появляется только после нажатия «Test endpoint». -- Точки рендерятся в заголовках трёх карточек: `` - (~407), `embedStatus` (~517), `sttStatus` (~634). - -### Какие данные уже доступны в компоненте - -Этого достаточно, чтобы вычислить «настроено» и «включено» синхронно, без сети: - -- **Поля настройки** (живые, из формы) — `form.values`: - `chatModel`, `baseUrl`, `embeddingModel`, `embeddingBaseUrl`, `sttModel`, - `sttBaseUrl`, `sttApiStyle`, и write-only буферы ключей `apiKey`, - `embeddingApiKey`, `sttApiKey`. -- **Наличие сохранённых ключей** — состояния `hasApiKey`, `hasEmbeddingApiKey`, - `hasSttApiKey` (строки ~122-130), синхронизируются с сервером и обновляются - при «Clear» и сохранении. -- **Тумблеры фич** (персистятся в `workspace.settings.ai`) — `chatEnabled` - (`settings.ai.chat`, строка ~108), `searchEnabled` (`settings.ai.search`, - ~111), `dictationEnabled` (`settings.ai.dictation`, ~114). -- **Семантика наследования** (важно для «настроено»): Embeddings и Voice - **наследуют Base URL и ключ от Chat**, если свои не заданы. Это прямо написано - в подзаголовке карточки Chat: «root endpoint — Embeddings and Voice inherit its - URL and key» (строка ~423), и реализовано в `resolveUrl(..., fallback)` - (~373-382). Значит у Embeddings/STT «свой Base URL» не обязателен. - -## Решение (точечное, только клиент) - -Файл: `apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx`. -Перепривязать цвет точки с «результата теста» на пару булевых признаков -**`configured` × `enabled`**. Результат теста остаётся как был — текстом рядом с -кнопкой («Connection successful» / ошибка), точку он больше не красит. - -### 1. Новый тип состояния и чистый хелпер выбора цвета - -```ts -// Three-state endpoint health shown by the header dot. Derived synchronously -// from the form + feature toggle — never from a network probe (the "Test -// endpoint" button still surfaces the live probe result as text). -// "ready" (green) — required fields are filled AND the feature is ON -// "configured" (yellow) — required fields are filled but the feature is OFF -// "off" (gray) — required fields missing (nothing to enable) -type CardStatus = "ready" | "configured" | "off"; - -// Pure + unit-testable. `configured` = the endpoint has everything it needs to -// work; `enabled` = the workspace feature toggle for this endpoint is ON. -function resolveCardStatus(configured: boolean, enabled: boolean): CardStatus { - if (!configured) return "off"; - return enabled ? "ready" : "configured"; -} -``` - -### 2. `StatusDot` — добавить жёлтый - -```ts -function StatusDot({ status }: { status: CardStatus }) { - const theme = useMantineTheme(); - const color = - status === "ready" - ? theme.colors.green[6] - : status === "configured" - ? theme.colors.yellow[6] // Mantine default palette has `yellow` - : theme.colors.gray[5]; - return ( - - ); -} -``` - -### 3. Признак «настроено» для каждой карточки - -Ключ (API key) считаем **необязательным** — локальные серверы (Ollama, speaches -/ faster-whisper-server) работают без ключа, поэтому требовать ключ нельзя. -«Настроено» = задана модель **и** есть Base URL (свой или унаследованный от Chat): - -```ts -const v = form.values; -const chatBase = v.baseUrl.trim(); - -// Chat is the root: needs its own model + base URL. -const chatConfigured = v.chatModel.trim() !== "" && chatBase !== ""; - -// Embeddings / Voice inherit the chat base URL when their own is empty. -const embedConfigured = - v.embeddingModel.trim() !== "" && (v.embeddingBaseUrl.trim() !== "" || chatBase !== ""); -const sttConfigured = - v.sttModel.trim() !== "" && (v.sttBaseUrl.trim() !== "" || chatBase !== ""); -``` - -### 4. Заменить вывод статусов (строки ~356-370) - -```ts -const chatStatus = resolveCardStatus(chatConfigured, chatEnabled); -const embedStatus = resolveCardStatus(embedConfigured, searchEnabled); -const sttStatus = resolveCardStatus(sttConfigured, dictationEnabled); -``` - -`chatTest` / `embedTest` / `sttTest` остаются для текстового результата под -кнопкой «Test endpoint» — их `data` просто больше не участвует в цвете точки. - -### 5. (Рекомендуется) Tooltip на точке — цвет не должен быть единственным сигналом - -Цвет в одиночку недоступен дальтоникам и неочевиден. Обернуть `StatusDot` в -Mantine `Tooltip` с текстовой расшифровкой (через `t(...)`), напр.: -`ready` → «Configured and enabled», `configured` → «Configured but disabled`», -`off` → «Not configured». `Tooltip` уже используется в соседнем -`mcp-settings.tsx`, импорт из `@mantine/core`. - -## Тонкие моменты / edge cases - -- **Источник «настроено» — `form.values` (живой), а не persisted `settings`.** - Тогда точка реагирует прямо при наборе. Минус: тумблер (`*Enabled`) — - персистентный, поэтому после правки полей и **до** «Save endpoints» возможна - кратковременная рассинхронизация (поля изменены, но ещё не сохранены). Это - приемлемо и логично (точка показывает «то, что введено»). Альтернатива — брать - поля из `settings` (тогда точка отражает строго сохранённое состояние, - согласованно с тумблером) — см. «Альтернативы». -- **Включено, но НЕ настроено** (`enabled && !configured`): админ включил фичу, но - не заполнил эндпоинт — реальная мисконфигурация. По строгой трёхцветной схеме - это **серый**, что прячет проблему. Варианты: (а) оставить серым (буквально по - ТЗ); (б) **рекомендуется** — отдельный «warning»-цвет (красный/оранжевый) и - тултип «Enabled but not configured», т.к. фича включена и работать не будет. - Решить в «Открытых вопросах». -- **Судьба красного «тест упал».** Сейчас красный = упавший тест. В новой схеме - цвета красного нет. Падение теста по-прежнему видно текстом под кнопкой, так что - сигнал не теряется. Опционально можно сохранить красный как 4-е состояние-оверрайд - (если тест **явно** запускали и он упал) — но это усложняет модель; по умолчанию - не делаем. -- **`yellow` в теме Mantine** есть в дефолтной палитре (Mantine 8) — `yellow[6]` - валиден; кастомная тема в проекте палитру не переопределяет (использовать - `theme.colors.yellow[6]`). -- **Все три карточки** ведут себя единообразно (одна `StatusDot` + один хелпер), - включая «Chat / LLM», которой нет на скриншоте, но логика та же. -- **Оптимистичные тумблеры**: `*Enabled` обновляются оптимистично и - откатываются при ошибке (`handleToggle*`). Цвет точки следует за состоянием - тумблера автоматически (реактивный `useState`). -- **trim()**: значения могут содержать пробелы — сравнивать после `.trim()` (как - в `resolveUrl`). - -## i18n - -- Новые пользовательские строки (тексты тултипов) **только через `t(...)`** и - добавить ключи в каталог `apps/client/public/locales/en-US/translation.json` - (он английско-ключевой: ключ == значение, напр. `"Configured and enabled"`). - Если используется warning-вариант — добавить и его строку. -- Комментарии в коде — на английском (правило проекта). - -## Тесты - -- `resolveCardStatus` — чистая функция, легко юнит-тестируется (Vitest на - клиенте): `(false, *) → "off"`, `(true, true) → "ready"`, `(true, false) → - "configured"`. Если экспортировать `*Configured`-предикаты как чистые - функции от `form.values` — их тоже можно покрыть (особенно наследование Base - URL у Embeddings/STT). -- Запустить `pnpm --filter client lint` и `pnpm --filter client test`. - -## Альтернативы / расширения (вне базового объёма) - -- **Брать «настроено» из persisted `settings`** (а не `form.values`): точка строго - отражает сохранённое состояние, согласовано с персистентным тумблером, но не - реагирует на ввод до «Save». `settings` (`IAiSettings`) уже содержит - `chatModel`/`embeddingModel`/`baseUrl`/`embeddingBaseUrl`/`sttModel`/ - `sttBaseUrl` + `hasApiKey`/`hasEmbeddingApiKey`/`hasSttApiKey`. -- **«настроено» = «тест прошёл»** вместо «поля заполнены»: точнее («корректно»), - но требует автопрогона теста на загрузке (сеть, латентность, лимиты провайдера) - — против идеи мгновенного индикатора. Не рекомендуется. -- **Учитывать ключ для облачных провайдеров**: если Base URL указывает на - публичный провайдер (OpenAI/OpenRouter), ключ де-факто обязателен. Можно - усложнить предикат (`configured` требует ключ, если host не локальный), но это - хрупкая эвристика — оставляем ключ необязательным. - -## Открытые вопросы (согласовать перед реализацией) - -- [ ] Случай «включено, но не настроено»: серый (буквально по ТЗ) или отдельный - warning-цвет (рекомендуется, чтобы не прятать мисконфигурацию)? -- [ ] Что значит «настроено»: «поля модель + Base URL заполнены» (рекомендуется, - ключ необязателен) — ок? Или требовать ещё и ключ? -- [ ] Источник полей: живой `form.values` (реактивно при вводе, рекомендуется) - или persisted `settings` (строго сохранённое состояние)? -- [ ] Добавлять ли `Tooltip` с текстовой расшифровкой (рекомендуется для - доступности) и сохранять ли красный как 4-е состояние «тест упал»? -- 2.49.1