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` рендерит `