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 360dc6db..7be52a46 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 @@ -61,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` @@ -72,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 ( - + + + ); } @@ -354,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( @@ -405,7 +446,7 @@ export default function AiProviderSettings() { - + {t("Chat / LLM")} {t("root")} @@ -530,7 +571,7 @@ export default function AiProviderSettings() { - + {t("Embeddings")} - + {t("Voice / STT")} ` - (~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-е состояние «тест упал»?