Merge pull request 'feat(ai-settings): rebind endpoint status dot to configured x enabled' (#19) from feat/ai-endpoint-status-dot-config-enabled into develop

This commit was merged in pull request #19.
This commit is contained in:
claude_code
2026-06-20 17:22:22 +03:00
4 changed files with 96 additions and 255 deletions

View File

@@ -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.",

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

@@ -61,8 +61,15 @@ const formSchema = z.object({
type FormValues = z.infer<typeof formSchema>;
// 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]
: status === "configured"
? theme.colors.yellow[6]
: status === "warning"
? theme.colors.orange[6]
: theme.colors.gray[5];
return (
<Tooltip label={label} position="top" withArrow>
<Box
w={9}
h={9}
style={{ borderRadius: "50%", background: color, flex: "none" }}
/>
</Tooltip>
);
}
@@ -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() {
<Paper withBorder radius="md" p="lg">
<Group justify="space-between" 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>
<Badge size="sm" variant="light" color="gray">
{t("root")}
@@ -530,7 +571,7 @@ export default function AiProviderSettings() {
<Paper withBorder radius="md" p="lg">
<Group justify="space-between" 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>
</Group>
<Switch
@@ -656,7 +697,7 @@ export default function AiProviderSettings() {
<Paper withBorder radius="md" p="lg">
<Group justify="space-between" 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>
</Group>
<Switch

View File

@@ -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».
- Точки рендерятся в заголовках трёх карточек: `<StatusDot status={chatStatus}/>`
(~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 (
<Box w={9} h={9} style={{ borderRadius: "50%", background: color, flex: "none" }} />
);
}
```
### 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-е состояние «тест упал»?