From 5d8860e47b9793a45fc67c031b2133de4546fd31 Mon Sep 17 00:00:00 2001 From: vvzvlad Date: Thu, 18 Jun 2026 20:02:01 +0300 Subject: [PATCH] docs(backlog): clean up backlog documentation Remove outdated process sections from several backlog markdown files and add new backlog items for AI chat step limits, endpoint status config, and API key field UI improvements. --- docs/backlog/ai-chat-current-page-fragile.md | 8 - ...chat-step-limit-and-forced-final-answer.md | 199 ++++++++++++++++ .../ai-chat-tool-definitions-duplicated.md | 21 +- .../ai-endpoint-status-dot-config-enabled.md | 224 ++++++++++++++++++ .../api-key-field-clear-in-place-of-eye.md | 157 ++++++++++++ docs/backlog/remove-broken-import-formats.md | 7 - 6 files changed, 582 insertions(+), 34 deletions(-) create mode 100644 docs/backlog/ai-chat-step-limit-and-forced-final-answer.md create mode 100644 docs/backlog/ai-endpoint-status-dot-config-enabled.md create mode 100644 docs/backlog/api-key-field-clear-in-place-of-eye.md diff --git a/docs/backlog/ai-chat-current-page-fragile.md b/docs/backlog/ai-chat-current-page-fragile.md index 1d8d2815..cd5feff2 100644 --- a/docs/backlog/ai-chat-current-page-fragile.md +++ b/docs/backlog/ai-chat-current-page-fragile.md @@ -127,11 +127,3 @@ Qwen — у которых свой объёмный системный пром Рекомендация: сначала разрешить противоречие логами (дёшево), потом A или B для клиента + C для устойчивости к прокси (C — единственное, что реально лечит исходный симптом «через прокси не видит страницу»). - -## Процесс - -- Чистая диагностика на текущий момент, код НЕ менялся. -- Реализация — режим делегирования (по умолчанию): нетривиально (роутинг + - серверный промпт/инструмент) → general-purpose кодеру, затем обязательный - прогон `review`. -- Не коммитить; в конце предложить сообщение коммита. diff --git a/docs/backlog/ai-chat-step-limit-and-forced-final-answer.md b/docs/backlog/ai-chat-step-limit-and-forced-final-answer.md new file mode 100644 index 00000000..fb2f4a86 --- /dev/null +++ b/docs/backlog/ai-chat-step-limit-and-forced-final-answer.md @@ -0,0 +1,199 @@ +# Лимит шагов AI-агента (8 → 20) и принудительный финальный ответ + +Контекст (симптом из реального чата): на узкий поисковый вопрос («Какой +процессор в первой версии Яндекс.Колонки?») агент сделал подряд ~8 вызовов +`Search_tavily_search` / `Search_tavily_extract` и **остановился без текстового +ответа** — ход завершился пустым. Пользователь отправил «?», что стартовало +новый ход с новым бюджетом, и агент продолжил. Причина — жёсткий потолок в +8 шагов на один ход агента: бюджет был израсходован на инструменты раньше, чем +модель дошла до шага с финальным текстом. + +Хотим две вещи: +1. поднять лимит шагов с 8 до 20; +2. гарантировать непустой ответ — на последнем шаге принудительно запрещать + инструменты, чтобы модель синтезировала лучший ответ из уже собранного. + +## Как сейчас устроен лимит (цепочка) + +Единственная точка ограничения — `stopWhen` в вызове `streamText`: + +- Импорт условия: `apps/server/src/core/ai-chat/ai-chat.service.ts:7` + (`stepCountIs` из `ai`). +- Потолок: `apps/server/src/core/ai-chat/ai-chat.service.ts:247` + — `stopWhen: stepCountIs(8)` внутри `streamText({...})` (вызов начинается на + `:237`). +- Системный промпт, который уходит в `streamText({ system, ... })`, собирается + заранее в локальной переменной `system`: + `apps/server/src/core/ai-chat/ai-chat.service.ts:146-150` + (`buildSystemPrompt({...})`). Эта переменная в области видимости рядом с + вызовом `streamText` — её можно переиспользовать в `prepareStep`. +- Терминальные колбэки `onFinish` / `onError` / `onAbort` + (`ai-chat.service.ts:249-301`) сохраняют ответ ассистента через + `persistAssistant` (`:210-230`). При пустом ходе `onFinish` приходит с + `text === ''`, и в историю пишется пустое сообщение — это и видит пользователь + как «агент ничего не ответил». + +### Что такое «шаг» (семантика AI SDK v6) + +Один шаг = одна генерация модели. Если в шаге есть вызовы инструментов, они +выполняются, результат возвращается модели, и запускается следующий шаг. +`stopWhen: stepCountIs(N)` останавливает цикл, как только число завершённых +шагов достигает `N`. Цикл также завершается естественно, если модель сделала шаг +**без** вызова инструментов (выдала финальный текст). + +Важно: `stepNumber` в `prepareStep` нумеруется с нуля; последний из `N` шагов — +это `stepNumber === N - 1`. Один шаг может содержать несколько параллельных +вызовов инструментов, поэтому `N` шагов ≠ всегда ровно `N` вызовов (в инциденте +они шли последовательно — получилось ровно 8). + +## Решение (точечное, только сервер) + +Файл: `apps/server/src/core/ai-chat/ai-chat.service.ts`. + +1. Завести модульную константу вместо «магической» восьмёрки: + +```ts +// Max agent steps per turn. One step = one model generation; a step that calls +// tools is followed by another step carrying the tool results. Raised from 8 so +// multi-search research questions are not cut off mid-investigation. +const MAX_AGENT_STEPS = 20; + +// System-prompt addendum injected ONLY on the final step (see prepareStep). It +// forbids further tool calls and tells the model to synthesize the best answer +// it can from what it already gathered, so a tool-heavy turn never ends empty. +const FINAL_STEP_INSTRUCTION = + 'You have reached the maximum number of tool-use steps for this turn. ' + + 'Do NOT call any more tools. Using only the information already gathered, ' + + "write the most complete, useful final answer you can now, in the user's " + + 'language. If the information is incomplete, say so explicitly: summarize ' + + 'what you found, what is still missing, and give your best partial conclusion.'; +``` + +2. Поднять потолок: + +```ts +stopWhen: stepCountIs(MAX_AGENT_STEPS), +``` + +3. Добавить `prepareStep` в опции `streamText({...})` (рядом со `stopWhen`, + перед `abortSignal`). На последнем разрешённом шаге запрещаем инструменты + (`toolChoice: 'none'` → модель обязана выдать текст) и дополняем системный + промпт инструкцией синтеза. На остальных шагах ничего не возвращаем → + действуют дефолтные настройки: + +```ts +// Forced finalization: reserve the LAST allowed step for a text-only answer. +// Without this, a turn that spends all its steps on tool calls ends with no +// assistant text (an empty turn). On the final step we forbid further tool +// calls and append a synthesis instruction. `system` is the prompt built above +// (in scope here); we CONCATENATE so the original persona/context is preserved +// — a bare `system` override would REPLACE the whole system prompt for the step. +prepareStep: ({ stepNumber }) => { + if (stepNumber >= MAX_AGENT_STEPS - 1) { + return { + toolChoice: 'none', + system: `${system}\n\n${FINAL_STEP_INSTRUCTION}`, + }; + } + return undefined; // default settings for all earlier steps +}, +``` + +Итог: до 19 шагов модель свободно работает с инструментами, 20-й (последний) +шаг гарантированно текстовый. Если модель завершилась раньше естественным +образом — `prepareStep` для ранних шагов возвращает `undefined`, поведение не +меняется. + +## Подтверждённые факты по API (установлено: `ai@6.0.207`) + +Проверено по `node_modules/ai/dist/index.d.ts`: + +- `prepareStep({ stepNumber, steps, model, messages }) => PrepareStepResult | + void` — колбэк опции `streamText`. +- `PrepareStepResult` (строки ~990-1019) содержит поля: + `model?`, `toolChoice?`, `activeTools?`, `system?`, `messages?` и др. +- `toolChoice?: ToolChoice`, где + `ToolChoice = 'auto' | 'none' | 'required' | { type:'tool', toolName }` + (строка 126) — значит `toolChoice: 'none'` валидно и заставляет модель + отвечать текстом. +- `system?: string | SystemModelMessage | Array` — override + системного сообщения **для шага**; это полная замена, поэтому конкатенируем с + исходным `system`, а не пишем голую инструкцию. +- `stepNumber` нумеруется с нуля (док. пример: `if (stepNumber === 0) {...}`). + +> ⚠️ При апгрейде до AI SDK v7 поле `system` в `prepareStep` переименовано в +> `instructions` (см. migration guide 7.0). На v6 (`^6.0.134`, фактически +> 6.0.207) корректно именно `system`. Учесть при будущем bump. + +## Тонкие моменты / edge cases + +- **Резерв ровно одного шага** — на 20-м шаге модель не сможет сделать ещё один + «дозапрос». Это осознанный компромисс: гарантированный ответ важнее одного + лишнего инструмента. Если захочется буфера — форсить на `stepNumber >= + MAX_AGENT_STEPS - 2` (зарезервировать 2 шага), но это режет полезную работу. +- **Естественное завершение** до последнего шага — не затрагивается: override + применяется только при `stepNumber >= MAX_AGENT_STEPS - 1`. +- **finishReason** последнего шага: при `toolChoice:'none'` модель выдаёт текст + без tool-calls → цикл завершается как `stop` (а не «оборвался на лимите»). + Пустых ходов больше не будет; `onFinish` получит непустой `text`. +- **Замена system** override-ом — единственная ловушка: НЕ потерять исходный + промпт. Переменная `system` (`ai-chat.service.ts:146`) в замыкании — берём её. +- **maxOutputTokens** на агенте намеренно не задан (коммент `:242-246`) — это + изменение его не трогает; токенов на финальный текстовый шаг достаточно. +- **Клиент не меняется**: рендер шагов и текста уже есть в + `apps/client/src/features/ai-chat/components/message-list.tsx`. Раньше пустой + ход показывался как ход без текста — после фикса будет нормальный ответ. +- **Внешние MCP-клиенты** (tavily и пр.) закрываются в терминальных колбэках + (`closeExternalClients`) — путь завершения не меняется, ликов не добавляем. + +## Тестирование + +- Цикл `streamText` целиком юнит-тестировать дорого. Рекомендуется вынести + логику выбора шага в чистую экспортируемую функцию (по образцу + `compactToolOutput`, который уже тестируется в `ai-chat.service.spec.ts`): + +```ts +// Pure, unit-testable: decide per-step overrides. Returns undefined for normal +// steps, and forces a text-only synthesis on the final step. +export function prepareAgentStep( + stepNumber: number, + system: string, +): { toolChoice: 'none'; system: string } | undefined { + if (stepNumber >= MAX_AGENT_STEPS - 1) { + return { toolChoice: 'none', system: `${system}\n\n${FINAL_STEP_INSTRUCTION}` }; + } + return undefined; +} +``` + + Тогда `prepareStep: ({ stepNumber }) => prepareAgentStep(stepNumber, system)`, + а тест проверяет: для `stepNumber < 19` → `undefined`; для `19` → объект с + `toolChoice === 'none'` и `system`, начинающимся с исходного промпта и + содержащим `FINAL_STEP_INSTRUCTION`. + +## Альтернативы / возможные расширения (вне базового объёма) + +- **Конфигурируемый лимит** — вынести `MAX_AGENT_STEPS` в настройку воркспейса + (admin → AI), как системный промпт (`AiSettingsService.resolve`). Сейчас же — + просто константа в коде. +- **UI-метка «ответ по неполным данным»** — если последний шаг был принудительным, + можно прокинуть флажок в metadata и показать бейдж в `message-list.tsx`. Не + обязательно для базовой фичи. + +## Открытые вопросы (согласовать перед реализацией) + +- [ ] Значение лимита: 20 — ок? (компромисс «глубина исследования» vs стоимость + токенов на ход.) +- [ ] Текст `FINAL_STEP_INSTRUCTION` — устраивает формулировка? Язык ответа + модель выбирает сама по контексту; инструкция на английском как и весь + системный промпт. +- [ ] Выносить ли логику шага в чистую функцию ради юнит-теста (рекомендуется), + или оставить инлайн в `prepareStep` без отдельного теста. + +## Процесс + +- Сейчас это только план; код НЕ менялся. +- Реализация — режим делегирования (по умолчанию): изменение логическое + (новый `prepareStep` + константы, >5 строк) → general-purpose кодеру, затем + обязательный прогон `review`. +- Не коммитить; в конце предложить сообщение коммита. diff --git a/docs/backlog/ai-chat-tool-definitions-duplicated.md b/docs/backlog/ai-chat-tool-definitions-duplicated.md index b0d30a05..48be329d 100644 --- a/docs/backlog/ai-chat-tool-definitions-duplicated.md +++ b/docs/backlog/ai-chat-tool-definitions-duplicated.md @@ -60,9 +60,9 @@ agent-claim, `docmost-client.loader.ts:159` — `getCollabToken`; см. план встроенный агент получал устаревшую подсказку. Это и есть материализованный parity-баг. -## Варианты фикса (выбрать при реализации) +## Фикс -- **A. Единый реестр спеков (полное устранение дублирования).** Вынести в +Единый реестр спеков (полное устранение дублирования).** Вынести в `packages/mcp` один источник на инструмент: `name` + zod-схема + model-facing описание + общий хелпер нормализации node-строки (для patch/insert/update). И `index.ts`, и `ai-chat-tools.service.ts` импортируют спеки и добавляют только @@ -76,20 +76,3 @@ agent-claim, `docmost-client.loader.ts:159` — `getCollabToken`; см. план форму, поэтому реестр экспортирует транспорт-агностичные `{name, schema, description}`, а каждая сторона оборачивает их сама. `zod` — общая зависимость обоих пакетов, типы переносятся. -- **B. Минимально — общий источник описаний + node-хелпер.** Свести в один - модуль только длинные model-facing описания (то, что реально разъезжается и - уже дало баг) и хелпер нормализации node-строки; zod-схемы и `execute` оставить - раздельными. Меньше риска и проще через ESM-границу (описания — просто строки), - закрывает основной симптом (дрейф описаний), но не убирает дубль схем. - -Рекомендация: B как дешёвый первый шаг (убирает дрейф описаний — главный -наблюдавшийся вред), A — когда набор инструментов начнёт активно расти (§16) и -дубль схем/квирков станет ощутимым. - -## Процесс - -- Реализация — режим делегирования (по умолчанию): рефакторинг через два пакета - (packages/mcp + apps/server) → general-purpose кодеру, затем обязательный - прогон `review`. Прогнать `packages/mcp` unit-тесты и серверные spec'и - (`ai-chat-tools.service.spec.ts`). -- Не коммитить; в конце предложить сообщение коммита. diff --git a/docs/backlog/ai-endpoint-status-dot-config-enabled.md b/docs/backlog/ai-endpoint-status-dot-config-enabled.md new file mode 100644 index 00000000..e7375524 --- /dev/null +++ b/docs/backlog/ai-endpoint-status-dot-config-enabled.md @@ -0,0 +1,224 @@ +# Индикатор-точка эндпоинта 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-е состояние «тест упал»? diff --git a/docs/backlog/api-key-field-clear-in-place-of-eye.md b/docs/backlog/api-key-field-clear-in-place-of-eye.md new file mode 100644 index 00000000..3376a4e8 --- /dev/null +++ b/docs/backlog/api-key-field-clear-in-place-of-eye.md @@ -0,0 +1,157 @@ +# Поле «API key»: убрать бесполезный «глазок», поставить Clear на его место + +Статус: **план, код не менялся.** UI-задача на клиенте. Бэкенда не касается. + +## Суть + +В настройках AI-провайдера (Workspace settings → AI) у каждого из трёх +эндпоинтов есть поле `PasswordInput` для API-ключа. Когда ключ уже сохранён на +сервере, поле показывает плейсхолдер `•••• set`, а справа — встроенный в +Mantine `PasswordInput` тогл видимости («глазок»). Под полем отдельной строкой +висит ссылка **Clear**. + +Проблема: **«глазок» бессмысленен.** Поле ключа — write-only буфер: реальный +ключ в него никогда не загружается (сервер отдаёт только факт «ключ есть», +`hasApiKey`, см. `ai-provider-settings.tsx:120-130, 154-177`). Когда ключ +сохранён, буфер пустой → нажатие «глазка» показывает пустоту. Полезного смысла +нет. + +Хотим: **в состоянии «ключ сохранён» показывать кнопку Clear прямо на месте +«глазка» (в правой секции поля), а не отдельной ссылкой снизу.** Сделать это во +**всех трёх эндпоинтах** (Chat / LLM, Embeddings, Voice / STT). + +## Где править (точные места) + +Один файл: +[ai-provider-settings.tsx](apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx) + +Три одинаковых по структуре блока — `` с `PasswordInput` + ссылкой +`Clear` снизу: + +1. **Chat / LLM** — строки ~433-445 (`apiKey`, `hasApiKey`, `handleClearKey`). +2. **Embeddings** — строки ~538-560 (`embeddingApiKey`, `hasEmbeddingApiKey`, + `handleClearEmbeddingKey`). +3. **Voice / STT** — строки ~657-679 (`sttApiKey`, `hasSttApiKey`, + `handleClearSttKey`). + +Обработчики очистки (`handleClearKey` / `handleClearEmbeddingKey` / +`handleClearSttKey`, строки 239-255) и вся логика буферов/payload +(`buildPayload`, строки 179-222) — **остаются без изменений.** Меняется только +разметка трёх полей. + +## Ключевой факт Mantine (подтверждён по докам) + +У `PasswordInput`: **если передать свой `rightSection`, встроенный тогл +видимости («глазок») не рендерится** (Mantine docs, PasswordInput → «Usage +without visibility toggle»: *“When the `rightSection` prop is used, the +visibility toggle button is not rendered.”*). + +То есть «поставить Clear на место глазка» = передать в `PasswordInput` +`rightSection` с кнопкой Clear. Отдельный костыль для скрытия глазка не нужен. + +## Рекомендуемое поведение + +Показывать Clear в правой секции **только когда ключ сохранён И буфер пуст** +(`hasApiKey && form.values.apiKey.length === 0`). Как только пользователь +начинает вводить НОВЫЙ ключ (буфер непустой) — возвращать дефолтный «глазок»: +вот тут он осмыслен (проверить, что набрал). После клика по Clear обработчик +ставит `hasApiKey=false` → `rightSection` снова `undefined` → поле становится +обычным пустым `PasswordInput` с глазком для ввода свежего ключа. Поведение +самосогласованное. + +Альтернатива (проще, но грубее): показывать Clear всегда, пока `hasApiKey` +(без проверки буфера). Тогда при вводе нового поверх старого глазка не будет. +Допустимо, но теряем удобную проверку набранного. Рекомендуется вариант с +проверкой буфера. + +## Эскиз правки (на примере Chat-поля; для двух других — аналогично) + +Было: +```tsx + + + {hasApiKey && ( + + {t("Clear")} + + )} + +``` + +Стало: +```tsx +{/* The key field is write-only: the stored key never loads back, so the + built-in visibility toggle reveals nothing. Replace it with a Clear action + in the right section. Passing rightSection suppresses the eye (Mantine). + While typing a new key (buffer non-empty) fall back to the default eye. */} + + + + + + ) : undefined + } + rightSectionPointerEvents="all" + {...form.getInputProps("apiKey")} +/> +``` + +Изменения по каждому из трёх блоков: +- Убрать обёртку `` и ссылку `Clear` + снизу (Clear переезжает внутрь поля). После удаления `Stack` второй ребёнок + `` — сам `PasswordInput`; раскладка «Model | API key» в две + колонки сохраняется. +- Подставить свои переменные/обработчики: эндпоинт 2 — `hasEmbeddingApiKey` / + `embeddingApiKey` / `handleClearEmbeddingKey`; эндпоинт 3 — `hasSttApiKey` / + `sttApiKey` / `handleClearSttKey`. + +## Тонкости / на что смотреть + +- **Импорты.** Добавить `ActionIcon`, `Tooltip` из `@mantine/core` и `IconX` + из `@tabler/icons-react` (рядом с уже импортируемым `IconPencil`). После + переезда Clear внутрь поля `Anchor` может стать неиспользуемым — проверить и + убрать из импорта, иначе словим lint-ошибку `no-unused-vars`. +- **Кликабельность правой секции.** У `Input`/`PasswordInput` правая секция по + умолчанию не всегда принимает клики — задать `rightSectionPointerEvents="all"`, + чтобы клик по Clear срабатывал. +- **Тип кнопки.** `ActionIcon` рендерит `