feat(ai-settings): put Clear inside the API key field, drop the eye #20
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { z } from "zod/v4";
|
||||
import {
|
||||
Anchor,
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
@@ -15,12 +15,13 @@ import {
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
useMantineTheme,
|
||||
} from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||
import { IconPencil } from "@tabler/icons-react";
|
||||
import { IconPencil, IconX } from "@tabler/icons-react";
|
||||
import { useAtom } from "jotai";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -430,19 +431,34 @@ export default function AiProviderSettings() {
|
||||
disabled={isLoading}
|
||||
{...form.getInputProps("chatModel")}
|
||||
/>
|
||||
<Stack gap={4}>
|
||||
<PasswordInput
|
||||
label={t("API key")}
|
||||
placeholder={hasApiKey ? t("•••• set") : ""}
|
||||
autoComplete="off"
|
||||
{...form.getInputProps("apiKey")}
|
||||
/>
|
||||
{hasApiKey && (
|
||||
<Anchor component="button" type="button" c="red" size="xs" onClick={handleClearKey}>
|
||||
{t("Clear")}
|
||||
</Anchor>
|
||||
)}
|
||||
</Stack>
|
||||
{/* 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 so the user can verify what they typed. */}
|
||||
<PasswordInput
|
||||
label={t("API key")}
|
||||
placeholder={hasApiKey ? t("•••• set") : ""}
|
||||
autoComplete="off"
|
||||
rightSection={
|
||||
hasApiKey && form.values.apiKey.length === 0 ? (
|
||||
<Tooltip label={t("Clear")} position="top" withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
aria-label={t("Clear")}
|
||||
type="button"
|
||||
onClick={handleClearKey}
|
||||
>
|
||||
<IconX size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : undefined
|
||||
}
|
||||
rightSectionPointerEvents="all"
|
||||
{...form.getInputProps("apiKey")}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<TextInput
|
||||
@@ -535,29 +551,38 @@ export default function AiProviderSettings() {
|
||||
disabled={isLoading}
|
||||
{...form.getInputProps("embeddingModel")}
|
||||
/>
|
||||
<Stack gap={4}>
|
||||
<PasswordInput
|
||||
label={t("Embedding API key")}
|
||||
placeholder={
|
||||
hasEmbeddingApiKey
|
||||
? t("•••• set")
|
||||
: t("Leave empty to use the chat API key")
|
||||
}
|
||||
autoComplete="off"
|
||||
{...form.getInputProps("embeddingApiKey")}
|
||||
/>
|
||||
{hasEmbeddingApiKey && (
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
c="red"
|
||||
size="xs"
|
||||
onClick={handleClearEmbeddingKey}
|
||||
>
|
||||
{t("Clear")}
|
||||
</Anchor>
|
||||
)}
|
||||
</Stack>
|
||||
{/* 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 so the user can verify what they typed. */}
|
||||
<PasswordInput
|
||||
label={t("Embedding API key")}
|
||||
placeholder={
|
||||
hasEmbeddingApiKey
|
||||
? t("•••• set")
|
||||
: t("Leave empty to use the chat API key")
|
||||
}
|
||||
autoComplete="off"
|
||||
rightSection={
|
||||
hasEmbeddingApiKey && form.values.embeddingApiKey.length === 0 ? (
|
||||
<Tooltip label={t("Clear")} position="top" withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
aria-label={t("Clear")}
|
||||
type="button"
|
||||
onClick={handleClearEmbeddingKey}
|
||||
>
|
||||
<IconX size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : undefined
|
||||
}
|
||||
rightSectionPointerEvents="all"
|
||||
{...form.getInputProps("embeddingApiKey")}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<TextInput
|
||||
@@ -654,29 +679,38 @@ export default function AiProviderSettings() {
|
||||
disabled={isLoading}
|
||||
{...form.getInputProps("sttModel")}
|
||||
/>
|
||||
<Stack gap={4}>
|
||||
<PasswordInput
|
||||
label={t("API key")}
|
||||
placeholder={
|
||||
hasSttApiKey
|
||||
? t("•••• set")
|
||||
: t("Leave empty to use the chat API key")
|
||||
}
|
||||
autoComplete="off"
|
||||
{...form.getInputProps("sttApiKey")}
|
||||
/>
|
||||
{hasSttApiKey && (
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
c="red"
|
||||
size="xs"
|
||||
onClick={handleClearSttKey}
|
||||
>
|
||||
{t("Clear")}
|
||||
</Anchor>
|
||||
)}
|
||||
</Stack>
|
||||
{/* 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 so the user can verify what they typed. */}
|
||||
<PasswordInput
|
||||
label={t("API key")}
|
||||
placeholder={
|
||||
hasSttApiKey
|
||||
? t("•••• set")
|
||||
: t("Leave empty to use the chat API key")
|
||||
}
|
||||
autoComplete="off"
|
||||
rightSection={
|
||||
hasSttApiKey && form.values.sttApiKey.length === 0 ? (
|
||||
<Tooltip label={t("Clear")} position="top" withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
aria-label={t("Clear")}
|
||||
type="button"
|
||||
onClick={handleClearSttKey}
|
||||
>
|
||||
<IconX size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : undefined
|
||||
}
|
||||
rightSectionPointerEvents="all"
|
||||
{...form.getInputProps("sttApiKey")}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Select
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
# Поле «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)
|
||||
|
||||
Три одинаковых по структуре блока — `<Stack gap={4}>` с `PasswordInput` + ссылкой
|
||||
`<Anchor>Clear</Anchor>` снизу:
|
||||
|
||||
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
|
||||
<Stack gap={4}>
|
||||
<PasswordInput
|
||||
label={t("API key")}
|
||||
placeholder={hasApiKey ? t("•••• set") : ""}
|
||||
autoComplete="off"
|
||||
{...form.getInputProps("apiKey")}
|
||||
/>
|
||||
{hasApiKey && (
|
||||
<Anchor component="button" type="button" c="red" size="xs" onClick={handleClearKey}>
|
||||
{t("Clear")}
|
||||
</Anchor>
|
||||
)}
|
||||
</Stack>
|
||||
```
|
||||
|
||||
Стало:
|
||||
```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. */}
|
||||
<PasswordInput
|
||||
label={t("API key")}
|
||||
placeholder={hasApiKey ? t("•••• set") : ""}
|
||||
autoComplete="off"
|
||||
rightSection={
|
||||
hasApiKey && form.values.apiKey.length === 0 ? (
|
||||
<Tooltip label={t("Clear")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
aria-label={t("Clear")}
|
||||
onClick={handleClearKey}
|
||||
>
|
||||
<IconX size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : undefined
|
||||
}
|
||||
rightSectionPointerEvents="all"
|
||||
{...form.getInputProps("apiKey")}
|
||||
/>
|
||||
```
|
||||
|
||||
Изменения по каждому из трёх блоков:
|
||||
- Убрать обёртку `<Stack gap={4}>…</Stack>` и ссылку `<Anchor>Clear</Anchor>`
|
||||
снизу (Clear переезжает внутрь поля). После удаления `Stack` второй ребёнок
|
||||
`<Group grow>` — сам `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` рендерит `<button>` (по умолчанию `type="button"`).
|
||||
Формы как `<form onSubmit>` тут нет — Save висит на отдельной `type="button"`
|
||||
кнопке (строки 735-744), так что случайного сабмита не будет. Для надёжности
|
||||
можно явно проставить `type="button"`.
|
||||
- **i18n.** Новый строковый ключ не нужен: `t("Clear")` уже используется
|
||||
(бывшая ссылка). Тултип и `aria-label` переиспользуют его. Плейсхолдер
|
||||
`•••• set` не трогаем.
|
||||
- **Ширина правой секции.** Иконка X помещается в штатный размер секции (как и
|
||||
глазок). Если решат оставить именно слово «Clear» текстом вместо иконки —
|
||||
понадобится `rightSectionWidth`, иначе текст обрежется. Рекомендуется
|
||||
иконка + тултип (компактно, как глазок).
|
||||
- **Доступность.** Обязателен `aria-label={t("Clear")}` на `ActionIcon` (иконка
|
||||
без видимого текста).
|
||||
|
||||
## Опционально (вне «трёх эндпоинтов»)
|
||||
|
||||
Тот же паттерн «бесполезный глазок + Clear снизу» есть в форме внешнего
|
||||
MCP-сервера —
|
||||
[ai-mcp-server-form.tsx](apps/client/src/features/workspace/components/settings/components/ai-mcp-server-form.tsx)
|
||||
(поле Authorization-заголовков, `PasswordInput` строка ~193, плейсхолдер
|
||||
`•••• set` строка ~196, `Anchor`-Clear строки ~207-209, обработчик
|
||||
`handleClearHeaders`). В запросе он не входит в «три эндпоинта», но логически
|
||||
страдает тем же. Можно причесать заодно для единообразия — отдельным мелким
|
||||
шагом, по той же схеме.
|
||||
Reference in New Issue
Block a user