From 19e083596dc84cf372fc3e69d7855ee008f1287e Mon Sep 17 00:00:00 2001 From: "glm5.2 agent 180" Date: Sat, 20 Jun 2026 15:41:54 +0300 Subject: [PATCH 1/4] docs: add search morphology + hybrid-search-exposure plans Two feature plans grounded in the current develop: - search-language-morphology-plan.md: make the FTS text-search config selectable (env SEARCH_TS_CONFIG, whitelist english|russian|simple|ru_en; recommend ru_en for the RU/EN wiki). Documents every hardcoded 'english' touchpoint (pages.tsv trigger, page_embeddings.fts generated column, attachments.tsv, search.service.ts, hybrid lexical CTE), the DDL-baked config constraint, reindex strategy, and the regconfig SQL-injection guard. - hybrid-search-general-plan.md: expose the existing pgvector/RRF hybrid search (today agent-only via searchPages) on the user-facing /search, the UI, and the MCP search tool, reusing page-embedding.repo.hybridSearch with identical CASL/permission post-filtering and lexical fallback. --- docs/hybrid-search-general-plan.md | 144 +++++++++++++++++++ docs/search-morphology-language-plan.md | 178 ++++++++++++++++++++++++ 2 files changed, 322 insertions(+) create mode 100644 docs/hybrid-search-general-plan.md create mode 100644 docs/search-morphology-language-plan.md diff --git a/docs/hybrid-search-general-plan.md b/docs/hybrid-search-general-plan.md new file mode 100644 index 00000000..af369801 --- /dev/null +++ b/docs/hybrid-search-general-plan.md @@ -0,0 +1,144 @@ +# Векторный / гибридный поиск в основном поиске (вынос из агента) — план + +> Статус: план (не реализовано). **Важно про текущее состояние:** векторный +> (pgvector) и гибридный (RRF) поиск в форке **уже есть** — но только внутри +> агента. Пользовательский поиск `/search` (а с ним и UI-поиск, и MCP-инструмент +> `search`) всё ещё **чисто лексический**. Эта фича — вынести существующий +> семантический/гибридный движок на общий поисковый поверхностный слой. + +## Как сверялось с реальным кодом (что есть, чего нет) + +**Семантика уже реализована — но только для агента:** +- `page_embeddings` — pgvector, **dimension-agnostic** колонка `embedding`, + `model_name`/`model_dimensions` по строке; per-workspace; индексация через + BullMQ (`reindexPage`/`reindexWorkspace`). Активная модель деплоя — OpenAI + `text-embedding-3-large` (3072d). (См. [rag-improvements-plan.md](./rag-improvements-plan.md).) +- [page-embedding.repo.ts](../apps/server/src/database/repos/ai-chat/page-embedding.repo.ts): + - `searchByEmbedding()` — косинус `<=>` по чанкам (~стр. 143). + - `hybridSearch()` — **RRF-слияние** косинуса и полнотекста (`fts`-CTE на + `websearch_to_tsquery`), `k = 60`, равные веса, scope по workspace + + доступным спейсам, фильтр по совпадающей размерности эмбеддинга (~стр. 211). + - Поиск идёт **seq-scan** по `<=>` (ANN-индекса нет; в комментарии репо прямо + сказано «re-add an HNSW index if [scale grows]»). +- Потребитель — **только** агент: инструмент `searchPages` в + [ai-chat-tools.service.ts](../apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts). + +**Основной поиск — лексический:** +- [search.service.ts](../apps/server/src/core/search/search.service.ts) (`/search`): + только `pages.tsv` + `to_tsquery('english', …)`. Никаких эмбеддингов. +- **MCP-инструмент `search`** дергает именно этот REST: + [packages/mcp/src/client.ts:1818](../packages/mcp/src/client.ts#L1818) → `POST /search`. + Значит, вынеся семантику в `/search`, мы автоматически прокачаем и MCP-поиск. +- `AiService.getEmbeddingModel(workspaceId)` ([ai.service.ts](../apps/server/src/integrations/ai/ai.service.ts)) + умеет строить embedding-модель из per-workspace конфига — то есть всё нужное для + получения вектора запроса уже есть. +- В окружении есть `SEARCH_DRIVER` (`database` | `typesense`). Семантику делаем + как улучшение драйвера `database`, **не** переопределяя `SEARCH_DRIVER`. + +**Вывод:** «добавить векторный поиск» = не писать с нуля, а **переиспользовать +`hybridSearch` в `SearchService`** с тем же контролем доступа, что у лексического +`/search`, + graceful-фолбэк. Это главная мысль плана. + +## Цель + +Дать семантический/гибридный результат на общем поисковом слое (UI-поиск, REST +`/search`, MCP `search`), а не только агенту — чтобы «уволить» находило +«расторжение трудового договора», и чтобы это было доступно вне чата. + +## Архитектура + +### Контракт API +Добавить в `SearchDTO` параметр `mode: 'lexical' | 'semantic' | 'hybrid'`. +- Дефолт — `lexical` (обратная совместимость; тайп-ахед остаётся дешёвым). +- `hybrid`/`semantic` — включается явно (страница полнотекстового поиска, тумблер + в UI, или MCP с `mode:'hybrid'`). + +### Поток в `SearchService.searchPage` для hybrid/semantic +1. Если эмбеддинги настроены (`getEmbeddingModel` не кидает) → эмбеддить **запрос** + (один вызов на поиск). +2. Вызвать `hybridSearch(workspaceId, queryVector, queryText, candidates, accessibleSpaceIds)` + — over-fetch чанков. +3. Чанки → страницы: дедуп по `pageId` (лучший score), маппинг в `SearchResponseDto`. +4. **Контроль доступа 1-в-1 с лексическим путём**: пост-фильтр через + `pagePermissionRepo.filterAccessiblePageIds(...)` (как в текущем `/search`, + стр. 129–139). Scope по доступным спейсам уже внутри `hybridSearch`, но + post-filter по правам страниц обязателен. +5. Highlight — `ts_headline` по `content` чанка (релевантнее, чем по странице). +6. Любой сбой/некочиг (эмбеддинги не настроены, embedding упал, нет доступных + спейсов, гибрид пуст) → **graceful fallback на лексический путь** (тот же + паттерн, что уже использует агентский инструмент). + +### MCP и UI +- MCP: после появления `mode` в `/search` — прокинуть его в + [packages/mcp/src/client.ts](../packages/mcp/src/client.ts) и в схему MCP-тула. + Помнить: `packages/mcp` держит **свою копию** схемы (по `AGENTS.md`). +- UI: тайп-ахед (`searchSuggestions`) остаётся лексическим; семантику включать на + полной странице поиска / тумблером, не на каждое нажатие клавиши. + +## Итерации + +### Итерация 1 (MVP, backend) +`mode` в DTO; ветка hybrid в `SearchService` (эмбеддинг запроса → `hybridSearch` +→ чанк→страница дедуп → пост-фильтр прав → highlight; иначе лексический фолбэк). +Спеки на: паритет прав (закрытая страница не утекает), фолбэк без эмбеддингов, +дедуп страниц. + +### Итерация 2 (MCP + UI) +Прокинуть `mode` в MCP-тул `search` (+ синхронизировать схему-зеркало) и добавить +переключатель/режим на странице поиска клиента. + +### Итерация 3 (производительность и качество) +- Кеш/дебаунс эмбеддингов запроса (не эмбеддить одинаковые запросы повторно). +- ANN-индекс при росте корпуса (см. оговорку про dimension-agnostic ниже). +- Общий оценочный харнес с [rag-improvements-plan.md §C](./rag-improvements-plan.md) + (один золотой датасет на агентский и пользовательский поиск). + +## Точки изменения + +- [search.service.ts](../apps/server/src/core/search/search.service.ts) — ветвление по `mode`, + переиспользование `hybridSearch`, маппинг чанк→страница, общий пост-фильтр прав. +- `search.dto.ts` — поле `mode`. +- [page-embedding.repo.ts](../apps/server/src/database/repos/ai-chat/page-embedding.repo.ts) — + `hybridSearch`/`searchByEmbedding` уже есть; при необходимости — перегрузка, + возвращающая поля под `SearchResponseDto` (без дублирования логики). +- `search.module.ts` — подключить доступ к embedding-модели и репозиторию + эмбеддингов (DI). +- [packages/mcp/src/client.ts](../packages/mcp/src/client.ts) + схема MCP-тула — `mode`. +- Клиент: страница/тумблер поиска (итерация 2). + +## Безопасность и граничные случаи + +- **Паритет прав — риск №1.** Агентский `searchPages` скоупит по доступным + спейсам и пост-фильтрует права; общий поиск **обязан** делать то же + (`filterAccessiblePageIds`), иначе семантика утечёт чанки закрытых страниц. + Покрыть спеком утечки. +- **Путь шар (`shareId`)** в `/search` — анонимный, без per-user скоупа + эмбеддингов. Для шар оставить лексический поиск (или строго ограничить + поддеревом шары); семантику для анонимов в MVP не включать. +- **Стоимость/латентность.** Каждый семантический запрос = 1 вызов embedding-API + (~сотни мс + токены). Поэтому дефолт `lexical`, семантика — по явному режиму, + не на тайп-ахед. +- **Чанк→страница.** Страница может прийти из нескольких чанков — дедуп с лучшим + score; иначе дубликаты в выдаче. +- **Свежие страницы.** Только что созданная/изменённая страница попадёт в + семантику после отработки BullMQ-`reindexPage`. До этого её ловит лексическая + сторона (если есть `fts`-чанк) либо общий лексический фолбэк. Документировать + как осознанный лаг. +- **Фильтр размерности.** `hybridSearch` сравнивает только чанки с + `model_dimensions == dim(query)`. После смены embedding-модели старые чанки + невидимы до переиндексации (свойство уже существующего движка). +- **ANN-индекс vs dimension-agnostic колонка.** Сейчас seq-scan по `<=>` — норм на + масштабе вики. HNSW/IVFFlat требуют фиксированной размерности, а колонка + намеренно dimension-agnostic → ANN потребует либо фиксации размерности, либо + частичных индексов на размерность. Решать при реальном росте, не в MVP. +- **Связь с морфологией.** Лексический CTE гибрида использует `'english'` + (`page_embeddings.fts`). План [search-morphology-language-plan.md](./search-morphology-language-plan.md) + меняет этот конфиг — координировать, чтобы язык был единым в обоих поисках. + +## Оговорки + +- Это **не дубль** [rag-improvements-plan.md](./rag-improvements-plan.md): тот про + качество retrieval агента (реранкер, чанкинг, вложения, харнес). Здесь — про + **поверхность** (вынос уже готового движка в пользовательский/MCP поиск). +- Реранкер из rag-плана (бэклог §A), когда появится, можно переиспользовать и + здесь — точка вставки та же (между over-fetch гибрида и финальным срезом). diff --git a/docs/search-morphology-language-plan.md b/docs/search-morphology-language-plan.md new file mode 100644 index 00000000..2cc32faa --- /dev/null +++ b/docs/search-morphology-language-plan.md @@ -0,0 +1,178 @@ +# Выбор языка морфологии для полнотекстового поиска — план + +> Статус: план (не реализовано). Контекст: gitmost — форк Docmost. Весь +> лексический поиск сейчас жёстко прибит к конфигу `'english'`, из-за чего на +> русской вики не работает стемминг (по запросу «сервер» не находятся +> «серверы / серверов / сервером»). Цель — сделать язык текстового поиска +> конфигурируемым, с разумным дефолтом для русско-английского контента. + +## Как сверялось с реальным кодом + +Все факты ниже проверены по дереву `develop` на момент написания. + +### Где сейчас зашит `'english'` + +**Индексная сторона (DDL — конфиг «запечён» в схему):** + +1. `pages.tsv` — наполняется **триггером** `pages_tsvector_trigger()` + (`BEFORE INSERT OR UPDATE ON pages`). Тело функции: + `setweight(to_tsvector('english', f_unaccent(coalesce(new.title,''))),'A') || setweight(to_tsvector('english', f_unaccent(...text_content...)),'B')`. + Заведено в [20240324T086800-pages-tsvector-trigger.ts](../apps/server/src/database/migrations/20240324T086800-pages-tsvector-trigger.ts), + обновлено под `f_unaccent` в [20250729T213756-add-unaccent-pg_trm-update-tsvector..ts](../apps/server/src/database/migrations/20250729T213756-add-unaccent-pg_trm-update-tsvector..ts). + GIN-индекс — `pages_tsv_idx`. +2. `page_embeddings.fts` — **GENERATED ALWAYS … STORED** колонка: + `to_tsvector('english', f_unaccent(content))`, GIN-индекс `idx_page_embeddings_fts`. + Заведена в [20260618T150000-page-embeddings-fts.ts](../apps/server/src/database/migrations/20260618T150000-page-embeddings-fts.ts). + Это лексическая сторона гибридного (RRF) поиска агента. +3. `attachments.tsv` — колонка `tsvector` + GIN `attachments_tsv_idx` + ([20250901T184612-attachments-search.ts](../apps/server/src/database/migrations/20250901T184612-attachments-search.ts)). + **Путь наполнения этой колонки в коде не локализован** (в миграции триггера + нет) — перед реализацией нужно найти, кто и каким конфигом её пишет, и + привести к тому же языку (или признать колонку неиспользуемой). + +**Сторона запроса (рантайм SQL — должна совпадать с индексом):** + +4. [search.service.ts](../apps/server/src/core/search/search.service.ts) — пользовательский + REST-поиск `/search`. Три вхождения `'english'`: `ts_rank(tsv, to_tsquery('english', …))` + (стр. 50), `ts_headline('english', …)` (стр. 53), `WHERE tsv @@ to_tsquery('english', …)` + (стр. 60). **Через этот же эндпоинт ходит MCP-инструмент `search`** + ([packages/mcp/src/client.ts:1818](../packages/mcp/src/client.ts#L1818) → `POST /search`), + поэтому фикс автоматически чинит и MCP-поиск. +5. [page-embedding.repo.ts](../apps/server/src/database/repos/ai-chat/page-embedding.repo.ts) — + лексический CTE гибридного поиска: `websearch_to_tsquery('english', f_unaccent(queryText))` + (~стр. 252) + `ts_rank(pe.fts, q.query)`. + +### Что уже есть и помогает + +- Расширения `unaccent` и `pg_trgm` **уже установлены** (миграция 20250729T213756), + `f_unaccent(text)` объявлена `IMMUTABLE` (важно: только IMMUTABLE-функцию можно + использовать в выражении GENERATED-колонки и в индексе). +- `searchSuggestions` (тайп-ахед по `users`/`groups`/`pages.title`) работает не + через `tsvector`, а через `ILIKE`/`f_unaccent` (подстрока) — он **уже + языконезависим**, морфология его не касается. Трогать не нужно. +- В окружении уже есть абстракция `SEARCH_DRIVER` (`database` | `typesense`, + дефолт `database`) — см. `environment.service.ts` / `environment.validation.ts`. + Наша задача относится к драйверу `database`. + +## Ключевое ограничение (почему это не «рантайм-переключатель») + +Конфиг текстового поиска у GIN-индекса и у `tsvector`-колонки **запечён в DDL** +(в теле триггера и в выражении GENERATED-колонки). При запросе конфиг в +`to_tsquery(, …)` **обязан совпадать** с тем, которым построен индекс, +иначе токены не сматчатся. Поэтому язык — это **выбор уровня деплоя**, а не +параметр запроса. Сделать конфиг по-настоящему «на строку» (per-workspace) можно, +но дорого: `to_tsvector(regconfig_column, text)` неиммутабельна, значит её +**нельзя** положить в GENERATED-колонку `page_embeddings.fts` (только в +триггер-наполняемую), а запрос по корпусу со смешанными конфигами требует знать +конфиг каждой строки. Это вариант D ниже — откладываем. + +Ещё нюанс выбора конфига: +- `russian` — снежковый стемминг + русские стоп-слова. Минус: режет английские + технические термины и выкидывает русские стоп-слова (по «и»/«в»/«не» не найти). +- `english` (как сейчас) — стеммит по-английски, для русского почти бесполезен. +- `simple` — без стемминга и стоп-слов: только токенизация + lowercase (+ наш + `f_unaccent`). Языконезависим, но нет морфологии («серверы» ≠ «сервер»). +- Технические RU+EN-вики (как WirenBoard) — это **смесь**: лучший охват даёт + объединённый вектор `to_tsvector('russian', x) || to_tsvector('english', x)`. + +## Варианты решения (по возрастанию сложности) + +### Вариант A — глобальный конфиг через env (рекомендуемый механизм) +Ввести `SEARCH_TS_CONFIG` (значения из белого списка: `english` | `russian` | +`simple` | `ru_en`), дефолт `english` (обратная совместимость для текущих +инсталляций). Значение применяется в трёх местах: тело триггера `pages`, +выражение GENERATED-колонки `page_embeddings.fts`, и интерполяция в запросах +(`search.service.ts`, `page-embedding.repo.ts`). +- **Плюсы:** одна понятная ручка; покрывает 95 % кейсов (один язык на инсталляцию). +- **Минусы:** смена значения требует пересборки индексов и переиндексации (см. ниже). + +### Вариант B — объединённый RU+EN вектор (значение `ru_en` варианта A) +В тех же местах генерировать `to_tsvector('russian', f_unaccent(x)) || to_tsvector('english', f_unaccent(x))`, +а на стороне запроса OR-ить два `to_tsquery`. **Рекомендуемый дефолт для этой +русско-английской вики.** +- **Плюсы:** морфология и для русского, и для английского без per-row конфига. +- **Минусы:** ~2× размер `tsvector`, чуть «шумнее» ранжирование (приемлемо на + масштабе вики в сотни–тысячи страниц). + +### Вариант C — `simple` + pg_trgm +`SEARCH_TS_CONFIG=simple` + триграммный фолбэк (`pg_trgm` уже стоит) для нечёткого +совпадения по `title`/`text_content`. +- **Плюсы:** работает на любом языке без выбора; дёшево. +- **Минусы:** нет морфологии; trgm даёт только похожесть подстрок, не словоформы. +Запасной вариант, если не хотим фиксировать язык. + +### Вариант D — per-workspace/per-space `regconfig` +Колонка `search_config regconfig` + триггерное наполнение `pages.tsv`; для +`page_embeddings.fts` пришлось бы заменить GENERATED-колонку на +триггер-наполняемую. Максимум гибкости для мультиязычных инсталляций, максимум +сложности и риска. **Откладываем**, пока не появится реальная мультиязычность. + +## Рекомендация + +Механизм — **вариант A** (env `SEARCH_TS_CONFIG`, белый список, дефолт `english`), +с поддержкой значения **`ru_en`** (вариант B) и рекомендацией ставить именно его +на этой вики. `simple`/`russian` остаются доступными значениями. + +## Точки изменения + +**Backend / DB:** +- Новая миграция (timestamp — позже последней применённой, см. правило + упорядочивания миграций в `AGENTS.md`): + - `CREATE OR REPLACE` функции `pages_tsvector_trigger()` с выбранным конфигом. + - Пересборка существующих строк: одноразовый `UPDATE pages SET title = title` + (перефайр триггера) либо явный `UPDATE pages SET tsv = <новое выражение>`. + - `page_embeddings.fts`: `DROP COLUMN fts` + повторный `ADD COLUMN fts … GENERATED …` + с новым конфигом (GENERATED-колонка пересчитается для всех строк + автоматически; переэмбеддинг **не нужен** — это только текст), пересоздать + `idx_page_embeddings_fts`. + - `attachments.tsv` — привести к тому же конфигу после локализации её писателя. +- `environment.validation.ts` / `environment.service.ts`: добавить `SEARCH_TS_CONFIG` + + геттер `getSearchTsConfig()` с **валидацией по белому списку** (см. безопасность). +- Запросы: [search.service.ts](../apps/server/src/core/search/search.service.ts) (3 места) и + [page-embedding.repo.ts](../apps/server/src/database/repos/ai-chat/page-embedding.repo.ts) + (лексический CTE) — взять конфиг из `EnvironmentService`. Для `ru_en` — OR двух + `to_tsquery`/`websearch_to_tsquery` и `||` двух `to_tsvector`. +- `.env.example` — задокументировать переменную. + +**Frontend:** изменений не требуется (поиск получает результаты как раньше). + +## Безопасность + +Имя `regconfig` **нельзя** интерполировать в SQL как сырую строку из env — это +SQL-инъекция/невалидный конфиг → 500. Разрешать только из **белого списка** +(`english`/`russian`/`simple`/`ru_en`) на уровне геттера; в SQL подставлять уже +сматченное константное имя, а не пользовательский ввод. + +## Граничные случаи и оговорки + +- **Highlight (`ts_headline`) должен использовать тот же конфиг**, что и матч, + иначе подсветка «съедет». Для `ru_en` подсветку проще делать одним конфигом + (`russian`) либо вызывать `ts_headline` по тому конфигу, который дал матч. +- **Стоп-слова `russian`** удаляются из индекса — по ним искать нельзя (компромисс + морфологии). `simple`/`ru_en` это смягчают. +- **Свежесозданные/изменённые страницы**: `pages.tsv` пересчитывается триггером + на каждый write — без проблем. `page_embeddings.fts` пересчитывается при + следующей переиндексации чанков (BullMQ `reindexPage`), но миграция уже + пересоберёт колонку для всех текущих строк. +- **Переиндексация после смены конфига обязательна** (иначе старые `tsv` останутся + в прежнем языке). Для `pages`/`attachments` — в самой миграции; для крошек/ + контента эмбеддингов — кнопка «Reindex now» (см. + [rag-improvements-plan.md](./rag-improvements-plan.md)). +- **Связь с гибридным поиском агента**: меняя конфиг в `page_embeddings.fts` и в + лексическом CTE `page-embedding.repo.ts`, мы меняем и качество RRF-поиска агента + — это согласованное улучшение, но проверить регрессии тестами `ai-chat`. +- **Зависимость с планом «гибридный поиск в основном поиске»** + ([hybrid-search-general-plan.md](./hybrid-search-general-plan.md)): оба плана + трогают `'english'` в лексических запросах. Координировать порядок, чтобы конфиг + везде был единым. + +## Тестирование + +- Интеграционный спек: проиндексировать страницу со словом «серверы», искать + «сервер» → при `russian`/`ru_en` находит, при `english` — нет. +- Смешанный RU+EN документ под `ru_en`: матч и по русской словоформе, и по + английскому термину. +- Проверка whitelist: некорректное значение env → конфигурация падает на старте + (validation), а не уходит в SQL. +- Регрессия MCP `search` и REST `/search` на латинице (поведение `english` + сохраняется при дефолте). -- 2.49.1 From ddfccb30f32f5e4f864c0e06aa5b50d6f650f876 Mon Sep 17 00:00:00 2001 From: "glm5.2 agent 180" Date: Sat, 20 Jun 2026 15:43:23 +0300 Subject: [PATCH 2/4] docs(backlog): add dependency update & security audit snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record outdated-deps and security-audit findings for the fork as of 2026-06-20 (pnpm outdated -r + pnpm audit --prod): 162 outdated entries, 50 major-behind, 51 vulnerabilities (16 high). Key finding: pnpm.overrides pin several packages to versions flagged by the audit (ws, undici, tmp, hono, protobufjs, dompurify) — cheapest fix is bumping the pins. Also flags direct-dep highs (@nestjs/platform-fastify auth middleware bypass, nodemailer, form-data, react-router-dom), risky majors to schedule separately (Mantine9/React19, Hocuspocus 4, CASL 7, TypeScript 6, zod 4, stripe), the deprecated @types/form-data, and @types/node drift across the workspace. --- .../dependency-updates-and-security-audit.md | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 docs/backlog/dependency-updates-and-security-audit.md diff --git a/docs/backlog/dependency-updates-and-security-audit.md b/docs/backlog/dependency-updates-and-security-audit.md new file mode 100644 index 00000000..9df72976 --- /dev/null +++ b/docs/backlog/dependency-updates-and-security-audit.md @@ -0,0 +1,117 @@ +# Обновление зависимостей: устаревшие версии и аудит безопасности + +Статус: **зафиксировано в беклоге, зависимости не менялись.** Это снимок состояния +на дату проверки — список задач на обновление, а не баг. Бо́льшая часть версий +**унаследована от upstream `docmost/main`**, поэтому массовые бампы разумно делать +вместе с ребейзом на upstream, а мажорные апгрейды — отдельными задачами. + +## Методика + +- Дата проверки: **2026-06-20**, ветка форка `feat/ai-agent-roles`. +- `pnpm outdated -r` (рекурсивно по воркспейсу: root `docmost`, `server`, `client`, + `@docmost/mcp`, `@docmost/editor-ext`). +- `pnpm audit --prod` (+ `--json`) — только прод-зависимости. +- Итог: **162** устаревших записи, из них **50** отстают на мажор и больше; + **51 уязвимость** (16 high / 26 moderate / 8 low). + +--- + +## 1. Безопасность — приоритет (51 уязвимость) + +### 1.1. Самый дешёвый фикс: `pnpm.overrides` пинят УЯЗВИМЫЕ версии + +В корневом `package.json` секция `pnpm.overrides` фиксирует ряд пакетов ровно на тех +версиях, на которые ругается `pnpm audit`. Достаточно поднять пины — код не трогаем. + +| override (текущий пин) | advisory | severity | поднять до | +|---|---|---|---| +| `ws: 8.20.1` | DoS из мелких фрагментов (`<8.21.0`) | **high** | `8.21.0+` | +| `undici: 7.24.0` | обход проверки TLS-сертификата (`<7.28.0`) | **high** | `7.28.0+` | +| `tmp: 0.2.6` | path traversal, обход `_assertPath` (`<0.2.7`) | **high** | `0.2.7+` | +| `hono: 4.12.18` | CORS отражает любой Origin с credentials (`<4.12.25`) | **high** | `4.12.25+` | +| `protobufjs: 7.5.8` | DoS через unbounded Any (`<=7.6.0`) | **high** | `7.6.3+` | +| `dompurify: 3.4.1` | мутация `allowedTags` в хуке (`<3.4.7`) | moderate | `3.4.11` | + +> Важно: `dompurify` — наш XSS-санитайзер, а override держит его на 3.4.1 (уязвимой), +> хотя в реестре уже 3.4.11. Это сводит на нет смысл санитайзера в части кейсов. + +### 1.2. Прямые зависимости — фикс бампом версии + +| пакет | у нас | где | advisory | severity | фикс | +|---|---|---|---|---|---| +| `@nestjs/platform-fastify` | `^11.1.19` (резолв 11.1.19) | server | обход middleware через trailing slash (`<=11.1.23`) | **high** | поднять lockfile до `11.1.27` | +| `nodemailer` | `^8.0.5` | server | `raw`-опция обходит `disableFileAccess` (`<=9.0.0`) | **high** | мажор `9.0.1` | +| `form-data` | `^4.0.0` (резолв 4.0.5) | @docmost/mcp | CRLF-инъекция (`<4.0.6`) | **high** | обновить lockfile до `4.0.6` | +| `react-router-dom` | `7.13.1` | client | произвольный контент через turbo-stream (`<=7.14.1`); CSRF на PUT/PATCH/DELETE (`<7.15.1`) | **high** + low | `7.15.1+` | + +> `@nestjs/platform-fastify`: middleware-bypass напрямую касается auth-цепочки — +> это самый «горящий» из прямых. Caret `^11.1.19` уже разрешает `11.1.27`, нужен +> только пересбор lockfile. + +### 1.3. Транзитивные (через зависимости) — фикс через override или бамп родителя + +- `fast-uri <=3.1.0` (**high**, path traversal) — через `fastify`. +- Прочие moderate, всплывающие транзитивно: `markdown-it <=14.1.1` (DoS), + `qs` (DoS), `uuid` (bounds check), `nanoid@^3` (предсказуемость), + `@opentelemetry/core` (unbounded memory), `undici` (cross-user disclosure), + `esbuild`/`@babel/core` (low, только dev-сервер/сборка). + +--- + +## 2. Очень старые — отставание на мажор (тех-долг) + +### 2.1. Рискованные мажоры — каждый отдельной задачей с тестированием + +| пакет | у нас | latest | замечание | +|---|---|---|---| +| `@mantine/*` 8 → 9 + `react`/`react-dom` 18 → 19 + `@types/react` 18 → 19 | 8.3.18 / 18.3.1 | 9.3.2 / 19.2.7 | Это апгрейд из upstream **PR #2293** (`chore: migrate to Mantine 9 and React 19`). Делать как у них: бамп + 3 паттерна (`useRef(undefined)`, обёртка `onChange`, шим `v8CssVariablesResolver`). Затрагивает в т.ч. EE-компоненты. | +| `@hocuspocus/{provider,server,transformer}` | 3.4.4 | 4.3.0 | Realtime-collab. Связано с `y-prosemirror`/`yjs` (на `yjs` уже есть патч `patches/yjs@13.6.30.patch` — учесть при бампе). upstream `main` тоже ещё на 3.x — координировать. | +| `@casl/ability` 6 → 7, `@casl/react` 5 → 7 | 6.8.0 / 5.0.1 | 7.0.0 | Библиотека прав доступа (авторизация) — мажор требует аккуратной проверки правил CASL. | +| `typescript` 5 → 6 | 5.9.3 | 6.0.3 | Мажор TS — глобально по трём пакетам, возможны новые ошибки типов. | +| `zod` 3 → 4 | 3.25.76 | 4.4.3 | В `@docmost/mcp`; zod 4 ломающий. Критично, т.к. zod описывает схемы инструментов AI/MCP (см. бэклог `ai-chat-tool-definitions-duplicated.md`). | +| `stripe` 17 → 22 | 17.7.0 | 22.2.2 | **+5 мажоров** (EE-биллинг). Если биллинг не используется — низкий приоритет, но разрыв самый большой. | + +### 2.2. Тулинг и прочие мажоры (рутинно, пачкой) + +`eslint` 9→10 + `@eslint/js` 9→10, `nx`/`@nx/js` 22→23, `i18next` 25→26 + +`react-i18next` 16→17 + `i18next-http-backend` 3→4, `undici` 7→8 (само приложение; +для security достаточно 7.28, мажор 8 — отдельно), `nodemailer` 8→9 (см. §1.2), +`marked` 17→18, `msgpackr` 1→2, `diff` 8→9, `concurrently` 9→10, +`@atlaskit/pragmatic-drag-and-drop*` 1→2/3 (DnD дерева), `react-clear-modal` 2→3, +`jsdom` 25/27→29 (dev/тесты), `@casl` (см. §2.1), плюс dev-types: +`@types/node`, `@types/nodemailer` 7→8, `@types/supertest` 6→7, `@types/yauzl` 2→3. + +--- + +## 3. Deprecated и несогласованность + +- **`@types/form-data` (2.5.2) — DEPRECATED.** Пакет `form-data` теперь поставляет + собственные типы. Зависимость в `@docmost/mcp` нужно **удалить**, а не обновлять. +- **`@types/node` рассинхронизирован по воркспейсу:** `@docmost/mcp` — 20.19, + `client` — 22.19, `server` — 25.5 (latest 26). Привести к единой мажорной линии + (по фактической версии Node в рантайме/Docker; в `package.json` поле `engines` + не задано — стоит зафиксировать). + +--- + +## 4. Рекомендованный порядок работ + +1. **Security-патч одной задачей (низкий риск):** поднять пины в `pnpm.overrides` + (§1.1) + пересобрать lockfile для caret-зависимостей (§1.2: fastify-platform, + form-data) + `react-router-dom` → 7.15.1 + `nodemailer` → 9. Прогнать + `pnpm audit --prod` до нуля high/critical. Убрать `@types/form-data`. +2. **Рутинные минор/патч-бампы** (большинство из 162) — пачкой вместе с + ближайшим ребейзом на upstream `docmost/main`. +3. **Мажоры из §2.1 — каждый отдельной веткой/задачей** с ручным тестом + соответствующей подсистемы (редактор, collab, права, i18n, AI-схемы). +4. Перепроверить, не конфликтуют ли бампы с локальными патчами + `patches/yjs@13.6.30.patch` и `patches/scimmy@1.3.5.patch` — при смене версии + путь патча (`yjs@13.6.30`) перестанет совпадать и `pnpm install` упадёт. + +## Оговорки + +- Снимок версий быстро устаревает — перед работой повторить `pnpm outdated -r` + и `pnpm audit --prod`. +- Многие «текущие» версии унаследованы от upstream; часть мажоров (Mantine9/React19, + Hocuspocus 4) upstream ещё не сделал — есть смысл дождаться/подсмотреть их подход, + чтобы не расходиться с веткой обновлений. -- 2.49.1 From 2936d16a43d601b85bab7e179b3dd4edd142e49d Mon Sep 17 00:00:00 2001 From: "glm5.2 agent 180" Date: Sat, 20 Jun 2026 15:43:44 +0300 Subject: [PATCH 3/4] docs: add history-diff performance redesign plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add docs/history-diff-perf-plan.md: deep-dive into the page-history inline diff performance problem and a phased redesign. - Root causes: O(K·D) recreateTransform (rfc6902 full-doc rebuild per op), full recompute on the "Highlight changes" toggle, a second full TipTap instance, all synchronous on the main thread. - Fix: drop recreateTransform; diff directly via prosemirror-changeset (getReplaceStep + ChangeSet.addSteps/computeDiff), keeping the existing decoration contract for visual parity. - Split the diff useEffect so the toggle no longer re-diffs. - Phased plan (P0 core, P1 large-doc guard + error handling, P2 worker), testing/parity strategy, risks and rollback. --- docs/history-diff-perf-plan.md | 266 +++++++++++++++++++++++++++++++++ 1 file changed, 266 insertions(+) create mode 100644 docs/history-diff-perf-plan.md diff --git a/docs/history-diff-perf-plan.md b/docs/history-diff-perf-plan.md new file mode 100644 index 00000000..4f0891b3 --- /dev/null +++ b/docs/history-diff-perf-plan.md @@ -0,0 +1,266 @@ +# История страниц: производительность инлайн-диффа — дизайн + +> Статус: **черновик / дизайн**. Реализация ещё не начата. +> Исходный кейс: при открытии истории страницы инлайн-дифф «адски тормозит» — +> вкладка фризится на больших страницах и **повторно** на каждый щелчок тумблера +> «Highlight changes». Цель — убрать фриз, сохранив визуальный результат диффа. +> +> Принятые на старте решения: +> - Серверную часть истории (снапшоты версий, REST `usePageHistoryQuery`) **не трогаем**. +> - Визуальный результат (что подсвечивается) должен остаться **эквивалентным** текущему — это рефактор производительности, не смена UX. +> - Корень проблемы — клиентский алгоритм восстановления шагов (`recreateTransform`) и то, как React гоняет его в `useEffect`. + +## 1. Что есть сейчас (как устроен дифф) + +Путь рендеринга: + +- [history-modal-body.tsx](../apps/client/src/features/page-history/components/history-modal-body.tsx) — модалка, тумблер `highlightChanges`, навигация по изменениям (`useDiffNavigation`), счётчики `diffCounts`. +- [history-view.tsx](../apps/client/src/features/page-history/components/history-view.tsx) — тянет **две** версии через `usePageHistoryQuery(historyId)` и `usePageHistoryQuery(prevHistoryId)`, передаёт `content` + `previousContent` в редактор. +- [history-editor.tsx](../apps/client/src/features/page-history/components/history-editor.tsx) — поднимает **второй** инстанс TipTap (`useEditor({ extensions: mainExtensions, editable: false })`) и в одном большом `useEffect` считает дифф и строит декорации. +- Движок диффа — вендоренный [recreate-transform](../packages/editor-ext/src/lib/recreate-transform/) (форк `prosemirror-recreate-transform`, на `rfc6902` + `diff`). + +Ядро вычисления в `history-editor.tsx`: + +```ts +const tr = recreateTransform(oldContent, newContent, { + complexSteps: false, + wordDiffs: true, + simplifyDiff: true, +}); +const changeSet = ChangeSet.create(oldContent).addSteps(tr.doc, tr.mapping.maps, []); +const changes = simplifyChanges(changeSet.changes, newContent); +// ... дальше из `changes` (fromA/toA/fromB/toB) строятся Decoration'ы ... +``` + +То есть `recreateTransform` нужен **только** чтобы получить набор шагов (`tr.mapping.maps`), который потом скармливается в `prosemirror-changeset`. Финальный набор `changes` и построение декораций уже идут через стандартный `ChangeSet` + `simplifyChanges`. + +## 2. Почему тормозит + +### 2.1 Алгоритм `recreateTransform` — приблизительно O(K · D) + +В [recreateTransform.ts](../packages/editor-ext/src/lib/recreate-transform/recreateTransform.ts) на каждую операцию JSON-патча выполняется работа над **всем документом целиком**: + +```ts +this.ops = createPatch(this.currentJSON, this.finalJSON); // rfc6902: diff JSON-деревьев, квадратичный по массивам +while (this.ops.length) { + const afterStepJSON = copy(this.currentJSON); // deep-clone ВСЕГО документа на каждую op + applyPatch(afterStepJSON, [op]); + toDoc = this.schema.nodeFromJSON(afterStepJSON); // пересборка ВСЕГО PM-дерева + toDoc.check(); // валидация ВСЕГО документа + // ... addReplaceStep -> this.schema.nodeFromJSON(this.currentJSON) — ещё одна полная пересборка +} +``` + +При `K` изменениях между версиями и документе размера `D` это даёт порядка `K · D` полных клонирований + `nodeFromJSON` + `check()`. Плюс сам `createPatch` (`rfc6902`) квадратичен по массивам узлов. На длинной странице с большим числом правок между ревизиями — **секунды синхронной работы на main-thread**. Это основной источник фриза. + +### 2.2 Полный пересчёт диффа на каждый тумблер «Highlight changes» + +В [history-editor.tsx](../apps/client/src/features/page-history/components/history-editor.tsx) весь расчёт сидит в одном `useEffect`, и в его зависимостях висит `highlightChanges`: + +```ts +}, [ title, content, editor, previousContent, highlightChanges, setDiffCounts ]); +``` + +При включении/выключении подсветки заново гоняется `recreateTransform` + `ChangeSet` + построение всех декораций + `editor.commands.setContent(content)`. Хотя для тумблера достаточно подменить **уже посчитанный** `decorationSet` на `DecorationSet.empty`. Каждый щелчок повторно платит всю стоимость п. 2.1. + +### 2.3 Второй полноценный редактор + `setContent` + `setOptions` + +`useEditor({ extensions: mainExtensions })` поднимает весь стек редактора ради read-only превью; `editor.commands.setContent(content)` повторно парсит документ; `editor.setOptions({ editorProps: … })` переконфигурирует плагины на каждом прогоне эффекта. Это оверхед поверх п. 2.1, особенно при переключении версий. + +### 2.4 Всё синхронно + +Расчёт идёт синхронно в обработчике эффекта — UI блокируется до конца. Нет ни воркера, ни отменяемости, ни лоадера: визуально это «зависшая» вкладка. + +**Сводка вкладов:** + +| Источник | Когда бьёт | Стоимость | +|---|---|---| +| `recreateTransform` (rfc6902 + per-op полный rebuild) | смена версии, тумблер | 🔴 O(K·D), главный | +| Пересчёт на тумблере | каждый щелчок | 🔴 повтор всего п. 2.1 | +| Второй TipTap + `setContent`/`setOptions` | смена версии, тумблер | 🟠 средний | +| Синхронность (нет воркера/лоадера) | всегда | 🟠 фриз вместо «думает…» | +| `diffWordsWithSpace` по узлам | смена версии | 🟢 мелочь | + +## 3. Цели + +- Тумблер «Highlight changes» — **мгновенный** (никакого пересчёта диффа). +- Смена версии — без фриза вкладки; тяжёлый расчёт не блокирует main-thread, либо укладывается в единицы–десятки мс на типичных страницах. +- Большие страницы не вешают UI (деградация вместо фриза). +- **Визуальный паритет**: тот же набор подсвеченных диапазонов, те же счётчики, та же навигация. +- Серверную часть и формат снапшотов не трогаем. +- Ошибки — по правилам [AGENTS.md](../AGENTS.md): полный лог + конкретное человекочитаемое сообщение, без «тихого» фолбэка. + +## 4. Ключевая идея: выкинуть `recreateTransform`, диффать через `prosemirror-changeset` напрямую + +`prosemirror-changeset@2.4.0` (уже в зависимостях) **сам умеет токенный дифф**. Внутри `ChangeSet.addSteps()` по изменённому диапазону прогоняется `computeDiff` (token-based, с детектом границ слов) — см. `node_modules/prosemirror-changeset/dist/index.js:577`. Нам не нужно кропотливо «восстанавливать» все шаги через JSON-патч ради `tr.mapping.maps`. + +В репозитории уже есть [getReplaceStep.ts](../packages/editor-ext/src/lib/recreate-transform/getReplaceStep.ts) — он строит **один минимальный `ReplaceStep`** между двумя документами через `findDiffStart`/`findDiffEnd` (это `O(D)`, а не `O(K·D)`). Достаточно скормить его map в `addSteps`, а дальше `prosemirror-changeset` сам разложит изменение до слов/символов. + +**Было:** + +```ts +const tr = recreateTransform(oldContent, newContent, { + complexSteps: false, wordDiffs: true, simplifyDiff: true, +}); +const changeSet = ChangeSet.create(oldContent).addSteps(tr.doc, tr.mapping.maps, []); +const changes = simplifyChanges(changeSet.changes, newContent); +``` + +**Стало:** + +```ts +import { ChangeSet, simplifyChanges } from "@tiptap/pm/changeset"; +import { getReplaceStep } from "@docmost/editor-ext"; // см. §4.1 — нужно до-экспортировать + +// один минимальный ReplaceStep между версиями — O(размер документа) +const step = getReplaceStep(oldContent, newContent); + +let changes: Change[] = []; +if (step) { + // addSteps внутри прогоняет computeDiff (token-diff) по изменённому диапазону → слова/символы + const changeSet = ChangeSet.create(oldContent).addSteps( + newContent, + [step.getMap()], + [], + ); + changes = simplifyChanges(changeSet.changes, newContent); +} +``` + +Почему это корректно и эквивалентно: + +- `getReplaceStep(old, new)` подбирает замену так, что её применение к `old` даёт `new`; `step.getMap()` — её `StepMap`. `addSteps(newDoc, maps, …)` ожидает именно документ-после-шагов и его карты — мы передаём `newContent` и `[step.getMap()]`. +- `addSteps` для затронутого диапазона вызывает `computeDiff(oldContent.content, newContent.content, range, encoder)` — тот же токенный дифф, что обеспечивал бы `wordDiffs`. Гранулярность «по словам» восстанавливает `simplifyChanges` (он расширяет смешанные вставки/удаления до границ слов — это ровно текущее поведение). +- На выходе — массив `Change` с теми же `fromA/toA/fromB/toB`. **Построитель декораций в `history-editor.tsx` не меняется вообще** (спец-ноды, виджеты удалений, счётчики) — он потребляет тот же контракт. Это и есть главный фактор низкого риска. +- Сложность: `getReplaceStep` — `O(D)` (два прохода `findDiffStart`/`findDiffEnd`); `addSteps`/`computeDiff` — пропорционально размеру **изменённого** диапазона, а не всему документу и не числу правок. Уходит и квадратичность `rfc6902`, и per-op полный rebuild. + +После этого `recreateTransform` / `rfc6902` / `diff` в пути истории больше не используются (можно оставить вендоренный модуль на месте, см. §10 про откат). + +### 4.1 Мелочь: до-экспортировать `getReplaceStep` + +Сейчас [recreate-transform/index.ts](../packages/editor-ext/src/lib/recreate-transform/index.ts) реэкспортит только `recreateTransform`. Добавить: + +```ts +export { getReplaceStep } from "./getReplaceStep"; +``` + +Корневой `packages/editor-ext/src/index.ts` уже делает `export * from "./lib/recreate-transform"`, так что символ станет доступен как `@docmost/editor-ext`. (Альтернатива — продублировать 25-строчную функцию прямо в `page-history`, чтобы вообще не зависеть от вендоренного модуля; но переиспользование чище.) + +## 5. Развязать вычисление и подсветку (React) + +Тумблер не должен пересчитывать дифф. Разносим один `useEffect` на два. + +### Вариант A (рекомендуется) — кэшировать `decorationSet`, тумблер только переключает + +```ts +const [decorationSet, setDecorationSet] = useState(DecorationSet.empty); + +// тяжёлое: считаем дифф ТОЛЬКО когда реально сменилась пара версий/документ +useEffect(() => { + if (!editor || !content) return; + // ... §4: getReplaceStep -> ChangeSet -> changes -> построение decorations ... + editor.commands.setContent(content); + setDiffCounts({ added, deleted, total }); + setDecorationSet(DecorationSet.create(newContent, decorations)); +}, [editor, content, previousContent]); // <-- highlightChanges УБРАН + +// дешёвое: тумблер лишь подменяет набор декораций, без пересчёта диффа +useEffect(() => { + if (!editor) return; + editor.setOptions({ + editorProps: { + ...editor.options.editorProps, + decorations: () => (highlightChanges ? decorationSet : DecorationSet.empty), + }, + }); +}, [editor, highlightChanges, decorationSet]); +``` + +- **Плюсы:** тумблер мгновенный; минимальная правка; контракт декораций не трогаем. +- **Минусы:** один лишний `useState` и аккуратность с зависимостями. + +### Вариант B — вынести расчёт в `useMemo`, keyed по `(prevHistoryId, historyId)` + +Считать `{ decorations, counts }` в `useMemo`, зависящем от идентификаторов версий (а не от ссылок на объекты `content`). React-Query и так отдаёт стабильные ссылки, но явный ключ по id защищает от лишних прогонов. + +- **Плюсы:** явная мемоизация; нет эффект-«дёрганья». +- **Минусы:** строить `DecorationSet` нужно от схемы редактора, который живёт в эффекте — `useMemo` придётся аккуратно синхронизировать с инстансом редактора. + +**Решение:** Вариант A (кэш `decorationSet` + два эффекта). B можно наложить сверху как ключевание тяжёлого эффекта по `(prevHistoryId, historyId)`, если профиль покажет лишние прогоны. + +## 6. Снять фриз на больших документах + +После §4 типичные страницы должны считаться за единицы мс. Для патологий — два рубежа: + +### 6.1 Guard по размеру документа + +Перед расчётом — порог (например, по числу узлов или суммарной длине текста, вынести в константу `HISTORY_DIFF_MAX_SIZE`). Если превышен: + +- не строить инлайн-подсветку, показать только счётчики и плашку «дифф слишком большой для подсветки» (i18n-строка); +- либо считать дифф **на уровне блоков** (узел добавлен/удалён/изменён) без захода внутрь текста. + +Это гарантирует деградацию вместо фриза независимо от качества алгоритма. + +### 6.2 Асинхронность / Web Worker (опционально, по результатам профиля) + +Если даже корректный дифф на гигантских страницах ощутим: + +- завернуть расчёт в отменяемую async-задачу + лоадер (`isDiffing`), чтобы переключение версий не морозило вкладку (отменять предыдущий расчёт при быстром перещёлкивании); +- либо вынести дифф в **Web Worker**: на вход — два документа в JSON, на выход — массив `changes` (он `JSON`-сериализуем; ноды восстанавливаются в основном потоке для декораций). `ChangeSet.computeDiff` чист и переносим. + +Делать только если §4 + §6.1 окажется недостаточно — добавляет заметную сложность (сериализация, восстановление схемы в воркере). + +### 6.3 Нужен ли второй редактор (отдельно, низкий приоритет) + +Поднятие полного `mainExtensions`-редактора ради read-only превью — оверхед. Возможная оптимизация — рендер через `DOMSerializer` + ручной слой декораций без полного TipTap. Это бóльшая переделка с риском по верстке/нодам; выносим в отдельный тикет, **не** в этот рефактор. + +## 7. Обработка ошибок (по AGENTS.md) + +Сейчас при сбое диффа — `console.error("History diff failed:", e)` и тихий фолбэк на контент без подсветки. По конвенции это надо усилить: + +- логировать полностью (`name`, `message`, `stack`, `cause`); +- показать пользователю **конкретную** причину (например, нотификация «Не удалось построить дифф версий: …»), а не молча скрывать подсветку. Контент при этом всё равно показываем (graceful degradation), но факт сбоя не замалчиваем. + +## 8. План внедрения по фазам + +**Фаза 0 (P0) — ядро, низкий риск, основной выигрыш.** +- §4: заменить `recreateTransform` на `getReplaceStep` + `ChangeSet.addSteps`; до-экспортировать `getReplaceStep` (§4.1). +- §5 Вариант A: разнести эффект, кэшировать `decorationSet` (тумблер мгновенный). +- Файлы: [history-editor.tsx](../apps/client/src/features/page-history/components/history-editor.tsx), [recreate-transform/index.ts](../packages/editor-ext/src/lib/recreate-transform/index.ts). +- Контракт `changes`/декораций не меняется → визуальный паритет. + +**Фаза 1 (P1) — устойчивость к патологиям.** +- §6.1 guard по размеру + i18n-плашка/counts-only. +- §7 нормальная обработка ошибок. +- Лоадер `isDiffing` при переключении версий (без воркера). + +**Фаза 2 (P2) — по необходимости.** +- §6.2 Web Worker offload, если профиль на больших страницах требует. +- §6.3 отказ от второго полного редактора (отдельный тикет). + +## 9. Тестирование и верификация + +- **Юнит (паритет диффа):** util, возвращающий `changes` для пар (old, new), на наборе кейсов: вставка/удаление слова, замена, добавление/удаление абзаца, спец-ноды (`image`, `table`, `callout`, `mathBlock`…), правка только марок (bold/italic), идентичные документы (`getReplaceStep` → `false` → пустой дифф). Снять «золотые» `changes` на текущем `recreateTransform`-пути и сверить с новым (диапазоны `fromB/toB` должны совпадать или быть эквивалентны после `simplifyChanges`). +- **Профиль до/после:** DevTools → Performance на «тяжёлой» странице; зафиксировать длительность смены версии и щелчка тумблера. Ожидание: исчезают длинные таски `createPatch`/`nodeFromJSON`/`check`; тумблер пропадает из профиля. +- **Большой фикстур:** страница на сотни абзацев с десятками правок — проверка отсутствия фриза и срабатывания guard (Фаза 1). +- **Edge cases:** удаления (виджет-декорации с `DOMSerializer`), спец-ноды целиком в диапазоне, навигация по изменениям (`useDiffNavigation`), счётчики `diffCounts`. + +## 10. Риски и откат + +- **Гранулярность диффа может чуть отличаться** от `recreateTransform` на смешанных правках. Снимаем golden-тестами (§9); при расхождении подкручиваем через `TokenEncoder` в `ChangeSet.create` (по умолчанию сравнение нод по имени и текста посимвольно, марки/атрибуты игнорируются — это совпадает с текущим поведением). +- **Правки только марок:** один `ReplaceStep` по диапазону марки покрывает кейс; явно покрыть тестом. +- **Откат:** `recreateTransform` остаётся в пакете нетронутым; вернуть старый путь — это revert одного блока в `history-editor.tsx`. Можно временно спрятать новый путь за флагом, пока golden-тесты не подтвердят паритет. + +## 11. Открытые вопросы + +- Порог `HISTORY_DIFF_MAX_SIZE` — в узлах или символах, и какое значение (подобрать по профилю). +- Нужен ли вообще второй TipTap-инстанс (§6.3) — решаем после Фазы 0/1. +- Воркер (§6.2) — оправдан ли на реальных страницах, или хватает §4 + §6.1. + +## Приложение: задействованный API `prosemirror-changeset@2.4.0` + +- `ChangeSet.create(doc, combine?, tokenEncoder?, changes?)` — создаёт набор от базового документа. +- `changeSet.addSteps(newDoc, maps: StepMap[], data)` — добавляет шаги; **внутри** по изменённым диапазонам прогоняет `computeDiff` (token-diff) и упрощает результат. +- `simplifyChanges(changes, doc)` — расширяет смешанные вставки/удаления до границ слов (наша «word-level» гранулярность). +- `ChangeSet.computeDiff(fragA, fragB, range, encoder?)` — низкоуровневый токенный дифф (доступен статически, если захотим обойтись без `addSteps`). +- `Change { fromA, toA, fromB, toB, deleted: Span[], inserted: Span[] }` — контракт, который потребляет построитель декораций (не меняется). -- 2.49.1 From 24bf0ab18fd5390dde2df003147b9271f8df7993 Mon Sep 17 00:00:00 2001 From: "glm5.2 agent 180" Date: Sat, 20 Jun 2026 15:54:23 +0300 Subject: [PATCH 4/4] feat(ai-chat): add reusable agent roles (persona + optional model) Roles are workspace-admin presets that customize the AI agent's system- prompt persona and, optionally, the model, attached to a chat at creation time. Examples: a 'Proofreader' that only touches grammar, a 'Fact- checker' that cites web sources. A role changes ONLY instructions and ( optional ) the model; the toolset stays full, so the security boundary (CASL via the per-user loopback token) is unchanged. Backend: - Migration 20260620T150000-ai-agent-roles: ai_agent_roles table (workspace-scoped, soft-delete, model_config jsonb) + ai_chats.role_id (ON DELETE SET NULL). - AiAgentRoleRepo / AiAgentRolesService / AiAgentRolesController at /workspace/ai-agent-roles. LIST (picker view) is open to all workspace members; create/update/delete are admin-only. The picker view omits instructions and model_config so they never leak to non-admins. - buildSystemPrompt: optional roleInstructions REPLACES the admin persona (priority order: role > admin > default). The non-removable SAFETY_FRAMEWORK is always appended - a role cannot strip it. - AiChatService.stream: persists roleId on first turn; subsequent turns read role_id from the chat row, never from the request body. The role's instructions are applied even if it was later disabled or soft-deleted (existing chats keep their persona). - AiService.getChatModel accepts an optional override. Same-driver overrides reuse the workspace key; cross-driver (openai/gemini) loads alternate creds from ai_provider_credentials and throws a clean 503 if they are missing (no silent fallback). Cross-driver ollama is rejected with a clear message (no per-driver ollama base URL exists yet). - Controller resolves the role model BEFORE res.hijack so misconfigured overrides return JSON 503, not a broken stream. Client: - New chat picker (Mantine Select) lists enabled roles, default 'Universal assistant' (roleId null). The roleId is sent only when starting a new chat; existing chats show the role as a fixed badge. - Role badge in the chat window header and conversation list. - Settings -> AI: new 'Agent roles' management section mirrors the external MCP servers UI (add/edit/delete + enable toggle + optional model override). Form fields: name, emoji, description, instructions, model override (driver + chatModel), with a reminder that the safety framework is always appended. Hardening after review: - Empty-string roleId coerced to null on both client and server (picker 'Universal assistant' option used to crash the uuid INSERT). - New-chat insert validates picker-eligibility (enabled + not soft-deleted + workspace-scoped); ineligible ids silently fall back to null. - findByCreator's role JOIN is workspace-scoped and every column ref is table-qualified (avoids Postgres ambiguous-column errors). - getChatModelForRole applies the same picker-eligibility gate as stream on the new-chat path, so model and persona resolve from one source. --- .../public/locales/en-US/translation.json | 25 ++ .../features/ai-chat/atoms/ai-chat-atom.ts | 9 + .../ai-chat/components/ai-chat-window.tsx | 57 ++++- .../ai-chat/components/chat-thread.tsx | 21 +- .../ai-chat/components/conversation-list.tsx | 9 +- .../features/ai-chat/types/ai-chat.types.ts | 37 +++ .../components/ai-agent-role-form.tsx | 236 ++++++++++++++++++ .../settings/components/ai-agent-roles.tsx | 184 ++++++++++++++ .../components/ai-provider-settings.tsx | 4 + .../workspace/queries/ai-agent-roles-query.ts | 107 ++++++++ .../services/ai-agent-roles-service.ts | 75 ++++++ .../src/core/ai-chat/ai-chat.controller.ts | 10 +- .../server/src/core/ai-chat/ai-chat.module.ts | 11 +- .../server/src/core/ai-chat/ai-chat.prompt.ts | 31 ++- .../src/core/ai-chat/ai-chat.service.ts | 114 +++++++++ .../roles/ai-agent-roles.controller.ts | 109 ++++++++ .../ai-chat/roles/ai-agent-roles.module.ts | 19 ++ .../ai-chat/roles/ai-agent-roles.service.ts | 196 +++++++++++++++ .../ai-chat/roles/dto/agent-role-id.dto.ts | 7 + .../roles/dto/create-agent-role.dto.ts | 68 +++++ .../roles/dto/update-agent-role.dto.ts | 63 +++++ apps/server/src/database/database.module.ts | 3 + .../20260620T150000-ai-agent-roles.ts | 78 ++++++ .../repos/ai-chat/ai-agent-role.repo.ts | 205 +++++++++++++++ .../database/repos/ai-chat/ai-chat.repo.ts | 34 ++- .../database/types/ai-agent-roles.types.ts | 45 ++++ apps/server/src/database/types/db.d.ts | 3 + .../server/src/database/types/db.interface.ts | 2 + .../server/src/database/types/entity.types.ts | 10 + .../integrations/ai/ai-settings.service.ts | 32 +++ apps/server/src/integrations/ai/ai.service.ts | 78 +++++- 31 files changed, 1845 insertions(+), 37 deletions(-) create mode 100644 apps/client/src/features/workspace/components/settings/components/ai-agent-role-form.tsx create mode 100644 apps/client/src/features/workspace/components/settings/components/ai-agent-roles.tsx create mode 100644 apps/client/src/features/workspace/queries/ai-agent-roles-query.ts create mode 100644 apps/client/src/features/workspace/services/ai-agent-roles-service.ts create mode 100644 apps/server/src/core/ai-chat/roles/ai-agent-roles.controller.ts create mode 100644 apps/server/src/core/ai-chat/roles/ai-agent-roles.module.ts create mode 100644 apps/server/src/core/ai-chat/roles/ai-agent-roles.service.ts create mode 100644 apps/server/src/core/ai-chat/roles/dto/agent-role-id.dto.ts create mode 100644 apps/server/src/core/ai-chat/roles/dto/create-agent-role.dto.ts create mode 100644 apps/server/src/core/ai-chat/roles/dto/update-agent-role.dto.ts create mode 100644 apps/server/src/database/migrations/20260620T150000-ai-agent-roles.ts create mode 100644 apps/server/src/database/repos/ai-chat/ai-agent-role.repo.ts create mode 100644 apps/server/src/database/types/ai-agent-roles.types.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 21f7c5f7..7a92a236 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -709,6 +709,31 @@ "No tools available": "No tools available", "Created successfully": "Created successfully", "Deleted successfully": "Deleted successfully", + "Agent roles": "Agent roles", + "Personas": "Personas", + "Add role": "Add role", + "Edit role": "Edit role", + "Delete role": "Delete role", + "Role name": "Role name", + "Emoji": "Emoji", + "Optional. Shown next to the role name in the picker.": "Optional. Shown next to the role name in the picker.", + "Description": "Description", + "Optional. Shown in the role picker to help users choose.": "Optional. Shown in the role picker to help users choose.", + "Instructions": "Instructions", + "The agent persona. Replaces the workspace system message for chats bound to this role. A built-in safety framework is always appended.": "The agent persona. Replaces the workspace system message for chats bound to this role. A built-in safety framework is always appended.", + "Model override": "Model override", + "Optional. Use a different model for chats bound to this role.": "Optional. Use a different model for chats bound to this role.", + "Use workspace default": "Use workspace default", + "Custom model": "Custom model", + "Driver": "Driver", + "Defaults to the workspace driver. Credentials for an alternate driver must be set in the provider settings.": "Defaults to the workspace driver. Credentials for an alternate driver must be set in the provider settings.", + "No agent roles configured": "No agent roles configured", + "Reusable agent personas for specialized chats.": "Reusable agent personas for specialized chats.", + "Are you sure you want to delete this role? Existing chats keep their persona.": "Are you sure you want to delete this role? Existing chats keep their persona.", + "Disabled roles are hidden from the picker but existing chats keep using them.": "Disabled roles are hidden from the picker but existing chats keep using them.", + "Universal assistant": "Universal assistant", + "Agent role": "Agent role", + "Default workspace persona": "Default workspace persona", "Clear": "Clear", "Provider": "Provider", "•••• set": "•••• set", diff --git a/apps/client/src/features/ai-chat/atoms/ai-chat-atom.ts b/apps/client/src/features/ai-chat/atoms/ai-chat-atom.ts index b3707cb9..9b5c0728 100644 --- a/apps/client/src/features/ai-chat/atoms/ai-chat-atom.ts +++ b/apps/client/src/features/ai-chat/atoms/ai-chat-atom.ts @@ -19,3 +19,12 @@ export const aiChatWindowOpenAtom = atom(false); // in ChatInput's local state, that remount would wipe text the user typed while // the agent was still streaming. Reset on deliberate chat switches. export const aiChatDraftAtom = atom(""); + +/** + * The role selected for the NEXT new chat (picker). Bound to the chat only on + * the first turn (sent as `roleId` in the `/ai-chat/stream` body when chatId is + * null); after that the role is fixed on the chat row and this atom is not read + * again for that chat. Reset to null (universal assistant) on "New chat". null + * means the universal assistant (no bound role). + */ +export const selectedRoleForNewChatAtom = atom(null as string | null); diff --git a/apps/client/src/features/ai-chat/components/ai-chat-window.tsx b/apps/client/src/features/ai-chat/components/ai-chat-window.tsx index 122f80ff..f686d5b7 100644 --- a/apps/client/src/features/ai-chat/components/ai-chat-window.tsx +++ b/apps/client/src/features/ai-chat/components/ai-chat-window.tsx @@ -6,7 +6,12 @@ import { useRef, useState, } from "react"; -import { Group, Loader, Tooltip } from "@mantine/core"; +import { + Group, + Loader, + Select, + Tooltip, +} from "@mantine/core"; import { IconArrowsDiagonal, IconCheck, @@ -25,6 +30,7 @@ import { activeAiChatIdAtom, aiChatWindowOpenAtom, aiChatDraftAtom, + selectedRoleForNewChatAtom, } from "@/features/ai-chat/atoms/ai-chat-atom.ts"; import { usePageQuery } from "@/features/page/queries/page-query.ts"; import { extractPageSlugId } from "@/lib"; @@ -33,6 +39,7 @@ import { useAiChatMessagesQuery, useAiChatsQuery, } from "@/features/ai-chat/queries/ai-chat-query.ts"; +import { useAiRolesQuery } from "@/features/workspace/queries/ai-agent-roles-query.ts"; import ConversationList from "@/features/ai-chat/components/conversation-list.tsx"; import ChatThread from "@/features/ai-chat/components/chat-thread.tsx"; import { buildChatMarkdown } from "@/features/ai-chat/utils/chat-markdown.ts"; @@ -102,6 +109,13 @@ export default function AiChatWindow() { const [windowOpen, setWindowOpen] = useAtom(aiChatWindowOpenAtom); const [activeChatId, setActiveChatId] = useAtom(activeAiChatIdAtom); const setDraft = useSetAtom(aiChatDraftAtom); + const [selectedRoleId, setSelectedRoleId] = useAtom( + selectedRoleForNewChatAtom, + ); + + // Roles for the chat-start picker (any workspace member can list). Loaded + // unconditionally so the picker is ready when the user starts a new chat. + const { data: roles } = useAiRolesQuery(); // History section starts collapsed (matches the former panel's behavior). const [historyOpen, setHistoryOpen] = useState(false); @@ -144,7 +158,8 @@ export default function AiChatWindow() { setActiveChatId(null); setHistoryOpen(false); setDraft(""); - }, [setActiveChatId, setDraft]); + setSelectedRoleId(null); + }, [setActiveChatId, setDraft, setSelectedRoleId]); const selectChat = useCallback( (chatId: string): void => { @@ -342,6 +357,17 @@ export default function AiChatWindow() { style={{ flex: "none" }} /> {t("AI chat")} + {activeChat?.roleName && ( + + + {activeChat.roleEmoji ? `${activeChat.roleEmoji} ` : ""} + {activeChat.roleName} + + + )}
{contextTokens > 0 && ( @@ -432,6 +458,33 @@ export default function AiChatWindow() { )}
+ {/* Role picker for a NEW chat only. The role is bound once at chat + creation; for an existing chat the role is fixed on the chat row and + shown as a badge in the drag bar above. Hidden when no roles exist. */} + {activeChatId === null && roles && roles.length > 0 && ( +
+