feat(ai-settings): status dot = configured × enabled (not test result) #7

Closed
Ghost wants to merge 2 commits from feat/ai-status-dot into develop
3 changed files with 60 additions and 251 deletions

View File

@@ -722,6 +722,10 @@
"Connection successful": "Connection successful",
"Connection failed": "Connection failed",
"Only workspace admins can manage AI provider settings.": "Only workspace admins can manage AI provider settings.",
"Configured and enabled": "Configured and enabled",
"Configured but disabled": "Configured but disabled",
"Enabled but not configured": "Enabled but not configured",
"Not configured": "Not configured",
"Sources": "Sources",
"AI Answers not available for attachments": "AI Answers not available for attachments",
"No answer available": "No answer available",

View File

@@ -15,6 +15,7 @@ import {
Text,
Textarea,
TextInput,
Tooltip,
useMantineTheme,
} from "@mantine/core";
import { useForm } from "@mantine/form";
@@ -60,8 +61,19 @@ 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";
// Endpoint health shown by the header dot, derived synchronously from the
// form + feature toggle (never from a network probe — the "Test endpoint"
// button still shows the live probe result as text).
// "ready" (green) — configured AND enabled
// "configured" (yellow) — configured but the feature is OFF
// "misconfigured" (orange/red warning) — enabled but NOT configured (real misconfig)
// "off" (gray) — not configured and not enabled (nothing to enable)
type CardStatus = "ready" | "configured" | "misconfigured" | "off";
function resolveCardStatus(configured: boolean, enabled: boolean): CardStatus {
if (configured) return enabled ? "ready" : "configured";
return enabled ? "misconfigured" : "off";
}
// 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 +83,35 @@ function resolveUrl(base: string, path: string, fallback = ""): string {
return `${trimmed}${path}`;
}
// Small colored dot used in each card header.
// Small colored dot used in each card header. Wrapped in a Tooltip so the
// state is conveyed by text too — color must not be the only signal (a11y).
function StatusDot({ status }: { status: CardStatus }) {
const theme = useMantineTheme();
const { t } = useTranslation();
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 === "misconfigured"
? theme.colors.orange[6]
: theme.colors.gray[5];
const label =
status === "ready"
? t("Configured and enabled")
: status === "configured"
? t("Configured but disabled")
: status === "misconfigured"
? t("Enabled but not configured")
: t("Not configured");
return (
<Box
w={9}
h={9}
style={{ borderRadius: "50%", background: color, flex: "none" }}
/>
<Tooltip label={label} withArrow>
<Box
w={9}
h={9}
style={{ borderRadius: "50%", background: color, flex: "none" }}
/>
</Tooltip>
);
}
@@ -353,21 +379,24 @@ 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";
// "Configured" = model filled AND a Base URL available (own or inherited from
// Chat). API key is intentionally NOT required — local servers (Ollama,
// speaches / faster-whisper-server) need none. Derived live from form.values
// so the dot reacts while typing. Compare after .trim() (values may have
// surrounding whitespace).
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(

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-е состояние «тест упал»?