feat(ai-settings): rebind endpoint status dot to configured x enabled #19
@@ -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.",
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<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`
|
||||
@@ -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 (
|
||||
<Box
|
||||
w={9}
|
||||
h={9}
|
||||
style={{ borderRadius: "50%", background: color, flex: "none" }}
|
||||
/>
|
||||
<Tooltip label={label} position="top" withArrow>
|
||||
<Box
|
||||
w={9}
|
||||
h={9}
|
||||
style={{ borderRadius: "50%", background: color, flex: "none" }}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
<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")}
|
||||
@@ -514,7 +556,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
|
||||
@@ -631,7 +673,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
|
||||
|
||||
@@ -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-е состояние «тест упал»?
|
||||
Reference in New Issue
Block a user