Merge remote-tracking branch 'gitea/develop' into feat/page-templates

# Conflicts:
#	apps/server/src/integrations/throttle/throttle.module.ts
#	apps/server/src/integrations/throttle/throttler-names.ts
This commit is contained in:
claude_code
2026-06-20 20:18:42 +03:00
130 changed files with 9951 additions and 3095 deletions

View File

@@ -1,362 +0,0 @@
# Роли агента (Agent Roles) — проектный план
> Статус: проработанная фича, **не реализована**. Контекст: gitmost — форк Docmost.
> Идея: дать возможность создавать переиспользуемые **роли агента** (например
> «Корректор» или «Факт-чекер, который ходит в веб и проверяет факты») и
> заводить чат, привязанный к выбранной роли. Роль задаёт поведение агента
> (системный промпт) и, опционально, модель.
>
> Зафиксированные решения по объёму (см. раздел «Развилки»):
> - **Владение** — только **админские, общие на воркспейс** роли (как провайдер и
> внешние MCP-серверы сегодня). Личных ролей в v1 нет.
> - **Гейтинг инструментов** — **нет**. Роль меняет только инструкции и (опц.) модель;
> набор инструментов всегда полный (тот же, что у обычного чата). Ограничение
> возможностей по ролям отложено (см. «Возможные расширения»).
> - **Артефакт этого шага** — только дизайн-документ; код не пишется.
## Зачем это (и почему ложится в текущую архитектуру)
Сегодня у встроенного AI-агента нет понятия персоны/роли на уровне чата: вся
настройка поведения — один системный промпт **на весь воркспейс**. Пользователь
хочет заводить разные чаты под разные задачи (вычитка орфографии, проверка фактов
по вебу и т. д.), каждый — со своей инструкцией и, возможно, своей моделью.
Три факта из текущего кода определяют дизайн (всё сверено по исходникам):
1. **Системный промпт — только на уровне воркспейса.** Собирается в
[ai-chat.prompt.ts](../apps/server/src/core/ai-chat/ai-chat.prompt.ts),
функция `buildSystemPrompt()`, по слоям: *базовая персона*
(`workspace.settings.ai.provider.systemPrompt` либо `DEFAULT_PROMPT`) →
*контекст* (имя воркспейса, открытая страница) → несъёмный `SAFETY_FRAMEWORK`.
Персоны на чат сейчас нет — её надо добавить как ещё один слой.
2. **Инструменты — всегда все включены.** В
[ai-chat.service.ts](../apps/server/src/core/ai-chat/ai-chat.service.ts):
`const tools = { ...external.tools, ...docmostTools }`. ~40 Docmost-инструментов
строит `AiChatToolsService.forUser()`
([tools/ai-chat-tools.service.ts](../apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts)),
внешние MCP-инструменты подмешивает `mcpClients.toolsFor(workspaceId)`
([external-mcp/mcp-clients.service.ts](../apps/server/src/core/ai-chat/external-mcp/mcp-clients.service.ts)).
Механизма включать подмножество инструментов нет — есть только CASL-проверка в
момент вызова (через персональный loopback-токен). **По зафиксированному
решению этот механизм мы и не вводим** — роль не трогает набор инструментов.
3. **Веб-доступ уже решён внешними MCP.** Внешние MCP-серверы
(`ai_mcp_servers`, напр. Tavily) с SSRF-защитой
([external-mcp/ssrf-guard.ts](../apps/server/src/core/ai-chat/external-mcp/ssrf-guard.ts))
и шифрованием заголовков — это и есть «факт-чекер ходит в гугл». Поскольку
гейтинга нет, веб-инструменты **уже доступны каждому чату**, если админ
подключил соответствующий MCP-сервер. Роль «Факт-чекер» работает чисто за счёт
инструкции «проверяй факты по веб-источникам и цитируй ссылки» — она направляет
модель пользоваться уже доступными инструментами, а не добавляет их.
4. **Чат создаётся неявно** при первом сообщении: клиент
([chat-thread.tsx](../apps/client/src/features/ai-chat/components/chat-thread.tsx))
шлёт POST `/api/ai-chat/stream` с `chatId: null`, сервер
([ai-chat.controller.ts](../apps/server/src/core/ai-chat/ai-chat.controller.ts))
создаёт строку `ai_chats`. Привязать чат к роли можно одним новым полем `role_id`,
которое клиент передаёт один раз при первом сообщении.
**Вывод:** роль — это тонкий слой поверх существующего пайплайна. Нужны:
новая таблица ролей + админский CRUD, поле `ai_chats.role_id`, новый слой в
`buildSystemPrompt()`, опциональный override модели в `getChatModel()`, пикер роли
и управление ролями в UI. Граница безопасности (CASL через loopback-токен)
**не меняется** — роль её не ослабляет и не усиливает (см. «Безопасность»).
## Модель
**Роль (Agent Role)** — именованный, общий на воркспейс пресет, который связывает:
| Часть | Что задаёт | Пример «Корректор» | Пример «Факт-чекер» |
| --- | --- | --- | --- |
| **instructions** | фрагмент системного промпта (персона/поведение) | «Исправляй только орфографию, пунктуацию и грамматику. Никогда не меняй смысл, факты, тон и структуру текста. Используй точечную правку текста» | «Проверяй фактические утверждения страницы по авторитетным веб-источникам. Цитируй ссылки. Помечай сомнительные места комментарием. Не редактируй текст страницы без явной просьбы» |
| **model (опц.)** | модель ≠ дефолт воркспейса | дешёвая модель | сильная модель |
| **presentation** | имя, emoji, описание | 🔤 «Корректор» | 🔎 «Факт-чекер» |
Чего роль в v1 **не** задаёт (по зафиксированным решениям): набор инструментов,
выбор конкретных внешних MCP-серверов, владельца (роли только общие/админские),
снапшот конфигурации на чат.
**Привязка чата к роли** — нулевое поле `ai_chats.role_id`. Чат «помнит», с какой
ролью создан; роль применяется на каждом ходу. Чат без роли (`role_id IS NULL`) —
обычный универсальный ассистент (текущее поведение).
## Модель данных (миграции)
Соглашение: `apps/server/src/database/migrations/YYYYMMDDThhmmss-description.ts`.
Только **добавляем** таблицы/столбцы (никогда не трогаем данные Docmost). Timestamp
новой миграции должен сортироваться **после** последней применённой; на момент
написания последняя — `20260618T160000-ai-stt-credentials.ts`, значит брать
`20260619T...`. После миграции — `pnpm --filter server migration:codegen` для
регенерации [db.d.ts](../apps/server/src/database/types/db.d.ts). Образец стиля —
[20260617T130000-ai-mcp-servers.ts](../apps/server/src/database/migrations/20260617T130000-ai-mcp-servers.ts).
**Миграция — таблица ролей + привязка чата:**
```sql
CREATE TABLE ai_agent_roles (
id uuid PRIMARY KEY DEFAULT gen_uuid_v7(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
creator_id uuid REFERENCES users(id) ON DELETE SET NULL, -- кто создал (аудит)
name varchar NOT NULL, -- "Корректор"
emoji varchar, -- presentation
description text,
instructions text NOT NULL, -- фрагмент system prompt
model_config jsonb, -- { driver?, chatModel } | NULL = дефолт воркспейса
enabled boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz -- soft delete (как у ai_chats)
);
CREATE INDEX idx_ai_agent_roles_workspace_id ON ai_agent_roles (workspace_id);
-- привязка чата к роли
ALTER TABLE ai_chats
ADD COLUMN role_id uuid REFERENCES ai_agent_roles(id) ON DELETE SET NULL;
```
Заметки:
- `creator_id ON DELETE SET NULL` — роль общая и переживает удаление автора
(в отличие от `ai_chats.creator_id`, который `NOT NULL`); это только аудит.
- `ai_chats.role_id ON DELETE SET NULL` — если роль жёстко удалят, чат
деградирует к универсальному поведению, а не ломается (см. edge-cases).
В сочетании с `deleted_at` основной путь удаления роли — **soft delete**:
старые чаты тогда продолжают видеть инструкции через JOIN с учётом `deleted_at`
(решение по поведению при удалении — в «Открытых вопросах»).
- `model_config jsonb``{ chatModel }` либо `{ driver, chatModel }`. Пусто/`NULL`
→ модель воркспейса. По образцу `publicShareChatModel` из
[public-share-assistant-plan.md](./public-share-assistant-plan.md): креды
(`apiKey`/`baseUrl`) берутся от провайдера соответствующего драйвера из
`ai_provider_credentials`, отдельные креды на роль не нужны.
Типы: добавить `AiAgentRoles` в `db.interface.ts` (или поднять через codegen),
`role_id` появится в `AiChats` автоматически после codegen.
## Бэкенд
### 1. Слой инструкций роли в системном промпте
В [ai-chat.prompt.ts](../apps/server/src/core/ai-chat/ai-chat.prompt.ts) добавить
вход `roleInstructions` в `buildSystemPrompt()`. Приоритет персоны:
```text
effectivePersona = roleInstructions?.trim() || adminPrompt?.trim() || DEFAULT_PROMPT
return `${effectivePersona}${context}\n${SAFETY_FRAMEWORK}`
```
Ключевое: **`SAFETY_FRAMEWORK` по-прежнему добавляется всегда и не отключается
ролью.** Роль задаёт только персону; контекст (воркспейс, открытая страница) и
safety-блок остаются как есть.
Решение «роль заменяет, а не дополняет admin-промпт» выбрано намеренно: для
узкой роли вроде «Корректора» нужно, чтобы её инструкция доминировала, а не
конкурировала с общим промптом воркспейса. (Альтернатива «конкатенировать
admin-промпт + роль» — в «Открытых вопросах».)
### 2. Применение роли в стриме
В [ai-chat.service.ts](../apps/server/src/core/ai-chat/ai-chat.service.ts) (метод
`stream()`), где сейчас резолвится `system` и `model`:
- Загрузить роль по `ai_chats.role_id` (если задан и не удалён).
- Передать `role.instructions` в `buildSystemPrompt({ ..., roleInstructions })`.
- Если у роли есть `model_config` — резолвить модель с override (см. п. 3).
- Набор инструментов **не меняется** (по решению).
Важно: `role_id` сервер берёт **из строки `ai_chats`, а не из тела запроса** на
каждом ходу — роль нельзя подменить пораздачно. Клиент сообщает `roleId` только
при создании чата (первое сообщение), сервер сохраняет его в `ai_chats.role_id`.
### 3. Override модели
`AiService.getChatModel(workspaceId)`
([integrations/ai/ai.service.ts](../apps/server/src/integrations/ai/ai.service.ts))
получает опциональный аргумент override модели (паттерн из
[public-share-assistant-plan.md](./public-share-assistant-plan.md) §5):
- `model_config.chatModel` — id модели вместо `chatModel` воркспейса;
- `model_config.driver` (опц.) — если указан другой драйвер, берём его креды из
`ai_provider_credentials`; если кредов нет → `AiNotConfiguredException` (503) с
**внятным сообщением** («для роли X выбран провайдер Y, но он не настроен»),
согласно конвенции об ошибках (никаких «Something went wrong»).
- Пусто → текущее поведение (модель воркспейса).
Резолв модели делать **до** hijack ответа, чтобы ненастроенный провайдер вернул
503, а не падал в середине стрима (как уже сделано в контроллере для воркспейс-модели).
### 4. CRUD ролей (админский модуль)
Новый модуль `core/ai-chat/roles/` рядом с `external-mcp/`:
`ai-agent-roles.controller.ts` + `ai-agent-roles.service.ts` + repo
(`database/repos/ai-agent-roles/`). Эндпоинты под `/api/ai-chat/roles` (или
`/api/ai-settings/roles` — рядом с MCP-серверами; выбрать единообразно с
существующим размещением, см. «Открытые вопросы»):
| Метод | Доступ | Назначение |
| --- | --- | --- |
| `list` | **любой участник воркспейса** | получить список ролей для пикера при создании чата |
| `create` / `update` / `delete` | **только админ** | управление ролями (как `ai-settings`) |
Нюанс CASL: создание/правка/удаление — под админской абилити (как
[ai-settings.controller.ts](../apps/server/src/core/.../ai-settings.controller.ts)
управляет провайдером и MCP-серверами), но **list должен быть доступен всем
участникам**, иначе обычный пользователь не сможет выбрать роль при заведении
чата. Все запросы строго скоупятся по `workspace_id` (мультитенант по хосту).
Валидация при create/update: непустые `name` и `instructions`; если задан
`model_config.driver` — он из числа поддерживаемых (`openai`/`gemini`/`ollama`).
## Клиент
### 1. Пикер роли при создании чата
В зоне «New chat» / композере
([ai-chat-window.tsx](../apps/client/src/features/ai-chat/components/ai-chat-window.tsx),
[chat-input.tsx](../apps/client/src/features/ai-chat/components/chat-input.tsx)) —
селектор роли (Mantine `Select`/`SegmentedControl`), дефолт «Универсальный
ассистент» (без роли). Выбранный `roleId` хранится в новом Jotai-атоме рядом с
[atoms/ai-chat-atom.ts](../apps/client/src/features/ai-chat/atoms/ai-chat-atom.ts)
и уходит в теле **первого** запроса на `/stream` (расширить
`prepareSendMessagesRequest` в `chat-thread.tsx`: добавить `roleId`). После того
как сервер создал чат с ролью, пикер для этого чата фиксируется (роль чата
неизменна; смена роли = новый чат — простое и предсказуемое поведение для v1).
### 2. Бейдж роли
Показывать emoji+имя роли в шапке окна чата и в строке списка
([conversation-list.tsx](../apps/client/src/features/ai-chat/components/conversation-list.tsx)),
чтобы было видно, «с кем» разговор. `role_id`/денормализованное имя+emoji роли
добавить в выдачу списка чатов и тип `IAiChat`
([types/ai-chat.types.ts](../apps/client/src/features/ai-chat/types/ai-chat.types.ts)).
### 3. Управление ролями в настройках
Новая секция «Роли агента» в Settings → AI
([pages/settings/workspace/ai-settings.tsx](../apps/client/src/pages/settings/workspace/ai-settings.tsx)),
рядом с «External tools». Переиспользовать паттерн add/edit/delete-модалки из
[ai-mcp-servers.tsx](../apps/client/src/features/workspace/components/settings/components/ai-mcp-servers.tsx).
Форма роли: имя, emoji, описание, **instructions** (textarea — как редактор
системного сообщения в
[ai-provider-settings.tsx](../apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx)),
опциональный override модели. Подпись-напоминание под полем instructions:
«встроенный safety-фреймворк добавляется автоматически» (как у системного сообщения).
### 4. Слой запросов
Новые TanStack Query хуки в
[queries/ai-chat-query.ts](../apps/client/src/features/ai-chat/queries/ai-chat-query.ts)
(или отдельный файл): `useAiRolesQuery()` (list), `useCreate/Update/DeleteAiRoleMutation()`
+ функции в
[services/ai-chat-service.ts](../apps/client/src/features/ai-chat/services/ai-chat-service.ts).
Тип `IAiRole` зеркалит серверную схему.
## Поток одного хода (с ролью)
1. Создание чата: клиент шлёт первое сообщение + `roleId``/ai-chat/stream`;
сервер создаёт `ai_chats` с `role_id`.
2. Последующие ходы: сервер читает `role_id` из строки чата (не из тела запроса).
3. Резолв: загрузить роль (если не удалена) → `instructions` + `model_config`.
4. `buildSystemPrompt({ workspace, adminPrompt, roleInstructions, openedPage })`
→ персона роли + контекст + несъёмный `SAFETY_FRAMEWORK`.
5. `getChatModel(workspaceId, role.model_config)` → модель роли или дефолт.
6. `streamText({ model, system, messages, tools, stopWhen: stepCountIs(8) })`
**набор инструментов полный, как у обычного чата**.
## Edge-cases (главное)
- **Роль удалена/выключена, а чаты на неё ссылаются.** При hard-delete
`ON DELETE SET NULL` обнуляет `ai_chats.role_id` → чат продолжает работать как
универсальный. Основной путь — soft-delete (`deleted_at`)/`enabled=false`:
тогда роль исчезает из пикера, но старые чаты могут продолжать применять её
инструкции (резолв учитывает `deleted_at` — точное поведение в «Открытых
вопросах»).
- **Роль отредактировали после создания чатов.** В v1 без снапшота правка
применяется «вживую» — старые чаты подхватывают новые инструкции на следующем
ходу. Приемлемо для кейсов «Корректор/Факт-чекер»; снапшот конфигурации на чат —
возможное расширение.
- **Safety не переопределяется.** `SAFETY_FRAMEWORK` добавляется всегда, что бы
ни написали в `instructions` роли (включая попытку «игнорируй прежние инструкции»).
- **Override модели на ненастроенный провайдер** → 503 с конкретным сообщением,
а не молчаливый фолбэк (конвенция об ошибках). Решить, делать ли мягкий фолбэк
на модель воркспейса (в «Открытых вопросах»).
- **Пустые `instructions`** недопустимы при создании (валидация); но если роль
как-то оказалась с пустыми инструкциями — персона падает на admin-промпт/дефолт.
- **Заголовок чата** генерируется фоново (`generateText`) — оставить на модели
воркспейса, чтобы экзотический override роли не ломал автозаголовок (мелочь).
- **Мультитенант.** Все операции с ролями скоупятся по `workspace_id`; роль из
чужого воркспейса не видна и не применима.
- **MCP-зеркало схемы** ([packages/mcp](../packages/mcp)) фичу не затрагивает —
роли живут только во встроенном AI-чате, не в standalone MCP.
## Безопасность
- **Граница безопасности не меняется.** Агент по-прежнему ходит в API через
персональный loopback-JWT (`AiChatToolsService.forUser`), и CASL ограничивает
его ровно правами текущего пользователя. Роль — это слой *формирования промпта
и выбора модели*, он не выдаёт и не отнимает прав.
- **Следствие решения «без гейтинга» (осознанный компромисс):**
- Роль «Корректор» инструкцией просят не менять смысл, но технически у чата
остаются все write-инструменты — модель *могла бы* отредактировать/удалить
(под soft-delete и CASL, т. е. обратимо и в пределах прав пользователя). Это
мягкая граница (промпт), а не жёсткая.
- Роль «Факт-чекер» полагается на то, что админ глобально подключил веб-MCP
(Tavily); тогда веб-инструменты доступны *всем* чатам, а не только этой роли.
Жёсткие границы возможностей по ролям — отдельная будущая фаза (см. ниже).
- **Instructions — доверенный контент:** их пишет админ воркспейса, они попадают
только в системный промпт чатов этого воркспейса и исполняются под правами
конкретного пользователя. Эскалации нет.
- **Внешние MCP** остаются под SSRF-guard; роли логику подключения MCP не трогают.
## Явные non-goals (v1)
- Нет гейтинга/ограничения инструментов по ролям (роль не сужает тулсет).
- Нет личных ролей (только общие админские).
- Нет выбора конкретных внешних MCP-серверов на роль (все включённые доступны всем).
- Нет снапшота конфигурации роли на чат (правка роли применяется вживую).
- Нет per-role параметров генерации сверх модели (temperature и т. п.).
- Нет композиции «скиллов» поверх роли (см. «Связь со „скиллами“»).
## Связь со «скиллами»
В терминах Anthropic Skills (подгружаемый по требованию пакет инструкций +
ресурсов/скриптов) текущая роль = MVP-«скилл»: только текстовая инструкция + выбор
модели. Естественная эволюция — сделать «скиллы» композируемыми (несколько скиллов
на одну роль), привязывать к роли эталонные страницы/файлы как контекст, и —
главное — добавить **жёсткий гейтинг инструментов** (тогда «Корректор» физически не
сможет удалять, а «Факт-чекер» получит веб ровно тогда, когда роль это разрешает).
Всё это — следующие итерации, вне scope v1.
## Развилки (зафиксированные решения)
| Развилка | Решение | Альтернативы (отклонены / отложены) |
| --- | --- | --- |
| Владение ролями | **Только админские, общие на воркспейс** | личные роли; личные + общие |
| Ограничение инструментов | **Нет (только промпт + модель)** | крупные группы возможностей; тонкий per-tool allowlist |
| Выбор MCP-серверов на роль | **Нет (все включённые доступны всем)** | мультиселект MCP-серверов на роль |
| Привязка чата к роли | **Поле `ai_chats.role_id`, неизменно после создания** | смена роли внутри чата; роль в теле каждого запроса |
| Персона роли vs admin-промпт | **Роль заменяет персону** (safety всегда добавляется) | конкатенация admin-промпт + роль |
| Снапшот конфигурации | **Нет (правка вживую)** | снапшот конфигурации роли на чат |
## Открытые вопросы (не блокируют дизайн)
1. **Размещение CRUD-эндпоинтов и UI:** `/ai-chat/roles` (рядом с чатом) или
`/ai-settings/roles` (рядом с MCP-серверами). Предлагаю в одном месте с MCP —
там уже живут админские AI-настройки.
2. **Поведение при удалении роли:** soft-delete с сохранением инструкций для старых
чатов vs hard-delete + `SET NULL` (старые чаты деградируют к универсальным).
Предлагаю soft-delete (`deleted_at`) — консистентно с `ai_chats`.
3. **Override модели на ненастроенный драйвер:** жёсткий 503 с внятным сообщением
vs мягкий фолбэк на модель воркспейса. Предлагаю 503 (явность важнее).
4. **Стартовые пресеты:** поставлять ли «Корректор» и «Факт-чекер» как
преднастроенные роли-шаблоны (seed) при включении фичи, чтобы админ не писал
инструкции с нуля. Предлагаю — да, как необязательный «вставить пример».
5. **Денормализация для бейджа:** хранить имя/emoji роли только в `ai_agent_roles`
и джойнить, либо денормализовать на `ai_chats` для дешёвого списка. Предлагаю
джойн (простота; список чатов не горячий путь).
## Объём работ
Бэкенд: 1 миграция (`ai_agent_roles` + `ai_chats.role_id`) + codegen типов;
новый CRUD-модуль ролей (controller/service/repo) под CASL; правка
`buildSystemPrompt()` (слой `roleInstructions`); правка `AiChatService.stream()`
(загрузка роли, передача инструкций и override модели); опциональный override
модели в `AiService.getChatModel()`. Клиент: пикер роли при создании чата + атом +
проброс `roleId` в первый запрос; бейдж роли в шапке и списке; секция управления
ролями в Settings → AI (модалка add/edit/delete по образцу MCP-серверов); хуки
запросов/мутаций. **Без изменений в `packages/mcp`. Набор инструментов агента не
трогаем.**

View File

@@ -1,202 +0,0 @@
# Follow-ups код-ревью фичи ai-chat
Контекст: мульти-аспектное ревью встроенного AI-агента (диапазон коммитов
`6e5d0300..4868ca8e`, вся фича ai-chat) прошло чисто по безопасности,
регрессиям и конвенциям. Ниже — находки, которые НЕ блокируют merge, но
должны быть закрыты: пробелы в тестах на критичном по безопасности коде,
доступность с клавиатуры, устаревшая документация и мелкие рефакторинги.
Сгруппировано по приоритету. Каждая запись: что → где (`file:line`) → почему →
фикс.
Сознательно НЕ входят в этот файл (вынесены отдельно): warning про неусечённый
реплей tool-выводов в `ai-chat.service.ts` и архитектурное предложение про
дублирование набора инструментов между in-app агентом и `packages/mcp`.
---
## Приоритет 1 — тесты на критичном по безопасности коде (warning)
### 1.1 Шифрование ключей провайдеров (AES-256-GCM) — ноль тестов
- **Где:** `apps/server/src/integrations/crypto/secret-box.ts`
`encryptSecret` (`:36-48`), `decryptSecret` (`:51-81`), сообщение об ошибке
(`:78`). Spec-файла нет (подтверждено grep'ом по `*.spec.ts`).
- **Почему:** это единственная защита API-ключей провайдеров в покое. Не
проверено: round-trip `encrypt → decrypt` возвращает исходный текст; два
шифрования одного текста дают разные блобы (random salt+iv, layout
`base64(salt | iv | authTag | ciphertext)`); ветка `catch` бросает ожидаемую
ошибку «APP_SECRET may have changed» на испорченном/обрезанном блобе или
неверном ключе (на это сообщение опирается UI). Ошибка в смещениях layout или
регресс auth-tag молча испортит все сохранённые креды.
- **Фикс:** `secret-box.spec.ts`, 4 кейса — (1) round-trip equality; (2) два
encrypt одного входа → разные блобы, оба декриптятся; (3) decrypt
подделанного ciphertext / флипнутого байта auth-tag → throw с нужным
сообщением; (4) decrypt под другим `APP_SECRET` → throw. `EnvironmentService`
тривиально стабается (`getAppSecret`).
### 1.2 SSRF-guard — ветки allow/deny полностью не покрыты
- **Где:** `apps/server/src/core/ai-chat/external-mcp/ssrf-guard.ts`
`isIpAllowed` (`:40`), `isUrlAllowed` (`:60-104`); `isIpAllowed`
вызывается для IP-литерала (`:80`) и для каждого DNS-резолва (`:97`).
- **Почему:** единственная защита от SSRF для admin-задаваемых URL внешних
MCP-серверов; тестов нет. Каждая непокрытая ветка = реальный эксплойт:
loopback (127.0.0.1, ::1), link-local/metadata (169.254.169.254), private
(10/172.16/192.168), CGNAT (100.64/10), ULA (fc00::/7), unspecified,
IPv4-mapped IPv6, не-http(s) схема, невалидный URL, DNS-rebinding (любой
резолвнутый адрес приватный ⇒ block). `isIpAllowed` — чистая синхронная
функция.
- **Фикс:** `ssrf-guard.spec.ts``isIpAllowed` по каждому блокируемому классу
+ публичный IP (allow); `isUrlAllowed` — bad-scheme, invalid-url,
IP-литерал-private и (с моком `dns.lookup`) кейс rebinding, где
резолвнутый адрес приватный.
### 1.3 `assistantParts()` — логика «сохранить ошибки/tool-calls в истории» без тестов
- **Где:** `apps/server/src/core/ai-chat/ai-chat.service.ts`
`assistantParts` (`:430-495`), родственные `serializeSteps` (`:610`),
`rowToUiMessage`. Spec'а у сервиса нет.
- **Почему:** чистая функция, чей вывод определяет, переиграется ли диалог.
Ключевая ветка (`:472-486`) эмитит синтетический `output-error` для tool-call
без пары — чтобы `convertToModelMessages` не бросил `MissingToolResultsError`
на следующем ходу. Это суть фиксов видимости ошибок (`dbd83b5a`/`4868ca8e`).
Регресс, убравший пару, молча вернёт краш. Не покрыты также ветки: step с
текстом vs без (`:451-453`, `:489-492`), call с результатом
(`output-available`, `:463-471`) vs без, skip битого call
(`!toolName || !toolCallId`, `:461`).
- **Фикс:** экспортировать чистые хелперы (или тонкая обёртка) и в spec
проверить: парный вызов → `output-available`; непарный → `output-error`; skip
битых; fallback на единственный `text` при отсутствии step-текста.
`rowToUiMessage` предпочитает `metadata.parts` над `content`. Тест на ветку
непарного вызова обязан падать на pre-fix коде.
### 1.4 (suggestion) Ветки парсинга JSON-строковых node-аргументов не покрыты
- **Где:** `apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts`
`patchNode` (`:686-693`), `insertNode` (`:745-752`), `updatePageJson`
(`:800-809`); сообщения об ошибке `:690`, `:749`, `:804`. Существующий
`ai-chat-tools.service.spec.ts` покрывает только guardrail `deletePage` +
наличие инструментов.
- **Почему:** фикс `59b99dba` добавил coercion string→object (то, что чинило
`insert_node` под OpenAI-tool-calls). Невалидная JSON-строка бросает «node was
a string but not valid JSON» / «content was a string…»; `updatePageJson`
различает undefined/null (title-only) vs object vs string-parse. Регресс,
убравший parse, молча вернёт падение `insert_node` под OpenAI.
- **Фикс:** в существующий spec (он уже стабает фейковый клиент) добавить:
JSON-строковый `node` парсится и форвардится как объект; невалидная строка →
throw с нужным сообщением; `updatePageJson` с `content === undefined`
форвардит `doc === undefined` (title-only), объект проходит как есть.
### 1.5 (suggestion) Фильтр размерности / пустые spaces в поиске эмбеддингов не покрыты
- **Где:** `apps/server/src/database/repos/ai-chat/page-embedding.repo.ts`
`searchByEmbedding` (`:143`), early-return на пустом `spaceIds` (`:149`),
фильтр `model_dimensions = queryEmbedding.length` (`:154` + where в запросе).
- **Почему:** early-return на пустых spaceIds — путь access-scoping с нулевым
результатом; фильтр размерности существует, чтобы избежать pgvector
dimension-mismatch, когда остались строки от ранее настроенной модели
эмбеддингов. Регресс, убравший фильтр, вернёт runtime-краш pgvector.
- **Фикс:** минимум — assert, что `searchByEmbedding(ws, vec, [], n)``[]` без
обращения к БД (ветка чистая). При наличии тест-БД — кейс со смешанными
размерностями: скорятся только строки той же размерности.
---
## Приоритет 2 — доступность и документация (suggestion)
### 2.1 Два новых кликабельных `div` без клавиатурной доступности (a11y)
- **Где:** `apps/client/src/features/ai-chat/components/ai-chat-window.tsx:342-354`
(заголовок «Chat history») и
`apps/client/src/features/ai-chat/components/conversation-list.tsx:107-119`
(строка диалога, `onClick` на `:118`).
- **Почему:** несемантические элементы с `onClick`, но без
`role`/`tabIndex`/`onKeyDown` — с клавиатуры/скринридером историю не
развернуть и прошлый чат не открыть. Это ниже планки самого проекта:
`apps/client/src/features/comment/components/comment-list-item.tsx` использует
`role="button"`, и бейдж AI-агента, добавленный в этом же изменении
(`apps/client/src/features/page-history/components/history-item.tsx:77-79`),
корректно ставит `role="button"` + `tabIndex={0}` + обработку Enter/Space.
- **Фикс:** применить тот же паттерн к обоим элементам (или Mantine
`UnstyledButton`).
### 2.2 Устаревший doc-комментарий перечисляет 9 инструментов из текущих ~40
- **Где:** `apps/client/src/features/ai-chat/utils/tool-parts.tsx:1-10`
(список инструментов на `:8-10`).
- **Почему:** комментарий описывает старый набор; после «expose full Docmost
toolset» и `drop updateComment` вводит в заблуждение. Не баг — дружелюбные
подписи `toolLabelKey` всё равно только у перечисленных, остальные идут в
generic-ветку «Ran tool {{name}}».
- **Фикс:** заменить жёсткий список на «см. `ai-chat-tools.service.ts`» (или
пометить, что дружелюбные подписи только у инструментов из `toolLabelKey`).
### 2.3 Реализация `secret-box` противоречит схеме крипто в плане
- **Где:** `apps/server/src/integrations/crypto/secret-box.ts:11-48` vs
`docs/ai-agent-chat-plan.md` §5.3 / §6.3.
- **Почему:** код использует per-record случайную соль
(`scryptSync(APP_SECRET, salt, 32)`) и layout
`base64(salt | iv | authTag | ciphertext)`; план описывает фиксированную
строковую соль `'ai-provider'` и layout без сегмента соли. Реализация лучше,
но план теперь описывает не те байты на диске — введёт в заблуждение при
написании ротации/отладке decrypt. План помечен «иллюстративным», поэтому
suggestion.
- **Фикс:** обновить §5.3 / §6.3 под фактический layout.
---
## Приоритет 3 — стабильность и рефакторинг (suggestion)
### 3.1 Новый чат, упавший на первом ходу, не «усыновляет» созданный сервером chat id
- **Где:** `apps/client/src/features/ai-chat/components/chat-thread.tsx:129-137`
(`useChat` с `onFinish` на `:136`, без `onError`). Целевой колбэк —
`onTurnFinished` в
`apps/client/src/features/ai-chat/components/ai-chat-window.tsx:154-157`
(инвалидирует `AI_CHATS_RQ_KEY`).
- **Почему:** в AI SDK v6 `onFinish` не срабатывает при ошибке стрима, поэтому
`onTurnFinished()` не вызывается. Сервер же уже создал строку чата и сохранил
error-сообщение — но клиент не инвалидирует список чатов и не подхватывает
новый id: ошибочный чат не появляется в истории до постороннего refresh.
Alert с ошибкой показывается, так что это UX-несогласованность, не потеря
данных.
- **Фикс:** передать в `useChat` `onError`, который тоже вызывает
`onTurnFinished()` (или инвалидирует `AI_CHATS_RQ_KEY` + подхватывает новый
id).
### 3.2 Дублированный хелпер `isToolPart` в двух компонентах
- **Где:** `apps/client/src/features/ai-chat/components/message-item.tsx:16` и
`apps/client/src/features/ai-chat/components/message-list.tsx:15`
идентичное `type.startsWith("tool-") || type === "dynamic-tool"`. Оба уже
импортируют из `utils/tool-parts.tsx`.
- **Почему:** копии молча разойдутся, если AI SDK добавит ещё один
tool-part-дискриминатор.
- **Фикс:** экспортировать `isToolPart` один раз из `tool-parts.tsx` (рядом с
`getToolName`), импортировать в оба компонента, локальные определения удалить.
### 3.3 Объект `initialValues` формы продублирован дословно
- **Где:**
`apps/client/src/features/workspace/components/settings/components/ai-mcp-server-form.tsx`
`useForm({ initialValues: {...} })` (`:75-82`) и эффект re-hydration
`form.setValues({...})` (`:87-95`): один и тот же 6-полевой объект из
`server`.
- **Почему:** должны меняться синхронно; добавить поле в одно и забыть второе —
лёгкий баг. (В соседнем `ai-provider-settings.tsx` этой проблемы нет — там
initialValues константны, а эффект мапит из `settings`.)
- **Фикс:** вынести `buildInitialValues(server)` и звать в обоих местах.
### 3.4 Идиома форматирования ошибки провайдера дублирует существующий хелпер
- **Где:** `apps/server/src/core/ai-chat/ai-chat.service.ts:274-275` и `:338-339`
— инлайн `e?.statusCode ? \`${e.statusCode}: ${e.message}\` : e.message`.
- **Почему:** в `apps/server/src/integrations/ai/ai-error.util.ts` уже есть
общий `describeProviderError(err)` (импортируется в
`apps/server/src/integrations/ai/ai.service.ts:14`, используется на `:193`,
`:210`). Два места в `ai-chat.service.ts` переизобретают его инлайном — формат
может разойтись.
- **Фикс:** заменить оба инлайн-места на `describeProviderError(err)` (при
необходимости расширив хелпер fallback-аргументом), чтобы формат ошибок
провайдера был единым.

View File

@@ -1,199 +0,0 @@
# Лимит шагов 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<TOOLS>`, где
`ToolChoice = 'auto' | 'none' | 'required' | { type:'tool', toolName }`
(строка 126) — значит `toolChoice: 'none'` валидно и заставляет модель
отвечать текстом.
- `system?: string | SystemModelMessage | Array<SystemModelMessage>` — 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`.
- Не коммитить; в конце предложить сообщение коммита.

View File

@@ -1,224 +0,0 @@
# Индикатор-точка эндпоинта AI: «настроено / включено» вместо «результат теста»
## Контекст (симптом)
В админских настройках AI (Workspace settings → AI) у каждой карточки-эндпоинта
(«Chat / LLM», «Embeddings», «Voice / STT») слева от заголовка есть маленькая
цветная точка. Сейчас её цвет означает **результат последнего ручного теста**
кнопкой «Test endpoint», а не состояние настройки:
- зелёная — тест «Test endpoint» прошёл (`ok`);
- красная — тест упал (`error`);
- серая — тест ещё **не запускали** (`idle`).
Поэтому на текущем экране у «Embeddings» точка зелёная (по карточке нажимали
«Test endpoint» → «Connection successful»), а у «Voice / STT» — серая, **хотя
тумблер «Voice dictation» включён и эндпоинт настроен**. Тумблеры фич
(`chat` / `search` / `dictation`) и сам факт заполненности полей (модель +
Base URL) на цвет точки сейчас никак не влияют.
Хотим, чтобы точка читалась с одного взгляда как состояние эндпоинта, без
ручного теста:
- **зелёная** — корректно настроено **и** включено;
- **жёлтая** — настроено, но **не** включено;
- **серая** — выключено / не настроено (нечего включать).
## Как сейчас устроено (цепочка)
Всё в одном файле клиента:
`apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx`.
- Тип состояния точки: `type CardStatus = "ok" | "error" | "idle";`
— строка ~64.
- Компонент `StatusDot` (строки ~75-90) красит круг: `ok``green[6]`,
`error``red[6]`, иначе → `gray[5]`.
- Источник статуса — **только** мутации теста (строки ~356-370):
```ts
const chatStatus: CardStatus = chatTest.data
? (chatTest.data.ok ? "ok" : "error")
: "idle";
// аналогично embedStatus (embedTest), sttStatus (sttTest)
```
`chatTest` / `embedTest` / `sttTest` — это `useTestAiConnectionMutation()`
(строки ~101-104); их `data` появляется только после нажатия «Test endpoint».
- Точки рендерятся в заголовках трёх карточек: `<StatusDot status={chatStatus}/>`
(~407), `embedStatus` (~517), `sttStatus` (~634).
### Какие данные уже доступны в компоненте
Этого достаточно, чтобы вычислить «настроено» и «включено» синхронно, без сети:
- **Поля настройки** (живые, из формы) — `form.values`:
`chatModel`, `baseUrl`, `embeddingModel`, `embeddingBaseUrl`, `sttModel`,
`sttBaseUrl`, `sttApiStyle`, и write-only буферы ключей `apiKey`,
`embeddingApiKey`, `sttApiKey`.
- **Наличие сохранённых ключей** — состояния `hasApiKey`, `hasEmbeddingApiKey`,
`hasSttApiKey` (строки ~122-130), синхронизируются с сервером и обновляются
при «Clear» и сохранении.
- **Тумблеры фич** (персистятся в `workspace.settings.ai`) — `chatEnabled`
(`settings.ai.chat`, строка ~108), `searchEnabled` (`settings.ai.search`,
~111), `dictationEnabled` (`settings.ai.dictation`, ~114).
- **Семантика наследования** (важно для «настроено»): Embeddings и Voice
**наследуют Base URL и ключ от Chat**, если свои не заданы. Это прямо написано
в подзаголовке карточки Chat: «root endpoint — Embeddings and Voice inherit its
URL and key» (строка ~423), и реализовано в `resolveUrl(..., fallback)`
(~373-382). Значит у Embeddings/STT «свой Base URL» не обязателен.
## Решение (точечное, только клиент)
Файл: `apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx`.
Перепривязать цвет точки с «результата теста» на пару булевых признаков
**`configured` × `enabled`**. Результат теста остаётся как был — текстом рядом с
кнопкой («Connection successful» / ошибка), точку он больше не красит.
### 1. Новый тип состояния и чистый хелпер выбора цвета
```ts
// Three-state endpoint health shown by the header dot. Derived synchronously
// from the form + feature toggle — never from a network probe (the "Test
// endpoint" button still surfaces the live probe result as text).
// "ready" (green) — required fields are filled AND the feature is ON
// "configured" (yellow) — required fields are filled but the feature is OFF
// "off" (gray) — required fields missing (nothing to enable)
type CardStatus = "ready" | "configured" | "off";
// Pure + unit-testable. `configured` = the endpoint has everything it needs to
// work; `enabled` = the workspace feature toggle for this endpoint is ON.
function resolveCardStatus(configured: boolean, enabled: boolean): CardStatus {
if (!configured) return "off";
return enabled ? "ready" : "configured";
}
```
### 2. `StatusDot` — добавить жёлтый
```ts
function StatusDot({ status }: { status: CardStatus }) {
const theme = useMantineTheme();
const color =
status === "ready"
? theme.colors.green[6]
: status === "configured"
? theme.colors.yellow[6] // Mantine default palette has `yellow`
: theme.colors.gray[5];
return (
<Box w={9} h={9} style={{ borderRadius: "50%", background: color, flex: "none" }} />
);
}
```
### 3. Признак «настроено» для каждой карточки
Ключ (API key) считаем **необязательным** — локальные серверы (Ollama, speaches
/ faster-whisper-server) работают без ключа, поэтому требовать ключ нельзя.
«Настроено» = задана модель **и** есть Base URL (свой или унаследованный от Chat):
```ts
const v = form.values;
const chatBase = v.baseUrl.trim();
// Chat is the root: needs its own model + base URL.
const chatConfigured = v.chatModel.trim() !== "" && chatBase !== "";
// Embeddings / Voice inherit the chat base URL when their own is empty.
const embedConfigured =
v.embeddingModel.trim() !== "" && (v.embeddingBaseUrl.trim() !== "" || chatBase !== "");
const sttConfigured =
v.sttModel.trim() !== "" && (v.sttBaseUrl.trim() !== "" || chatBase !== "");
```
### 4. Заменить вывод статусов (строки ~356-370)
```ts
const chatStatus = resolveCardStatus(chatConfigured, chatEnabled);
const embedStatus = resolveCardStatus(embedConfigured, searchEnabled);
const sttStatus = resolveCardStatus(sttConfigured, dictationEnabled);
```
`chatTest` / `embedTest` / `sttTest` остаются для текстового результата под
кнопкой «Test endpoint» — их `data` просто больше не участвует в цвете точки.
### 5. (Рекомендуется) Tooltip на точке — цвет не должен быть единственным сигналом
Цвет в одиночку недоступен дальтоникам и неочевиден. Обернуть `StatusDot` в
Mantine `Tooltip` с текстовой расшифровкой (через `t(...)`), напр.:
`ready` → «Configured and enabled», `configured` → «Configured but disabled`»,
`off` → «Not configured». `Tooltip` уже используется в соседнем
`mcp-settings.tsx`, импорт из `@mantine/core`.
## Тонкие моменты / edge cases
- **Источник «настроено» — `form.values` (живой), а не persisted `settings`.**
Тогда точка реагирует прямо при наборе. Минус: тумблер (`*Enabled`) —
персистентный, поэтому после правки полей и **до** «Save endpoints» возможна
кратковременная рассинхронизация (поля изменены, но ещё не сохранены). Это
приемлемо и логично (точка показывает «то, что введено»). Альтернатива — брать
поля из `settings` (тогда точка отражает строго сохранённое состояние,
согласованно с тумблером) — см. «Альтернативы».
- **Включено, но НЕ настроено** (`enabled && !configured`): админ включил фичу, но
не заполнил эндпоинт — реальная мисконфигурация. По строгой трёхцветной схеме
это **серый**, что прячет проблему. Варианты: (а) оставить серым (буквально по
ТЗ); (б) **рекомендуется** — отдельный «warning»-цвет (красный/оранжевый) и
тултип «Enabled but not configured», т.к. фича включена и работать не будет.
Решить в «Открытых вопросах».
- **Судьба красного «тест упал».** Сейчас красный = упавший тест. В новой схеме
цвета красного нет. Падение теста по-прежнему видно текстом под кнопкой, так что
сигнал не теряется. Опционально можно сохранить красный как 4-е состояние-оверрайд
(если тест **явно** запускали и он упал) — но это усложняет модель; по умолчанию
не делаем.
- **`yellow` в теме Mantine** есть в дефолтной палитре (Mantine 8) — `yellow[6]`
валиден; кастомная тема в проекте палитру не переопределяет (использовать
`theme.colors.yellow[6]`).
- **Все три карточки** ведут себя единообразно (одна `StatusDot` + один хелпер),
включая «Chat / LLM», которой нет на скриншоте, но логика та же.
- **Оптимистичные тумблеры**: `*Enabled` обновляются оптимистично и
откатываются при ошибке (`handleToggle*`). Цвет точки следует за состоянием
тумблера автоматически (реактивный `useState`).
- **trim()**: значения могут содержать пробелы — сравнивать после `.trim()` (как
в `resolveUrl`).
## i18n
- Новые пользовательские строки (тексты тултипов) **только через `t(...)`** и
добавить ключи в каталог `apps/client/public/locales/en-US/translation.json`
(он английско-ключевой: ключ == значение, напр. `"Configured and enabled"`).
Если используется warning-вариант — добавить и его строку.
- Комментарии в коде — на английском (правило проекта).
## Тесты
- `resolveCardStatus` — чистая функция, легко юнит-тестируется (Vitest на
клиенте): `(false, *) → "off"`, `(true, true) → "ready"`, `(true, false) →
"configured"`. Если экспортировать `*Configured`-предикаты как чистые
функции от `form.values` — их тоже можно покрыть (особенно наследование Base
URL у Embeddings/STT).
- Запустить `pnpm --filter client lint` и `pnpm --filter client test`.
## Альтернативы / расширения (вне базового объёма)
- **Брать «настроено» из persisted `settings`** (а не `form.values`): точка строго
отражает сохранённое состояние, согласовано с персистентным тумблером, но не
реагирует на ввод до «Save». `settings` (`IAiSettings`) уже содержит
`chatModel`/`embeddingModel`/`baseUrl`/`embeddingBaseUrl`/`sttModel`/
`sttBaseUrl` + `hasApiKey`/`hasEmbeddingApiKey`/`hasSttApiKey`.
- **«настроено» = «тест прошёл»** вместо «поля заполнены»: точнее («корректно»),
но требует автопрогона теста на загрузке (сеть, латентность, лимиты провайдера)
— против идеи мгновенного индикатора. Не рекомендуется.
- **Учитывать ключ для облачных провайдеров**: если Base URL указывает на
публичный провайдер (OpenAI/OpenRouter), ключ де-факто обязателен. Можно
усложнить предикат (`configured` требует ключ, если host не локальный), но это
хрупкая эвристика — оставляем ключ необязательным.
## Открытые вопросы (согласовать перед реализацией)
- [ ] Случай «включено, но не настроено»: серый (буквально по ТЗ) или отдельный
warning-цвет (рекомендуется, чтобы не прятать мисконфигурацию)?
- [ ] Что значит «настроено»: «поля модель + Base URL заполнены» (рекомендуется,
ключ необязателен) — ок? Или требовать ещё и ключ?
- [ ] Источник полей: живой `form.values` (реактивно при вводе, рекомендуется)
или persisted `settings` (строго сохранённое состояние)?
- [ ] Добавлять ли `Tooltip` с текстовой расшифровкой (рекомендуется для
доступности) и сохранять ли красный как 4-е состояние «тест упал»?

View File

@@ -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`). В запросе он не входит в «три эндпоинта», но логически
страдает тем же. Можно причесать заодно для единообразия — отдельным мелким
шагом, по той же схеме.

View File

@@ -1,181 +0,0 @@
# Панель комментариев: сделать плотнее (меньше воздуха, меньше шрифт)
Статус: **план, код не менялся.** Чисто UI-задача на клиенте (CSS + пропсы
Mantine). Бэкенда, схемы данных и логики не касается.
## Суть
Сейчас панель комментариев (правый aside, вкладка «Comments») выглядит
разреженной: крупные отступы между карточками и внутри них, большой межстрочный
интервал, тело комментария набрано базовым размером редактора (16px). На узкой
колонке это «съедает» вертикаль — на экран помещается мало комментариев, много
пустого места.
Хотим: **уплотнить раскладку** — уменьшить внутренние/внешние отступы карточек,
зазор «аватар ↔ текст», вертикальный ритм редактора — **и уменьшить шрифт**
тела комментария, имени автора и цитаты выделения. Цель — больше комментариев
на экран без потери читабельности.
## Где сейчас живёт «воздух» (точные места)
Вся вёрстка панели — в фиче `apps/client/src/features/comment/`.
### 1. Карточка комментария — [comment-list-with-tabs.tsx](apps/client/src/features/comment/components/comment-list-with-tabs.tsx)
- `renderComments`, обёртка каждого треда (~строки 121-129):
`<Paper shadow="sm" radius="md" p="sm" mb="sm" withBorder>``p="sm"` (12px
внутренний отступ) и `mb="sm"` (12px зазор между комментариями).
- Разделитель перед редактором ответа (~строка 148): `<Divider my={4} />`.
- Вкладки (`Tabs.Panel pt="xs"`, строки 226 и 245) и пустое состояние
(`<Center py="xl">`, строки 228 и 247) — второстепенные источники воздуха.
- Нижнее поле ввода `PageCommentInput` (строки ~361-405): `paddingTop` = `sm`,
`paddingBottom: 25`, аватар `marginTop: 10`, кнопка отправки спозиционирована
`bottom: 30`. Эти величины связаны (плавающая кнопка над полем) — трогать
осторожно.
### 2. Элемент комментария — [comment-list-item.tsx](apps/client/src/features/comment/components/comment-list-item.tsx)
- Внешняя обёртка (строка 119): `<Box ref={ref} pb="xs">` — 10px снизу у каждого
элемента (включая вложенные ответы).
- Шапка «аватар ↔ контент» (строка 120): `<Group>` **без** `gap` → дефолтный
`gap="md"` (16px) между аватаром и блоком с именем/телом. Это главный
горизонтальный «воздух».
- Имя автора (строка 129): `<Text size="sm" fw={500} lineClamp={1}>` — 14px.
- Время (строки 157-161): уже `<Text size="xs">` (12px) — оставить.
- Цитата выделения (строка 180): `<Text size="sm">{comment?.selection}</Text>`
14px, внутри блока `.textSelection`.
### 3. Стили — [comment.module.css](apps/client/src/features/comment/components/comment.module.css)
- `.textSelection` (строки 9-21): `margin-top: 4px`, `padding: 8px`.
- `.commentEditor .ProseMirror :global(.ProseMirror)` (строки 35-44):
`margin-top: 10px`, `margin-bottom: 2px`, паддинги 6px. **font-size не задан**
тело комментария наследует глобальный
`.ProseMirror { font-size: var(--mantine-font-size-md) }` (16px) из
[core.css:10](apps/client/src/features/editor/styles/core.css#L10).
- `.wrapper` (строки 1-3) — `padding: md`, **в коде не используется** (можно
игнорировать или удалить заодно).
### 4. Внешняя рамка панели (ВНИМАНИЕ: общая) — [aside.tsx](apps/client/src/components/layouts/global/aside.tsx)
- `<Box p="md">` (строка 47) и шапка `<Group ... mb="md">` с
`<Title order={2} size="h6">` (строки 50-51) дают 16px отступа по краям панели
и под заголовком. **Этот контейнер общий для трёх вкладок** aside
(`comments` / `toc` / `details`). Менять его — значит уплотнить заодно
«Содержание» и «Детали». См. «Открытые вопросы».
Шкалы Mantine в проекте без переопределений (`theme.ts` палитру/контраст меняет,
но не размеры): шрифт `xs=12px / sm=14px / md=16px`; spacing `xs=10 / sm=12 /
md=16`.
## Решение (точечное, в границах фичи comment)
Базовый объём — **только компоненты `features/comment/`**, чтобы вкладки
«Содержание»/«Детали» (общий `aside.tsx`) не задеть. Уплотнение рамки панели —
отдельный опциональный пункт (см. ниже).
### Правки по файлам
**`comment-list-with-tabs.tsx`**
- `<Paper>` в `renderComments`: `p="sm"``p="xs"`, `mb="sm"``mb="xs"`
(12 → 10px). `shadow="sm"`, `radius="md"`, `withBorder` — оставить.
- `<Divider my={4} />``my={2}`.
**`comment-list-item.tsx`**
- `<Box ref={ref} pb="xs">``pb={6}`.
- Шапка `<Group>` (аватар + контент, строка 120): добавить `gap="xs"`
(дефолтные 16px → 10px). НЕ трогать внутренние `<Group justify="space-between">`
и `<Group gap="xs">`, у них зазор уже задан.
- Имя: `<Text size="sm" ...>``size="xs"`. `fw={500}` и `lineClamp={1}`
оставить (см. «иерархия шрифта» ниже).
- Цитата: `<Text size="sm">{comment?.selection}</Text>``size="xs"`.
**`comment.module.css`**
- В `.ProseMirror :global(.ProseMirror)` добавить
`font-size: var(--mantine-font-size-sm);` (16 → 14px) и `line-height: 1.4;`,
заменить `margin-top: 10px``margin-top: 4px`. Остальные декларации
(`border-radius`, `max-width`, `white-space`, `word-break`, паддинги,
`margin-bottom`) — без изменений.
- `.textSelection`: `margin-top: 4px``2px`, `padding: 8px``6px`.
### Эскиз (ключевой фрагмент CSS)
```css
.commentEditor {
/* ... */
.ProseMirror :global(.ProseMirror) {
border-radius: var(--mantine-radius-sm);
max-width: 100%;
white-space: pre-wrap;
word-break: break-word;
padding-left: 6px;
padding-right: 6px;
/* Denser comments: shrink body text from the global 16px ProseMirror size
to 14px and tighten the rhythm vs. the comment header. */
font-size: var(--mantine-font-size-sm);
line-height: 1.4;
margin-top: 4px; /* was 10px */
margin-bottom: 2px;
}
}
.textSelection {
margin-top: 2px; /* was 4px */
padding: 6px; /* was 8px */
/* ...остальное без изменений... */
}
```
## Тонкие моменты / edge cases
- **Не трогать `aside.tsx` в базовом объёме.** Его `p="md"` и шапка общие для
вкладок `toc`/`details` — правка уплотнит и их. Если это нежелательно, держать
изменения строго внутри `features/comment/`.
- **Иерархия шрифта (принято).** После правок: имя — `xs` (12px, `fw=500`),
тело — `sm` (14px), время — `xs` (12px). Тело крупнее имени — это норма
(имя/мета как «капс-лейбл», тело как основной текст).
- **`font-size` ставится на внутренний `:global(.ProseMirror)`,** т.к. размер
приходит из глобального правила `core.css`. Класс-модуль `.commentEditor`
скоупит переопределение только на редактор комментариев — основной редактор
страницы не затрагивается.
- **Тёмная тема.** Меняем только размеры/отступы, цвета берутся из токенов
Mantine — отдельной проверки палитры не требуется, но визуально глянуть стоит.
- **Вложенные ответы** рендерятся тем же `CommentListItem` → уплотнение `pb`,
`gap`, шрифтов применится и к ним автоматически (так и нужно).
- **Markdown/код в теле.** `pre`/`code`/списки внутри комментария наследуют
`font-size` от `.ProseMirror` контейнера — после `font-size: sm` они тоже
станут компактнее; проверить, что код-блоки не разъезжаются.
- **Цитата выделения кликабельна** (`role="button"`, переход к месту в тексте) —
уменьшение `padding`/`size` не должно сломать зону клика; визуально проверить.
- **Нижнее поле ввода** (`PageCommentInput`) с плавающей кнопкой: величины
`paddingBottom: 25` / `bottom: 30` связаны. В базовом объёме не трогаем; если
захотим уплотнить и его — менять обе согласованно и проверить, что кнопка
отправки не наезжает на текст.
## Тесты / проверка
- Прогнать `pnpm --filter client lint` и `pnpm --filter client test`
(изменения косметические — падений быть не должно).
- Ручная проверка во вкладке Comments: тред с длинным телом, тред с цитатой
выделения, вложенные ответы, режим редактирования, светлая/тёмная тема, узкая
ширина aside. Убедиться, что вкладки «Содержание»/«Детали» не изменились
(если `aside.tsx` не трогали).
## Опционально / расширения (вне базового объёма)
- **Уплотнить рамку панели** — `aside.tsx`: `p="md"``p="sm"`, шапка
`mb="md"``mb="sm"`. Даст ощутимо меньше воздуха по краям, **но затронет все
вкладки aside** (см. «Открытые вопросы»).
- **Компактные вкладки Tabs** — `Tabs.Panel pt="xs"``pt={6}`, бейджи счётчиков
уже `size="sm"`.
- **Удалить мёртвый `.wrapper`** из `comment.module.css` (не используется).
- **Уменьшить аватары** с `size="sm"` до `size="xs"` в `CommentListItem` и
`PageCommentInput` для ещё большей плотности (проверить, что инициалы/картинка
не мельчат до нечитаемости).
## Принятые решения
Решения зафиксированы — реализовать можно сразу, без доп. согласований:
- **Границы правки:** строго `features/comment/`. Общую рамку aside (`p="md"`,
шапка `mb="md"`) **не трогаем** — она общая с вкладками «Содержание»/«Детали»,
и правка задела бы их (см. «Опционально», если позже захотим уплотнить и их).
- **Шрифт тела:** `sm` (14px) — не мельче.
- **Иерархия:** имя `xs` (12px, `fw=500`), тело `sm` (14px), время `xs` (12px).
- **Нижнее поле ввода и размер аватаров:** оставляем как есть.

View File

@@ -1,416 +0,0 @@
# Встроенный `/mcp`: авторизация под текущим пользователем (а не сервисным аккаунтом)
Статус: **план, код не менялся.** Фича сервер (`apps/server` + `packages/mcp`).
Затрагивает безопасность — менять аккуратно.
**Решение принято: основной путь — логин/пароль текущего пользователя через
HTTP Basic** (`Authorization: Basic base64(email:password)`). Токен-варианты
(Bearer access-JWT / community PAT / OAuth) описаны ниже как альтернативы и
возможные доработки, но делаем именно логин/пароль.
## Суть
Сейчас встроенный MCP-сервер на `/mcp` ходит в Docmost **под одним сервисным
аккаунтом** (`MCP_DOCMOST_EMAIL` / `MCP_DOCMOST_PASSWORD`). Любой клиент,
подключившийся к `/mcp`, действует с правами этого аккаунта — независимо от того,
кто реально сидит за MCP-клиентом. Это значит: единые CASL-права на всех, нет
атрибуции правок конкретному человеку (в истории страниц всё — от сервисного
юзера), и без env-кредов фича вообще не поднимается (отдаёт `503 "MCP is not
configured"`).
Хотим: чтобы `/mcp` авторизовался **под текущим пользователем** (его логином и
паролем) — тогда каждый запрос исполняется под его CASL-правами, правки
атрибутируются ему, и сервисный аккаунт перестаёт быть обязательным.
## Почему сейчас сервисный аккаунт (контекст)
`/mcp`**внешний протокольный эндпоинт** (MCP Streamable-HTTP / JSON-RPC). В
сессии MCP нет личности Docmost: сессия идентифицируется случайным UUID
([http.ts:68-74](packages/mcp/src/http.ts#L68-L74), `sessionIdGenerator: () =>
randomUUID()`) и заголовком `mcp-session-id`, а транспорт **не несёт JWT/куку
пользователя**. Поэтому пакет `@docmost/mcp` спроектирован как standalone-клиент:
логинится один раз по `email/password` ([auth-utils.ts:41-86](packages/mcp/src/lib/auth-utils.ts#L41-L86),
достаёт куку `authToken`) и дальше ходит в REST + collab как обычный внешний
клиент.
Контраст — встроенный AI-чат: он крутится **внутри авторизованного NestJS-запроса**,
поэтому чеканит loopback-токен именно текущего пользователя и каждый инструмент
исполняется под его CASL ([ai-chat-tools.service.ts:54-85](apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts#L54-L85)).
Наша задача — принести эту же модель «per-user токен» во внешний `/mcp`.
**Хорошая новость: клиентская половина уже готова.** `DocmostClient` принимает
union-конфиг — либо `{email,password}` (сервис-аккаунт, вызывает `performLogin`),
либо `{getToken}` (берёт **готовый bare-JWT** пользователя как Bearer и **не**
логинится) ([client.ts:99-160](packages/mcp/src/client.ts#L99-L160),
[client.ts:223-241](packages/mcp/src/client.ts#L223-L241)). Этот `getToken`-вариант
уже используется внутренним AI-чатом. Не хватает только **связки в самом
`/mcp`-хендлере** — он сейчас строит конфиг статически из env.
## Где сейчас живёт код (точные места)
### Хендлер `/mcp` (NestJS-обвязка)
- [mcp.service.ts:114-144](apps/server/src/integrations/mcp/mcp.service.ts#L114-L144)
`handle(req, res)`: (1) опц. статичный гард `MCP_TOKEN` против
`Authorization: Bearer` (стр. 118-125); (2) `isEnabled()` — тумблер воркспейса
`ai.mcp` (403 если выкл.); (3) `credsConfigured()` — наличие env-кредов (**это
и есть источник твоего `503`**, стр. 132-144); (4) `res.hijack()` и проброс
raw req/res в MCP-транспорт.
- [mcp.service.ts:47-64](apps/server/src/integrations/mcp/mcp.service.ts#L47-L64)
`getEmail/getPassword/getApiUrl/credsConfigured` — читают env.
- [mcp.service.ts:85-112](apps/server/src/integrations/mcp/mcp.service.ts#L85-L112)
`getHandler()` — лениво создаёт **один** HTTP-handler через
`createMcpHttpHandler({apiUrl,email,password})` и кэширует его.
### MCP-пакет
- [http.ts:13](packages/mcp/src/http.ts#L13) `createMcpHttpHandler(config:
DocmostMcpConfig)` — принимает **один статический** конфиг; создаёт по
`McpServer` + транспорту **на каждую сессию** при `initialize`
([http.ts:68-82](packages/mcp/src/http.ts#L68-L82): `createDocmostMcpServer(config)`
→ `server.connect(transport)`). Идентичность сессии фиксируется здесь, на
инициализации.
- [index.ts:50-54](packages/mcp/src/index.ts#L50-L54) `createDocmostMcpServer(config)`
— пробрасывает union-конфиг в `new DocmostClient(config)`.
- [client.ts:99-160](packages/mcp/src/client.ts#L99-L160) `DocmostMcpConfig` =
`{email,password} | {getToken}` (+ опц. `getCollabToken`); конструктор
ветвится: `getToken`-вариант не логинится, использует bare-JWT как Bearer.
### Auth / токены (сервер)
- [token.service.ts:30-54](apps/server/src/core/auth/services/token.service.ts#L30-L54)
`generateAccessToken(user, sessionId, provenance?)` → JWT `type=ACCESS`.
- [token.service.ts:119-138](apps/server/src/core/auth/services/token.service.ts#L119-L138)
`generateApiToken({apiKeyId,user,workspaceId,expiresIn})` → JWT `type=API_KEY`.
- [token.service.ts:164-176](apps/server/src/core/auth/services/token.service.ts#L164-L176)
`verifyJwt(token, type)` — проверка подписи + типа.
- [jwt.strategy.ts:26-34](apps/server/src/core/auth/strategies/jwt.strategy.ts#L26-L34)
`jwtFromRequest = cookie authToken || Bearer` — **bearer уже принимается** на
`/api`.
- [jwt.strategy.ts:80-81](apps/server/src/core/auth/strategies/jwt.strategy.ts#L80-L81)
провенанс: токен без `actor` → `'user'` (нам и нужно — правки как пользователя).
- [jwt.strategy.ts:86-109](apps/server/src/core/auth/strategies/jwt.strategy.ts#L86-L109)
`validateApiKey` — путь `type=API_KEY` **требует EE-модуль**
(`ee/api-key/api-key.service`), которого в форке нет → бросает «Enterprise API
Key module missing». То есть полноценных PAT сейчас **нет**.
- [auth.controller.ts:184-193](apps/server/src/core/auth/auth.controller.ts#L184-L193)
`POST /auth/collab-token` под `JwtAuthGuard` — выдаёт collab-токен по
bearer/cookie (этим уже пользуется и сервис-аккаунт, и AI-чат).
- [environment.service.ts:63-64](apps/server/src/integrations/environment/environment.service.ts#L63-L64)
`JWT_TOKEN_EXPIRES_IN` по умолчанию **`90d`** — access-JWT долгоживущий, годится
как «токен пользователя».
- [utils.ts:109](apps/server/src/common/helpers/utils.ts#L109)
`extractBearerTokenFromHeader(req)` — переиспользуемый парсер `Authorization`.
- [migration 20250912T101500-api-keys.ts](apps/server/src/database/migrations/20250912T101500-api-keys.ts)
— таблица `api_keys` (`id, name, creator_id, workspace_id, expires_at,
last_used_at, deleted_at`) **уже существует**, но community-сервиса под неё нет.
- [.env.example:72-79](.env.example#L72) — `MCP_DOCMOST_EMAIL/PASSWORD`,
`MCP_DOCMOST_API_URL`, `MCP_TOKEN`, `MCP_SESSION_IDLE_MS`.
## Как именно логиниться под пользователем — варианты
Пользователь подключает к `/mcp` внешний MCP-клиент (Claude Desktop и т.п.).
Авторизоваться «под текущим пользователем» можно несколькими путями с разной
ценой и безопасностью. Все они сводятся к одному и тому же на уровне клиента:
получить пользовательский JWT и ходить под ним; разница — **откуда** берётся
токен (приносит пользователь / логинит сервер / выдаёт OAuth).
### Вариант L — логин/пароль пользователя через HTTP Basic ✅ ВЫБРАН
MCP-клиент шлёт `Authorization: Basic base64(email:password)`; `/mcp` декодит и
строит per-session конфиг `{email, password}` → `DocmostClient` сам делает
`performLogin` (`POST /auth/login`) и дальше ходит под этим пользователем. Это
**ровно тот же путь, что у сервисного аккаунта сегодня**, только с кредами
текущего пользователя — клиентская механика уже готова
([client.ts:99-160](packages/mcp/src/client.ts#L99-L160),
[auth-utils.ts:41-86](packages/mcp/src/lib/auth-utils.ts#L41-L86)).
- **Плюсы:** минимум нового кода (переиспользуется `{email,password}`-ветка
`performLogin`); пользователю не надо доставать токен — привычные логин/пароль;
сервисный аккаунт становится необязательным.
- **Минусы:** **сырой пароль лежит в конфиге MCP-клиента** и уходит на сервер при
каждом коннекте (токен безопаснее — отзываем/скоупится без смены пароля);
**не работает с MFA** (статические креды не пройдут интерактивный челлендж) —
в этом форке MFA-модуль удалён (EE), поэтому сейчас вопрос моот, но при
возврате MFA или `workspace.enforceMfa` ([auth.controller.ts:64-103](apps/server/src/core/auth/auth.controller.ts#L64-L103))
путь сломается; **SSO/OIDC**-пользователи могут не иметь локального пароля;
логин жмёт `/auth/login` throttle ([AUTH_THROTTLER](apps/server/src/core/auth/auth.controller.ts#L41),
раз на сессию + переавторизация на 401).
- **Вывод:** хорош для single-user self-host без MFA; как дефолт лучше токен.
### Вариант A — pass-through access-JWT (альтернатива / возможна параллельно)
MCP-клиент шлёт `Authorization: Bearer <access-JWT>`, где токен — это значение
куки `authToken` пользователя (валиден 90 дней). `/mcp` извлекает его, валидирует
как `ACCESS`-JWT и передаёт в `DocmostClient` как `getToken`. Все REST + collab
идут под CASL этого пользователя; правки атрибутируются ему (`actor='user'`).
- **Плюсы:** минимальный диф, переиспользует уже готовый `getToken`-путь клиента;
bearer уже принимается на `/api`; сервисный аккаунт становится необязательным.
- **Минусы:** токен надо достать руками (DevTools → Cookies → `authToken`),
токен привязан к сессии (логаут/revoke сессии убивает его), он же даёт полный
доступ как у пользователя (не сужен скоупом). Приемлемо для self-host, но это
не «красивый» PAT.
### Вариант B — community PAT / API-keys (доработка на будущее)
Реализовать сообществом то, что было в EE: пользователь создаёт в настройках
**именованный, отзываемый, с TTL** персональный токен; его и кладёт в MCP-клиент.
- Таблица `api_keys` уже есть; `JwtApiKeyPayload`+`generateApiToken` есть; не
хватает **community `ApiKeyService`** (хранить хеш/строку ключа, валидировать
по `apiKeyId` из JWT, обновлять `last_used_at`, проверять `expires_at`/
`deleted_at`) + CRUD-эндпоинты + UI выдачи/отзыва.
- Поправить [jwt.strategy.ts:86-109](apps/server/src/core/auth/strategies/jwt.strategy.ts#L86-L109):
путь `API_KEY` должен звать community-сервис вместо `require('./../../../ee/...')`.
- **Плюсы:** стабильный, отзываемый, именованный токен; не завязан на браузерную
сессию; виден и управляем в UI. Это «правильный» долгоживущий ответ.
- **Минусы:** заметно больше работы (сервис + контроллер + миграция типов + UI),
и это самостоятельная фича auth, шире чем сам `/mcp`.
### Вариант C — OAuth 2.1 для MCP (доработка на будущее, «с логином» из коробки)
MCP-спека описывает авторизацию через OAuth 2.1: Docmost поднимает
authorization-server metadata + token endpoint, а MCP-клиент (Claude Desktop)
делает **интерактивный логин** и сам получает токен — это и есть «mcp с логином».
- **Плюсы:** самый стандартный и удобный UX (логин в браузере, без копипасты
токенов), refresh из коробки.
- **Минусы:** самый большой объём (discovery-эндпоинты, согласие, refresh,
привязка к существующему JWT-стеку). Избыточно для текущего запроса.
> **Решение:** делаем **L** (логин/пароль через HTTP Basic) основным и
> единственным путём на этот заход. Это закрывает «авторизация под текущим
> пользователем» минимальным кодом (переиспользуется `performLogin`) и привычным
> для пользователя способом — логин/пароль. **A/B/C** оставляем в доке как
> совместимые доработки на будущее: все варианты сходятся в одной точке —
> per-session `DocmostClient` под пользовательским JWT, отличается лишь источник
> токена (`performLogin` от сервера / Bearer от пользователя / PAT / OAuth), так
> что добавить их позже можно поверх той же связки без переделки.
## Детальный дизайн выбранного пути — логин/пароль (HTTP Basic)
Идея: вместо **одного статического** конфига хендлер получает **резолвер конфига
от запроса**, который на инициализации каждой MCP-сессии решает, под кем ходить.
Для выбранного пути резолвер читает `Authorization: Basic`, **валидирует
логин/пароль на сервере** и строит per-session `DocmostClient`, ходящий под этим
пользователем.
### 1) `packages/mcp/src/http.ts` — принять резолвер конфига
```ts
// Accept either a static config (service-account / stdio, unchanged) OR a
// per-request resolver. The resolver runs once per MCP session, at initialize,
// so the session's DocmostClient is bound to that request's identity.
export type McpConfigResolver = (
req: IncomingMessage,
) => DocmostMcpConfig | Promise<DocmostMcpConfig>;
export function createMcpHttpHandler(
config: DocmostMcpConfig | McpConfigResolver,
) { /* ... */ }
// inside handleRequest, at session init (POST initialize, http.ts:68-82):
const sessionConfig =
typeof config === "function" ? await config(req) : config;
const server = createDocmostMcpServer(sessionConfig);
```
Обратная совместимость полная: stdio ([stdio.ts](packages/mcp/src/stdio.ts)) и
существующий вызов с объектом-конфигом работают как раньше (это не функция →
ветка `else`).
### 2) `apps/server/.../mcp.service.ts` — разобрать Basic, провалидировать креды, выпустить токен
Креды валидируем **на сервере** через `AuthService.login` и в конфиг кладём
**уже выпущенный пользовательский JWT** (`getToken`-вариант), а не сам пароль —
тогда пароль не уходит дальше в loopback-клиент, а ошибки логина видны сразу,
чистым JSON-ответом до `res.hijack()`.
```ts
// Resolve the per-session identity from the request. Primary path: HTTP Basic
// (current user's email:password) -> validate on the server -> issue the user's
// JWT -> client acts as that user. Bearer (variant A) and the service account
// (back-compat) are accepted as fallbacks.
private async resolveSessionConfig(req): Promise<DocmostMcpConfig> {
const auth = req.headers['authorization'] as string | undefined;
// --- chosen path: Basic login/password ---
if (auth?.startsWith('Basic ')) {
const decoded = Buffer.from(auth.slice(6), 'base64').toString('utf8');
const sep = decoded.indexOf(':'); // password may contain ':'
const email = decoded.slice(0, sep);
const password = decoded.slice(sep + 1);
// Single-workspace assumption (loopback) — same as the AI-chat tools path.
const workspace = await this.workspaceRepo.findFirst();
// Throws UnauthorizedException('Email or password does not match') on bad
// creds -> surfaced as a specific 401 (never a generic error). NOTE: calling
// AuthService.login directly BYPASSES the controller's throttle + MFA gate
// (both EE/controller-level) — see Security below.
const authToken = await this.authService.login({ email, password }, workspace.id);
return { apiUrl: this.getApiUrl(), getToken: async () => authToken };
}
// --- fallback A: Bearer access-JWT (user-supplied token) ---
const bearer = extractBearerTokenFromHeader(req); // utils.ts:109
if (bearer) {
await this.tokenService.verifyJwt(bearer, JwtType.ACCESS); // specific 401
return { apiUrl: this.getApiUrl(), getToken: async () => bearer };
}
// --- fallback B: service account (existing behaviour, optional) ---
if (this.credsConfigured()) {
return { apiUrl: this.getApiUrl(), email: this.getEmail()!, password: this.getPassword()! };
}
throw new UnauthorizedException(
'MCP requires Basic auth (email:password) or a Bearer token, ' +
'or a configured MCP_DOCMOST_EMAIL/PASSWORD service account.',
);
}
```
- `getHandler()` зовёт `createMcpHttpHandler((req) => this.resolveSessionConfig(req))`
(резолвер, не статический объект).
- Auth-разбор (Basic decode + `AuthService.login` / `verifyJwt`) делать в
`handle()` **до** `res.hijack()`, чтобы на плохих кредах вернуть чистый
`401 {error: "..."}`, а не рвать hijack-нутый ответ. Резолвер тогда может
просто отдать уже посчитанный конфиг (напр. через `(req.raw as any).__mcpConfig`).
- Проверку `credsConfigured()` (стр. 132-144) **заменить** на «есть Basic ИЛИ
Bearer ИЛИ env-креды», иначе осмысленный `401/503` (не глотать).
- Инжектнуть в `McpService` `AuthService` (для `login`) и `TokenService` (для
`verifyJwt` в fallback A); `WorkspaceRepo` уже есть. Подтянуть нужные модули в
`integrations/mcp`.
### 3) Гард `MCP_TOKEN` — развести с пользовательскими кредами
Сейчас `MCP_TOKEN` едет в `Authorization: Bearer`
([mcp.service.ts:118-125](apps/server/src/integrations/mcp/mcp.service.ts#L118-L125)).
В per-user режиме `Authorization` занят кредами/токеном пользователя. Решение:
- в per-user режиме **убрать** статичный `MCP_TOKEN`-гард на `Authorization`
(аутентификацией служат сами креды; эндпоинт по-прежнему закрыт тумблером
воркспейса и сетевой изоляцией), **или**
- если нужен доп. общий шлагбаум — перенести `MCP_TOKEN` в **отдельный заголовок**
(`X-MCP-Token`), чтобы не конфликтовал с `Authorization`.
### 4) Collab / провенанс — ничего лишнего не нужно
`getCollabToken`-провайдер **не задаём**: `DocmostClient` сам сходит в
`POST /auth/collab-token` с выпущенным пользовательским JWT
([auth.controller.ts:184-193](apps/server/src/core/auth/auth.controller.ts#L184-L193))
и получит обычный пользовательский collab-токен. Так правки через collab
атрибутируются пользователю (`actor='user'` по умолчанию,
[jwt.strategy.ts:80-81](apps/server/src/core/auth/strategies/jwt.strategy.ts#L80-L81)).
Никакого «AI agent»-бейджа здесь не вешаем — это живой человек.
> **Альтернатива по объёму (если не хочется тянуть `AuthService` в McpService):**
> отдать креды как есть в конфиг `{ email, password }` — `DocmostClient` сам
> сделает `performLogin` по loopback (это буквально путь сервис-аккаунта). Минус:
> пароль идёт в loopback-клиент и ошибка логина всплывает позже, из пакета, после
> hijack. Серверная валидация (вариант выше) чище и безопаснее — её и берём.
## Тонкие моменты / edge cases
- **Идентичность привязана к сессии.** `DocmostClient` создаётся один раз на
MCP-сессию (на `initialize`) и кэширует токен; последующие запросы той же
`mcp-session-id` пойдут под пользователем, зафиксированным при инициализации.
Грань безопасности: на повторных запросах **проверять, что предъявленные креды/
токен резолвятся в того же пользователя** (`email`/`sub`), что и при инициализации
сессии, иначе `401` — чтобы нельзя было «подсесть» в чужую сессию (session
fixation / подмена кред).
- **Новая Docmost-сессия на каждый логин.** `AuthService.login` →
`sessionService.createSessionAndToken` ([auth.service.ts:97](apps/server/src/core/auth/services/auth.service.ts#L97))
создаёт **запись пользовательской сессии** на каждый MCP-логин. При частых
реконнектах сессии копятся (idle-eviction MCP-сессий их не чистит). Прикинуть:
переиспользовать токен в пределах MCP-сессии (одна сессия = один логин, уже так),
и/или TTL/чистку висящих сессий — отдельной заботой.
- **Истечение токена.** Выпущенный access-JWT живёт 90 дней — на 401 от loopback
клиент перезайдёт. Удобство Basic: креды у клиента постоянны, поэтому
переавторизация прозрачна (повторный `login`), в отличие от вручную вставленного
токена. Опционально — per-session mutable-холдер токена, чтобы переавторизация
не пересоздавала MCP-сессию.
- **Откат на сервис-аккаунт.** Сохранить как опцию (нет bearer + есть env-креды →
старое поведение). Это не ломает существующие инсталляции и даёт «безличный»
режим, где он нужен (CI, скрипты). Если откат нежелателен — сделать его
переключаемым (`MCP_REQUIRE_USER_TOKEN=true`).
- **Мульти-тенантность / loopback.** `127.0.0.1` не резолвит воркспейс по
субдомену → таргетится дефолтный воркспейс (та же single-workspace-оговорка,
что и у сервис-аккаунта и AI-чата, см.
[ai-chat-tools.service.ts:25-28](apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts#L25-L28)).
`jwt.strategy` сверяет `req.raw.workspaceId` с `payload.workspaceId`
([jwt.strategy.ts:41-43](apps/server/src/core/auth/strategies/jwt.strategy.ts#L41-L43));
на loopback `req.raw.workspaceId` не выставлен → проверка проходит. Для
мульти-воркспейс деплоя нужен явный workspace-скоуп (отдельная задача).
- **Idle-eviction.** Сессии чистятся по `MCP_SESSION_IDLE_MS` (30 мин)
([http.ts:21-39](packages/mcp/src/http.ts#L21-L39)) — без изменений; protected
per-user сессии тоже истекают по бездействию, это ок.
- **Ошибки не глотать.** Невалидный/просроченный токен → `console`/logger с
полной ошибкой **и** конкретный текст в ответе (реальная причина), не «MCP
error» (CLAUDE.md «Errors must never be swallowed»). Текущее одноразовое
warning про отсутствие кредов — оставить/адаптировать.
- **Логи/PII.** Не логировать сам токен. Сейчас `auth-utils` прячет тело ответа
за `DEBUG` — сохранить этот принцип.
## Безопасность (на ревью проверить отдельно)
- **Прямой `AuthService.login` обходит throttle и MFA-гейт.** Контроллерный
`/auth/login` защищён `ThrottlerGuard` и (в EE) MFA-проверкой
([auth.controller.ts:41](apps/server/src/core/auth/auth.controller.ts#L41),
[:64-103](apps/server/src/core/auth/auth.controller.ts#L64-L103)); вызывая
`authService.login` напрямую, мы их минуем. Следствия: (1) **brute-force через
`/mcp`** — добавить свой rate-limit на неудачные логины `/mcp` (по IP/почте);
(2) если MFA когда-либо вернётся/`enforceMfa` — Basic-путь должен **повторить
MFA-гейт или быть запрещён** для MFA-пользователей, а не молча пускать.
- **Креды в логах/трейсах.** Никогда не логировать `Authorization`, decoded
`email:password` и тело ответа логина (`auth-utils` уже прячет тело за `DEBUG`
— держать тот же принцип). На ошибке логина — конкретный `401`, но без эха
пароля.
- Per-user CASL: убедиться, что **все** инструменты идут только через loopback
REST/collab под пользовательским JWT и нигде не остаётся фолбэка на
сервис-аккаунт внутри уже инициализированной per-user сессии.
- Привязка к сессии (см. edge case) — анти-fixation проверка `email`/`sub`.
- `MCP_TOKEN`-развод: не оставить «дыру», где `Authorization` молча игнорируется.
- SSO/OIDC-пользователи без локального пароля: Basic для них не сработает —
вернуть понятный `401`, а не generic (и направить на токен-путь, если он есть).
- Доработка B (PAT): ключ хранить **хешем**, `last_used_at` обновлять, отзыв
(`deleted_at`) и `expires_at` проверять в `validateApiKey`.
## Миграции / конфиг / env / docs
- **Выбранный путь (Basic):** миграций нет. Обновить
[.env.example:72-79](.env.example#L72): пометить `MCP_DOCMOST_EMAIL/PASSWORD`
как **опциональные** (теперь это фолбэк-сервис-аккаунт, а не обязательный),
описать per-user Basic-режим и (если выбран) `X-MCP-Token`/
`MCP_REQUIRE_USER_TOKEN`. Обновить README: как прописать в MCP-клиенте
`Authorization: Basic` (свои email:password) — у клиентов это обычно поле
«headers» в конфиге сервера.
- **Доработка B (PAT):** `api_keys` таблица уже есть; добавить типы в `db.d.ts`
(`migration:codegen`), при необходимости — индексы; новый модуль/сервис/контроллер
и клиентский UI в `apps/client/src/features/.../settings`.
## Тесты / проверка
- **Сервер (`pnpm --filter server test`):**
- `mcp.service` резолвер: `Basic email:password` → `AuthService.login` зовётся
с дефолтным воркспейсом → `getToken`-конфиг с выпущенным токеном; неверные
креды → `401` с конкретным сообщением (не generic); Bearer-fallback →
`verifyJwt(ACCESS)`; нет ничего + есть env-креды → сервис-аккаунт; нет ничего
→ осмысленный 401/503.
- **пароль с `:`** парсится корректно (split по первому `:`).
- анти-fixation: второй запрос с кредами другого пользователя в той же сессии
→ 401.
- **MCP-пакет (`pnpm --filter @docmost/mcp test`):** `createMcpHttpHandler`
принимает и статический конфиг, и резолвер; резолвер зовётся один раз на
инициализацию сессии; статический путь (stdio/сервис-аккаунт) не задет.
- **Ручная:** прописать в MCP-клиенте `Authorization: Basic base64(email:pass)`
своего юзера → проверить, что (1) видны только доступные пользователю спейсы/
страницы (CASL), (2) правки в истории атрибутируются этому пользователю, а не
сервисному, (3) без env-кредов `/mcp` работает по логину/паролю, (4) неверный
пароль → понятная ошибка, а не generic, (5) залогировано без утечки пароля.
## Открытые вопросы
1. ~~Какой путь делаем~~ — **решено: логин/пароль через HTTP Basic** (вариант L).
A/B/C — совместимые доработки на будущее.
2. **Сервис-аккаунт:** оставить как откат (нет Basic/Bearer → старое поведение)
или полностью убрать в пользу обязательного per-user логина
(`MCP_REQUIRE_USER_TOKEN`)?
3. **`MCP_TOKEN`:** убрать в per-user режиме или перенести в отдельный заголовок
`X-MCP-Token` как доп. общий шлагбаум?
4. **Brute-force / throttle:** добавлять ли свой rate-limit на неудачные логины
`/mcp` (прямой `AuthService.login` минует контроллерный `ThrottlerGuard`)?
5. **Накопление сессий:** нужно ли чистить/ограничивать Docmost-сессии, создаваемые
`AuthService.login` на каждый MCP-логин, или достаточно «одна MCP-сессия = один
логин»?
6. **Серверная валидация vs pass-through:** валидировать креды через
`AuthService.login` (чище/безопаснее, тянет сервис в McpService) или отдать
`{email,password}` в `performLogin` пакета (минимум кода)? В дизайне выбрана
серверная валидация.
7. **Мульти-воркспейс:** loopback таргетит дефолтный воркспейс (как у AI-чата).
Нужен ли явный workspace-скоуп для мульти-тенант деплоя — или отдельная задача?

View File

@@ -1,387 +0,0 @@
# Realtime-дерево: сделать обновления сервер-авторитетными (как контент)
## Контекст (проблема)
Контент страницы синхронизируется между пользователями в реальном времени всегда,
а **дерево страниц в сайдбаре не обновляется**, когда кто-то создаёт / перемещает /
удаляет страницу — у других участников спейса (а часто и у самого автора в соседней
вкладке) дерево «застывает» до ручного refetch (перезагрузка страницы или
переключение спейса).
Причина — в том, что это два разных realtime-канала с разной «авторитетностью»:
- **Контент — сервер-авторитетный (Yjs / Hocuspocus).** Любое изменение текста
проходит через collab-сервер (`apps/server/src/collaboration/`) и раздаётся всем
подписчикам документа независимо от того, кто и каким способом редактировал.
- **Дерево — ретрансляция, инициируемая клиентом.** Броадкаст изменения дерева
делает **браузер автора**, а не сервер. Сервер только пересылает уже готовое
сообщение другим клиентам и **сам по событиям жизненного цикла страницы ничего
не вещает**.
Поэтому дерево обновляется у других **только если** страница создана через UI-дерево,
в открытой вкладке, при живом сокете, и вкладка не закрылась/не сменила URL в течение
~50 мс после действия. **Любой другой путь создания/изменения страницы броадкаста не
даёт вообще:** AI-агент (`core/ai-chat/tools/`), встроенный MCP `/mcp` и standalone
`@docmost/mcp`, REST API напрямую, импорт markdown/zip, копирование/дублирование
страницы, фоновые серверные операции.
Цель фичи: **перенести источник истины tree-событий на сервер** — чтобы дерево
обновлялось у всех в спейсе при любом способе изменения, надёжно, по аналогии с
контентом.
## Как сейчас устроено (цепочка)
### Клиентский relay (единственный текущий источник tree-событий)
- `apps/client/src/features/page/tree/hooks/use-tree-mutation.ts`
- `handleCreate` (строки ~133-191): после успешного `createPageMutation` делает
оптимистичную вставку в `treeDataAtom`, затем через `setTimeout(50)`
`emit({ operation: "addTreeNode", spaceId, payload: { parentId, index, data } })`.
- `handleMove` (~46-131): оптимистично двигает узел, затем `emit("moveTreeNode", …)`.
- `handleDelete` (~207-254): удаляет узел, затем `emit("deleteTreeNode", …)`.
- `handleRename` (~193-205): оптимистично меняет имя, **emit НЕ делает**.
- `apps/client/src/features/websocket/use-query-emit.ts`: `emit` — это просто
`socket?.emit("message", input)`.
### Сервер — только пересылка
- `apps/server/src/ws/ws.gateway.ts` (`@SubscribeMessage('message')`, ~64-69):
если `wsService.isTreeEvent(data)` — отдаёт в `wsService.handleTreeEvent`.
- `apps/server/src/ws/ws.service.ts` `handleTreeEvent` (~27-58):
`client.broadcast.to(getSpaceRoomName(spaceId)).emit('message', data)` — пересылка
пришедшего от клиента события в комнату спейса (с учётом ограничений доступа).
- `apps/server/src/database/listeners/page.listener.ts`: слушает `PAGE_CREATED` /
`PAGE_UPDATED` / `PAGE_DELETED` / `PAGE_SOFT_DELETED` / `PAGE_RESTORED`, но **только
ставит задачи в очереди (search / AI)** — WebSocket не трогает.
### Что уже есть для серверного броадкаста (но не используется)
- `apps/server/src/ws/ws-tree.service.ts``WsTreeService` с методами
`notifyPermissionGranted` (строит готовый payload `addTreeNode`) и
`notifyPageRestricted` (payload `deleteTreeNode`). **Нигде не вызывается** (мёртвый
код) — но это точный шаблон формата событий и доказательство, что инфраструктура
серверного броадкаста работоспособна.
- `WsService.emitCommentEvent(spaceId, pageId, data)` (~66-87) — образец
**серверного** броадкаста в комнату спейса с проверкой ограничений доступа
(`spaceHasRestrictions``hasRestrictedAncestor``broadcastToAuthorizedUsers`).
- `WsModule``@Global`, экспортирует `WsService` и `WsTreeService`.
### Приёмник на клиенте (переиспользуем как есть)
- `apps/client/src/features/websocket/use-tree-socket.ts` (`socket.on("message")`):
- `addTreeNode` (~55-74): вставляет узел; **идемпотентен**
`if (treeModel.find(prev, event.payload.data.id)) return prev;` (повторная
доставка того же id безопасна).
- `moveTreeNode` (~75-117), `deleteTreeNode` (~119-138), `updateOne` (~36-54).
- `apps/client/src/features/websocket/use-query-subscription.ts`: на те же события
синхронизирует кэш TanStack Query сайдбара (`invalidateOnCreatePage`,
`updateCacheOnMovePage`, `invalidateOnDeletePage`).
## Целевое поведение
При **любом** способе изменения структуры (UI, AI-агент, MCP, REST API, импорт,
копирование, фоновые операции) сервер сам рассылает соответствующее tree-событие всем
клиентам в комнате спейса (с учётом ограничений доступа), и у всех участников дерево
обновляется без ручного refetch:
- создание страницы → `addTreeNode`;
- перемещение/переупорядочивание → `moveTreeNode`;
- мягкое/жёсткое удаление → `deleteTreeNode`;
- восстановление из корзины → `addTreeNode` (или `refetchRootTreeNodeEvent`);
- (расширение) переименование / смена иконки → `updateOne`;
- (расширение) перенос между спейсами → `deleteTreeNode` в старом спейсе +
`addTreeNode` в новом.
## Решение (архитектура)
Перенести генерацию tree-событий на сервер и сделать его единственным источником
истины. Состоит из трёх частей: (1) серверный эмиттер, (2) обогащённые доменные
события, (3) удаление клиентского relay.
### 1. Серверный метод броадкаста tree-события
В `WsService` добавить метод по образцу `emitCommentEvent` — рассылка в комнату спейса
с учётом ограничений доступа. Не исключаем автора: повторная доставка безопасна
благодаря идемпотентности приёмника (см. edge cases).
```ts
// apps/server/src/ws/ws.service.ts
// Server-origin tree broadcast. Mirrors emitCommentEvent: respects per-space page
// restrictions, then fans the event out to everyone in the space room. The author
// is NOT excluded — the client receiver is idempotent (addTreeNode early-returns if
// the node id already exists), so the author's optimistic node is preserved and
// non-UI creators (MCP / AI / API) still see their own page appear.
async emitTreeEvent(spaceId: string, pageId: string, data: any): Promise<void> {
const room = getSpaceRoomName(spaceId);
const hasRestrictions = await this.spaceHasRestrictions(spaceId);
if (!hasRestrictions) {
this.server.to(room).emit('message', data);
return;
}
const isRestricted = await this.pagePermissionRepo.hasRestrictedAncestor(pageId);
if (!isRestricted) {
this.server.to(room).emit('message', data);
return;
}
await this.broadcastToAuthorizedUsers(room, null, pageId, data);
}
```
`WsTreeService` расширить методами, которые строят payload и вызывают `emitTreeEvent`
(переиспользуя формат из существующих `notifyPermissionGranted`/`notifyPageRestricted`):
```ts
// apps/server/src/ws/ws-tree.service.ts
async broadcastPageCreated(page: TreeNodeData): Promise<void> {
await this.wsService.emitTreeEvent(page.spaceId, page.id, {
operation: 'addTreeNode',
spaceId: page.spaceId,
payload: {
parentId: page.parentPageId ?? null,
// Receivers should place by `position`, not this index — see edge cases.
index: 0,
data: {
id: page.id, slugId: page.slugId,
name: page.title ?? '', title: page.title, icon: page.icon,
position: page.position, spaceId: page.spaceId,
parentPageId: page.parentPageId, hasChildren: false, children: [],
},
},
});
}
async broadcastPageDeleted(page: TreeNodeData): Promise<void> {
await this.wsService.emitTreeEvent(page.spaceId, page.id, {
operation: 'deleteTreeNode',
spaceId: page.spaceId,
payload: { node: { id: page.id, slugId: page.slugId, parentPageId: page.parentPageId } },
});
}
async broadcastPageMoved(p: MovedTreeNodeData): Promise<void> {
await this.wsService.emitTreeEvent(p.spaceId, p.id, {
operation: 'moveTreeNode',
spaceId: p.spaceId,
payload: {
id: p.id, parentId: p.parentPageId ?? null, oldParentId: p.oldParentId ?? null,
index: 0, position: p.position,
pageData: { id: p.id, slugId: p.slugId, title: p.title, icon: p.icon,
position: p.position, spaceId: p.spaceId, parentPageId: p.parentPageId,
hasChildren: p.hasChildren },
},
});
}
```
### 2. Источник событий: обогатить payload и/или эмитить из сервиса post-commit
Главная сложность — листенеру нужны поля, которых нет в `PageEvent`
(`{ pageIds, workspaceId }`), а дочитывание из БД по `pageId` гонится с транзакцией
(`insertPage`/`removePage` эмитят событие, иногда находясь внутри ещё не
закоммиченного `trx` — отдельный SELECT может не увидеть строку). Два варианта (см.
«Открытые вопросы», по умолчанию — **A**):
**Вариант A (рекомендуется): обогатить доменные события снимком узла.** Добавить в
payload событий тонкие поля дерева, чтобы листенер не читал БД:
```ts
// apps/server/src/database/listeners/page.listener.ts (PageEvent)
export class PageEvent {
pageIds: string[];
workspaceId: string;
// Optional tree snapshots so the WS listener can broadcast without a DB read
// (avoids the in-transaction visibility race on PAGE_CREATED / PAGE_SOFT_DELETED).
pages?: TreeNodeSnapshot[]; // { id, slugId, title, icon, position, spaceId, parentPageId }
}
```
`insertPage` уже делает `returning(this.baseFields)` — снимок собирается из `result`
без доплат. `removePage` знает удаляемые `pageIds`; для `deleteTreeNode` достаточно
`{ id, slugId, parentPageId, spaceId }`, которые можно вернуть из того же `withRecursive`.
**Вариант B: эмитить tree-broadcast из сервиса после завершения операции (post-commit).**
Внедрить `WsTreeService` в `PageService` и вызывать `broadcastPage*` после успешного
`insertPage`/`removePage`/`movePage` (когда транзакция уже закоммичена и данные на
руках). Минус — размазывает realtime-логику по доменному сервису вместо одного
листенера.
### 3. Отдельное событие для перемещения
`movePage` сейчас эмитит общий `PAGE_UPDATED` — он непригоден: (а) не несёт
`oldParentId`/`position`, (б) срабатывает также на rename и сохранение контента (шум,
ложные `moveTreeNode`). Ввести выделенное событие:
```ts
// apps/server/src/common/events/event.contants.ts
PAGE_MOVED = 'page.moved',
```
`pageService.movePage()` знает старого родителя (читает страницу до апдейта), новый
`parentPageId` и новый `position` — эмитить `PAGE_MOVED` с полным снимком (вариант A)
после апдейта. Листенер вешает `@OnEvent(EventName.PAGE_MOVED)`
`wsTreeService.broadcastPageMoved(...)`.
### 4. Новый листенер в модуле ws
```ts
// apps/server/src/ws/listeners/page-ws.listener.ts
@Injectable()
export class PageWsListener {
constructor(private readonly wsTree: WsTreeService) {}
@OnEvent(EventName.PAGE_CREATED)
async onCreated(e: PageEvent) {
for (const p of e.pages ?? []) await this.wsTree.broadcastPageCreated(p);
}
@OnEvent(EventName.PAGE_SOFT_DELETED)
@OnEvent(EventName.PAGE_DELETED)
async onDeleted(e: PageEvent) {
for (const p of e.pages ?? []) await this.wsTree.broadcastPageDeleted(p);
}
@OnEvent(EventName.PAGE_MOVED)
async onMoved(e: PageMovedEvent) { await this.wsTree.broadcastPageMoved(e); }
@OnEvent(EventName.PAGE_RESTORED)
async onRestored(e: PageEvent) {
// Restore can re-attach a subtree; simplest correct option is a root refetch
// hint (see edge cases) instead of N addTreeNode events.
// await this.wsTree.broadcastRefetchRoot(spaceId);
}
}
```
Зарегистрировать `PageWsListener` в `WsModule.providers`. `WsTreeService` уже там;
`PageRepo` доступен из глобального `DatabaseModule` (если выберем вариант B/дочитывание).
### 5. Убрать клиентский relay (источник истины — только сервер)
После включения серверного броадкаста убрать `emit(...)` из
`use-tree-mutation.ts` (`handleCreate`/`handleMove`/`handleDelete`) и связанный
`setTimeout(50)`. Оптимистичные локальные обновления **оставить** (мгновенный отклик у
автора). Тогда на каждую операцию будет ровно один броадкаст (серверный), исчезает
гонка 50 мс и зависимость от того, успел ли браузер автора отправить событие.
> Безопасный порядок выката: серверный броадкаст можно включить, **не** удаляя relay
> сразу — приёмник идемпотентен, дубль `addTreeNode`/`deleteTreeNode` безвреден (второй
> — no-op). Это позволяет проверить серверный путь в изоляции, затем удалить relay
> отдельным коммитом. `moveTreeNode` при двойной доставке тоже идемпотентен по позиции.
## Тонкие моменты / edge cases
- **Гонка видимости транзакции.** Главная причина выбрать вариант A (снимок в
событии): `insertPage`/`removePage` эмитят событие, находясь иногда внутри
незакоммиченного `trx`; отдельный SELECT в листенере может не увидеть строку.
Существующие листенеры (search/AI) не страдают, т.к. лишь ставят отложенную задачу,
выполняемую после коммита. Синхронный re-fetch для броадкаста — нет.
- **Двойная вставка у автора.** Не исключаем автора из рассылки: приёмник `addTreeNode`
делает `if (treeModel.find(prev, id)) return prev` — у UI-автора оптимистичный узел
уже есть, серверное событие игнорируется (и не затирает редактируемое имя). У
non-UI автора (MCP/AI/API) узла нет — он его получит. Это и есть аргумент против
`emitToSpaceExceptUsers([creatorId])`: исключение автора сломало бы non-UI случай.
- **Порядок/позиция.** Сервер не знает локальный `index` каждого получателя (корневой
список пагинируется, у клиентов разный набор загруженных узлов). Поэтому в payload
кладём `position` (фракционный индекс — реальный порядок), а приёмник `addTreeNode`
стоит доработать так, чтобы вставлять **по `position`** среди уже загруженных
сиблингов, а не по абсолютному `index` отправителя. Сейчас `treeModel.insert`
принимает `index`; нужна вставка с сортировкой по `position` (или отдельный
`insertByPosition`). Без этого порядок у получателей может разойтись.
- **Пагинация корня → дубликаты.** Если новая корневая страница по `position` попадает
за пределы уже загруженного «окна» корневого инфинит-списка, прямая вставка в атом
может позже задвоиться при подгрузке следующей страницы. `use-query-subscription.ts`
уже инвалидирует кэш сайдбара на `addTreeNode` (`invalidateOnCreatePage`) — следить,
чтобы оба приёмника (`useTreeSocket` мутирует атом, `useQuerySubscription`
инвалидирует query) сходились к одному состоянию и не дублировали узлы.
- **Перенос между спейсами (`movePageToSpace`).** Сейчас эмитит `PAGE_MOVED_TO_SPACE`
**без листенера**. Корректный realtime: в **старом** спейсе — `deleteTreeNode`, в
**новом**`addTreeNode` (для всего перенесённого поддерева — вероятно проще
`refetchRootTreeNodeEvent` на оба спейса). Вынести в отдельный пункт объёма.
- **Восстановление из корзины (`PAGE_RESTORED`).** Может вернуть целое поддерево и
переприкрепить его к родителю. N точечных `addTreeNode` хрупки по порядку — проще
отправить `refetchRootTreeNodeEvent` (он уже поддержан и сервером-пересыльщиком, и
`use-query-subscription`), пусть клиенты перезапросят корень спейса.
- **Rename / иконка.** `handleRename` сейчас emit не делает, а `updateOne` хоть и
обрабатывается приёмником, серверно не рассылается → переименования тоже не
пропагируются. Естественное расширение этой же фичи: на `PAGE_UPDATED`, когда
изменились `title`/`icon`, слать `updateOne` (но фильтровать, чтобы не слать на
каждое сохранение контента). Вынесено в расширения, чтобы не раздувать базовый объём.
- **Каскадное мягкое удаление.** `removePage` удаляет всё поддерево и эмитит **все**
`pageIds` потомков. Для дерева достаточно одного `deleteTreeNode` по корню удаляемого
поддерева (клиент `treeModel.remove` убирает узел с детьми). Слать событие только по
корню удаления, а не по каждому потомку, иначе лишний трафик.
- **Ограничения доступа** наследуются бесплатно из `emitCommentEvent`-паттерна
(`spaceHasRestrictions``hasRestrictedAncestor``broadcastToAuthorizedUsers`):
закрытые страницы не утекут неавторизованным.
- **Мёртвый `WsTreeService`.** Его текущие `notifyPermissionGranted` /
`notifyPageRestricted` нигде не вызываются — заодно проверить, не должны ли они
вызываться при смене прав доступа на страницу (отдельный, но смежный баг realtime).
- **Идемпотентность move/delete.** `moveTreeNode` (place по позиции) и `deleteTreeNode`
(`if (!find) return prev`) тоже безопасны к повторной доставке — это позволяет
поэтапный выкат (п. 5).
- **Комментарии в коде — на английском** (правило проекта).
## Объём работ (файлы)
Сервер:
- [ ] `apps/server/src/common/events/event.contants.ts` — добавить `PAGE_MOVED`
(и при необходимости тип `PageMovedEvent`).
- [ ] `apps/server/src/database/listeners/page.listener.ts` — обогатить `PageEvent`
снимками узлов (вариант A); экспортировать общий тип снимка.
- [ ] `apps/server/src/database/repos/page/page.repo.ts` — класть снимок в payload
`PAGE_CREATED` (`insertPage`) и `PAGE_SOFT_DELETED` (`removePage`, только корень
удаления).
- [ ] `apps/server/src/core/page/services/page.service.ts``movePage` эмитит
`PAGE_MOVED` со старым/новым родителем и `position``movePageToSpace` — для
расширения).
- [ ] `apps/server/src/ws/ws.service.ts``emitTreeEvent(spaceId, pageId, data)`.
- [ ] `apps/server/src/ws/ws-tree.service.ts``broadcastPageCreated/Deleted/Moved`
(+ опц. `broadcastRefetchRoot`).
- [ ] `apps/server/src/ws/listeners/page-ws.listener.ts` — новый листенер.
- [ ] `apps/server/src/ws/ws.module.ts` — зарегистрировать `PageWsListener`.
Клиент:
- [ ] `apps/client/src/features/page/tree/hooks/use-tree-mutation.ts` — убрать
`emit(...)` и `setTimeout(50)` из create/move/delete (оптимистику оставить).
- [ ] `apps/client/src/features/page/tree/model/tree-model.ts`
вставка `addTreeNode` по `position` среди сиблингов (а не по абсолютному index).
- [ ] Проверить согласованность `use-tree-socket.ts` и `use-query-subscription.ts`
(мутация атома vs инвалидация кэша) — без дубликатов узлов.
## Тесты
- Сервер (Jest): юнит на `WsTreeService.broadcastPage*` — корректный формат payload
(`operation`, `spaceId`, `payload.data/node/pageData`) для create/delete/move.
`emitTreeEvent` — рассылка в комнату спейса и ветка ограничений (restricted →
только авторизованные). Запуск: `pnpm --filter server test`.
- Клиент (Vitest): приёмник `addTreeNode` идемпотентен (повтор того же id — no-op);
вставка по `position` даёт верный порядок при разном наборе загруженных сиблингов.
- Линт: `pnpm --filter server lint`, `pnpm --filter client lint`.
- Ручная проверка матрицы способов создания: UI-дерево, AI-агент, MCP `/mcp`, REST
`POST /pages/create`, импорт markdown — во всех случаях дерево обновляется у второго
пользователя без перезагрузки.
## Альтернативы
- **Только клиентский патч (быстро, не рекомендуется).** Убрать `setTimeout(50)` и/или
слать `refetchRootTreeNodeEvent` после create. Лечит лишь UI-сценарий между людьми,
не покрывает AI/MCP/API и остаётся клиент-зависимым — против цели фичи.
- **Сервер всегда шлёт `refetchRootTreeNodeEvent` вместо точечных событий.** Проще
(не нужен снимок узла, нет проблемы порядка), но грубее: каждый клиент перезапрашивает
корневое дерево спейса на любое изменение — больше нагрузки и моргание UI. Возможен
как временный/откатной режим для сложных случаев (restore, move-to-space).
- **Вариант B (эмит из сервиса post-commit)** вместо обогащения событий — см. п. 2.
Надёжно по транзакциям, но размазывает realtime-логику по доменному сервису.
## Открытые вопросы (согласовать перед реализацией)
- [ ] Источник данных для броадкаста: обогатить доменные события снимком узла
(**вариант A, рекомендуется**) или эмитить из сервиса post-commit (вариант B)?
- [ ] Удалять клиентский relay сразу в той же задаче или вторым коммитом после
проверки серверного пути (приёмник идемпотентен — оба варианта безопасны)?
- [ ] `restore` и `move-to-space`: точечные `addTreeNode`/`deleteTreeNode` или более
простой и устойчивый `refetchRootTreeNodeEvent` на затронутые спейсы?
- [ ] Включать ли в базовый объём rename/иконку (`updateOne` от сервера на
`PAGE_UPDATED`) или вынести в отдельную задачу?
- [ ] Чинить ли заодно мёртвый `WsTreeService` (broadcast при смене прав доступа) —
в рамках этой задачи или отдельной?

View File

@@ -1,301 +0,0 @@
# Дерево страниц: кнопки «Развернуть всё» / «Свернуть всё»
Статус: **план, код не менялся.** Фича клиент+сервер. По решению владельца выбран
**серверный путь**: эндпоинт отдаёт **всё поддерево/всё дерево спейса разом**
(«отдать всё»), а клиент за один-два запроса разворачивает дерево целиком. От
клиентского рекурсивного обхода по одному уровню — отказались (см. «Почему так»).
## Суть
В сайдбаре спейса (дерево «Pages») сейчас узлы разворачиваются/сворачиваются
только поодиночке кликом по шеврону. Есть шорткат `*` (разворачивает **сиблингов**
сфокусированного узла, паттерн WAI-ARIA tree), но глобального «развернуть/свернуть
всё дерево» нет.
Хотим: две команды в шапке дерева — **«Развернуть всё»** (раскрыть все ветки
текущего спейса) и **«Свернуть всё»** (схлопнуть до корней). Это навигационная
операция над видом — прав на запись не требует, доступна любому, кто видит спейс.
## Почему так (выбор архитектуры)
Дети узлов **загружаются лениво, по одному уровню**: у свёрнутой ветки
`hasChildren === true`, но `children === []`, а эндпоинт `/pages/sidebar-pages`
отдаёт **только прямых детей** одного `pageId`. «Развернуть всё» поверх такого
API = рекурсивный BFS на десятки-сотни HTTP-запросов (шторм запросов, лимиты,
долгий индикатор, защитный потолок). Это и был отвергнутый вариант.
**Решение — отдать всё одним запросом на сервере.** У бэкенда уже есть готовые
кирпичи для рекурсивной выборки поддерева с учётом прав (используются в
`movePageToSpace`):
- `pageRepo.getPageAndDescendants(parentPageId, { includeContent: false })`
([page.repo.ts:557](apps/server/src/database/repos/page/page.repo.ts#L557)) —
рекурсивный CTE: страница + все потомки одним запросом.
- `pageRepo.getPageAndDescendantsExcludingRestricted(parentPageId, opts)`
([page.repo.ts:612](apps/server/src/database/repos/page/page.repo.ts#L612)) —
то же, но **обрезает закрытые (restricted) поддеревья прямо в SQL** (один
запрос, не тянет лишнее).
- `pageService.filterAccessibleTreePages(allPages, rootId, userId, spaceId)`
([page.service.ts:1136](apps/server/src/core/page/services/page.service.ts#L1136))
— точечная фильтрация дерева по правам с сохранением целостности (для
per-page permissions сверх restricted-спейсов).
- `pageRepo.withHasChildren(eb)`
([page.repo.ts:539](apps/server/src/database/repos/page/page.repo.ts#L539)) —
вычисление `hasChildren` в SQL (при отдаче всего дерева `hasChildren` можно и
вывести на клиенте — у узла есть дети, если в ответе есть страница с
`parentPageId === id`).
Плюсы серверного пути: один-два запроса вместо сотен; предсказуемо даже на
тысячах страниц; права считаются на сервере (единый источник правды); на клиенте
нет BFS/ограничителя параллелизма/защитного потолка. Минус — нужна работа на
бэкенде (новый рекурсивный режим эндпоинта) и контроль размера ответа.
## Где сейчас живёт код (точные места)
### Клиент — фича `apps/client/src/features/page/tree/`
- **Состояние раскрытия** —
[open-tree-nodes-atom.ts](apps/client/src/features/page/tree/atoms/open-tree-nodes-atom.ts):
`openTreeNodesAtom`, тип `OpenMap = Record<string, boolean>` (id → раскрыт ли),
**персист в localStorage**, ключ `openTreeNodes:{workspaceId}:{userId}`.
**Карта общая для всех спейсов воркспейса.**
- **Данные дерева** —
[tree-data-atom.ts](apps/client/src/features/page/tree/atoms/tree-data-atom.ts):
`treeDataAtom: SpaceTreeNode[]`, накопительно по спейсам; на рендере
фильтруется по `spaceId`.
- **Модель узла** —
[types.ts](apps/client/src/features/page/tree/types.ts): `SpaceTreeNode`
(`id`, `spaceId`, `hasChildren`, `children`, `name`, `icon`, `position`,
`parentPageId`, `canEdit`, `slugId`).
- **Обёртка/тоггл/загрузка** —
[space-tree.tsx](apps/client/src/features/page/tree/components/space-tree.tsx):
`filteredData` (стр. 184-187, узлы текущего спейса), `handleToggle` (стр.
164-182, ленивая загрузка уровня), `spaceIdRef` (стр. 46-47, защита от гонок).
- **Модель-операции** —
[tree-model.ts](apps/client/src/features/page/tree/model/tree-model.ts):
`find`, `appendChildren`, `visible`, `siblingsOf`.
- **HTTP-загрузка** —
[page-query.ts](apps/client/src/features/page/queries/page-query.ts) +
[page-service.ts](apps/client/src/features/page/services/page-service.ts):
`getSidebarPages` / `getAllSidebarPages` (паджинируют **один уровень**),
`fetchAllAncestorChildren`, утилиты `buildTree` / `buildTreeWithChildren` /
`mergeRootTrees` ([utils.ts](apps/client/src/features/page/tree/utils/utils.ts)).
- **Шапка дерева (куда вешать команды)** —
[space-sidebar.tsx:117-149](apps/client/src/features/space/components/sidebar/space-sidebar.tsx#L117):
`SpaceMenu` (дропдаун на `IconDots`, стр. 172-281, уже с `Menu.Item`/
`Menu.Divider`) + кнопка «+» (Create page).
### Сервер — фича `apps/server/src/core/page/`
- **Эндпоинт сайдбара** —
[page.controller.ts:540](apps/server/src/core/page/page.controller.ts#L540)
`POST /pages/sidebar-pages` (`SidebarPageDto`: `spaceId | pageId`),
CASL-скоуп на спейс, отдаёт **один уровень**.
- **Сервис** —
[page.service.ts:304](apps/server/src/core/page/services/page.service.ts#L304)
`getSidebarPages(spaceId, pagination, pageId?, userId?, spaceCanEdit?)`:
выборка одного уровня + `withHasChildren` + **двухветочная фильтрация прав**
если в спейсе нет ограничений (`pagePermissionRepo.hasRestrictedPagesInSpace`)
`canEdit = spaceCanEdit`; иначе per-page фильтр через
`filterAccessiblePageIdsWithPermissions` + корректировка `hasChildren` по
`getParentIdsWithAccessibleChildren`. **Эту же логику прав надо повторить в
рекурсивном режиме.**
## Решение
### Серверная часть — «отдать всё поддерево» одним запросом
Добавить рекурсивный режим выдачи дерева. Варианты оформления (выбрать на ревью):
- флаг `recursive: true` (и опц. `depth`) к существующему `POST /pages/sidebar-pages`, **или**
- отдельный эндпоинт `POST /pages/tree` (`{ spaceId }` → всё дерево спейса;
`{ pageId }` → всё поддерево страницы).
Контракт ответа: **плоский список элементов в точно том же shape, что и текущий
`/pages/sidebar-pages`** (`id`, `slugId`, `title`, `icon`, `position`,
`parentPageId`, `spaceId`, `hasChildren`, `canEdit`), чтобы клиентские
`buildTree`/`buildTreeWithChildren` собрали дерево без изменений. Порядок — по
`position` (collate "C"), как сейчас.
Сервисный метод (эскиз), переиспользует существующие кирпичи:
```ts
// Whole subtree (pageId) or whole space tree (spaceId only) in a single query,
// permission-filtered, returned as a flat list matching the sidebar item shape.
async getSidebarPagesTree(spaceId, userId, spaceCanEdit, pageId?) {
const hasRestrictions = await this.pagePermissionRepo.hasRestrictedPagesInSpace(spaceId);
// Seed: a single page subtree, or all root pages of the space.
// - restricted space -> *ExcludingRestricted (prunes closed subtrees in SQL)
// - open space -> plain recursive descendants
// For the whole-space case add a space-rooted recursive CTE (seed:
// parentPageId is null AND spaceId = ? AND deletedAt is null), mirroring
// getPageAndDescendants/...ExcludingRestricted.
let pages = hasRestrictions
? await this.pageRepo.getSpaceDescendantsExcludingRestricted(spaceId, pageId, { includeContent: false })
: await this.pageRepo.getSpaceDescendants(spaceId, pageId, { includeContent: false });
// Fine-grained per-page permissions on top of restricted pruning.
if (hasRestrictions) {
pages = await this.filterAccessibleTreePages(pages, pageId ?? null, userId, spaceId);
}
// Derive hasChildren from the returned set; stamp canEdit (per-page when
// restricted, else spaceCanEdit). Same two-branch logic as getSidebarPages().
return shapeAsSidebarItems(pages, { hasRestrictions, spaceCanEdit /*, permissionMap */ });
}
```
Где `getSpaceDescendants` / `getSpaceDescendantsExcludingRestricted` — новые
тонкие обёртки над существующими рекурсивными CTE (для случая «всё дерево спейса»
— CTE, засеянный корнями спейса вместо одного `parentPageId`).
**Важно про права:** обязательно сохранить **обе ветки** фильтрации из
`getSidebarPages` (restricted / не-restricted) и корректировку `hasChildren`,
иначе рекурсивный эндпоинт начнёт отдавать страницы, к которым у пользователя нет
доступа. Это критичная грань — на ревью проверить отдельно.
### Клиентская часть — упрощённый `expandAll`
Поскольку дерево приходит целиком, BFS/параллелизм/потолок не нужны.
`page-service.ts` — новый вызов:
```ts
// Fetch the whole space tree (all roots + descendants) in one shot.
export async function getSpaceTree(params: { spaceId: string; pageId?: string }): Promise<IPage[]> {
const req = await api.post("/pages/tree", params); // or /sidebar-pages { recursive: true }
return req.data.items;
}
```
`space-tree.tsx` — превратить `SpaceTree` в `forwardRef` и выставить
`useImperativeHandle`:
```ts
export type SpaceTreeApi = {
expandAll: () => Promise<void>;
collapseAll: () => void;
isExpanding: boolean;
};
const expandAll = useCallback(async () => {
const startSpaceId = spaceIdRef.current;
setIsExpanding(true);
try {
// One request: the entire space tree, permission-filtered server-side.
const items = await getSpaceTree({ spaceId: startSpaceId });
if (spaceIdRef.current !== startSpaceId) return; // space switched — abort
const fullTree = buildTreeWithChildren(items);
setData((prev) => {
// Replace current-space nodes with the full tree; keep other spaces intact.
const others = prev.filter((n) => n?.spaceId !== startSpaceId);
return [...others, ...mergeRootTrees(prev.filter((n) => n?.spaceId === startSpaceId), fullTree)];
});
// Open every branch node of the current space.
const branchIds = collectBranchIds(fullTree); // nodes with children
setOpenTreeNodes((prev) => {
const next = { ...prev };
for (const id of branchIds) next[id] = true;
return next;
});
} catch (err) {
// Never swallow: log full error + show the real reason (project convention).
console.error("[tree] expandAll failed", err);
notifications.show({ color: "red",
message: t("Couldn't expand the tree: {{reason}}", { reason: err?.response?.data?.message ?? err?.message ?? String(err) }) });
} finally {
setIsExpanding(false);
}
}, [/* setData, setOpenTreeNodes, t */]);
```
`collapseAll` — снимать раскрытие **только у узлов текущего спейса** (карта общая):
```ts
const collapseAll = useCallback(() => {
// The open-map is shared across spaces; clearing it wholesale would drop
// other spaces' expanded state. Collapse only current-space ids.
const ids = new Set<string>();
const walk = (nodes: SpaceTreeNode[]) => {
for (const n of nodes) { ids.add(n.id); if (n.children?.length) walk(n.children); }
};
walk(filteredData);
setOpenTreeNodes((prev) => {
const next = { ...prev };
for (const id of ids) next[id] = false;
return next;
});
}, [filteredData, setOpenTreeNodes]);
```
`space-sidebar.tsx``const treeRef = useRef<SpaceTreeApi | null>(null)`, передать
в `<SpaceTree ref={treeRef} ... />`, и подвесить команды в шапке. **Без
`canManage`-гейта** — это операция над видом, не над данными.
## UX-развилка по размещению
В шапке уже два значка (`IconDots` меню + `IconPlus` создать). Варианты:
- **(1) Две `ActionIcon`** «развернуть»/«свернуть» (`IconChevronsDown` /
`IconChevronsUp`) → 4 значка в узкой шапке, явно и в один клик.
- **(2) Одна `ActionIcon`-тоггл** развернуть↔свернуть → 3 значка, компактнее, но
состояние менее очевидно.
- **(3) Два `Menu.Item`** в `SpaceMenu` (`Развернуть всё` / `Свернуть всё` +
`Menu.Divider`) → шапка не растёт, но в два клика и менее заметно.
> **Рекомендация:** **(3)** как самый чистый по вёрстке (узкая колонка) либо
> **(1)**, если важна доступность в один клик. Тултипы/`aria-label`:
> `t("Expand all")` / `t("Collapse all")`; во время загрузки — `loading`/
> `disabled` (`isExpanding`).
## Тонкие моменты / edge cases
- **Права в рекурсивном эндпоинте.** Самый важный пункт: повторить **обе** ветки
фильтрации (restricted / открытый спейс) и корректировку `hasChildren` из
`getSidebarPages`. Предпочесть `*ExcludingRestricted` (обрезает закрытые
поддеревья в SQL) + `filterAccessibleTreePages` для per-page прав. На ревью —
тест: пользователь без доступа к ветке не должен видеть её через «развернуть
всё».
- **Размер ответа.** Всё дерево спейса может быть большим. `content` **не**
тянуть (`includeContent: false`). Прикинуть потолок (число узлов) и поведение
при очень больших спейсах — отдавать всё или ограничить + честно сообщить
(конвенция: не молчать про усечение).
- **Скоуп карты раскрытия.** `openTreeNodesAtom` общая для спейсов — и
`expandAll`, и `collapseAll` работают **только по узлам текущего спейса**.
- **Гонки при смене спейса.** Запрос асинхронный; сверяться с
`spaceIdRef.current` и прерывать мёрдж/раскрытие, если спейс сменился (паттерн
уже есть в эффектах `space-tree.tsx`).
- **Мёрдж с уже загруженным.** Полное дерево вмёрджить в `treeDataAtom`, заместив
узлы текущего спейса (`mergeRootTrees`/замена ветки), **не трогая** узлы
других спейсов.
- **Ошибки не глотать.** Любой сбой — `console.error` с полным объектом **и**
уведомление с реальной причиной (`err.response?.data?.message`/`err.message`),
не «что-то пошло не так» (CLAUDE.md «Errors must never be swallowed»).
- **Индикатор.** На крупном спейсе запрос заметный — кнопку в `loading`, чтобы не
было повторных кликов/ощущения зависания.
- **Рост localStorage-карты.** `expandAll` пишет много ключей; для удалённых
страниц ключи «висят». Не критично; уборка карты — отдельная задача.
- **Пустой спейс / одни листья.** Кнопки — no-op; «развернуть» можно `disabled`.
- **Шорткат `*`** (развернуть сиблингов,
[doc-tree.tsx](apps/client/src/features/page/tree/components/doc-tree.tsx)) не
трогаем — дополняем его.
- **Виртуализация.** Дерево на `@tanstack/react-virtual` — раскрытие тысяч строк
рендер не убьёт (рисуются видимые), но резко меняет высоту скролла; проверить,
что позиция/скролл не прыгают.
## Тесты / проверка
- **Сервер:** `pnpm --filter server test` (unit на новый сервисный метод).
Кейсы: открытый спейс (видно всё), restricted-спейс (закрытые ветки и их
поддеревья **не** попадают в ответ), per-page права (`canEdit`), корректный
`hasChildren`, порядок по `position`, `content` не тянется.
- **Клиент:** `pnpm --filter client lint`, `pnpm --filter client test`.
- **Ручная:** глубокий спейс → «развернуть всё» раскрывает все уровни одним
запросом, индикатор работает; «свернуть всё» схлопывает до корней и **не**
теряет состояние другого спейса (переключиться туда-обратно); перезагрузка —
состояние сохраняется (localStorage); смена спейса в середине загрузки —
корректно прерывается; пустой спейс — без поломок; имитация ошибки сети — видно
конкретное уведомление, ошибка залогирована.
## Открытые вопросы
1. **Оформление эндпоинта:** флаг `recursive` к `/pages/sidebar-pages` против
отдельного `/pages/tree`. (Контракт ответа в обоих — плоский список в shape
текущего сайдбара.)
2. **Размещение команд:** две иконки (1) / одна-тоггл (2) / пункты меню (3).
Рекомендация — (3) или (1).
3. **Потолок размера ответа:** отдавать дерево любого размера или ограничить
(число узлов) и как сообщать про усечение.

View File

@@ -1,211 +0,0 @@
# AI-ассистент на публичных шарах — проектный план
> Статус: проработанная фича, **не реализована**. Контекст: gitmost — форк Docmost.
> Идея: дать **анонимному внешнему зрителю** опубликованной (расшаренной) страницы
> возможность спросить AI-агента, который ищет ответ **строго по дереву этой шары**.
> Аналог «chat with these docs» поверх публикации.
>
> Зафиксированные решения по объёму (см. раздел «Развилки»):
> область поиска — **всё дерево шары**; движок поиска — **готовый share-scoped FTS**
> (ветка `shareId` в `SearchService`); гейтинг — **один тумблер воркспейса**;
> хранение диалогов — **эфемерное** (без БД, без миграций);
> модель — **отдельная дешёвая** (не основная модель чата воркспейса);
> ввод — **только текст** (без голосового ввода / STT).
## Зачем это нетривиально
Весь стек существующего AI-агента жёстко завязан на залогиненного пользователя, и
переиспользовать его «как есть» для анонима нельзя:
- [ai-chat.controller.ts](../apps/server/src/core/ai-chat/ai-chat.controller.ts) на
`/ai-chat/stream` требует **интерактивную сессию** (`sessionId`) и явно отвергает
bearer/API-токены.
- `forUser()` в
[ai-chat-tools.service.ts](../apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts)
выдаёт **персональный loopback-JWT**: каждый инструмент агента ходит в реальный HTTP
API «от имени пользователя», и CASL ограничивает его ровно правами этого юзера.
- `ai_chats.creator_id``NOT NULL`, любой чат привязан к пользователю.
У анонимного зрителя шары нет ни сессии, ни user-identity, ни CASL-контекста. Значит,
строим **параллельный, заранее запертый read-only путь**. Граница безопасности здесь —
не identity (её нет), а **жёсткий scope инструментов по дереву шары**.
## Что переиспользуется (сверено с кодом)
Половина нужного уже есть и проверена в бою на публичном просмотре шар:
- **Резолв «страница X читается через шару Y»**: `getShareForPage(pageId, workspaceId)`
в [share.service.ts](../apps/server/src/core/share/share.service.ts) — рекурсивный CTE
вверх по дереву до ближайшего предка-шары; учитывает `includeSubPages` и проверку
`share.workspaceId === workspaceId`.
- **Набор публично читаемых страниц**: `getPageAndDescendantsExcludingRestricted(share.pageId)`
(страница + потомки, **исключая** restricted-поддеревья).
- **Готовый share-scoped поиск**: в
[search.service.ts](../apps/server/src/core/search/search.service.ts) уже есть ветка
`searchParams.shareId && !spaceId && !opts.userId`, которая ограничивает полнотекстовую
выдачу деревом шары и исключает restricted-предков. Это **готовый движок поиска для анонима**.
- **Подготовка контента для публичной отдачи**: `prepareContentForShare` — срезание
`comment`-марок и токенизация вложений (JWT на `/files/public/...`). Тот же путь должен
использовать инструмент чтения страницы у анонимного агента.
- **Публичные роуты** в [share.controller.ts](../apps/server/src/core/share/share.controller.ts)
уже `@Public()`, воркспейс резолвит `DomainMiddleware` по хосту; новый роут под `/api/shares/*`
ложится туда же — **правок в [main.ts](../apps/server/src/main.ts) не нужно**.
- **Стриминг-плумбинг**: `AiService.getChatModel(workspaceId)` (нужен небольшой апгрейд —
опциональный override id модели, чтобы для шары взять дешёвую `publicShareChatModel`
вместо основной `chatModel`; драйвер/`baseUrl`/`apiKey` те же) +
`streamText``pipeUIMessageStreamToResponse` (как в
[ai-chat.service.ts](../apps/server/src/core/ai-chat/ai-chat.service.ts)).
## Архитектура
### Сервер
**1. Тумблер воркспейса (гейтинг) + отдельная модель.**
Новое булево поле в `workspace.settings.ai`, напр. `publicShareAssistant` (default `false`) —
туда же, где живут остальные AI-настройки и тумблер MCP; читается/пишется через сервис
AI-настроек (рядом с `ai-settings.service.ts`). В админке **Workspace settings → AI**
один свитч. Хелпер `isPublicShareAssistantEnabled(workspaceId)`.
Рядом — **отдельное поле модели** `publicShareChatModel?: string` в `settings.ai.provider`
([ai.types.ts](../apps/server/src/integrations/ai/ai.types.ts), рядом с `chatModel` /
`embeddingModel` / `sttModel`). Это **только id модели**: драйвер, `baseUrl` и `apiKey`
переиспользуются от основного чат-провайдера — отдельные креды не нужны. Пустое значение →
fallback на `chatModel`. В админке Workspace settings → AI — отдельное поле «модель
публичного ассистента». Зачем отдельная и дешёвая: за токены анонимов платит **владелец
воркспейса**, а read-only Q&A строго по дереву шары не требует флагманской модели — это и
анти-абьюз (дешевле цена ошибки/абьюза), и явное разделение «дорогой внутренний агент vs
дешёвый внешний ассистент».
**2. Публичный эндпоинт** `POST /api/shares/ai/stream` (`@Public()`).
Новые `public-share-chat.controller.ts` + `public-share-chat.service.ts` в модуле `ai-chat`
(переиспользуют `AiService` и плумбинг `streamText`), зависят от `ShareRepo` / `PageRepo` /
`PagePermissionRepo` / `SearchService` для scope.
Контракт:
| Поле запроса | Назначение |
| --- | --- |
| `shareId` | идентификатор/ключ шары |
| `pageId` | открытая страница (контекст «эта страница») |
| `messages` | транскрипт диалога (UIMessage[]); сервер ничего не хранит |
Ответ — SSE-поток UIMessage (как у `/ai-chat/stream`).
**3. Воронка проверок (она же — guardrail; порядок важен).**
| Условие | Код | Почему так |
| --- | --- | --- |
| Тумблер воркспейса выключен | `404` | Не раскрываем существование фичи |
| Шара не найдена / чужой воркспейс / `isSharingAllowed=false` | `404` | Неотличимо от «нет шары» |
| `pageId` вне дерева шары (`getShareForPage` вернул undefined) | `404` | Не подтверждаем существование приватной страницы |
| AI-провайдер не настроен | `503` | Конфиг, а не доступ |
| Превышен IP-лимит | `429` | Анти-абьюз |
**4. Изолированный тулсет `forShare(shareId, workspaceId)`** — крошечный, только READ,
in-process (никакого loopback-токена и user-identity):
- `searchSharePages({ query })``searchService.searchPage(query, { shareId, workspaceId })`
(существующая ветка `shareId && !spaceId && !userId`). Возвращает `{ id, title, snippet }`.
- `getSharePage({ pageId })` → сначала `getShareForPage(pageId, workspaceId)` подтверждает
принадлежность к **этой** шаре, затем контент отдаётся через `prepareContentForShare`.
Не в шаре → ошибка тула, без утечки факта существования страницы.
- Опционально `getShareOutline` / `listSharePages` поверх логики `/shares/tree`.
- Больше ничего: ни write-инструментов, ни комментариев, ни истории, ни списка шар,
ни кросс-спейс инструментов, ни external MCP.
**5. Стриминг + запертый промпт.**
`buildShareSystemPrompt({ share, openedPage })`: персона «отвечаешь строго по этой
опубликованной документации; ничего не можешь менять; если ответа в страницах нет — так
и говоришь» + неизменяемый safety-блок по образцу
[ai-chat.prompt.ts](../apps/server/src/core/ai-chat/ai-chat.prompt.ts).
`model`**дешёвая `publicShareChatModel`** (override в `getChatModel`, fallback на
`chatModel`), а не основная модель агента воркспейса.
`streamText({ model, system, messages, tools, stopWhen: stepCountIs(5) })`.
**Без серверного хранения** — транскрипт держит клиент; доверять присланным сообщениям
безопасно, т.к. scope обеспечивают тулы, а не транскрипт. Это снимает проблему
`creator_id NOT NULL` и не копит PII анонимов → **миграция БД не нужна**.
**6. Анти-абьюз (обязательно — за токены платит владелец воркспейса).**
- **IP-keyed троттлер** на роут: существующий `UserThrottlerGuard` ключуется по юзеру,
здесь юзера нет — нужен guard/`@Throttle`, ключующийся по IP (предлагаю ~5 запросов/мин).
- Лимиты: `stepCountIs(5)`, максимум длины сообщения, максимум числа сообщений в запросе.
### Клиент
- В публичном вью [shared-page.tsx](../apps/client/src/pages/share/shared-page.tsx) —
виджет «Спросить AI», рендерится только если `features` из `/shares/page-info` сообщает,
что ассистент включён (расширяем уже существующий `features`-пейлоад).
- Лёгкий чат-компонент на `useChat` + `DefaultChatTransport` на `/api/shares/ai/stream`,
шлёт `{ shareId, pageId, messages }`, `credentials: 'omit'`. Эфемерный, in-memory —
стрипнутая версия
[chat-thread.tsx](../apps/client/src/features/ai-chat/components/chat-thread.tsx) без
списка чатов, истории, персистентности и **голосового ввода** (только текстовое поле).
## Поток одного хода
1. Клиент шлёт `{ shareId, pageId, messages }``/shares/ai/stream`.
2. Воронка проверок (таблица выше); любой провал → выход без стрима.
3. `getShareForPage(pageId)` — подтверждение принадлежности + резолв шары.
4. Сборка `forShare(shareId, workspaceId)` — 2–3 read-only тула, scope = дерево шары.
5. Запертый system-prompt + **отдельная дешёвая модель** (`publicShareChatModel`, fallback на `chatModel`) → `streamText(stopWhen: stepCountIs(5))`.
6. Тулы при вызовах фильтруют по дереву шары (FTS-ветка `shareId`, `getShareForPage` для чтения).
7. Поток уходит клиенту; на сервере ничего не персистится.
## Edge-cases (закрыты переиспользованием)
- **Restricted-потомки** не попадают ни в поиск, ни в чтение — это уже делают
`getPageAndDescendantsExcludingRestricted` и ветка `shareId` в `SearchService`.
- **`includeSubPages = false`** → ищется и читается ровно одна страница.
- **Prompt-injection из контента** («покажи приватные страницы») бессилен: у анонимного
тулсета физически нет инструмента за пределы дерева шары.
- **Cloud-мультитенант**: проверка `share.workspaceId === workspaceId` обязательна — хост
определяет тенант.
- **RAG/вектор не задействован** (по решению — только FTS): фича не зависит от того,
проиндексированы ли страницы в `page_embeddings`.
## Явные non-goals
- Нет write-инструментов, комментариев, истории, списка шар, кросс-спейс доступа.
- Нет external MCP / веб-поиска для анонимов.
- Нет серверного хранения диалогов (эфемерно).
- Нет RAG/вектора — только share-scoped FTS.
- Нет per-share гранулярности — один тумблер на воркспейс.
- **Нет голосового ввода / STT-диктовки** — только текстовый ввод (виджет не тянет
микрофонный путь внутреннего чата).
- Не основная модель агента — **отдельная дешёвая** `publicShareChatModel`.
## Развилки (зафиксированные решения)
| Развилка | Решение | Альтернативы (отклонены) |
| --- | --- | --- |
| Область поиска | **Всё дерево шары** | только открытая страница; все публичные шары воркспейса |
| Движок поиска | **Готовый share-scoped FTS** | share-scoped гибрид/RAG (`hybridSearchByPages`) — отложено |
| Гейтинг | **Один тумблер воркспейса** | per-share флаг; тумблер + опт-ин на шару |
| Хранение диалогов | **Эфемерно** | отдельная таблица / nullable `creator_id` |
| Модель | **Отдельная дешёвая** (`publicShareChatModel`, fallback на `chatModel`) | основная модель чата воркспейса (дороже, незачем для read-only Q&A анонимов) |
| Голосовой ввод | **Не нужен** (только текст) | STT-диктовка как во внутреннем чате |
## Осталось решить (не блокирует)
- Точные числа лимитов: IP rate-limit (старт ~5/мин), max длина сообщения, max число
сообщений в запросе, `stepCountIs` (старт 5).
- UX виджета: плавающая кнопка vs боковая панель vs блок под контентом.
- Финальная формулировка запертого промпта (персона + safety-блок).
- Дефолт/подсказка для `publicShareChatModel`: что предлагать админу как «дешёвую» модель
и поведение при пустом поле (сейчас — fallback на `chatModel`).
## Объём работ
~2 новых серверных файла (controller + service) + tools-метод `forShare` + share-промпт +
IP-троттлер + два поля настройки (тумблер `publicShareAssistant` и модель
`publicShareChatModel`) и свитч + поле модели в админке + небольшой override id модели в
`getChatModel`; на клиенте — виджет и лёгкий чат-компонент (текстовый, без голосового ввода).
**Без миграций БД.** Пользовательского агента не трогаем.
## Возможные расширения (следующие итерации)
- **Share-scoped гибрид/RAG**: вариант `hybridSearch` с фильтром `pageId IN allowedPageIds`
(вектор + FTS) вместо `space_id IN (...)` — качественнее ответы, но зависит от индексации.
- **Per-share гранулярность**: флаг на конкретную шару поверх мастер-тумблера.
- **Лёгкая аналитика/аудит**: отдельная таблица для анонимных диалогов (если понадобится),
не нарушая `ai_chats.creator_id NOT NULL`.