Merge develop for the 0.93.0 release
This commit is contained in:
@@ -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`. Набор инструментов агента не
|
||||
трогаем.**
|
||||
@@ -1,95 +0,0 @@
|
||||
# Вставка произвольного HTML/CSS/JS в страницы — анализ и подходы
|
||||
|
||||
> Статус: **черновик / обсуждение**. Решение по модели изоляции ещё не принято — см. раздел «Развилка».
|
||||
> Исходный кейс: нужно вставлять трекер (счётчик аналитики) на вики-страницы.
|
||||
|
||||
## 1. Почему «из коробки» произвольный HTML вставить нельзя
|
||||
|
||||
Контент страницы в Docmost хранится не как HTML, а как **ProseMirror JSON** (документ TipTap, синхронизируется через Yjs). Любой путь, которым контент попадает в страницу — ручной ввод, вставка из буфера (paste), импорт Markdown/HTML — проходит парсинг строго по схеме редактора:
|
||||
|
||||
`apps/server/src/common/helpers/prosemirror/html/generateJSON.ts:45`
|
||||
|
||||
```ts
|
||||
PMDOMParser.fromSchema(schema).parse(doc.body, options)
|
||||
```
|
||||
|
||||
`PMDOMParser.fromSchema` оставляет только те теги, для которых в схеме есть нода/марк с правилом `parseHTML` (`p`, `h1–h6`, списки, `blockquote`, `code`/`pre`, `a`, `strong`/`em`, таблицы, картинки, callout и т.п.). Всё остальное — `<div>`, `<style>`, `<script>`, инлайн-стили, кастомные теги — **молча отбрасывается**, выживает максимум текст внутри.
|
||||
|
||||
- Ноды «сырой HTML» в схеме нет (`rawHtml`/`htmlNode` в `packages/editor-ext/src/lib` отсутствуют).
|
||||
- `marked` сам по себе HTML пропускает насквозь (санитайзер не подключён в `packages/editor-ext/src/lib/markdown/utils/marked.utils.ts`), но это неважно — финальным фильтром выступает схема ProseMirror на следующем шаге.
|
||||
- Единственное, что отдалённо похоже на «вставку HTML» — embed-нода (`packages/editor-ext/src/lib/embed.ts`), но это `<iframe>` на URL известных провайдеров с санитизацией ссылки, а не произвольная разметка.
|
||||
|
||||
**Вывод:** чтобы получить произвольный HTML на странице, нужно добавлять в схему редактора отдельную ноду со своим `parseHTML`/`renderHTML` и собственным рендерингом.
|
||||
|
||||
## 2. Механика: как добавить такую ноду (одинаково для любого варианта)
|
||||
|
||||
По образцу `packages/editor-ext/src/lib/excalidraw.ts`:
|
||||
|
||||
1. **Новая нода** в `packages/editor-ext/src/lib/html-embed/`:
|
||||
`Node.create({ name: 'htmlEmbed', group: 'block', atom: true, isolating: true })`.
|
||||
Атрибут `source` (сырой HTML/CSS/JS строкой) с `parseHTML`/`renderHTML` через `data-`-атрибут или base64, чтобы корректно гонялось туда-обратно через HTML↔JSON. Экспорт добавить в `packages/editor-ext/src/index.ts`.
|
||||
2. **Регистрация в ДВУХ схемах** (иначе сервер вырежет ноду при сохранении/коллаборации):
|
||||
- клиент: `apps/client/src/features/editor/extensions/extensions.ts`
|
||||
- сервер: `tiptapExtensions` в `apps/server/src/collaboration/collaboration.util.ts:58`
|
||||
3. **React NodeView** на клиенте — то, что реально показывает контент. Здесь и зарыта безопасность (см. развилку).
|
||||
4. **Markdown-сериализация** (turndown/marked в `packages/editor-ext/src/lib/markdown`) — если нода должна выживать при импорте/экспорте Markdown, иначе там она потеряется.
|
||||
5. **UI вставки** — slash-команда/кнопка тулбара + модалка с редактором кода.
|
||||
|
||||
## 3. Развилка: модель изоляции (ключевое решение)
|
||||
|
||||
«Произвольный JS» в многопользовательской вики — это не фича рендеринга, а **модель доверия**. От выбора зависит весь NodeView и безопасность всего инстанса.
|
||||
|
||||
### Вариант A — Sandboxed iframe
|
||||
Контент кладётся в `<iframe sandbox="allow-scripts" srcdoc="...">`.
|
||||
- JS/CSS работают, но изолированы: нет доступа к DOM вики, кукам, токену сессии, localStorage.
|
||||
- Безопасно, stored-XSS закрыт. Так делают HTML-эмбеды в Notion/Confluence.
|
||||
- Минусы: скрипт не может управлять самой страницей; авто-высоту приходится решать через `postMessage`.
|
||||
|
||||
### Вариант B — Raw-инъекция в DOM страницы
|
||||
`dangerouslySetInnerHTML` + выполнение `<script>`.
|
||||
- Полная власть: скрипт выполняется в origin вики, может всё.
|
||||
- Это **stored-XSS by design**: скрипт любого автора выполняется в браузере каждого читателя с его сессией → кража токенов, захват аккаунтов.
|
||||
- Допустимо только на доверенном/одно-пользовательском инстансе.
|
||||
|
||||
### Вариант C — Raw-инъекция, но admin-only
|
||||
Полная мощь raw-инъекции, но вставка такой ноды разрешена только админам/доверенным ролям; обычные авторы её добавлять не могут. Компромисс между мощью и риском.
|
||||
|
||||
## 4. Заработает ли трекер в песочнице? — НЕТ (для настоящего трекера)
|
||||
|
||||
Без `allow-same-origin` у iframe **opaque origin** (`null`). Из этого следуют ограничения, ломающие именно трекеры:
|
||||
|
||||
| Что делает трекер | В sandbox (`allow-scripts`) |
|
||||
|---|---|
|
||||
| Загрузить внешний `<script src>` и выполнить | ✅ работает |
|
||||
| Отправить запрос/пиксель/`sendBeacon` на свой сервер | ✅ работает (fire-and-forget) |
|
||||
| Поставить куку (`document.cookie`) | ❌ блокируется/кидает ошибку |
|
||||
| `localStorage` / `sessionStorage` | ❌ `SecurityError` в opaque origin |
|
||||
| Прочитать URL / referrer / title **самой вики-страницы** | ❌ видит только `about:srcdoc`, не родителя |
|
||||
| Достучаться до DOM страницы (`window.parent`) | ❌ запрещено sandbox'ом |
|
||||
|
||||
**Итог:** GA4 / Яндекс.Метрика / Matomo внутри песочницы либо упадут на попытке поставить `_ga`/`_ym`, либо отправят «хит», где страница = `about:srcdoc`, а уникальный посетитель не сохраняется → данные мусорные. Песочница и «считать саму страницу» — взаимоисключающие вещи by design.
|
||||
|
||||
Добавлять `allow-same-origin` вместе с `allow-scripts` как «компромисс» нельзя: при одинаковом origin это снимает песочницу полностью (предупреждение MDN) — то есть это та же raw-инъекция окольным путём.
|
||||
|
||||
## 5. Что это значит для выбора
|
||||
|
||||
- **Цель — аналитика самих вики-страниц** (посещения, поведение, уники) → нужен скрипт в origin вики = **raw-инъекция**. Песочница тут бесполезна в принципе. Риск — stored-XSS, поэтому разумно держать это под **admin-only** (вариант C).
|
||||
- **Цель — самодостаточный встроенный виджет** (калькулятор, демка, виджет без кук и без доступа к родителю) → песочницы (вариант A) хватает.
|
||||
|
||||
## 6. Возможные направления решения (выбрать позже)
|
||||
|
||||
1. **Admin-only raw-инъекция** — нода `htmlEmbed` с полным выполнением скрипта в origin вики; вставка только для админов/доверенных ролей. Трекер работает полноценно (куки, уники, URL страницы). Компромисс мощь/риск.
|
||||
2. **Raw-инъекция без ограничений** — любой автор может вставить произвольный JS. Максимум гибкости, но stored-XSS для всех читателей. ОК только если все редакторы полностью доверенные.
|
||||
3. **Узкая фича «только трекер», без произвольного JS** — вместо универсальной HTML-ноды поле в настройках для ID счётчика (GA/Метрика), сниппет вставляется в шаблон страницы. Безопасно и решает именно задачу трекинга.
|
||||
4. **Sandboxed iframe (вариант A)** — для встраиваемых виджетов; для аналитики самих страниц не годится.
|
||||
|
||||
---
|
||||
|
||||
### Ссылки на код
|
||||
- Парсинг по схеме (фильтр HTML): `apps/server/src/common/helpers/prosemirror/html/generateJSON.ts`
|
||||
- Серверный список расширений: `apps/server/src/collaboration/collaboration.util.ts:58`
|
||||
- Клиентский список расширений: `apps/client/src/features/editor/extensions/extensions.ts`
|
||||
- Реестр нод editor-ext: `packages/editor-ext/src/index.ts`
|
||||
- Образец кастомной ноды: `packages/editor-ext/src/lib/excalidraw.ts`
|
||||
- Образец iframe-ноды: `packages/editor-ext/src/lib/embed.ts`
|
||||
- Markdown ↔ HTML: `packages/editor-ext/src/lib/markdown/`
|
||||
@@ -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-аргументом), чтобы формат ошибок
|
||||
провайдера был единым.
|
||||
165
docs/backlog/ai-chat-role-cards-empty-state.md
Normal file
165
docs/backlog/ai-chat-role-cards-empty-state.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# Выбор agent role карточками в пустом окне чата (вместо выпадающего списка)
|
||||
|
||||
Контекст: при создании нового чата identity (agent role) выбирается из
|
||||
выпадающего списка Mantine `<Select>`. Просьба: заменить список на **карточки
|
||||
разных цветов с названием identity по центру пустого окна чата**. Клик по
|
||||
карточке применяет роль; если пользователь карточку не нажал и просто написал
|
||||
сообщение — срабатывает дефолтный Universal assistant.
|
||||
|
||||
Скриншот текущего поведения приложил пользователь: «Agent role» + раскрытый
|
||||
список (Universal assistant ✓, Пират, Дедушка).
|
||||
|
||||
## Как сейчас устроен выбор роли (цепочка)
|
||||
|
||||
1. Picker рисуется только для нового чата (`activeChatId === null`), когда есть
|
||||
включённые роли, как `<Select label="Agent role">`:
|
||||
`apps/client/src/features/ai-chat/components/ai-chat-window.tsx:543-561`.
|
||||
Значение `""` → «Universal assistant» (роль `null`); остальные опции —
|
||||
`enabledRoles` (эмодзи + имя).
|
||||
2. Список включённых ролей фильтруется клиентом из всех живых ролей:
|
||||
`ai-chat-window.tsx:144-147` (`enabledRoles = roles.filter(r => r.enabled)`).
|
||||
Источник — `useAiRolesQuery(windowOpen)`
|
||||
(`apps/client/src/features/ai-chat/queries/ai-chat-query.ts:131-137`).
|
||||
3. Выбранный id хранится в jotai-атоме:
|
||||
`apps/client/src/features/ai-chat/atoms/ai-chat-atom.ts:23`
|
||||
(`selectedAiRoleIdAtom`, `null` = Universal assistant). Сбрасывается в `null`
|
||||
при «New chat»: `ai-chat-window.tsx:168-174` (`startNewChat`).
|
||||
4. Выбранный id прокидывается в тред и уходит в теле первого запроса:
|
||||
`ai-chat-window.tsx:570-578` (`roleId={activeChatId === null ? selectedRoleId : null}`)
|
||||
→ `apps/client/src/features/ai-chat/components/chat-thread.tsx:95-96, 128-138`
|
||||
(`roleIdRef` → `prepareSendMessagesRequest` кладёт `roleId` в body).
|
||||
Сервер учитывает `roleId` ТОЛЬКО при создании чата и фиксирует роль навсегда;
|
||||
для существующего чата роль читается из строки чата (бейдж в шапке окна:
|
||||
`ai-chat-window.tsx:433-440`).
|
||||
5. Пустая область чата сейчас — бледный текст по центру:
|
||||
`apps/client/src/features/ai-chat/components/message-list.tsx:130-140`
|
||||
(`<Center>` + `emptyState ?? t("Ask the AI agent anything...")`).
|
||||
Важно: `MessageList` УЖЕ принимает произвольный `emptyState: ReactNode`
|
||||
(`message-list.tsx:10-33, 64-70`) — этим пользуется публичный шэр.
|
||||
|
||||
Данные роли в picker-представлении (доступны не-админам):
|
||||
`id, name, emoji, description, enabled` —
|
||||
`apps/server/src/core/ai-chat/roles/ai-agent-roles.service.ts:35-41, 164-173`.
|
||||
То есть для карточек есть эмодзи и название (описание опционально).
|
||||
|
||||
## Желаемое поведение
|
||||
|
||||
- Вместо `<Select>` — карточки разных цветов по центру пустого окна чата.
|
||||
- Каждая карточка = identity (роль), отдельный цвет, по центру эмодзи + имя.
|
||||
- Отдельная карточка **Universal assistant** (дефолт), подсвечена по умолчанию.
|
||||
- Клик по карточке выбирает/применяет identity (визуальная подсветка выбранной).
|
||||
- Если ни одна карточка не нажата и пользователь отправил сообщение → роль `null`
|
||||
→ Universal assistant (текущая дефолтная ветка сервера).
|
||||
- После отправки первого сообщения карточки исчезают (чат больше не пуст).
|
||||
|
||||
## Ключевое архитектурное решение
|
||||
|
||||
Рисовать карточки **как empty-state** окна чата через уже существующий проп
|
||||
`emptyState` у `MessageList`, а НЕ отдельным блоком над полем ввода. Почему так:
|
||||
|
||||
- «посреди пустого окна чата» получается само: `MessageList` оборачивает
|
||||
`emptyState` в `<Center>` (`message-list.tsx:130-140`).
|
||||
- «не нажал и написал сообщение → дефолт» получается само: как только
|
||||
`messages.length > 0`, empty-state (и карточки) не рендерится, а
|
||||
`selectedRoleId` остаётся `null` → Universal assistant. Никакой логики
|
||||
«сбросить выбор при отправке» не нужно.
|
||||
- Состояние выбора остаётся в том же `selectedAiRoleIdAtom`, поэтому вся
|
||||
серверная обвязка (`roleId` в body, фиксация роли при создании чата) **не
|
||||
меняется** — изменения чисто фронтовые.
|
||||
|
||||
Поток: `AiChatWindow` собирает узел карточек → новый проп `emptyState` у
|
||||
`ChatThread` → форвард в `MessageList`.
|
||||
|
||||
## Состав изменений
|
||||
|
||||
1. **Новый компонент `role-cards.tsx`** (+ `role-cards.module.css`),
|
||||
`apps/client/src/features/ai-chat/components/`:
|
||||
- Пропсы: `roles: IAiRole[]`, `selectedRoleId: string | null`,
|
||||
`onSelect: (id: string | null) => void`.
|
||||
- Рендер: контейнер карточек с переносом (flex-wrap), по центру:
|
||||
- первая карточка — Universal assistant (значение `null`), нейтрально-серая,
|
||||
подсвечена когда `selectedRoleId === null`;
|
||||
- по карточке на каждую роль: цвет по индексу, по центру эмодзи (если есть)
|
||||
+ имя; подсвечена когда `selectedRoleId === r.id`.
|
||||
- Карточка — `UnstyledButton` (доступность + темизация Mantine). Клик →
|
||||
`onSelect(value)`. Выбранная — более яркий бордер/кольцо + галочка.
|
||||
- Цвета — фиксированная палитра имён Mantine, циклично по индексу:
|
||||
`blue, grape, teal, orange, pink, cyan, lime, indigo, red, violet`.
|
||||
Через theme-aware CSS-переменные (корректны и в светлой, и в тёмной теме):
|
||||
фон `var(--mantine-color-${c}-light)`, текст
|
||||
`var(--mantine-color-${c}-light-color)`, бордер выбранной
|
||||
`var(--mantine-color-${c}-filled)`. Universal — `gray`.
|
||||
- Раскладка (размер карточек ~100–130px, отступы, hover, кольцо выбора,
|
||||
прокрутка при большом числе ролей) — в CSS-модуле; цвет инжектится инлайн.
|
||||
|
||||
2. **`ai-chat-window.tsx`**:
|
||||
- Удалить блок `<Select>` (`:543-561`) и импорт `Select` (`:9`, используется
|
||||
только там — проверить, что `Group/Loader/Tooltip` остаются нужны).
|
||||
- Собрать узел карточек только когда `activeChatId === null &&
|
||||
enabledRoles.length > 0`, иначе `undefined`.
|
||||
- Передать его в `<ChatThread emptyState={...} />` (`:570-578`). Существующее
|
||||
`roleId={...}` без изменений.
|
||||
|
||||
3. **`chat-thread.tsx`**:
|
||||
- Добавить необязательный проп `emptyState?: ReactNode` (импорт `ReactNode`)
|
||||
и форварднуть в `<MessageList emptyState={...} />` (`:164`).
|
||||
|
||||
4. **`message-list.tsx`** — без изменений (проп `emptyState` уже поддержан).
|
||||
|
||||
Иллюстративный набросок (НЕ финальный код), `AiChatWindow`:
|
||||
|
||||
```tsx
|
||||
// Role cards become the empty-state ONLY for a brand-new chat that has roles.
|
||||
const roleCardsNode =
|
||||
activeChatId === null && enabledRoles.length > 0 ? (
|
||||
<RoleCards
|
||||
roles={enabledRoles}
|
||||
selectedRoleId={selectedRoleId}
|
||||
onSelect={setSelectedRoleId}
|
||||
/>
|
||||
) : undefined;
|
||||
// ...
|
||||
<ChatThread
|
||||
...
|
||||
roleId={activeChatId === null ? selectedRoleId : null}
|
||||
emptyState={roleCardsNode}
|
||||
/>
|
||||
```
|
||||
|
||||
## Краевые случаи
|
||||
|
||||
- **Нет включённых ролей** → карточки не показываем (`emptyState = undefined`),
|
||||
остаётся обычный дефолтный текст empty-state.
|
||||
- **Существующий чат** (`activeChatId !== null`) → карточек нет; роль уже
|
||||
зафиксирована и показана бейджем в шапке (`ai-chat-window.tsx:433-440`).
|
||||
- **Сброс выбора** при «New chat» уже делается (`setSelectedRoleId(null)`,
|
||||
`startNewChat`) — поведение сохраняется.
|
||||
- **Много ролей** → контейнер с переносом и прокруткой, чтобы не ломать пустую
|
||||
область чата.
|
||||
- **Тёмная тема** → за счёт `-light`/`-filled` переменных Mantine цвета
|
||||
корректны в обеих темах.
|
||||
- **Эмодзи нет** → карточка показывает только имя (как сейчас в `<Select>`:
|
||||
`r.emoji ? ... : ''`).
|
||||
|
||||
## Локализация
|
||||
|
||||
Новых ключей не требуется: переиспользуем существующие `t("Agent role")` и
|
||||
`t("Universal assistant")` (есть в `apps/client/public/locales/en-US/translation.json:1220-1221`;
|
||||
остальные локали падают на ключ — как сейчас у `<Select>`). Если решим добавить
|
||||
подпись-подсказку (например «или просто начните печатать») — это один новый ключ
|
||||
в `en-US/translation.json`; по умолчанию в объём не закладываю.
|
||||
|
||||
## Режим работы при реализации
|
||||
|
||||
Изменение нетривиальное (новый компонент + логика выбора/цветов + интеграция с
|
||||
empty-state), поэтому — делегирование кодеру с обязательным последующим ревью
|
||||
(`review` subagent), затем верификация перечитыванием файлов.
|
||||
|
||||
## Открытые вопросы (решить перед/во время реализации)
|
||||
|
||||
- [ ] Нужна ли карточка Universal assistant отдельной плиткой, или достаточно
|
||||
«ничего не выбрано = дефолт»? Предлагается отдельная карточка (явный
|
||||
возврат к дефолту после клика по роли) — подтвердить.
|
||||
- [ ] Показывать ли `description` роли на карточке (есть в picker-view) или
|
||||
только эмодзи + имя? По умолчанию — только эмодзи + имя, описание в `title`.
|
||||
- [ ] Нужна ли подпись-подсказка над карточками (тогда +1 ключ локали).
|
||||
@@ -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`.
|
||||
- Не коммитить; в конце предложить сообщение коммита.
|
||||
@@ -60,6 +60,36 @@ agent-claim, `docmost-client.loader.ts:159` — `getCollabToken`; см. план
|
||||
встроенный агент получал устаревшую подсказку. Это и есть материализованный
|
||||
parity-баг.
|
||||
|
||||
## Расширение: дублируется не только описания инструментов — ещё и конвертер (PM ↔ Markdown)
|
||||
|
||||
Зафиксировано при планировании встраивания git-синка (`docmost-sync` → gitmost,
|
||||
нативная in-process интеграция). Та же болезнь «несколько рукописных копий одного
|
||||
кода» теперь касается слоя конвертации ProseMirror ↔ Markdown и его lib, а не
|
||||
только метаданных инструментов.
|
||||
|
||||
- **Копия в gitmost** — `packages/mcp/src/lib/`: `markdown-converter.ts` (~885
|
||||
строк), `markdown-document.ts` (~136), `node-ops.ts`, `diff.ts`,
|
||||
`docmost-schema.ts`. Канонизатора (`canonicalize.ts`) здесь НЕТ.
|
||||
- **Копия в docmost-sync** — `packages/docmost-client/src/lib/`: тот же набор +
|
||||
`canonicalize.ts` (~11 КБ, держит идемпотентность round-trip, SPEC §11) +
|
||||
`markdown-document.ts` с режимом «тело + якоря, без тредов комментов»
|
||||
(`includeCommentThreads:false`, на ~20 строк больше).
|
||||
- **Третья копия (планируется)** — план git-синка вендорит чистую часть
|
||||
конвертера в новый `packages/git-sync` (collab-файл не нужен: запись идёт
|
||||
нативно через `openDirectConnection` + `@docmost/editor-ext`).
|
||||
|
||||
Копии уже молча разъехались (docmost-sync vs `packages/mcp`): `collaboration.ts`
|
||||
~329 изменённых строк, `node-ops.ts` ~53, `markdown-converter.ts` ~24,
|
||||
`markdown-document.ts` ~20. Отдельно: `docmost-schema.ts` в lib дублирует
|
||||
**реальную** схему сервера `@docmost/editor-ext` (её использует collab/persistence)
|
||||
— расхождение схем = риск битой конвертации нод.
|
||||
|
||||
Вывод: тот же фикс-вектор (единый источник правды), что и для инструментов, стоит
|
||||
распространить на конвертер — общий пакет конвертации, потребляемый `mcp`,
|
||||
`git-sync` и (в идеале) сервером. До конвергенции git-sync держит вендоренную
|
||||
копию валидированного конвертера с гейтом round-trip против схемы `editor-ext`
|
||||
(осознанный долг «третья копия сейчас, объединяем позже»).
|
||||
|
||||
## Фикс
|
||||
|
||||
Единый реестр спеков (полное устранение дублирования).** Вынести в
|
||||
|
||||
@@ -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-е состояние «тест упал»?
|
||||
@@ -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`). В запросе он не входит в «три эндпоинта», но логически
|
||||
страдает тем же. Можно причесать заодно для единообразия — отдельным мелким
|
||||
шагом, по той же схеме.
|
||||
@@ -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).
|
||||
- **Нижнее поле ввода и размер аватаров:** оставляем как есть.
|
||||
93
docs/backlog/feature-test-coverage-deferred.md
Normal file
93
docs/backlog/feature-test-coverage-deferred.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Отложенные тесты по фичам с коммита 053a9c0d (хвост от PR #49)
|
||||
|
||||
## Контекст
|
||||
|
||||
PR #49 («test: cover features since 053a9c0d + repair test tooling») закрыл
|
||||
основную массу покрытия новых фич gitmost (+~330 тестов: server/Jest,
|
||||
client/Vitest, editor-ext/Vitest, packages/mcp/node:test) и починил
|
||||
тест-инструментарий (FIX-0 сломанные спеки transclusion, BUILD-0 сборка
|
||||
editor-ext перед серверными тестами, INFRA-0 резолв `.tsx` email-шаблонов).
|
||||
|
||||
Часть тестов из принятого тест-плана **намеренно отложена** — им нужен
|
||||
тестовый Postgres, реальный Redis или HTTP/e2e-харнес, которых в проекте
|
||||
сейчас нет, либо инвазивный рефактор продакшн-кода. Ниже — что осталось и
|
||||
почему, чтобы не потерять.
|
||||
|
||||
---
|
||||
|
||||
## 1. Интеграционные тесты против БД (нужен тестовый Postgres)
|
||||
|
||||
Сейчас все repo-зависимые проверки делаются на моках; SQL-уровень не
|
||||
исполняется. Чтобы покрыть это честно, нужен поднимаемый в CI Postgres
|
||||
(testcontainers или сервис в pipeline) + хелпер миграций.
|
||||
|
||||
- **`AiAgentRoleRepo` — изоляция и индексы.**
|
||||
`apps/server/src/database/repos/ai-agent-roles/ai-agent-roles.repo.ts`.
|
||||
Проверить против реальной БД: `findById`/`listByWorkspace` исключают
|
||||
soft-deleted строки; `findById` для roleId из ЧУЖОГО workspace → undefined
|
||||
(tenant-изоляция); дубль имени в одном workspace → 23505; то же имя
|
||||
переиспользуемо после softDelete (partial unique index
|
||||
`WHERE deleted_at IS NULL`, миграция `20260620T120000-ai-agent-roles.ts`);
|
||||
одинаковое имя в разных workspace разрешено. Это «хребет» безопасности —
|
||||
сейчас только предполагается unit-моками.
|
||||
|
||||
- **`AiChatRepo.findByCreator` — join role-badge.**
|
||||
`apps/server/src/database/repos/ai-chat/ai-chat.repo.ts` (~:27-70).
|
||||
Чат с enabled-ролью → roleName/roleEmoji заполнены; с soft-deleted ролью →
|
||||
бейдж NULL; с DISABLED ролью → бейдж NULL (должно совпадать с
|
||||
`resolveRoleForRequest`); ORDER BY квалифицирован `aiChats.*` (нет
|
||||
ambiguous column после join). Не проверяемо чистым unit-ом.
|
||||
|
||||
- **`WorkspaceService.update` / `WorkspaceRepo.updateSetting` — jsonb-merge.**
|
||||
`apps/server/src/core/workspace/services/workspace.service.ts` (~:514),
|
||||
`apps/server/src/database/repos/workspace/workspace.repo.ts` (~:275).
|
||||
Сейчас покрыта только форма вызова сервиса
|
||||
(`workspace-html-embed.spec.ts`). Не покрыто (нужна БД): `htmlEmbed:true`
|
||||
персистится через jsonb-merge **не затирая** соседние настройки (ai,
|
||||
sharing). Это и есть «kill-switch пишется» — критично, что write-половина
|
||||
тоггла не ломает остальной settings-namespace.
|
||||
|
||||
- **FK `page_template_references` onDelete('cascade').**
|
||||
Миграция `20260620T131000-page-template-references.ts`. Проверить, что
|
||||
удаление source/reference-страницы каскадит строки ссылок.
|
||||
|
||||
## 2. HTTP / e2e-харнес (его нет в apps/server)
|
||||
|
||||
- **Public-share ассистент: обход per-IP throttle ротацией XFF, но
|
||||
per-workspace cap держит.**
|
||||
Контроллер использует стоковый `@UseGuards(ThrottlerGuard)`
|
||||
(`apps/server/src/core/ai-chat/public-share-chat.controller.ts`), IP берётся
|
||||
из Fastify `trustProxy` → `X-Forwarded-For`. Единственный оправданный e2e
|
||||
(named journey «аноним спамит ассистента»): ротация XFF обходит per-IP
|
||||
лимит 5/min, но per-workspace cost-cap всё равно отдаёт 429. Требует
|
||||
поднятого HTTP-слоя Nest + trusted-proxy конфигурации.
|
||||
|
||||
- **Достоверность Lua-окна cost-cap против реального Redis.**
|
||||
`apps/server/src/core/ai-chat/public-share-workspace-limiter.ts`
|
||||
(`SLIDING_WINDOW_LUA`). Сейчас cap тестируется против TS-реализации
|
||||
`FakeRedis` в `public-share-chat.spec.ts` — баг в самой Lua-строке
|
||||
(`>=` vs `>`, неверный PEXPIRE) не поймается. Нужен интеграционный тест
|
||||
против реального/testcontainers Redis.
|
||||
|
||||
## 3. Полная интеграция `AiChatService.stream` (рефактор R1-stream)
|
||||
|
||||
`apps/server/src/core/ai-chat/ai-chat.service.ts`. В PR #49 извлечён и
|
||||
покрыт только чистый `buildErrorAssistantRecord`. Полные интеграционные
|
||||
сценарии — **запись чата, упавшего на первом ходу** (onError), жизненный
|
||||
цикл external-MCP клиентов (закрытие при throw/onFinish), и
|
||||
**история восстанавливается из БД, а не из `body.messages`** (анти-tamper) —
|
||||
требуют сидирования SDK `streamText` (инъекция/seam колбэков `onError`/
|
||||
`onFinish`/`onAbort` + `res.hijack`). Отложено, чтобы не дестабилизировать
|
||||
287-строчный `stream()`; делать вместе с выносом testable turn-pipeline.
|
||||
|
||||
---
|
||||
|
||||
## Сопутствующие НЕ-тестовые находки
|
||||
|
||||
Вынесены в отдельные issues (всплыли во время написания тестов):
|
||||
|
||||
- #52 — ai-roles: нет серверной валидации модели роли + дрейф enum драйверов.
|
||||
- #53 — ws: `invalidateSpaceRestrictionCache` без вызывающих (30с stale-окно).
|
||||
- #54 — page-embed: серверный guard глубины/циклов раскрытия.
|
||||
- #55 — transclusion: cycle-guard в `collectPageEmbedsFromPmJson`.
|
||||
- #56 — test-infra: jest DI + lib0 ESM (16 падающих сьютов).
|
||||
@@ -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-скоуп для мульти-тенант деплоя — или отдельная задача?
|
||||
@@ -1,121 +0,0 @@
|
||||
# /pages/import отдаёт 400 «Error processing file content» (регресс)
|
||||
|
||||
Статус: **диагностируемость починена** (fix #1 применён); корневая причина **не
|
||||
подтверждена** — на текущем коде локально баг воспроизвести не удалось.
|
||||
Ниже — что удалось выяснить, главный подозреваемый и что проверить дальше.
|
||||
|
||||
## Симптом
|
||||
|
||||
На задеплоенном инстансе эндпоинт `POST /pages/import` отдаёт
|
||||
`400 BadRequest` с телом «Error processing file content». Раньше работал —
|
||||
похоже на регресс после редеплоя гитмоста.
|
||||
|
||||
Через этот эндпоинт грузит контент MCP-инструмент `create_page` (это
|
||||
единственный эндпоинт, принимающий контент при создании страницы —
|
||||
см. комментарий в `packages/mcp/src/client.ts:961`).
|
||||
|
||||
Что при этом **исправно** (важно для локализации):
|
||||
- `POST /pages/create` — создание пустой страницы.
|
||||
- `update_page_json` — запись контента через realtime-коллаборацию (Yjs).
|
||||
|
||||
## Где именно бросается ошибка
|
||||
|
||||
`apps/server/src/integrations/import/services/import.service.ts:93-97` —
|
||||
`try/catch` вокруг обработки контента:
|
||||
|
||||
```ts
|
||||
} catch (err) {
|
||||
const message = 'Error processing file content';
|
||||
this.logger.error(message, err); // реальная причина логируется ТОЛЬКО в логи
|
||||
throw new BadRequestException(message); // наружу уходит generic-строка
|
||||
}
|
||||
```
|
||||
|
||||
Реальный текст ошибки/стек **проглатывается** (наружу — generic-строка), что
|
||||
нарушает конвенцию проекта (см. CLAUDE.md, «Errors must never be swallowed»).
|
||||
Поэтому по ответу 400 причину не видно — её надо читать в логах сервера
|
||||
(`logger.error(message, err)` пишет полный err) ИЛИ воспроизвести локально.
|
||||
|
||||
## Цепочка обработки для .md (что внутри try)
|
||||
|
||||
`importPage` → `processMarkdown(fileContent)`:
|
||||
1. `markdownToHtml` (`packages/editor-ext/.../marked.utils.ts`) — marked, чистый JS, без DOM.
|
||||
2. `processHTML`: cheerio `load` → `normalizeImportHtml` (`utils/import-formatter.ts`) — чистый JS.
|
||||
3. `htmlToJson` (`apps/server/src/collaboration/collaboration.util.ts:118`) →
|
||||
`generateJSON(html, tiptapExtensions)`.
|
||||
|
||||
## Ключевая зацепка: путь импорта зависит от happy-dom, рабочие пути — нет
|
||||
|
||||
`generateJSON` (`apps/server/src/common/helpers/prosemirror/html/generateJSON.ts`)
|
||||
парсит HTML через **happy-dom**: `new Window()` + `new localWindow.DOMParser()` +
|
||||
`parseFromString(...)`, затем `PMDOMParser.fromSchema(schema).parse(doc.body)`.
|
||||
|
||||
А исправные пути DOM-парсер НЕ используют:
|
||||
- `/pages/create` — пустая страница, контент не парсится.
|
||||
- `update_page_json` — пишет готовый ProseMirror-JSON в Yjs
|
||||
(`TiptapTransformer.toYdoc`), без HTML→DOM.
|
||||
|
||||
То есть единственное, что есть в сломанном пути и отсутствует в рабочих, —
|
||||
**серверный парсинг HTML через happy-dom**.
|
||||
|
||||
## Главный подозреваемый: бамп happy-dom (14 → 20)
|
||||
|
||||
- Изначально было `"happy-dom": "^14.12.3"`.
|
||||
- Сейчас запинено `"happy-dom": "20.8.9"` в `apps/server/package.json:83`
|
||||
(+ override в корневом `package.json`).
|
||||
- Пин на `20.8.9` пришёл в коммите `17da7629 "overrides"`
|
||||
(Philipinho, 2026-03-28), где `20.8.4` → `20.8.9`.
|
||||
- Скачок 14 → 20 — это 6 мажоров; у happy-dom между мажорами ломающие
|
||||
изменения в API `Window`/`DOMParser` и в поведении парсинга HTML. Очень
|
||||
вероятно, что `generateJSON` ломается на новом happy-dom.
|
||||
|
||||
Версия в node_modules подтверждена: `happy-dom@20.8.9` (симлинк свежий).
|
||||
|
||||
## Второстепенный подозреваемый
|
||||
|
||||
`getSchema(tiptapExtensions)` / `PMDOMParser.parse(...)` могут спотыкаться на
|
||||
`parseHTML`-правилах недавно добавленных нод (synced blocks/transclusion,
|
||||
page break, indent, columns, status — все они в `tiptapExtensions`). Но
|
||||
`getSchema` используется и в рабочем пути (`createYdoc`/`update_page_json`),
|
||||
поэтому сам по себе билд схемы скорее всего цел — под подозрением именно
|
||||
DOM-парс-ветка, уникальная для импорта.
|
||||
|
||||
## Направления фикса
|
||||
|
||||
1. **Диагностируемость — ✅ СДЕЛАНО (по конвенции проекта).** В catch-блоках
|
||||
`import.service.ts` (обработка контента + вставка страницы) реальная
|
||||
причина теперь прокидывается наружу: `BadRequestException` несёт
|
||||
`${err.name}: ${err.message}`, а в лог пишется полный `err` со стеком.
|
||||
Раньше наружу уходила generic-строка "Error processing file content".
|
||||
Теперь при повторе 400 на проде реальный reason будет виден прямо в теле
|
||||
ответа — без необходимости лезть в логи.
|
||||
2. **Корневой фикс — ⏳ НЕ ПОДТВЕРЖДЁН.** Гипотеза happy-dom 14→20 **не
|
||||
подтвердилась** при локальном воспроизведении на текущем коде (см. ниже).
|
||||
Применять блайнд-даунгрейд happy-dom нельзя — нужен реальный stack из
|
||||
логов/ответа после повторения.
|
||||
|
||||
## Локальное воспроизведение (выполнено)
|
||||
|
||||
На текущем `main` (happy-dom 20.8.9) вся цепочка импорта `.md` отработала
|
||||
без ошибок через `tsx` (импорты прямо из source, не из dist):
|
||||
|
||||
- `markdownToHtml` → cheerio `load` → `normalizeImportHtml` → `generateJSON`
|
||||
с полным набором из 44 `tiptapExtensions` — **OK** для:
|
||||
- базового markdown (заголовки, bold/italic, списки, таблицы, code-block,
|
||||
blockquote)
|
||||
- edge-cases: пустой контент, whitespace, HTML-сущности, вложенные списки,
|
||||
task-list, emoji, кириллица, спецсимволы в code, ссылки, изображения, hr
|
||||
- API happy-dom 20.8.9, используемые в `generateJSON`, существуют и работают:
|
||||
`new Window()`, `new localWindow.DOMParser()`, `parseFromString('…',
|
||||
'text/html')`, `happyDOM.abort()` (async), `happyDOM.close()` (async).
|
||||
- Блок `finally` в `generateJSON` вызывает `abort()/close()` без `await` и без
|
||||
`try/catch`, но эти методы не бросают синхронно и не перезаписывают
|
||||
результат — **не является** причиной 400 (проверено отдельным тестом).
|
||||
- Все `parseHTML`-правила расширений (status, transclusion, page-break,
|
||||
columns, subpages и т.д.) участвуют в успешном тесте — ни одно не падает.
|
||||
|
||||
Вывод: на текущем коде баг **не воспроизводится**. Вероятные объяснения —
|
||||
контент-специфичный кейс, которого нет в тестах; разница между source и
|
||||
собранным `dist`; либо временное состояние задеплоенного инстанса. После
|
||||
применения fix #1 повторный 400 покажет реальный reason — по нему и искать
|
||||
корень.
|
||||
@@ -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 при смене прав доступа) —
|
||||
в рамках этой задачи или отдельной?
|
||||
@@ -1,86 +0,0 @@
|
||||
# Удаление нерабочих импортов (DOCX / PDF / Confluence)
|
||||
|
||||
Контекст: DOCX, PDF и Confluence-импорт опирались на приватный EE-модуль,
|
||||
который выпилен из репозитория. В community-сборке эти пути либо бросают
|
||||
"enterprise license" (DOCX/PDF), либо молча ничего не делают (Confluence).
|
||||
Решено убрать эти форматы целиком.
|
||||
|
||||
## Уже сделано (фронтенд) — лежит в рабочем дереве, НЕ закоммичено
|
||||
|
||||
- `apps/client/src/features/page/components/page-import-modal.tsx`
|
||||
— убраны кнопки Word (DOCX), PDF, Confluence + связанный мёртвый код
|
||||
(импорты иконок `IconFileTypeDocx`/`IconFileTypePdf`/`ConfluenceIcon`,
|
||||
рефы `docxFileRef`/`pdfFileRef`/`confluenceFileRef`, ветка `confluence`
|
||||
в `handleZipUpload`, сбросы docx/pdf в `handleFileUpload`).
|
||||
Остались рабочие: Markdown, HTML, Notion, generic-zip.
|
||||
- `apps/client/src/components/icons/confluence-icon.tsx` — удалён (git rm),
|
||||
больше нигде не импортируется.
|
||||
|
||||
Статус git на момент записи:
|
||||
- `D apps/client/src/components/icons/confluence-icon.tsx`
|
||||
- `M apps/client/src/features/page/components/page-import-modal.tsx`
|
||||
|
||||
Предложенное сообщение коммита для фронтенд-части уже сформулировано
|
||||
(refactor(import): remove non-functional DOCX/PDF/Confluence import buttons).
|
||||
|
||||
## Осталось сделать (бэкенд) — ТЕКУЩАЯ ЗАДАЧА: удалить заглушки
|
||||
|
||||
Заглушки = EE-require шимы, которые throw/return. Точки правок:
|
||||
|
||||
1. `apps/server/src/integrations/import/services/import.service.ts`
|
||||
- удалить метод `processDocx` (~160-194) — EE-require → BadRequestException.
|
||||
- удалить метод `processPdf` (~196-230) — то же.
|
||||
- в `importPage` удалить ветки диспетчера `else if (.docx)` и `else if (.pdf)`
|
||||
(~76-91); оставить `.md` и `.html`.
|
||||
- удалить вычисление `pageId` (~65-69): после удаления docx/pdf оно всегда
|
||||
`undefined`, поэтому убрать и спред `...(pageId ? { id: pageId } : {})`
|
||||
в `insertPage` (~115).
|
||||
- `uuid7` (импорт, стр. 26) — НЕ трогать: используется в `importZip`
|
||||
(`const fileTaskId = uuid7();`, ~320).
|
||||
- `moduleRef` (конструктор ~45, импорт `ModuleRef` стр. 31) — ПРОВЕРИТЬ:
|
||||
использовался только в processDocx/processPdf? Если да — убрать параметр
|
||||
конструктора и импорт. (grep был прерван, нужно перепроверить.)
|
||||
|
||||
2. `apps/server/src/integrations/import/services/file-import-task.service.ts`
|
||||
- удалить ветку `if (fileTask.source === FileImportSource.Confluence) {...}`
|
||||
(~118-138) — EE-require с тихим `return`.
|
||||
- после удаления проверить, что импорт `FileImportSource` всё ещё нужен
|
||||
(Generic/Notion используются на ~109-110 — нужен).
|
||||
|
||||
3. `apps/server/src/integrations/import/import.controller.ts`
|
||||
- стр. 54: `validFileExtensions = ['.md', '.html', '.docx', '.pdf']`
|
||||
→ `['.md', '.html']`.
|
||||
- стр. ~101-106 `sourceMap`: убрать записи `'.docx': 'docx'` и `'.pdf': 'pdf'`.
|
||||
- стр. 164: `validZipSources = ['generic', 'notion', 'confluence']`
|
||||
→ `['generic', 'notion']`.
|
||||
- стр. 167: текст ошибки → "must either be generic or notion".
|
||||
|
||||
4. `apps/server/src/integrations/import/utils/file.utils.ts`
|
||||
- стр. 13: убрать `Confluence = 'confluence'` из enum `FileImportSource`
|
||||
(после удаления ветки значение не используется).
|
||||
ПРОВЕРИТЬ grep'ом, что больше нет ссылок на `FileImportSource.Confluence`.
|
||||
|
||||
5. `apps/server/src/common/features.ts`
|
||||
- стр. 9: `CONFLUENCE_IMPORT: 'import:confluence'` — ПРОВЕРИТЬ использование
|
||||
по серверу и клиенту; если не используется — убрать.
|
||||
|
||||
## Вне scope (НЕ заглушки — рабочий, но теперь недостижимый код)
|
||||
|
||||
- `isConfluenceImport`-обвязка в
|
||||
`apps/server/src/integrations/import/services/import-attachment.service.ts`
|
||||
(стр. 57, 67, 98, 674, 682, 756, 770) и confluence-стриппинг путей в
|
||||
`apps/server/src/integrations/import/utils/import.utils.ts` (стр. 45-62).
|
||||
Это реальная логика разбора вложений, а не заглушка. После удаления
|
||||
Confluence-импорта флаг `isConfluenceImport` никогда не станет true →
|
||||
код станет мёртвым, но он внутри shared-сервиса, которым пользуются
|
||||
generic/notion. Удаление — отдельный, более рискованный рефакторинг.
|
||||
Решение: пока оставить (либо отдельной задачей).
|
||||
- Комментарий в миграции `20250521T154949-file_tasks.ts:11` "(generic, notion,
|
||||
confluence)" — это просто комментарий, схему/старые миграции не трогаем.
|
||||
|
||||
## Открытые вопросы (проверить перед/во время реализации; grep был прерван)
|
||||
|
||||
- [ ] `moduleRef` в import.service.ts — используется только docx/pdf?
|
||||
- [ ] Все ссылки на `FileImportSource.Confluence` — только удаляемая ветка?
|
||||
- [ ] `CONFLUENCE_IMPORT` / `import:confluence` — где используется (сервер+клиент)?
|
||||
- [ ] `isConfluenceImport=true` ставится где-то кроме удалённого EE-модуля?
|
||||
@@ -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. **Потолок размера ответа:** отдавать дерево любого размера или ограничить
|
||||
(число узлов) и как сообщать про усечение.
|
||||
@@ -1,244 +0,0 @@
|
||||
# Сноски (footnotes) — проект фичи
|
||||
|
||||
> Статус: **проработанный план, готов к реализации**. Ключевые решения приняты.
|
||||
> - Архитектура: **reference + definitions** (модель Markdown/pandoc), а не «самодостаточный inline-атом со вложенным под-редактором».
|
||||
> - Объём: **полная интеграция** — редактор + коллаборация (Yjs/Hocuspocus) + Markdown round-trip + зеркало схемы в MCP + AI-хелпер.
|
||||
>
|
||||
> Исходный кейс: переводы технических статей (например, про дефлокуляцию при шликерном литье) требуют сносок переводчика и ссылок на источники. Сейчас их некуда деть, кроме инлайновых комментариев или костыля `[1]` руками.
|
||||
|
||||
## 1. Цели и требования
|
||||
|
||||
1. **Читать сноску прямо в тексте** — навёл/кликнул на надстрочный номер → всплывающее окно с текстом сноски, не уходя со строки.
|
||||
2. **Определения внизу страницы как часть текста** — текст сносок живёт реальным редактируемым блоком в конце документа (выделяется, копируется, экспортируется), а не виртуальной отрисовкой.
|
||||
3. **Авто-нумерация** — номера проставляются и пересчитываются автоматически при вставке/удалении/перемещении.
|
||||
4. **Безопасно для совместного редактирования** — работает поверх Hocuspocus/Yjs без расхождений между клиентами.
|
||||
5. **Переживает Markdown** — экспорт/импорт страниц со сносками (формат pandoc/GFM `[^id]`).
|
||||
6. **Доступно AI-агенту и MCP** — агент и MCP-инструменты умеют читать/создавать сноски; существующий хелпер `commentsToFootnotes` переводится на настоящие ноды.
|
||||
|
||||
## 2. Развилка (решена): почему НЕ «классический» footnote-атом
|
||||
|
||||
Есть два принципиально разных способа хранить текст сноски в ProseMirror/Tiptap.
|
||||
|
||||
### Вариант A — самодостаточный inline-атом (официальный пример ProseMirror)
|
||||
|
||||
Текст сноски лежит **внутри** inline-атома (`inline: true, atom: true, content: "text*"`), редактируется во вложенном под-редакторе в тултипе. См. [prosemirror.net/examples/footnote](https://prosemirror.net/examples/footnote/) и расширение [tiptap-extension-footnote](https://github.com/LAbigael/tiptap-extension-footnote).
|
||||
|
||||
Минусы для нашего стека:
|
||||
- **Несовместим с коллаборацией.** Вложенный под-редактор синхронизирует шаги транзакций вручную (`dispatchInner`, флаг `fromOutside`). Поверх Hocuspocus/Yjs (`TiptapTransformer`) это даёт конфликты/расхождения — известная больная точка. У нас коллаборация — это ядро ([collaboration.gateway.ts](../apps/server/src/collaboration/collaboration.gateway.ts), [yjs.util.ts](../apps/server/src/collaboration/yjs.util.ts)).
|
||||
- **Текст нельзя «положить вниз как часть текста».** Он заперт в атоме; нижний список пришлось бы рисовать виртуально (CSS/декорации) — он не выделяется и плохо экспортируется.
|
||||
- Само расширение помечено `ALPHA, DO NOT USE FOR PRODUCTION`.
|
||||
|
||||
### Вариант B — reference + definitions (ВЫБРАН)
|
||||
|
||||
Маркер в тексте и текст сноски — **разные обычные ноды**, связанные по `id`:
|
||||
- inline-атом-ссылка без контента (просто надстрочный номер);
|
||||
- блок определений внизу страницы из обычных редактируемых нод.
|
||||
|
||||
Плюсы — это ровно то, что нужно:
|
||||
- **Только обычные ноды → Yjs обрабатывает их нативно**, без вложенных редакторов. Главный выигрыш для коллаборативного стека.
|
||||
- Нижний блок — **реальная часть документа**: выделяется, копируется, экспортируется (требование 2).
|
||||
- Чтение в тексте — **read-only поповер**, который просто читает определение по `id`; под-редактор не нужен (требование 1).
|
||||
- **1:1 ложится на Markdown-сноски** pandoc/GFM (`[^id]` … `[^id]: …`) → импорт/экспорт и хелпер `commentsToFootnotes` выравниваются естественно (требования 5, 6).
|
||||
|
||||
Минусы (управляемые, см. §4–§5): нужно держать ссылки и определения в синхроне (сироты/висячие ссылки) и считать номера/порядок плагином.
|
||||
|
||||
## 3. Модель документа
|
||||
|
||||
Три новые ноды. Источник истины — **ссылка**: есть `footnoteReference` → есть парное `footnoteDefinition`; удаление ссылки каскадно удаляет определение в той же транзакции (один Ctrl+Z восстанавливает оба).
|
||||
|
||||
```jsonc
|
||||
// 1) Маркер в тексте — inline atom, без контента, только id.
|
||||
// Видимый номер НЕ хранится в документе (см. §4).
|
||||
{ "type": "footnoteReference", "attrs": { "id": "fn_a1b2c3" } }
|
||||
|
||||
// 2) Контейнер внизу страницы — реальный блок, всегда последний в документе.
|
||||
{ "type": "footnotesList", "content": [ /* footnoteDefinition+ */ ] }
|
||||
|
||||
// 3) Одно определение — обычный редактируемый блок с id, привязывающим к ссылке.
|
||||
{ "type": "footnoteDefinition",
|
||||
"attrs": { "id": "fn_a1b2c3" },
|
||||
"content": [ { "type": "paragraph", "content": [ /* текст сноски, inline */ ] } ] }
|
||||
```
|
||||
|
||||
### Почему нода, а не mark
|
||||
|
||||
Ссылка на сноску — это **вставляемый в точку курсора надстрочный глиф**, а не выделение существующего текста. Mark (как у комментариев в [comment.ts](../packages/editor-ext/src/lib/comment/comment.ts)) оборачивает диапазон; нам нужна точечная inline-нода-атом — образец [mention.ts](../packages/editor-ext/src/lib/mention.ts) (`inline: true, atom: true, selectable: true`).
|
||||
|
||||
### Схемные ограничения
|
||||
|
||||
| Нода | Параметры схемы | Где разрешена / что внутри |
|
||||
|---|---|---|
|
||||
| `footnoteReference` | `group: "inline"`, `inline: true`, `atom: true`, `selectable: true`, `draggable: false` | в любом inline-контексте, **кроме** code-block и **кроме** содержимого `footnoteDefinition` (запрет вложенных сносок) |
|
||||
| `footnotesList` | `group: "block"`, `content: "footnoteDefinition+"`, `isolating: true`, `selectable: false` | единственный экземпляр, всегда **последний** дочерний узел документа |
|
||||
| `footnoteDefinition` | `content: "paragraph+"` (или `block+` без вложенных сносок), `defining: true`, `isolating: true` | только внутри `footnotesList`; атрибут `id` обязателен |
|
||||
|
||||
`id` генерируется как `uuidv7` (как у mention/unique-id), хранится в `data-*`-атрибуте для HTML round-trip.
|
||||
|
||||
## 4. Нумерация и порядок — ключевая тонкость
|
||||
|
||||
**Решение: номера НЕ хранятся в документе.** Их вычисляет ProseMirror-плагин, проходя `footnoteReference` в порядке документа, и отрисовывает декорациями (на надстрочнике и на маркере определения).
|
||||
|
||||
Почему так:
|
||||
- Детерминированность: каждый клиент считает одинаковые номера из одного и того же документа → **никаких расхождений в коллаборации**, никаких `appendTransaction` в ответ на чужие шаги (что и есть источник конфликтов).
|
||||
- Дёшево: пересчёт на каждый рендер, без мутаций документа.
|
||||
|
||||
### Порядок определений внизу
|
||||
|
||||
Чтобы нижний список визуально шёл `1, 2, 3`, реальные ноды `footnoteDefinition` должны лежать в порядке ссылок (декорации не переставляют DOM). Стратегия:
|
||||
|
||||
1. **На создании** — команда `setFootnote` вставляет определение в **правильную позицию** (считает, сколько ссылок идёт до точки вставки, и кладёт определение по этому индексу). Покрывает и добавление в конец, и вставку в середину.
|
||||
2. **Нормализация** — плагин-нормализатор приводит порядок определений к порядку ссылок, если он нарушился (например, пользователь вырезал и переставил абзац со ссылкой). Это **чистая функция от состояния документа** → все клиенты вычисляют одинаковую перестановку и сходятся. Чтобы два клиента не дёргали нормализацию одновременно, выполнять её в `appendTransaction` с guard-метой и идемпотентно (no-op, если порядок уже верный).
|
||||
|
||||
> Главный риск реализации — именно нормализация порядка при перемещении ссылок в коллаборации. Для MVP достаточно правильной вставки на создании (п.1) + нормализации только на локальных транзакциях; перемещение ссылок между местами — редкий кейс, его можно довести во вторую очередь.
|
||||
|
||||
Визуальные номера можно при желании продублировать CSS-счётчиками (`counter-reset`/`counter-increment`, как в alpha-расширении), но decoration-подход надёжнее в коллаборации и не зависит от порядка узлов.
|
||||
|
||||
## 5. Жизненный цикл, команды и UX
|
||||
|
||||
### Команды (в ноде, через `addCommands` + `declare module "@tiptap/core"`)
|
||||
|
||||
- `setFootnote()` — в одной транзакции: вставляет `footnoteReference` с новым `id` в позицию курсора + создаёт `footnotesList` (если его нет, в самом конце документа) + добавляет туда пустое `footnoteDefinition` с тем же `id` в правильную позицию + переносит фокус в это определение, чтобы сразу печатать текст.
|
||||
- `removeFootnote(id)` — удаляет ссылку и её определение (каскад в одной транзакции). Если определений не осталось — удаляет пустой `footnotesList`.
|
||||
- `scrollToFootnote(id)` / `scrollToReference(id)` — навигация «ссылка ↔ определение» (для кнопки в поповере и «↩» в определении).
|
||||
|
||||
### Ввод
|
||||
|
||||
- **Slash-меню** `/footnote` (или `/сноска`) — пункт в [slash-menu](../apps/client/src/features/editor/components/slash-menu), вызывает `setFootnote`.
|
||||
- **Кнопка тулбара** и шорткат (например `Mod-Alt-F`).
|
||||
- Опционально input-rule (по образцу `wrappingInputRule` в callout) — например `[^` → вставка сноски; решить при реализации, не обязательно для MVP.
|
||||
|
||||
### Плагин синхронизации (`addProseMirrorPlugins`)
|
||||
|
||||
Минимальный, guard’нутый, идемпотентный:
|
||||
- **Подчистка сирот**: `footnoteDefinition` без парной ссылки — удалить (или пометить, см. §12).
|
||||
- **Вставка/коллизии при paste**: ссылка без определения → создать пустое определение; определение без ссылки → удалить; при вставке с конфликтом `id` — регенерировать `id` у пары.
|
||||
- **Пустой контейнер**: нет определений → удалить `footnotesList`.
|
||||
- **Read-only / share**: плагин **не мутирует документ** (только декорации нумерации), чтобы не трогать общий документ при простом просмотре.
|
||||
|
||||
## 6. Чтение в тексте (поповер)
|
||||
|
||||
NodeView надстрочника (`ReactNodeViewRenderer`, образец mention/callout) по hover/click открывает поповер через `@floating-ui/dom` — тот же паттерн, что в [render-items.ts](../apps/client/src/features/editor/components/slash-menu/render-items.ts) и [mention-suggestion.ts](../apps/client/src/features/editor/components/mention/mention-suggestion.ts) (offset/flip/shift, autoUpdate, закрытие по outside-click).
|
||||
|
||||
Поповер показывает **read-only** текст определения, найденного по `id` прямо в `editor.state` (никакого под-редактора). Кнопка «редактировать»/«перейти» вызывает `scrollToFootnote(id)` и фокусит определение внизу. Работает и в read-only/share-режиме — там используется тот же `mainExtensions` ([extensions.ts](../apps/client/src/features/editor/extensions/extensions.ts), [readonly-page-editor.tsx](../apps/client/src/features/editor/readonly-page-editor.tsx)).
|
||||
|
||||
## 7. Нижний блок (footnotesList)
|
||||
|
||||
NodeView контейнера рисует визуальный разделитель: верхняя граница + заголовок («Footnotes» / «Примечания», локализуется), список `footnoteDefinition`. Каждое определение — `NodeViewContent` (редактируемый контент) + декоративный номер (из §4) + «↩» для возврата к ссылке. Стили — CSS-модули + Mantine, как у остальных NodeView ([components/callout](../apps/client/src/features/editor/components/callout)).
|
||||
|
||||
## 8. HTML round-trip (parseHTML / renderHTML)
|
||||
|
||||
Для лосслесс HTML↔JSON (экспорт, `generateHTML`, серверный рендер, зеркало MCP) у каждой ноды строгие `parseHTML`/`renderHTML`:
|
||||
|
||||
| Нода | renderHTML (примерно) | parseHTML |
|
||||
|---|---|---|
|
||||
| `footnoteReference` | `<sup data-footnote-ref data-id="…">` (атом, без контента; номер ставит CSS/декорация) | `sup[data-footnote-ref]` |
|
||||
| `footnotesList` | `<section data-footnotes>…</section>` (или `<ol>`) | `section[data-footnotes]` |
|
||||
| `footnoteDefinition` | `<div data-footnote-def data-id="…">…0…</div>` (`0` — дырка под контент) | `div[data-footnote-def]` |
|
||||
|
||||
## 9. Markdown
|
||||
|
||||
Маппинг на сноски pandoc/GFM:
|
||||
- `footnoteReference` → `[^id]` в тексте;
|
||||
- `footnoteDefinition` → `[^id]: текст` в конце документа.
|
||||
|
||||
Точки правки:
|
||||
- **Экспорт HTML→Markdown (клиент/сервер):** правило turndown в [turndown.utils.ts](../packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts) (образец — правило callout).
|
||||
- **Импорт Markdown→JSON:** плагин/расширение marked в [marked.utils.ts](../packages/editor-ext/src/lib/markdown/utils/marked.utils.ts), плюс ноды должны быть в схеме `generateJSON`.
|
||||
- **MCP JSON→Markdown:** case в [markdown-converter.ts](../packages/mcp/src/lib/markdown-converter.ts) (образцы — mention/callout).
|
||||
- **Fallback:** при экспорте в формат без сносок — деградация в инлайновые `[n]` + список (текущее поведение `commentsToFootnotes`).
|
||||
|
||||
## 10. Сервер и коллаборация
|
||||
|
||||
Новые ноды обязаны попасть в серверный список расширений `tiptapExtensions` ([collaboration.util.ts](../apps/server/src/collaboration/collaboration.util.ts)) — иначе:
|
||||
- сервер вырежет ноды при сохранении/коллаборации (`getSchema` в [yjs.util.ts](../apps/server/src/collaboration/yjs.util.ts));
|
||||
- сломается серверный рендер HTML ([generateHTML.ts](../apps/server/src/common/helpers/prosemirror/html/generateHTML.ts)) и экспорт ([export.service.ts](../apps/server/src/integrations/export/export.service.ts)).
|
||||
|
||||
Поскольку это обычные ноды (а не атом с под-редактором), Yjs/`TiptapTransformer` обрабатывает их автоматически — отдельной регистрации в Yjs не нужно. Миграции БД не требуется (это уровень ProseMirror-документа, не схемы Postgres).
|
||||
|
||||
## 11. MCP: зеркало схемы и конвертер
|
||||
|
||||
`packages/mcp` **не** импортирует `editor-ext`, а держит собственное зеркало схемы. Синхронизировать вручную:
|
||||
- определения трёх нод (`parseHTML`/`renderHTML`, атрибуты) — в [docmost-schema.ts](../packages/mcp/src/lib/docmost-schema.ts);
|
||||
- сериализацию в Markdown — в [markdown-converter.ts](../packages/mcp/src/lib/markdown-converter.ts);
|
||||
- перевод существующего хелпера `commentsToFootnotes` ([transforms.ts](../packages/mcp/src/lib/transforms.ts)) с текстовых `[N]` + `orderedList` на настоящие ноды `footnoteReference`/`footnotesList`/`footnoteDefinition`; обновить подсчёт маркеров в [diff.ts](../packages/mcp/src/lib/diff.ts).
|
||||
|
||||
> ⚠️ При любом изменении схемы документа держать `packages/mcp/src/lib/` и `packages/editor-ext` в синхроне — это явное требование CLAUDE.md.
|
||||
|
||||
## 12. Краевые случаи и решения
|
||||
|
||||
| Случай | Решение |
|
||||
|---|---|
|
||||
| Удалили ссылку | Каскадно удалить определение в той же транзакции (undo восстанавливает оба) |
|
||||
| Удалили последнюю ссылку | Удалить весь `footnotesList` |
|
||||
| Paste ссылки без определения | Создать пустое определение |
|
||||
| Paste определения без ссылки | Удалить (сирота) — либо v2: пометить «осиротевшим» |
|
||||
| Коллизия `id` при paste | Регенерировать `id` у вставленной пары |
|
||||
| Перемещение ссылки (cut/paste абзаца) | Нормализатор переупорядочивает определения (§4) |
|
||||
| Вложенная сноска (ссылка внутри определения) | Запретить схемой |
|
||||
| Ссылка в code-block | Запретить |
|
||||
| Несколько ссылок на одну сноску | v2 (MVP: строго 1:1) |
|
||||
| Экспорт в формат без сносок | Fallback на `[n]` + список |
|
||||
| Read-only / share | Только декорации нумерации, без мутаций документа |
|
||||
|
||||
## 13. Затрагиваемые файлы (полный список)
|
||||
|
||||
**Редактор (editor-ext):**
|
||||
- `packages/editor-ext/src/lib/footnote/` — новые: три ноды, плагин нумерации/нормализации, команды, NodeView’ы (новый каталог).
|
||||
- [packages/editor-ext/src/index.ts](../packages/editor-ext/src/index.ts) — экспорт.
|
||||
|
||||
**Клиент:**
|
||||
- [apps/client/src/features/editor/extensions/extensions.ts](../apps/client/src/features/editor/extensions/extensions.ts) — регистрация в `mainExtensions`, привязка React-NodeView.
|
||||
- `apps/client/src/features/editor/components/footnote/` — NodeView надстрочника + поповер чтения, NodeView нижнего блока, CSS-модули (новый каталог).
|
||||
- [apps/client/src/features/editor/components/slash-menu](../apps/client/src/features/editor/components/slash-menu) — пункт `/footnote`.
|
||||
|
||||
**Сервер / коллаборация:**
|
||||
- [apps/server/src/collaboration/collaboration.util.ts](../apps/server/src/collaboration/collaboration.util.ts) — добавить ноды в `tiptapExtensions`.
|
||||
|
||||
**Markdown round-trip:**
|
||||
- [packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts](../packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts)
|
||||
- [packages/editor-ext/src/lib/markdown/utils/marked.utils.ts](../packages/editor-ext/src/lib/markdown/utils/marked.utils.ts)
|
||||
|
||||
**MCP:**
|
||||
- [packages/mcp/src/lib/docmost-schema.ts](../packages/mcp/src/lib/docmost-schema.ts)
|
||||
- [packages/mcp/src/lib/markdown-converter.ts](../packages/mcp/src/lib/markdown-converter.ts)
|
||||
- [packages/mcp/src/lib/transforms.ts](../packages/mcp/src/lib/transforms.ts) (+ [diff.ts](../packages/mcp/src/lib/diff.ts))
|
||||
|
||||
## 14. План реализации по фазам
|
||||
|
||||
1. **Схема (editor-ext):** три ноды + команды + input-rule + экспорт в `index.ts`. Минимальный плагин нумерации (декорации). Это фундамент, от него зависит всё.
|
||||
2. **Клиент UI:** NodeView надстрочника + поповер чтения (floating-ui), NodeView нижнего блока, slash-меню, CSS, регистрация в `extensions.ts`. Проверить read-only/share.
|
||||
3. **Сервер/коллаборация:** регистрация в `tiptapExtensions`; проверить сохранение, коллаборацию двух клиентов, серверный рендер/экспорт HTML.
|
||||
4. **Markdown round-trip:** turndown + marked; тест «JSON → MD → JSON» без потерь.
|
||||
5. **MCP:** зеркало схемы + конвертер + перевод `commentsToFootnotes` на ноды + `diff.ts`.
|
||||
6. **Шлифовка:** нормализация порядка при перемещении ссылок, edge-cases из §12, доступность (ARIA для надстрочника/поповера).
|
||||
|
||||
## 15. Тестирование
|
||||
|
||||
- **Unit (mcp, `node --test`):** JSON↔Markdown round-trip сносок; `commentsToFootnotes` → ноды; нумерация/нормализация как чистая функция.
|
||||
- **Unit (editor-ext):** команды `setFootnote`/`removeFootnote`, каскадное удаление, вставка определения в правильную позицию.
|
||||
- **Client (Vitest):** рендер надстрочника и поповера, навигация ссылка↔определение.
|
||||
- **Ручной/e2e:** два коллаборативных клиента (одновременная вставка сносок, отсутствие расхождений нумерации), экспорт в PDF/Markdown, публичная шара (поповер в read-only).
|
||||
|
||||
## 16. Открытые вопросы / v2
|
||||
|
||||
- Повторное использование одной сноски несколькими ссылками (pandoc допускает) — отложено.
|
||||
- Сноски-сироты: удалять молча или показывать предупреждение/«осиротевший» бейдж.
|
||||
- Концевые сноски (endnotes) на уровне спейса/книги vs постраничные — вне объёма.
|
||||
- Доп. форматы экспорта (DOCX и т.п.) — отдельно.
|
||||
|
||||
---
|
||||
|
||||
### Ссылки на код
|
||||
|
||||
- Образец inline-атома: [packages/editor-ext/src/lib/mention.ts](../packages/editor-ext/src/lib/mention.ts)
|
||||
- Образец блок-ноды с контентом + NodeView + input-rule: [packages/editor-ext/src/lib/callout/callout.ts](../packages/editor-ext/src/lib/callout/callout.ts)
|
||||
- Образец mark с id + плагин-декорация: [packages/editor-ext/src/lib/comment/comment.ts](../packages/editor-ext/src/lib/comment/comment.ts)
|
||||
- Реестр нод editor-ext: [packages/editor-ext/src/index.ts](../packages/editor-ext/src/index.ts)
|
||||
- Клиентский список расширений: [apps/client/src/features/editor/extensions/extensions.ts](../apps/client/src/features/editor/extensions/extensions.ts)
|
||||
- Поповеры через floating-ui: [slash-menu/render-items.ts](../apps/client/src/features/editor/components/slash-menu/render-items.ts), [mention/mention-suggestion.ts](../apps/client/src/features/editor/components/mention/mention-suggestion.ts)
|
||||
- Серверный список расширений: [apps/server/src/collaboration/collaboration.util.ts](../apps/server/src/collaboration/collaboration.util.ts)
|
||||
- Yjs-схема / рендер: [apps/server/src/collaboration/yjs.util.ts](../apps/server/src/collaboration/yjs.util.ts), [apps/server/src/common/helpers/prosemirror/html/generateHTML.ts](../apps/server/src/common/helpers/prosemirror/html/generateHTML.ts)
|
||||
- Markdown ↔ HTML: [packages/editor-ext/src/lib/markdown](../packages/editor-ext/src/lib/markdown)
|
||||
- Зеркало схемы MCP: [packages/mcp/src/lib/docmost-schema.ts](../packages/mcp/src/lib/docmost-schema.ts)
|
||||
- MCP конвертер / хелпер сносок: [packages/mcp/src/lib/markdown-converter.ts](../packages/mcp/src/lib/markdown-converter.ts), [packages/mcp/src/lib/transforms.ts](../packages/mcp/src/lib/transforms.ts)
|
||||
- Прообраз из примера ProseMirror: [prosemirror.net/examples/footnote](https://prosemirror.net/examples/footnote/)
|
||||
534
docs/git-sync-plan.md
Normal file
534
docs/git-sync-plan.md
Normal file
@@ -0,0 +1,534 @@
|
||||
# Git-sync: спека реализации (встраивание docmost-sync в gitmost)
|
||||
|
||||
Статус: **спецификация, код не менялся.** Детальный план реализации фичи
|
||||
«двусторонний синк страниц Docmost ↔ локальная git-папка Markdown», встроенной
|
||||
прямо в gitmost.
|
||||
|
||||
Источник движка: `https://gitea.vvzvlad.xyz/vvzvlad/docmost-sync`
|
||||
(ветка `main`, на момент спеки HEAD `b03eb35`). Все сигнатуры ниже сверены с этим
|
||||
исходником и с текущим кодом gitmost.
|
||||
|
||||
Предыстория и обоснование архитектурных развилок — в бэклоге
|
||||
[ai-chat-tool-definitions-duplicated.md](backlog/ai-chat-tool-definitions-duplicated.md)
|
||||
(раздел про дублирование конвертера) и в исходном `SPEC.md` репозитория
|
||||
docmost-sync (нумерация §-параграфов ниже ссылается на него).
|
||||
|
||||
---
|
||||
|
||||
## 0. Зафиксированные решения
|
||||
|
||||
Из обсуждения архитектуры (выбор пользователя) и трёх суб-решений:
|
||||
|
||||
1. **Нативная in-process интеграция.** Никаких REST-к-себе и сервис-юзера: чтение
|
||||
через репозитории gitmost, запись тела — через collab `openDirectConnection`,
|
||||
триггеры — через `EventEmitter2` вместо поллинга `/recent`.
|
||||
2. **Встроенный NestJS-модуль** `GitSyncModule` в `apps/server/src/integrations/git-sync`
|
||||
с `@Interval`/событиями и **leader-lock на Redis** (single-writer при нескольких
|
||||
репликах).
|
||||
3. **Настройка по спейсам в UI** — флаг в `space.settings.gitSync`, секреты
|
||||
(git-remote) — через ENV/`EnvironmentService`.
|
||||
4. **Конвертер** — вендорим *чистую* часть из docmost-sync в `packages/git-sync`,
|
||||
гейт = round-trip-идемпотентность против схемы `@docmost/editor-ext`.
|
||||
5. **Vault** — **репозиторий на спейс**; `move-to-space` = кросс-репо delete+create.
|
||||
6. **Провенанс** — отдельное значение `lastUpdatedSource = 'git-sync'`.
|
||||
|
||||
Вне scope v1 (как и в SPEC): комментарии (только якоря, без тредов), права/ACL,
|
||||
вложения как отдельный поток (едут ссылками внутри контента), realtime-подписка
|
||||
на Hocuspocus (остаётся поллинг-страховка + события).
|
||||
|
||||
---
|
||||
|
||||
## 1. Архитектура верхнего уровня
|
||||
|
||||
```
|
||||
gitmost server (NestJS, один процесс)
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ GitSyncModule │
|
||||
│ │
|
||||
│ GitSyncOrchestrator ── @Interval + Redis leader-lock │
|
||||
│ │ (per enabled space: pull-cycle / push-cycle) │
|
||||
│ │ │
|
||||
│ ├── engine (vendored docmost-sync, IO инжектируется) │
|
||||
│ │ pull.ts / push.ts / reconcile / layout / stabilize │
|
||||
│ │ │
|
||||
│ ├── GitmostDataSource ── реализует подмножество │
|
||||
│ │ DocmostClient НАТИВНО: │
|
||||
│ │ reads → PageRepo / SpaceRepo (Kysely) │
|
||||
│ │ writes → CollaborationGateway.openDirectConnection│
|
||||
│ │ + PageService (create/move/delete/...) │
|
||||
│ │ │
|
||||
│ └── VaultGit ── shell-out в системный git (как есть) │
|
||||
│ │
|
||||
│ PageChangeListener ── подписка на EventName.PAGE_* → │
|
||||
│ debounce → enqueue push-cycle │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
▲ читает/пишет страницы ▼ git push/pull
|
||||
PostgreSQL (pages/spaces) data/git-sync/<spaceId>/ (vault) → remote
|
||||
```
|
||||
|
||||
Ключ интеграции: движок docmost-sync уже **полностью построен на dependency
|
||||
injection** — весь внешний IO (REST-клиент, git, файловая система) передаётся
|
||||
через узкие интерфейсы. Мы НЕ переписываем движок; мы подставляем нативные
|
||||
реализации в его DI-швы.
|
||||
|
||||
---
|
||||
|
||||
## 2. Состав вендоринга из docmost-sync
|
||||
|
||||
В новый пакет `packages/git-sync` копируем (с сохранением истории смысла —
|
||||
backport-friendly, как сделано с `packages/mcp`):
|
||||
|
||||
### 2.1. Движок (engine) — `src/engine/`
|
||||
| Файл | Что несёт | IO | Берём |
|
||||
| --- | --- | --- | --- |
|
||||
| `pull.ts` | Docmost→FS: reconcile + write + commit + merge | client+git+fs (инжектируется) | да |
|
||||
| `push.ts` | FS→Docmost: diff + classify + apply + refs | client+git+fs (инжектируется) | да |
|
||||
| `git.ts` | `VaultGit` — обёртка git shell-out | системный `git` | да, как есть |
|
||||
| `reconcile.ts` | чистый планировщик | нет | да |
|
||||
| `layout.ts` | чистый маппер дерево→пути | нет | да |
|
||||
| `sanitize.ts` | чистая санитизация имён | нет | да |
|
||||
| `stabilize.ts` | fixpoint-нормализация md (SPEC §11) | нет (lib-вызовы) | да |
|
||||
| `loop-guard.ts` | `bodyHash` (sha256) | нет | да |
|
||||
| `settings.ts` | zod-конфиг | `.env` | **адаптируем** (см. §7) |
|
||||
| `index.ts` | тонкий CLI-скаффолд | — | нет (заменяем на NestJS) |
|
||||
|
||||
### 2.2. Конвертер (чистая часть) — `src/lib/`
|
||||
Из `packages/docmost-client/src/lib/` берём **только** чистый конвертер и формат
|
||||
файла (collab/auth REST-части НЕ нужны — запись нативная):
|
||||
|
||||
| Файл | Экспорт |
|
||||
| --- | --- |
|
||||
| `markdown-converter.ts` | `convertProseMirrorToMarkdown(content): string` |
|
||||
| `collaboration.ts` (только конвертер-функция) | `markdownToProseMirror(md): Promise<doc>` ⚠️ |
|
||||
| `markdown-document.ts` | `serializeDocmostMarkdownBody`, `parseDocmostMarkdown`, `serializeDocmostMarkdown`, тип `DocmostMdMeta` |
|
||||
| `canonicalize.ts` | `canonicalizeContent(node)`, `docsCanonicallyEqual(a,b)` |
|
||||
| `docmost-schema.ts` | tiptap-схема для `markdownToProseMirror` |
|
||||
| `node-ops.ts`, `diff.ts` | трансформации/диф (нужны транзитивно) |
|
||||
|
||||
⚠️ `markdownToProseMirror` физически лежит в `collaboration.ts` docmost-client
|
||||
(строка 289) — это **чистая** функция (marked→HTML→generateJSON), не путать с
|
||||
collab/websocket write-path из того же файла, который НЕ берём.
|
||||
|
||||
> **Долг (зафиксирован в бэклоге):** это третья копия конвертера (есть в
|
||||
> docmost-sync, в `packages/mcp`, теперь в `packages/git-sync`). Конвергенция в
|
||||
> общий пакет — отдельная задача; здесь сознательно вендорим валидированную
|
||||
> копию ради сохранения идемпотентности.
|
||||
|
||||
### 2.3. НЕ берём
|
||||
`pull`/`push` CLI-обёртки, `roundtrip.ts` (харнес переносим в тесты, см. §13),
|
||||
`docmost-client` REST-клиент целиком, `lib/collaboration.ts` (websocket-write),
|
||||
`lib/auth-utils.ts`, `Makefile`, Docker-обвязку docmost-sync.
|
||||
|
||||
---
|
||||
|
||||
## 3. Главный шов: `GitmostDataSource`
|
||||
|
||||
Движок дёргает Docmost через `Pick<DocmostClient, …>`. Мы реализуем класс,
|
||||
**структурно совместимый** с этими сигнатурами, но нативный внутри. Это
|
||||
единственный нетривиальный новый код.
|
||||
|
||||
### 3.1. Точный набор методов, которых требует движок
|
||||
|
||||
Из `pull.ts` (`ApplyPullActionsDeps.client`) и обхода дерева:
|
||||
```ts
|
||||
listSpaceTree(spaceId: string, rootPageId?: string): Promise<{ pages: PageNode[]; complete: boolean }>;
|
||||
getPageJson(pageId: string): Promise<{ id; slugId; title; parentPageId; spaceId; updatedAt; content }>;
|
||||
```
|
||||
|
||||
Из `push.ts` (`ApplyPushDeps.client`):
|
||||
```ts
|
||||
importPageMarkdown(pageId: string, fullMarkdown: string): Promise<{ updatedAt?: string; /* … */ }>;
|
||||
createPage(title: string, content: string, spaceId: string, parentPageId?: string): Promise<{ data: { id: string }; updatedAt?: string }>;
|
||||
deletePage(pageId: string): Promise<unknown>;
|
||||
movePage(pageId: string, parentPageId: string | null, position?: string): Promise<unknown>;
|
||||
renamePage(pageId: string, title: string): Promise<unknown>;
|
||||
```
|
||||
|
||||
Для непрерывного режима/детекции удалений (фаза B+, SPEC §8):
|
||||
```ts
|
||||
listRecentSince(spaceId: string | undefined, sinceIso: string | null, hardPageCap?: number): Promise<any[]>;
|
||||
listTrash(spaceId: string): Promise<any[]>;
|
||||
restorePage(pageId: string): Promise<unknown>;
|
||||
```
|
||||
|
||||
### 3.2. Маппинг на нативные сервисы gitmost
|
||||
|
||||
| Метод адаптера | Нативная реализация |
|
||||
| --- | --- |
|
||||
| `listSpaceTree(spaceId)` | `SpaceRepo.findById(spaceId, wsId)` + `PageRepo.getSpaceDescendants(spaceId, { includeContent: false })` → map в `PageNode { id, title, slugId, parentPageId, hasChildren }`. **`complete: true` всегда** (читаем БД, не пагинированный REST) → суппрессия `incomplete-fetch` из SPEC §8 нативно не срабатывает. |
|
||||
| `getPageJson(pageId)` | `PageRepo.findById(pageId, { includeContent: true })` → `{ id, slugId, title, parentPageId, spaceId, updatedAt, content }`. `content` — ProseMirror JSON в схеме `editor-ext`. |
|
||||
| `importPageMarkdown(pageId, fullMd)` | `parseDocmostMarkdown(fullMd)` → body; `await markdownToProseMirror(body)` → doc; **запись через collab** (см. §3.3). Вернуть `{ updatedAt }` свежей страницы. |
|
||||
| `createPage(title, body, spaceId, parent?)` | `PageService.create(userId, wsId, { spaceId, title, parentPageId }, provenance)` → shell; затем тело через collab (§3.3). Вернуть `{ data: { id }, updatedAt }`. |
|
||||
| `deletePage(pageId)` | `PageService.removePage(pageId, userId, wsId)` (soft-delete → Trash, обратимо). |
|
||||
| `movePage(pageId, parent, pos?)` | `PageService.movePage({ pageId, parentPageId: parent, position }, movedPage, provenance)`. **`position` обязателен** для Docmost-move — вычисляем `fractional-indexing-jittered` ключ между соседями (соседей берём из `PageRepo`). |
|
||||
| `renamePage(pageId, title)` | `PageService.update(page, { title }, user, provenance)`. |
|
||||
| `listRecentSince` | `PageRepo.getRecentPagesInSpace(spaceId, { … })`, фильтр по `updatedAt > since`. |
|
||||
| `listTrash(spaceId)` | `PageRepo` запрос с `deletedAt IS NOT NULL` по спейсу. |
|
||||
| `restorePage(pageId)` | `PageService.restore(...)`. |
|
||||
|
||||
`userId`/`wsId` берём из конфигурации спейса (сервисный аккаунт воркспейса или
|
||||
владелец спейса — см. §7). `provenance` всегда несёт `source: 'git-sync'` (§8).
|
||||
|
||||
### 3.3. Нативная запись тела (linchpin)
|
||||
|
||||
Подтверждено в коде: `CollaborationGateway.openDirectConnection(documentName, context)`
|
||||
([collaboration.gateway.ts:148](../apps/server/src/collaboration/collaboration.gateway.ts#L148-L150))
|
||||
+ паттерн `withYdocConnection`
|
||||
([collaboration.handler.ts:118-133](../apps/server/src/collaboration/collaboration.handler.ts#L118-L133)).
|
||||
Имя документа — `page.<pageId>` ([getPageId](../apps/server/src/collaboration/collaboration.util.ts#L163-L165)).
|
||||
Схему берём из `tiptapExtensions` ([collaboration.util.ts](../apps/server/src/collaboration/collaboration.util.ts)).
|
||||
|
||||
```ts
|
||||
// In-process body write — no loopback websocket, no service-user token.
|
||||
// Mirrors collaboration.handler.ts 'replace' operation exactly.
|
||||
private async writeBody(pageId: string, prosemirrorJson: JSONContent): Promise<void> {
|
||||
const conn = await this.collabGateway.openDirectConnection(
|
||||
`page.${pageId}`,
|
||||
{ actor: 'git-sync' }, // provenance flows into PersistenceExtension (see §8)
|
||||
);
|
||||
try {
|
||||
await conn.transact((doc) => {
|
||||
const fragment = doc.getXmlFragment('default');
|
||||
if (fragment.length > 0) fragment.delete(0, fragment.length);
|
||||
const next = TiptapTransformer.toYdoc(prosemirrorJson, 'default', tiptapExtensions);
|
||||
Y.applyUpdate(doc, Y.encodeStateAsUpdate(next));
|
||||
});
|
||||
} finally {
|
||||
await conn.disconnect();
|
||||
}
|
||||
// PersistenceExtension.onStoreDocument persists ydoc+content+textContent
|
||||
// consistently, stamps lastUpdatedSource, broadcasts 'page.updated'.
|
||||
}
|
||||
```
|
||||
|
||||
**Схема-совместимость (критично).** `markdownToProseMirror` производит
|
||||
ProseMirror JSON в схеме docmost-client, а `TiptapTransformer.toYdoc` валидирует
|
||||
его в схеме `editor-ext`. Аналогично на чтении `convertProseMirrorToMarkdown`
|
||||
получает `content` в схеме `editor-ext`. Эти две схемы **должны совпадать по
|
||||
именам нод/марок/атрибутов**, иначе ноды потеряются. Это и есть гейт §13.1.
|
||||
|
||||
---
|
||||
|
||||
## 4. `VaultGit` и git-бинарь
|
||||
|
||||
`VaultGit` (engine/git.ts) оставляем как есть — он шеллит в системный `git` через
|
||||
`execFile` (args-массив, без инъекций), всегда `cwd=<vaultPath>`. Константы:
|
||||
`DEFAULT_BRANCH = "main"`, `BOT_AUTHOR_NAME = "Docmost Sync"`,
|
||||
`BOT_AUTHOR_EMAIL = "docmost-sync@local"`; в push.ts: `DOCMOST_BRANCH = "docmost"`,
|
||||
`LAST_PUSHED_REF = "refs/docmost/last-pushed"`, провенанс-трейлеры
|
||||
`Docmost-Sync-Source: docmost|local`.
|
||||
|
||||
**Ops-требование:** в рантайм-образ gitmost добавить пакет `git`
|
||||
([Dockerfile](../Dockerfile)) — сейчас его там может не быть. Без бинаря
|
||||
`VaultGit.assertGitAvailable()` падает на старте цикла.
|
||||
|
||||
**Модель веток (пер-репо, SPEC §5):** `main` (правит человек/файлы) ↔ `docmost`
|
||||
(зеркало Docmost, пишет только движок) ↔ `merge-base` как базлайн;
|
||||
`refs/docmost/last-pushed` — что из `main` уже отражено в Docmost.
|
||||
|
||||
---
|
||||
|
||||
## 5. Топология vault: репозиторий на спейс
|
||||
|
||||
- Корень: `<DATA_DIR>/git-sync/<spaceId>/` — отдельный git-репо на каждый
|
||||
включённый спейс. `layout.ts` уже спейс-скоупный (корень спейса → `segments: []`).
|
||||
- Remote — пер-спейс (из конфигурации спейса/ENV). Изоляция конфликтов, блокировок
|
||||
и blast-radius.
|
||||
- `move-to-space` (страница меняет спейс) → **кросс-репо**: `delete` в исходном
|
||||
репо + `create` в целевом. Ловим по событию `PAGE_MOVED_TO_SPACE`.
|
||||
- Redis-lock ключ — `git-sync:lock:<spaceId>` (§9).
|
||||
|
||||
---
|
||||
|
||||
## 6. NestJS-модуль `GitSyncModule`
|
||||
|
||||
Структура (шаблон — `McpModule`):
|
||||
```
|
||||
apps/server/src/integrations/git-sync/
|
||||
git-sync.module.ts
|
||||
git-sync.constants.ts # QueueJob/event-имена, дефолты
|
||||
services/
|
||||
gitmost-datasource.service.ts # §3 адаптер
|
||||
git-sync.orchestrator.ts # @Interval + leader-lock + цикл по спейсам
|
||||
vault-registry.service.ts # путь vault на спейс, VaultGit-инстансы
|
||||
fractional-index.util.ts # position для move (reuse server util)
|
||||
listeners/
|
||||
page-change.listener.ts # подписка на EventName.PAGE_* + debounce
|
||||
git-sync.controller.ts # (опц.) ручной trigger/status для админа
|
||||
```
|
||||
|
||||
```ts
|
||||
@Module({
|
||||
imports: [DatabaseModule, EnvironmentModule, ScheduleModule.forRoot()],
|
||||
providers: [
|
||||
GitmostDataSourceService,
|
||||
GitSyncOrchestrator,
|
||||
VaultRegistryService,
|
||||
PageChangeListener,
|
||||
],
|
||||
})
|
||||
export class GitSyncModule {}
|
||||
```
|
||||
- Регистрируем в [app.module.ts](../apps/server/src/app.module.ts) рядом с `McpModule`.
|
||||
- Зависимости: `PageRepo`/`SpaceRepo` (через `DatabaseModule`), `PageService`,
|
||||
`CollaborationGateway` (экспортировать из `CollaborationModule`),
|
||||
`EnvironmentService`, ioredis-клиент.
|
||||
- `ScheduleModule.forRoot()` уже подключается в `TelemetryModule`; повторный вызов
|
||||
безопасен, но лучше вынести в общий модуль или убедиться, что forRoot один раз.
|
||||
|
||||
---
|
||||
|
||||
## 7. Конфигурация
|
||||
|
||||
### 7.1. Per-space (UI) — `space.settings.gitSync`
|
||||
Расширяем существующий паттерн `settings.sharing` / `settings.comments`.
|
||||
|
||||
Сервер:
|
||||
- `UpdateSpaceDto` ([update-space.dto.ts](../apps/server/src/core/space/dto/update-space.dto.ts)):
|
||||
добавить `@IsOptional() @IsBoolean() gitSyncEnabled?: boolean;` (+ опц.
|
||||
`gitSyncRemote?: string`, если решим хранить remote в БД, а не только в ENV).
|
||||
- `SpaceService.updateSpace(dto, wsId)`
|
||||
([space.service.ts:120](../apps/server/src/core/space/services/space.service.ts#L120)):
|
||||
обработать как `disablePublicSharing`/`allowViewerComments`.
|
||||
- `SpaceRepo`: добавить `updateGitSyncSettings(spaceId, wsId, prefKey, prefValue, trx?)`
|
||||
по образцу `updateSharingSettings`
|
||||
([space.repo.ts:92](../apps/server/src/database/repos/space/space.repo.ts#L92)) —
|
||||
jsonb-merge в `settings.gitSync.<key>`.
|
||||
- Гард: CASL `SpaceCaslAction.Manage / SpaceCaslSubject.Settings` (как в
|
||||
[space.controller.ts:147](../apps/server/src/core/space/space.controller.ts#L147)).
|
||||
|
||||
Клиент:
|
||||
- Тоггл в форме настроек спейса
|
||||
([edit-space-form.tsx](../apps/client/src/features/space/components/edit-space-form.tsx))
|
||||
через `useUpdateSpaceMutation()` → `updateSpace({ spaceId, gitSyncEnabled })`.
|
||||
Образец — `mcp-settings.tsx`. `readOnly` при отсутствии `Manage/Settings`.
|
||||
|
||||
Форма `space.settings.gitSync`:
|
||||
```jsonc
|
||||
{ "gitSync": { "enabled": true, "remote": "git@…", "branch": "main" } }
|
||||
```
|
||||
|
||||
### 7.2. Секреты/тюнинг (ENV) — `EnvironmentService`
|
||||
Движковый `settings.ts` (zod, читает `.env`) **заменяем** на чтение из gitmost
|
||||
`EnvironmentService`: `parseSettings(env)` оставляем как чистую функцию для тестов,
|
||||
но в проде собираем `Settings` из `EnvironmentService`-геттеров.
|
||||
|
||||
Новые переменные (объявить в
|
||||
[environment.validation.ts](../apps/server/src/integrations/environment/environment.validation.ts)
|
||||
class-validator-декораторами, геттеры — в
|
||||
[environment.service.ts](../apps/server/src/integrations/environment/environment.service.ts)):
|
||||
|
||||
| ENV | Назначение | Обяз. |
|
||||
| --- | --- | --- |
|
||||
| `GIT_SYNC_ENABLED` | глобальный мастер-выключатель | нет (default false) |
|
||||
| `GIT_SYNC_DATA_DIR` | корень vault'ов (default `<DATA_DIR>/git-sync`) | нет |
|
||||
| `GIT_SYNC_REMOTE_TEMPLATE` | шаблон remote, напр. `git@host:vault-{spaceId}.git` | нет |
|
||||
| `GIT_SYNC_SSH_KEY_PATH` / креды remote | доступ к git-remote (secret) | по ситуации |
|
||||
| `GIT_SYNC_POLL_INTERVAL_MS` | страховочный поллинг (default 15000) | нет |
|
||||
| `GIT_SYNC_DEBOUNCE_MS` | окно дебаунса событий (default 2000) | нет |
|
||||
| `GIT_SYNC_SERVICE_USER_ID` | от чьего имени писать в Docmost | да (если синк включён) |
|
||||
|
||||
> git-remote = доступ ко всей вики спейса (SPEC §12): креды только в ENV/secret
|
||||
> store, никогда в БД/коммиты. В UI — только `enabled` (+ опц. имя remote из
|
||||
> заранее разрешённого списка).
|
||||
|
||||
---
|
||||
|
||||
## 8. Провенанс и loop-guard
|
||||
|
||||
### 8.1. Значение `'git-sync'`
|
||||
Сегодня `lastUpdatedSource ∈ { 'user', 'agent' }`
|
||||
([persistence.extension.ts:132-134](../apps/server/src/collaboration/extensions/persistence.extension.ts#L132-L134)).
|
||||
Добавляем `'git-sync'`:
|
||||
- `PersistenceExtension`: `context.actor === 'git-sync'` → `lastUpdatedSource = 'git-sync'`.
|
||||
- Снапшот истории для `'git-sync'` — дебаунс (как у человека), а не немедленный
|
||||
(немедленный — только для `'agent'`,
|
||||
[persistence.extension.ts:321](../apps/server/src/collaboration/extensions/persistence.extension.ts#L321)).
|
||||
- Для `create/move/rename/delete` через `PageService` передаём
|
||||
`AuthProvenanceData` c `source: 'git-sync'` (тип уже используется для агента —
|
||||
расширить допустимые значения; точную форму подтвердить на реализации).
|
||||
- Клиент: в истории
|
||||
([history-item.tsx:128](../apps/client/src/features/page-history/components/history-item.tsx#L128))
|
||||
не показывать агентский бейдж/дип-линк для `'git-sync'`; добавить значение в
|
||||
тип [page.types.ts:23-26](../apps/client/src/features/page-history/types/page.types.ts#L23-L26)
|
||||
(опц. свой бейдж «sync»).
|
||||
|
||||
### 8.2. Подавление петли (SPEC §10)
|
||||
На pull-стороне игнорируем страницу как «свою запись», если:
|
||||
`page.lastUpdatedSource === 'git-sync'` **И** `bodyHash(exportedBody)` совпадает
|
||||
с последним запушенным (`PushedPageRecord.bodyHash` из `push.ts`). После записи в
|
||||
Docmost сохраняем `updatedAt` ответа, чтобы поллинг-страховка не утянул свою же
|
||||
запись обратно.
|
||||
|
||||
---
|
||||
|
||||
## 9. Single-writer (Redis leader-lock)
|
||||
|
||||
В кодовой базе `@Interval`-задачи (`trash-cleanup`, `telemetry`, `session-cleanup`)
|
||||
**не защищены** от мультиинстанса. Для синка добавляем явный лок.
|
||||
|
||||
- ioredis уже есть (`RedisModule` из `@nestjs-labs/nestjs-ioredis`,
|
||||
[app.module.ts](../apps/server/src/app.module.ts); прямой `RedisClient`
|
||||
используется в collab-gateway).
|
||||
- Лок на спейс: `SET git-sync:lock:<spaceId> <instanceId> NX PX <ttl>`; держим
|
||||
цикл только при успехе, продлеваем по heartbeat, освобождаем в `finally`
|
||||
(Lua-CAS на удаление по `instanceId`, чтобы не снять чужой лок).
|
||||
- TTL > максимальной длительности цикла; на краше лок истекает сам.
|
||||
|
||||
```ts
|
||||
// Acquire per-space leadership; returns false if another replica holds it.
|
||||
private async acquire(spaceId: string): Promise<boolean> {
|
||||
const ok = await this.redis.set(`git-sync:lock:${spaceId}`, this.instanceId, 'PX', LOCK_TTL_MS, 'NX');
|
||||
return ok === 'OK';
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Планировщик и событийные триггеры
|
||||
|
||||
- **События (основной триггер).** `PageChangeListener` подписывается на
|
||||
`EventName.PAGE_CREATED | PAGE_UPDATED | PAGE_MOVED | PAGE_SOFT_DELETED |
|
||||
PAGE_RESTORED | PAGE_MOVED_TO_SPACE` и job `PAGE_CONTENT_UPDATED`
|
||||
([event.contants.ts](../apps/server/src/common/events/event.contants.ts)).
|
||||
Фильтр по `spaceId` (только включённые спейсы) → дебаунс (`GIT_SYNC_DEBOUNCE_MS`)
|
||||
→ ставит pull/push-цикл спейса в очередь оркестратора.
|
||||
- Loop-guard: события от собственных записей (`source==='git-sync'` + совпавший
|
||||
хэш) пропускаем (§8.2).
|
||||
- **Поллинг-страховка.** `@Interval(GIT_SYNC_POLL_INTERVAL_MS)` в оркестраторе:
|
||||
по каждому включённому спейсу (под локом) — реконсиляция (`listRecentSince` +
|
||||
`listTrash`), ловит пропущенные события и стартовую сверку после простоя
|
||||
(SPEC §12).
|
||||
- Один цикл на спейс за раз (внутри-процессный мьютекс на `spaceId` поверх
|
||||
Redis-лока).
|
||||
|
||||
---
|
||||
|
||||
## 11. Потоки данных (walkthroughs)
|
||||
|
||||
### 11.1. Первичный клон спейса (initial clone, SPEC §12)
|
||||
1. `VaultGit.ensureRepo()` + `ensureBranch('docmost','main')` + `checkout('docmost')`.
|
||||
2. `dataSource.listSpaceTree(spaceId)` → `{ pages, complete:true }`.
|
||||
3. `readExisting({ listTracked: () => git.listTrackedFiles('*.md'), readFile })`.
|
||||
4. `computePullActions({ pages, treeComplete:true, existing })` → план.
|
||||
5. `applyPullActions(deps, actions, vaultRoot)`: на каждую страницу
|
||||
`getPageJson` → `stabilizePageFile(content, meta)` (export→import→export
|
||||
fixpoint, SPEC §11) → запись файла; затем `stageAll` + `commit` (трейлер
|
||||
`docmost`) на `docmost`; `checkout('main')` + `merge('docmost')`.
|
||||
6. Зафиксировать max `updatedAt` как стартовый `T_last`; `git push` в remote.
|
||||
|
||||
### 11.2. Docmost → FS (pull-цикл)
|
||||
Триггер: событие/поллинг → (под локом) шаги §11.1 п.1–5 инкрементально. 3-way
|
||||
merge `docmost→main` делает git: непересекающиеся правки сливаются, реальное
|
||||
пересечение → conflict-маркеры в файле. **При конфликте push этой страницы в
|
||||
Docmost блокируется** до ручного резолва (SPEC §9; фаза D).
|
||||
|
||||
### 11.3. FS → Docmost (push-цикл)
|
||||
`runPush(deps, { dryRun })`:
|
||||
1. `git.ensureRepo` / `isMergeInProgress` (abort при merge) / `checkout('main')`.
|
||||
2. `stageAll` + `commit('local: working-tree changes')` (локально, в Docmost не шлёт).
|
||||
3. База диффа: `readRef(LAST_PUSHED_REF)` ?? `docmost`; `revParse('main')` → `pushedCommit`.
|
||||
4. `diffNameStatus(base, 'main')` → changes; префетч `metaAt(path, side)`.
|
||||
5. `computePushActions({ changes, metaAt })` → creates/updates/deletes/renamesMoves/skipped.
|
||||
6. `dryRun` → лог плана и выход (клиент НЕ создаётся).
|
||||
7. `--apply`: `makeClient(settings)` → наш `GitmostDataSource`;
|
||||
`applyPushActions`:
|
||||
- update → `importPageMarkdown(pageId, fullMd)` (collab-write, §3.3);
|
||||
- create → `createPage(...)` → записать присвоенный `pageId` обратно в meta;
|
||||
- delete → `deletePage(pageId)` (Trash);
|
||||
- rename/move → `classifyRenameMoves` → `movePage`/`renamePage`;
|
||||
- при пустых failures: `updateRef(LAST_PUSHED_REF, pushedCommit)` +
|
||||
`fastForwardBranch('docmost', pushedCommit)`.
|
||||
8. Записать `bodyHash` + `updatedAt` (loop-guard, §8.2); `git push`.
|
||||
|
||||
---
|
||||
|
||||
## 12. Фазирование
|
||||
|
||||
- **A. Каркас + односторонний pull (нативно).** `packages/git-sync` (вендоринг
|
||||
§2), `GitmostDataSource` (чтение через репозитории), `GitSyncModule`, конфиг из
|
||||
`EnvironmentService`, ручной/однократный pull-цикл на один спейс. **Гейт §13.1.**
|
||||
- **B. Push + непрерывность.** Нативная запись (§3.3), `runPush`, ветки/refs,
|
||||
loop-guard (§8), Redis-лок (§9), `@Interval` + `PageChangeListener` (§10).
|
||||
- **C. Per-space UI.** `space.settings.gitSync` (§7.1), DTO/сервис/репо/гард,
|
||||
тоггл на клиенте, скоуп оркестратора по включённым спейсам.
|
||||
- **D. Харднинг.** Conflict-gating (SPEC §9), удаления через Trash + git (§5),
|
||||
стартовая реконсиляция и `move-to-space` кросс-репо, провенанс на клиенте,
|
||||
Dockerfile `git`, полный набор тестов.
|
||||
|
||||
---
|
||||
|
||||
## 13. Тестирование
|
||||
|
||||
### 13.1. Гейт идемпотентности (блокирует фазу B)
|
||||
Перенести round-trip-харнес docmost-sync (`roundtrip.ts` + `test/fixtures/corpus`)
|
||||
в тесты `packages/git-sync`, но прогонять **против схемы `editor-ext`**:
|
||||
`content (editor-ext) → convertProseMirrorToMarkdown → markdownToProseMirror →
|
||||
TiptapTransformer.toYdoc(…, tiptapExtensions) → fromYdoc → canonicalizeContent`
|
||||
должно давать `docsCanonicallyEqual === true`. Любая потеря нод/атрибутов =
|
||||
расхождение схем → чинить `docmost-schema.ts` под `editor-ext`.
|
||||
|
||||
### 13.2. Юнит (чистая логика, переносится как есть)
|
||||
`reconcile` (planReconciliation / decideAbsenceDeletions / mass-delete guards),
|
||||
`layout` (коллизии/санитизация), `computePullActions`, `computePushActions`,
|
||||
`classifyRenameMoves`, `bodyHash`.
|
||||
|
||||
### 13.3. Интеграция (нативный адаптер)
|
||||
`GitmostDataSource` против тестовой БД: `listSpaceTree`/`getPageJson` корректно
|
||||
маппят; `createPage`/`movePage`/`deletePage`/`importPageMarkdown` пишут через
|
||||
collab и проставляют `lastUpdatedSource='git-sync'`; loop-guard не зацикливается
|
||||
(write → poll → no-op).
|
||||
|
||||
### 13.4. e2e (под локом)
|
||||
Полный pull→push round-trip на временном vault + временном спейсе: правка в
|
||||
Docmost доезжает в файл и наоборот; конфликт даёт маркеры и блокирует push.
|
||||
|
||||
---
|
||||
|
||||
## 14. Риски и открытые пункты
|
||||
|
||||
1. **Схема-совместимость конвертера** (§3.3, §13.1) — главный риск; гейт
|
||||
обязателен до фазы B.
|
||||
2. **`AuthProvenanceData`** — точную форму типа подтвердить; возможно, потребует
|
||||
расширения enum источника на сервере и в истории.
|
||||
3. **Согласованность Yjs** — писать строго через `openDirectConnection`/`transact`;
|
||||
не трогать `content`-колонку напрямую.
|
||||
4. **`position` для move** — обязателен в Docmost-move; нужен
|
||||
`fractional-indexing-jittered` между соседями (соседей брать сортировкой
|
||||
`position COLLATE "C"`).
|
||||
5. **`git` в рантайме** — добавить в Dockerfile.
|
||||
6. **`ScheduleModule.forRoot()`** — не задублировать `forRoot`.
|
||||
7. **Сервисный пользователь записи** (`GIT_SYNC_SERVICE_USER_ID`) — от чьего имени
|
||||
идут create/move (влияет на `creatorId`/права); согласовать политику.
|
||||
8. **Конфликты и удаления** — фаза D строго по SPEC §8/§9 (маркеры никогда не
|
||||
уезжают в Docmost).
|
||||
|
||||
---
|
||||
|
||||
## 15. Чек-лист изменений по файлам
|
||||
|
||||
**Новый пакет**
|
||||
- `packages/git-sync/**` — движок + чистый конвертер (§2), `package.json`
|
||||
(`@docmost/git-sync`, `workspace:*`), `tsconfig.json`.
|
||||
|
||||
**Сервер (`apps/server/src`)**
|
||||
- `integrations/git-sync/**` — модуль, оркестратор, адаптер, листенер (§6).
|
||||
- `app.module.ts` — импорт `GitSyncModule`.
|
||||
- `collaboration/collaboration.module.ts` — экспорт `CollaborationGateway`.
|
||||
- `collaboration/extensions/persistence.extension.ts` — источник `'git-sync'` (§8.1).
|
||||
- `core/space/dto/update-space.dto.ts` — `gitSyncEnabled?` (§7.1).
|
||||
- `core/space/services/space.service.ts` — обработка флага.
|
||||
- `database/repos/space/space.repo.ts` — `updateGitSyncSettings` (§7.1).
|
||||
- `integrations/environment/environment.validation.ts` + `environment.service.ts` —
|
||||
новые ENV (§7.2).
|
||||
- `Dockerfile` — пакет `git`.
|
||||
|
||||
**Клиент (`apps/client/src`)**
|
||||
- `features/space/components/edit-space-form.tsx` — тоггл git-sync.
|
||||
- `features/space/types` — поле `settings.gitSync`.
|
||||
- `features/page-history/types/page.types.ts` + `components/history-item.tsx` —
|
||||
значение `'git-sync'` в `lastUpdatedSource`.
|
||||
|
||||
**Корень**
|
||||
- `pnpm-workspace.yaml` уже покрывает `packages/*`; `apps/server/package.json` —
|
||||
зависимость `@docmost/git-sync: workspace:*`.
|
||||
@@ -1,184 +0,0 @@
|
||||
# Шаблоны страниц — живая вставка целой страницы в другие — дизайн
|
||||
|
||||
> Статус: **черновик / дизайн**. Реализация ещё не начата.
|
||||
> Исходный кейс: одну страницу-«шаблон» нужно вставлять в несколько других так,
|
||||
> чтобы при правке источника вставки обновлялись автоматически.
|
||||
>
|
||||
> Принятые на старте решения (выбор пользователя):
|
||||
> - **Семантика** — живая синхронная вставка (контент источника обновляется в местах вставки), НЕ статическая копия.
|
||||
> - **Сценарий** — вставка ноды в тело существующей страницы через slash-команду + пикер.
|
||||
> - **Источник** — обычная страница со спец-флагом `is_template`.
|
||||
|
||||
## 1. Что уже есть в кодовой базе (и почему мы это расширяем)
|
||||
|
||||
В Gitmost уже реализована **блочная транслюзия** (synced blocks) — она покрывает «вставить ОДИН блок живой ссылкой в другие страницы»:
|
||||
|
||||
- Ноды `transclusionSource` / `transclusionReference` — [packages/editor-ext/src/lib/transclusion/](../packages/editor-ext/src/lib/transclusion/).
|
||||
- Таблицы `page_transclusions` (снапшот каждого source-блока на странице) и `page_transclusion_references` (кто кого ссылается) — [миграция](../apps/server/src/database/migrations/20260501T202258-page-transclusions.ts).
|
||||
- Сервис [transclusion.service.ts](../apps/server/src/core/page/transclusion/transclusion.service.ts): `lookup`, `lookupWithAccessSet`, `syncPageTransclusions`, `syncPageReferences`, `unsyncReference`, `listReferences`, `insert*ForPages`.
|
||||
- Контроль доступа: `filterViewerAccessiblePageIds` (членство в space + page-permissions) и публичный share-путь `ShareService.lookupTransclusionForShare` (граф доступа share, токенизация вложений, срезание комментариев).
|
||||
- Клиент: read-only рендерер [transclusion-content.tsx](../apps/client/src/features/editor/components/transclusion/transclusion-content.tsx), батчинг-контекст [transclusion-lookup-context.tsx](../apps/client/src/features/editor/components/transclusion/transclusion-lookup-context.tsx), вьюха ссылки [transclusion-reference-view.tsx](../apps/client/src/features/editor/components/transclusion/transclusion-reference-view.tsx).
|
||||
- Синхронизация ссылок происходит в [persistence.extension.ts](../apps/server/src/collaboration/extensions/persistence.extension.ts) (`syncTransclusion` после сохранения документа), **только для Yjs-путей** (живой коллаб). REST-обновления контента сейчас транслюзию не пересинхронизируют.
|
||||
|
||||
**Вывод:** нужная фича — это та же транслюзия, но на уровне **целой страницы**, а не блока, плюс пометка источника флагом. ~70 % инфраструктуры переиспользуется; писать с нуля нужно только нодy `pageEmbed`, whole-page lookup, флаг `is_template` и UI-вставку.
|
||||
|
||||
### Что НЕ переиспользуем
|
||||
|
||||
В БД есть upstream-таблица `Templates` (Docmost), настройка `allowMemberTemplates`, тип избранного `template` и урезанный `TemplateSlashCommand`/`templateExtensions`. **Это другая, статическая механика** («создать страницу из шаблона-копии») и она не подходит под выбранный сценарий (живой синхрон + источник-страница). Не конфликтуем с ней, но и не строим на ней — ведём отдельный флаг `is_template` на странице. Урезанный `TemplateSlashCommand` к нашей фиче отношения не имеет.
|
||||
|
||||
## 2. Модель
|
||||
|
||||
- **Шаблон** = обычная, живая, редактируемая страница с `pages.is_template = true`. Флаг меняет только то, *как* страница всплывает (пикер шаблонов, опционально — группировка/скрытие в дереве), но не запрещает её редактировать или открывать как обычную.
|
||||
- **Вставка** = новая Tiptap-нода `pageEmbed` (блочная, `atom`, `isolating`) с атрибутом `sourcePageId`. Рендерится read-only: вьюха тянет **весь** текущий контент страницы-источника и показывает его. Снапшот контента в документе хоста НЕ хранится — только ссылка `sourcePageId`. За счёт этого вставка «живая».
|
||||
- **Обратные ссылки** = таблица `page_template_references` (`reference_page_id`, `source_page_id`) — чтобы знать «где используется этот шаблон» (для предупреждения при удалении и инвалидации кэша). Аналог `page_transclusion_references`, но whole-page.
|
||||
|
||||
## 3. Развилка: отдельная нода `pageEmbed` vs расширение `transclusionReference`
|
||||
|
||||
### Вариант A (рекомендуется) — отдельная нода `pageEmbed`
|
||||
`transclusionReference` адресует конкретный блок по `transclusionId` внутри `sourcePageId`. У whole-page нет `transclusionId`. Можно было бы подставлять sentinel (`transclusionId = '__page__'`), но это засоряет инварианты уже работающей блочной транслюзии и её UNIQUE-констрейнт.
|
||||
|
||||
- **Плюсы:** проверенный блочный путь не трогаем (нулевой риск регрессии); чистое разделение; при этом переиспользуем хелперы (рендерер, батчинг, контроль доступа).
|
||||
- **Минусы:** чуть больше нового кода (новая нода, вьюха, эндпоинт, таблица).
|
||||
|
||||
### Вариант B — расширить `transclusionReference` на whole-page (`transclusionId = null`)
|
||||
- **Плюсы:** максимум переиспользования (та же нода, lookup, unsync, ремап при duplicate).
|
||||
- **Минусы:** NULL в UNIQUE-констрейнте Postgres ведёт себя нетривиально (NULL-ы различны); ломаются инварианты рабочей фичи; риск регрессии блочной транслюзии.
|
||||
|
||||
**Решение:** Вариант A. Дальше дизайн исходит из `pageEmbed`.
|
||||
|
||||
## 4. Модель данных (миграции)
|
||||
|
||||
Соглашение по именованию: `apps/server/src/database/migrations/YYYYMMDDThhmmss-description.ts`. Только ДОБАВЛЯЕМ столбцы/таблицы. После — `pnpm --filter server migration:codegen` для регенерации `src/database/types/db.d.ts`.
|
||||
|
||||
**Миграция 1 — флаг шаблона:**
|
||||
```sql
|
||||
ALTER TABLE pages ADD COLUMN is_template boolean NOT NULL DEFAULT false;
|
||||
-- частичный индекс под пикер шаблонов
|
||||
CREATE INDEX pages_is_template_idx ON pages (workspace_id) WHERE is_template;
|
||||
```
|
||||
|
||||
**Миграция 2 — обратные ссылки whole-page (можно отложить до фазы 2, см. §9):**
|
||||
```sql
|
||||
CREATE TABLE page_template_references (
|
||||
id uuid PRIMARY KEY DEFAULT gen_uuid_v7(),
|
||||
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
reference_page_id uuid NOT NULL REFERENCES pages(id) ON DELETE CASCADE, -- где встроено
|
||||
source_page_id uuid NOT NULL REFERENCES pages(id) ON DELETE CASCADE, -- какой шаблон
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (reference_page_id, source_page_id)
|
||||
);
|
||||
CREATE INDEX page_template_references_source_idx ON page_template_references (source_page_id);
|
||||
CREATE INDEX page_template_references_ws_idx ON page_template_references (workspace_id);
|
||||
```
|
||||
|
||||
## 5. Бэкенд
|
||||
|
||||
### 5.1. Флаг `is_template`
|
||||
- Тоггл: новый `POST /pages/toggle-template` (или поле в существующем `POST /pages/update`) → `pages.is_template`. Авторизация — стандартная CASL (право `Edit` на page/space, как у прочих мутаций страницы).
|
||||
- `is_template` добавить в выдачу `pageRepo.findById` (колонка уже попадёт в `pages` select; убедиться, что отдаётся клиенту в `IPage`).
|
||||
- Поиск: расширить search-suggestions фильтром `onlyTemplates` (для пикера показывать только `is_template = true`).
|
||||
|
||||
### 5.2. Whole-page lookup (для авторизованных)
|
||||
Новый эндпоинт `POST /pages/template/lookup`:
|
||||
```
|
||||
Body: { sourcePageIds: string[] } // ≤ 50, как у block-lookup
|
||||
Resp: { items: Array<
|
||||
| { sourcePageId, title, icon, content, sourceUpdatedAt }
|
||||
| { sourcePageId, status: 'no_access' | 'not_found' }
|
||||
> }
|
||||
```
|
||||
- Доступ: переиспользовать `filterViewerAccessiblePageIds` (членство в space + `pagePermissionRepo.filterAccessiblePageIds`). Если страница недоступна → `no_access`; удалена/нет → `not_found`.
|
||||
- Контент: брать `pages.content`; **срезать `comment`-марки** (комментарии принадлежат источнику) через `removeMarkTypeFromDoc(doc, 'comment')` — как делает share-путь.
|
||||
- `not_template`: можно НЕ запрещать встраивать не-шаблон (флаг — это про обнаружение в пикере, а не жёсткий констрейнт). Решение: lookup отдаёт контент любой доступной страницы; пикер же показывает только шаблоны. Это упрощает и не создаёт «битых» вставок, если со страницы потом сняли флаг.
|
||||
|
||||
### 5.3. Синхронизация обратных ссылок
|
||||
- Добавить `collectPageEmbedsFromPmJson(doc)` рядом с [transclusion-prosemirror.util.ts](../apps/server/src/core/page/transclusion/utils/transclusion-prosemirror.util.ts) — обход PM JSON, сбор `pageEmbed` нод → `{ sourcePageId }[]` (дедуп).
|
||||
- Добавить `syncPageTemplateReferences(referencePageId, workspaceId, pmJson)` (diff с `page_template_references`) и дёрнуть его в `persistence.extension.syncTransclusion`.
|
||||
- **Известный пробел:** REST-обновления контента (агент/AI через `updatePageContent`) не вызывают `syncTransclusion`. Для нашей фичи это терпимо: lookup работает по `sourcePageId` из самой ноды, а рассинхрон затронет только обратную таблицу (UI «где используется»). Отметить как follow-up.
|
||||
|
||||
### 5.4. Публичный share-путь (фаза 2)
|
||||
Зеркалить `ShareService.lookupTransclusionForShare` → `POST /shares/template/lookup`:
|
||||
- источник-шаблон резолвится, только если он сам попадает в граф доступа share (его шарили / есть расшаренный предок с `includeSubPages`);
|
||||
- токенизация вложений источника, срезание комментариев, схлопывание `not_found → no_access` (анти-утечка).
|
||||
- **UX-нюанс:** шаблоны обычно лежат вне расшаренного поддерева → по умолчанию в публичном share они дадут `no_access` (вьюха покажет плейсхолдер). Это безопасный дефолт (без случайной утечки). Альтернатива «запекать контент шаблона в хост для share-зрителя» — отдельное решение, фаза 3.
|
||||
|
||||
### 5.5. Ремап при дублировании страниц
|
||||
В `duplicatePage` ([page.service.ts](../apps/server/src/core/page/services/page.service.ts)) уже ремапятся `mention` и `transclusionReference.sourcePageId`. Добавить ремап `pageEmbed.sourcePageId` (если источник тоже в копируемом наборе → указать на новую копию; иначе оставить как есть). Плюс `insertTemplateReferencesForPages` по аналогии с `insertReferencesForPages`.
|
||||
|
||||
### 5.6. Регистрация ноды в серверной схеме (критично!)
|
||||
Нода `pageEmbed` должна быть зарегистрирована в **серверном** `tiptapExtensions` ([collaboration.util.ts](../apps/server/src/collaboration/collaboration.util.ts)), иначе сервер вырежет её при сохранении/коллаборации (та же ловушка, что описана в [arbitrary-html-embed-plan.md](./arbitrary-html-embed-plan.md) §2). MCP-зеркало схемы (`packages/mcp/src/lib/`) — обновлять не обязательно для MVP (MCP может трактовать ноду как opaque), отметить как follow-up.
|
||||
|
||||
## 6. Клиент
|
||||
|
||||
### 6.1. Нода `pageEmbed`
|
||||
- Новый модуль `packages/editor-ext/src/lib/page-embed/page-embed.ts`: `Node.create({ name:'pageEmbed', group:'block', atom:true, isolating:true })`, атрибут `sourcePageId` с `parseHTML`/`renderHTML` через `data-source-page-id` (для round-trip HTML↔JSON и paste). Экспорт в `packages/editor-ext/src/index.ts`.
|
||||
- Регистрация в клиентских `mainExtensions` ([extensions.ts](../apps/client/src/features/editor/extensions/extensions.ts)) и серверной схеме (§5.6).
|
||||
|
||||
### 6.2. NodeView `page-embed-view.tsx`
|
||||
- Тянет whole-page контент через `useTemplateLookup` (расширить/обобщить батчинг-паттерн `transclusion-lookup-context.tsx`, или TanStack Query с ключом `sourcePageId`).
|
||||
- Тело рендерит read-only вложенным редактором по образцу [transclusion-content.tsx](../apps/client/src/features/editor/components/transclusion/transclusion-content.tsx) (изоляция событий, `editable=false`, `UniqueID` с `updateDocument:false`).
|
||||
- Шапка: иконка+заголовок шаблона со ссылкой на источник, кнопка «обновить», меню «отвязать → превратить в статическую копию» (новый `unsyncPageEmbed`, запекает текущий контент в документ хоста — по образцу `unsyncReference`).
|
||||
- **Защита от циклов** (см. §7.1).
|
||||
|
||||
### 6.3. Slash-команда + пикер
|
||||
- Slash-пункт `/template` (или `/embed page`) открывает пикер страниц — переиспользовать [mention-list.tsx](../apps/client/src/features/editor/components/mention/mention-list.tsx) + search-query с фильтром `onlyTemplates` → вставляет `pageEmbed` с выбранным `sourcePageId`.
|
||||
|
||||
### 6.4. Пометить страницу как шаблон
|
||||
- Тоггл «Сделать шаблоном / Снять» в меню узла дерева ([space-tree-node-menu.tsx](../apps/client/src/features/page/tree/components/space-tree-node-menu.tsx)) и/или в «...» меню заголовка страницы → мутация на `POST /pages/toggle-template`.
|
||||
- (Опционально, фаза 2) Галерея/раздел «Шаблоны».
|
||||
|
||||
## 7. Краевые случаи (главное)
|
||||
|
||||
### 7.1. Циклы / бесконечная рекурсия (самое важное)
|
||||
A встраивает B, B встраивает A → бесконечная вложенность на клиенте. Сервер из lookup отдаёт «сырой» контент одного уровня и зациклиться не может — **гард обязателен на клиенте**:
|
||||
- React-контекст с цепочкой `sourcePageId` предков; если текущий `sourcePageId` уже в цепочке → рендерить плейсхолдер «циклическая вставка», не рекурсировать.
|
||||
- Жёсткий лимит глубины вложенности (например, 5).
|
||||
- При выборе в пикере запрещать вставку самой текущей страницы (self-embed). Полное обнаружение циклов на вставке (обход графа) — избыточно, опираемся на рендер-гард.
|
||||
|
||||
### 7.2. Удаление шаблона
|
||||
Удаление страницы-шаблона — soft-delete (корзина) → вставки дают `not_found`/`no_access`, вьюха показывает «шаблон в корзине/не найден». Таблица `page_template_references` позволяет предупредить «используется в N страницах» перед удалением. При восстановлении вставки снова резолвятся.
|
||||
|
||||
### 7.3. Доступ
|
||||
Зритель хоста может не иметь доступа к странице-источнику (другой space/ограничение) → lookup вернёт `no_access`, вьюха — плейсхолдер. Это корректно (без утечки).
|
||||
|
||||
### 7.4. Комментарии
|
||||
Срезать `comment`-марки из встроенного контента (`removeMarkTypeFromDoc`) — комментарии относятся к источнику.
|
||||
|
||||
### 7.5. Вложения
|
||||
Встроенный контент ссылается на вложения источника. Для авторизованных доступ обычный (lookup уже проверил доступ к источнику). Для публичных share — токенизация по образцу share-пути (фаза 2).
|
||||
|
||||
### 7.6. Вложенные транслюзии внутри шаблона
|
||||
Шаблон может содержать `transclusionSource`/`transclusionReference`/`pageEmbed`. При whole-page рендере они отрисуются своими вьюхами (доп. вложенные lookup-и) — работает, но учитывать в гарде глубины (§7.1).
|
||||
|
||||
### 7.7. История версий хоста
|
||||
В истории хоста хранится только нода-ссылка (мелкая), не снапшот. Значит старые версии хоста покажут *текущий* контент шаблона (живой), без point-in-time точности. Снапшот-режим — вне scope, отметить.
|
||||
|
||||
### 7.8. Экспорт (Markdown/HTML) и RAG/поиск
|
||||
`jsonToHtml`/`jsonToMarkdown`/`jsonToText` на сервере не развернут `pageEmbed` (в документе только ссылка) → экспорт и `textContent` хоста не содержат текста шаблона; полнотекстовый/RAG-поиск не найдёт хост по тексту шаблона. Для MVP — плейсхолдер/ссылка; серверное разворачивание вставок при экспорте/индексации — фаза 3.
|
||||
|
||||
## 8. Реестр переиспользования
|
||||
|
||||
| Что | Файл | Как используем |
|
||||
| --- | --- | --- |
|
||||
| Read-only рендерер | `transclusion-content.tsx` | тело `pageEmbed` |
|
||||
| Батчинг lookup | `transclusion-lookup-context.tsx` | `useTemplateLookup` |
|
||||
| Контроль доступа | `transclusion.service.ts::filterViewerAccessiblePageIds` / `lookupWithAccessSet` | whole-page lookup |
|
||||
| Share-путь | `share.service.ts::lookupTransclusionForShare` | `lookupTemplateForShare` (фаза 2) |
|
||||
| Sync ссылок | `persistence.extension.ts::syncTransclusion` + `collectReferencesFromPmJson` | `+ collectPageEmbedsFromPmJson` / `syncPageTemplateReferences` |
|
||||
| Unsync→копия | `transclusion.service.ts::unsyncReference` | `unsyncPageEmbed` |
|
||||
| Пикер страниц | `mention-list.tsx` + search-query | пикер шаблонов (`onlyTemplates`) |
|
||||
| Ремап при копировании | `page.service.ts::duplicatePage` | `+ ремап pageEmbed.sourcePageId` |
|
||||
| Меню страницы | `space-tree-node-menu.tsx` | тоггл «Сделать шаблоном» |
|
||||
| Серверная схема | `collaboration.util.ts::tiptapExtensions` | регистрация `pageEmbed` (критично) |
|
||||
|
||||
## 9. Этапность
|
||||
|
||||
- **MVP:** флаг `is_template` + тоггл-UI; нода `pageEmbed` + вьюха (живой read-only fetch с гардом циклов); `/template` slash + пикер; auth-эндпоинт lookup; синхронизация ссылок; ремап при duplicate. Без share (на публичных страницах — плейсхолдер), без разворачивания при экспорте. Таблица `page_template_references` — желательна, но можно начать с резолва по in-doc нодам.
|
||||
- **Фаза 2:** публичный share-lookup; «отвязать → статическая копия»; «используется в N страницах» + предупреждение при удалении; галерея шаблонов.
|
||||
- **Фаза 3:** разворачивание вставок на сервере для экспорта/RAG/textContent; режим point-in-time снапшота; обновление MCP-зеркала схемы; sync ссылок на REST-пути.
|
||||
|
||||
## 10. Открытые вопросы
|
||||
|
||||
1. Прятать ли страницы-шаблоны из обычного дерева space или показывать с бейджем? (предлагаю: показывать с бейджем, отдельную «галерею» — фаза 2).
|
||||
2. Ограничивать ли источник только `is_template`-страницами на бэке, или разрешать встраивать любую доступную (флаг — только для пикера)? (предлагаю второе — меньше «битых» вставок).
|
||||
3. Нужен ли whole-page embed на публичных share сразу в MVP или плейсхолдер достаточен на старте? (предлагаю плейсхолдер → фаза 2).
|
||||
@@ -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`.
|
||||
@@ -1,145 +0,0 @@
|
||||
# Улучшение качества RAG-поиска агента — план по итерациям
|
||||
|
||||
> Статус: живой документ. Итерация 1 **реализована** (см. ниже). Остальное —
|
||||
> бэклог на следующие итерации, отсортированный по «качество / усилие».
|
||||
> Контекст: gitmost — форк Docmost. Семантический поиск агента: per-workspace
|
||||
> эмбеддинги в `page_embeddings` (pgvector, dimension-agnostic колонка, seq-scan
|
||||
> с `<=>`), индексация через BullMQ (`reindexPage` / `reindexWorkspace`).
|
||||
> Активная embedding-модель деплоя: OpenAI `text-embedding-3-large` (3072d).
|
||||
|
||||
## Как сверялось с реальным кодом
|
||||
|
||||
Внешнее предложение по улучшению RAG было сверено с кодовой базой. Точные факты
|
||||
на момент итерации 1:
|
||||
|
||||
- Хранилище: [page_embeddings](../apps/server/src/database/migrations/20260617T120000-page-embeddings.ts),
|
||||
колонка `embedding` сделана dimension-agnostic в
|
||||
[20260617T140000](../apps/server/src/database/migrations/20260617T140000-page-embeddings-dimension-agnostic.ts);
|
||||
`model_name` / `model_dimensions` хранятся по строке.
|
||||
- Полнотекстовые индексы **уже существуют** (предложение ошибочно утверждало
|
||||
обратное): `pages_tsv_idx` на `pages.tsv` и `attachments_tsv_idx`. Конфигурация —
|
||||
`to_tsvector('english', f_unaccent(...))` + `setweight`
|
||||
([тут](../apps/server/src/database/migrations/20250729T213756-add-unaccent-pg_trm-update-tsvector..ts)).
|
||||
- Чанкинг: `RecursiveCharacterTextSplitter` 1000/200, без префиксов.
|
||||
- Префиксы `query:` / `passage:` **не нужны**: они требуются для e5/bge/gte/Qwen3,
|
||||
а деплой на OpenAI `text-embedding-3-large` (этот пункт предложения неприменим).
|
||||
- Вложения (`attachment_id` в схеме есть) **не индексируются** — индексатор всегда
|
||||
пишет `attachmentId: null`.
|
||||
|
||||
---
|
||||
|
||||
## Итерация 1 — РЕАЛИЗОВАНО
|
||||
|
||||
Три «низковисящих фрукта»:
|
||||
|
||||
### 1. Хлебные крошки заголовков в чанках
|
||||
Файл: [embedding-indexer.service.ts](../apps/server/src/core/ai-chat/embedding/embedding-indexer.service.ts).
|
||||
Каждый чанк префиксуется путём заголовков `«Заголовок страницы > H1 > H2»` перед
|
||||
эмбеддингом. Крошки строятся обходом **ProseMirror JSON** (`heading`-ноды с
|
||||
`attrs.level`), а не markdown-текста — поэтому `#` внутри fenced-код-блока (типичный
|
||||
bash-сниппет в WirenBoard-вики) **никогда** не принимается за заголовок. Деградация
|
||||
к старому plain-text чанкингу при отсутствии/сбое `content`. Префикс попадает и в
|
||||
эмбеддинг, и в `content` (а значит — в лексический индекс `fts` и в сниппет агента).
|
||||
|
||||
### 2. Гибридный поиск (RRF), слияние двух инструментов в один
|
||||
- Миграция [20260618T150000-page-embeddings-fts.ts](../apps/server/src/database/migrations/20260618T150000-page-embeddings-fts.ts):
|
||||
генерируемая колонка `fts tsvector GENERATED ALWAYS AS (to_tsvector('english',
|
||||
f_unaccent(content))) STORED` + GIN-индекс. Конфиг совпадает с `pages.tsv` (та же
|
||||
обработка unaccent/Cyrillic); `f_unaccent` IMMUTABLE → триггер не нужен.
|
||||
- Репозиторий: метод `hybridSearch` в
|
||||
[page-embedding.repo.ts](../apps/server/src/database/repos/ai-chat/page-embedding.repo.ts) —
|
||||
один SQL-запрос, два CTE (cosine + `websearch_to_tsquery`), слияние Reciprocal Rank
|
||||
Fusion через FULL OUTER JOIN на уровне чанков. `k=60` (дефолт Cormack 2009 /
|
||||
ES / OpenSearch / Weaviate), равные веса 1.0/1.0. RRF сливает **ранги**, поэтому
|
||||
несовместимость шкал BM25 и косинуса не требует нормализации. Dimension-фильтр —
|
||||
только на семантической стороне.
|
||||
- Инструменты: `semanticSearch` удалён, `searchPages` стал единым гибридным
|
||||
инструментом ([ai-chat-tools.service.ts](../apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts)).
|
||||
Контроль доступа сохранён 1-в-1 (scope по доступным спейсам + пост-фильтр прав
|
||||
страниц). Если эмбеддинги не настроены / эмбеддинг упал / нет доступных спейсов /
|
||||
гибрид пуст → graceful fallback на прежний REST-полнотекст (CASL-enforced).
|
||||
|
||||
### 3. Переписывание запроса + описания инструментов
|
||||
- Описание `searchPages` теперь явно просит агента переформулировать вопрос в
|
||||
сфокусированный поисковый запрос и переискивать при слабой выдаче (это переживает
|
||||
кастомный admin-промпт, т.к. лежит в описании инструмента).
|
||||
- Одна строка-подсказка добавлена в `DEFAULT_PROMPT`
|
||||
([ai-chat.prompt.ts](../apps/server/src/core/ai-chat/ai-chat.prompt.ts)).
|
||||
|
||||
> ВАЖНО после деплоя: чтобы крошки и `fts` появились у существующих страниц, нужна
|
||||
> **переиндексация корпуса** (кнопка «Reindex now» / `WORKSPACE_CREATE_EMBEDDINGS`).
|
||||
> Миграция заполнит `fts` у текущих строк автоматически, но крошки добавляются только
|
||||
> при переиндексации (она же перезапишет `content`).
|
||||
|
||||
### Известные нюансы текущей реализации (осознанные компромиссы)
|
||||
- Гибрид покрывает только проиндексированные чанки. Свежесозданная страница
|
||||
становится искомой после отработки её BullMQ-`reindexPage`. Пока эмбеддинги не
|
||||
настроены — работает только REST-fallback (полнотекст уровня страницы по `pages.tsv`).
|
||||
- Если **весь** пул кандидатов гибрида (до 200 чанков) оказался из закрытых для
|
||||
пользователя страниц, инструмент вернёт пусто, а не уйдёт в keyword-fallback.
|
||||
Узкий кейс; возможное улучшение — fallback и при пустом результате пост-фильтра.
|
||||
- `fts` использует конфиг `english` (как и `pages.tsv`) — без русской стеммизации.
|
||||
Для русской вики это консистентно с текущим поиском; переход на `simple`/`russian`
|
||||
конфиг — отдельная задача с переиндексацией.
|
||||
- `candidates` (=clamp(limit×5, 50, 200)) служит и per-CTE лимитом, и финальным
|
||||
лимитом слияния; веса RRF равные. Тюнится после появления оценочного харнесса.
|
||||
|
||||
---
|
||||
|
||||
## Бэклог следующих итераций (по приоритету «качество / усилие»)
|
||||
|
||||
### A. Реранкер (cross-encoder) — наибольший ROI после гибрида
|
||||
Вставить между over-fetch гибрида и дедупом: брать топ-50–100 кандидатов от
|
||||
`hybridSearch`, реранкать, оставлять топ-5–10. Ожидаемый прирост precision/MRR
|
||||
+10–25 %. Точка вставки уже готова — это шаг между `hybridSearch(... candidates)` и
|
||||
циклом дедупа в `searchPages`.
|
||||
- Хостовый старт (раз уже на OpenAI-инфраструктуре): **Cohere Rerank** или
|
||||
**Voyage `rerank-2.5`** — провайдер по аналогии с текущим pluggable embedding-конфигом.
|
||||
- Self-hosted (под Ollama-этос): **BGE-reranker-v2-m3** через HF Text Embeddings
|
||||
Inference (`/rerank`), либо FlashRank (ONNX/CPU, ~15–30 мс).
|
||||
- Диагностика: если реранк не двигает метрики — узкое место в recall (чанкинг/гибрид),
|
||||
а не в ранжировании.
|
||||
|
||||
### B. Индексация вложений — закрыть пробел покрытия
|
||||
Схема уже готова (`attachment_id`). Добавить в BullMQ-flow шаг извлечения текста из
|
||||
PDF/документов (PyMuPDF для цифровых PDF; OCR для сканов; для таблиц — markdown через
|
||||
LLM-парсер) и вливать его в тот же путь чанк→эмбеддинг→`fts`, помечая `attachment_id`.
|
||||
Структура извлечённых данных важнее голой точности OCR.
|
||||
|
||||
### C. Тюнинг гибрида и оценочный харнесс
|
||||
- Золотой датасет 30–100 примеров (вопрос → нужная страница/чанк) + Ragas/DeepEval
|
||||
(Recall@k, MRR/nDCG, context precision/recall, faithfulness). Прогон до/после
|
||||
каждого изменения. **Прерогатива пропущена в итерации 1 осознанно** — без неё все
|
||||
нижеследующие тюнинги делаются «на глаз».
|
||||
- После харнесса: тюнить веса RRF (старт 1.0/1.0), `k` (старт 60), число `candidates`.
|
||||
- Эксперимент: чанки ~512 симв. против 1000 (предложение указывает на рост precision).
|
||||
|
||||
### D. Contextual Retrieval (Anthropic), если крошек мало
|
||||
Один LLM-вызов на чанк добавляет предложение-контекст. Снижение провалов выдачи
|
||||
на 35–49 %. Ложится в BullMQ-`reindexPage`; на сотнях страниц с prompt caching — копейки.
|
||||
Применять, только если хлебных крошек окажется недостаточно против потери контекста.
|
||||
|
||||
### E. ParadeDB `pg_search` (настоящий BM25), если лексика станет узким местом
|
||||
Нативный `ts_rank` использует только TF и длину документа, без IDF. `pg_search`
|
||||
(Rust/Tantivy) даёт честный BM25-индекс. Не drop-in (свои операторы вместо `@@`) —
|
||||
это изменение кода, а не флаг. На сотнях страниц нативного `tsvector` хватает; брать
|
||||
только если качество лексического ранжирования упрётся в потолок.
|
||||
|
||||
### F. Прочее
|
||||
- **Префиксы query/passage** — НЕ нужны на OpenAI. Внедрять только при переходе на
|
||||
e5/bge/gte/Qwen3 (тогда индексатор ставит `passage:`, запрос — `query:`; BGE-v1.5,
|
||||
наоборот, префиксов НЕ должна получать). Зафиксировано как ловушка на будущее.
|
||||
- **Апгрейд embedding-модели** — уже на `text-embedding-3-large` (топ среди закрытых).
|
||||
Matryoshka (обрезка размерности) — запас на будущее; dimension-agnostic колонка
|
||||
делает миграцию тривиальной (цена — переэмбеддинг корпуса).
|
||||
- **HyDE и широкий multi-query/RAG-Fusion** — НЕ рекомендуются как дефолт: в свежих
|
||||
бенчмарках уступали и добавляют задержку/галлюцинации.
|
||||
|
||||
## Оговорки
|
||||
- Все внешние числа (62→84 % precision, +17 % Recall@5, −35…49 % провалов, +10–25 %
|
||||
от реранка) получены на ДРУГИХ корпусах (SEC-отчёты, финтекст, право, медицина).
|
||||
На этой вики величины будут иными — поэтому пункт C (свой датасет) обязателен перед
|
||||
тонким тюнингом. Внешние числа — направление, не гарантия величины.
|
||||
- Часть источников предложения — вендорский маркетинг (Cohere, Voyage, ParadeDB);
|
||||
направление подтверждается независимыми (T2-RAGBench, оценка Anthropic), но величины
|
||||
у вендоров могут быть завышены.
|
||||
Reference in New Issue
Block a user