From 059f2bd7e50efe91cc854769ea0bc4b728ae7140 Mon Sep 17 00:00:00 2001 From: vvzvlad Date: Fri, 19 Jun 2026 17:52:13 +0300 Subject: [PATCH 001/331] docs: add multi-cursor editing plan --- docs/multi-cursor-editing-plan.md | 205 ++++++++++++++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 docs/multi-cursor-editing-plan.md diff --git a/docs/multi-cursor-editing-plan.md b/docs/multi-cursor-editing-plan.md new file mode 100644 index 00000000..4614c708 --- /dev/null +++ b/docs/multi-cursor-editing-plan.md @@ -0,0 +1,205 @@ +# Множественные курсоры (multi-cursor editing) — анализ и подходы + +> Статус: **черновик / обсуждение**. Код не пишется; цель этого документа — зафиксировать архитектурный вердикт, развилку подходов и рекомендацию. +> +> Важное уточнение термина: речь про **несколько собственных курсоров одного пользователя в одном документе** (как в VS Code: `Alt+Click` добавить курсор, `Ctrl/Cmd+D` — следующее вхождение, `Ctrl/Cmd+Shift+L` — все вхождения), чтобы править несколько мест одновременно. **Не** про collaborative-курсоры соавторов — те в проекте уже работают (`CollaborationCaret` + Hocuspocus awareness). +> +> Зафиксированные выводы (см. разделы ниже): +> - Полноценный VS Code-style multi-cursor нельзя «включить флагом»: движок редактора (ProseMirror) хранит в состоянии **ровно одно выделение**, в отличие от Monaco/CodeMirror с массивом selections. Готового production-пакета в экосистеме Tiptap/ProseMirror нет. +> - ~80% пользовательской ценности даёт ограниченный MVP («выделить все вхождения + одновременный ввод»), который опирается на **уже работающий** в проекте механизм `replaceAll` из расширения `SearchAndReplace`. +> - Рекомендация: реализовать MVP (Вариант A); полноценный набор (Вариант B) — отдельный большой эпик, имеет смысл браться только если MVP окажется недостаточно. + +## 0. О чём речь (и о чём НЕ речь) + +**Что хочется** — несколько кареток в одном документе; набранный текст и `Backspace`/`Delete` применяются ко всем позициям одновременно; одно `Cmd/Ctrl+Z` откатывает всю мульти-правку целиком. Сценарии из VS Code: + +| Действие | Горячая клавиша | Суть | +| --- | --- | --- | +| Добавить курсор | `Alt+Click` | Курсор в произвольной точке клика | +| Добавить курсор строкой выше/ниже | `Ctrl/Cmd+Alt+↑/↓` | Копия курсора на соседней строке | +| Выделить следующее вхождение | `Ctrl/Cmd+D` | Добавить к набору следующее вхождение слова | +| Выделить все вхождения | `Ctrl/Cmd+Shift+L` | Все вхождения сразу | +| Колонковое/блочное выделение | `Alt+drag` | Прямоугольник курсоров по строкам | + +**О чём НЕ речь** — collaborative-курсоры (видеть, где сейчас находится другой соавтор). Это в Gitmost уже есть и работает отдельно: `CollaborationCaret` в [extensions.ts](apps/client/src/features/editor/extensions/extensions.ts) подключается через `collabExtensions(...)`, а сервер Hocuspocus по умолчанию форвардит awareness. Этот документ её не касается. + +## 1. Архитектурный вердикт: почему это не «включить флаг» + +Редактор Gitmost — **Tiptap поверх ProseMirror** (`@tiptap/core` 3.20.4, `@tiptap/pm` 3.20.4). Принципиальное отличие от VS Code: Monaco/CodeMirror хранит **массив selections**, а ProseMirror хранит в `EditorState` **ровно один** `Selection`: + +``` +EditorState = { doc, selection: Selection /* единственное */, storedMarks, ... } +``` + +На этой единственной selection завязано в ProseMirror почти всё: +- команды ввода (`insertText`, `insertContent`) работают с текущей `selection`; +- обработчики `handleTextInput`, `handleKeyDown`, `handlePaste`, `handleDrop` получают одно выделение; +- история (undo/redo) оперирует transactions с одним выделением; +- **критично для нас** — синхронизация через y-prosemirror тоже опирается на единственную selection (свою «awareness-selection» отдельно, но не на локальный массив). + +Доказательства из первоисточников: +- Tiptap issue [ueberdosis/tiptap#3370](https://github.com/ueberdosis/tiptap/issues/3370) «Multiple cursors per user» — открыт, официальной поддержки нет. +- Ответ **marijnh** (автор ProseMirror) на [discuss.prosemirror.net](https://discuss.prosemirror.net/t/multi-cursor-editing-in-prosemirror-or-tiptap/8397): готовой реализации нет, но путь обозначен — **«кастомный подкласс `Selection`, по аналогии с `CellSelection` из `prosemirror-tables`, который умеет содержать несколько отдельных диапазонов»**. +- Production-готового пакета multi-cursor для Tiptap/ProseMirror в npm **нет** — пилить с нуля. + +**Вывод:** полноценный multi-cursor — это R&D-проект против устройства движка, а не настройка. Но самый ценный сценарий («поправить повторяющиеся одинаковые куски сразу в нескольких местах») реализуем дёшево, потому что массовая правка в одном transaction у нас уже написана. + +## 2. Что уже есть в коде и переиспользуемо + +В проекте уже есть расширение [SearchAndReplace](packages/editor-ext/src/lib/search-and-replace/search-and-replace.ts) (в `editor-ext`, подключено и в клиентском редакторе). Это почти готовый фундамент для главного сценария multi-cursor: + +- [search-and-replace.ts:100-174](packages/editor-ext/src/lib/search-and-replace/search-and-replace.ts#L100-L174) — `processSearches` уже находит **все** вхождения терма и возвращает массив `results: Range[]` (диапазоны `from`/`to`). +- [search-and-replace.ts:157-168](packages/editor-ext/src/lib/search-and-replace/search-and-replace.ts#L157-L168) — уже рисует `Decoration.inline` для **всех** совпадений одновременно (это переиспользуется для подсветки «активных» курсоров). +- [search-and-replace.ts:213-246](packages/editor-ext/src/lib/search-and-replace/search-and-replace.ts#L213-L246) — `replaceAll` уже выполняет **массовую правку в одном transaction**, идя **с конца**, чтобы корректно учитывать сдвиг позиций после каждой вставки/удаления. Это ровно та механика, что нужна для одновременного ввода в несколько курсоров. + +```ts +// search-and-replace.ts:213-246 — готовый эталон массового transaction +const replaceAll = (replaceTerm, results, { tr, dispatch }) => { + // Process replacements in reverse order to avoid position shifting issues + for (let i = resultsCopy.length - 1; i >= 0; i -= 1) { + const { from, to } = resultsCopy[i]; + // ... собрать marks, удалить старый текст, вставить новый + tr.delete(from, to); + if (replaceTerm) tr.insert(from, tr.doc.type.schema.text(replaceTerm, marks)); + } + dispatch(tr); // одна транзакция → одна запись в истории (один undo) +}; +``` + +То есть самая хитрая часть multi-cursor — применить правку к N позициям за один `tr` с корректным маппингом — у нас **уже работает** в `replaceAll`. + +Дополнительно в клиенте уже есть инфраструктура для горячих клавиш: в [page-editor.tsx:258-280](apps/client/src/features/editor/page-editor.tsx#L258-L280) есть блок `handleDOMEvents.keydown`, и используется утилита `platformModifierKey` (Cmd на macOS, Ctrl на других ОС — ровно то, что нужно для совместимых с VS Code шорткатов). + +## 3. Развилка: три подхода + +### 3.1 Вариант A — MVP: «выделить все вхождения + одновременный ввод» (рекомендация) + +Реализует главный сценарий из VS Code: +- `Ctrl/Cmd+Shift+L` — берём слово под курсором (или текущее выделение), находим все вхождения, превращаем их в «активные курсоры»; +- `Ctrl/Cmd+D` — добавить следующее вхождение к набору; +- дальнейший ввод текста и `Backspace`/`Delete` применяются ко всем позициям одновременно через один transaction (копия механики `replaceAll`); +- `Esc` — выйти из multi-cursor (один курсор). + +**Что переиспользуется:** массив `results` и логика массового `tr` берутся из [SearchAndReplace](packages/editor-ext/src/lib/search-and-replace/search-and-replace.ts) почти готовыми. + +**Визуальные каретки:** через `Decoration.widget(pos, () => cursorDomElement)` — ProseMirror умеет «из коробки»; для диапазонов — `Decoration.inline`. + +**Объём работы:** средний. Один новый Tiptap-extension в `packages/editor-ext/src/lib/multi-cursor/` + wiring в клиентском редакторе + горячие клавиши + CSS + юнит-тесты. + +**Риски:** средние и ограниченные. Скоуп узкий (только текстовые вхождения), сценарии предсказуемые, тестируются конечным числом кейсов. + +### 3.2 Вариант B — полноценный multi-cursor (как Monaco) + +Полный набор из §0: `Alt+Click` (произвольная точка), `Alt+drag` (колонковое выделение), `Ctrl/Cmd+Alt+↑/↓` (курсор на соседней строке), а также произвольный набор **несвязанных** курсоров (не по вхождениям). + +**Путь:** кастомный `MultiSelection extends Selection` (по подсказке мейнтейнера ProseMirror, по образцу `CellSelection` из `prosemirror-tables`), плюс **полная маршрутизация ввода**: +- перехват `handleTextInput`, `handleKeyDown` (Backspace/Delete/стрелки/Enter/Home/End), `handlePaste`, `handleDrop`; +- построение одного мульти-position transaction для каждого события; +- визуальный рендер нескольких кареток и диапазонов; +- undo-группировка (одно `Cmd/Ctrl+Z` откатывает все позиции разом); +- перемапливание позиций курсоров при **любых** изменениях документа, включая remote Yjs-правки. + +**Объём работы:** очень большой (многие недели). Готового референса в экосистеме нет — это самостоятельный R&D с отладкой на реальном контенте. + +**Риски:** высокие — см. риск-карту в §4 (IME/composition, конфликты со сложными нодами вроде таблиц и code-блоков, взаимодействие с коллаборацией). + +### 3.3 Вариант C — эмуляция через коллаборацию (отбрасываем) + +Идея из Tiptap#3370: «проигрывать правки через отдельного pseudo-user через collaborative-слой». **Не берём:** ломает provenance правок (в проекте есть бейдж авторства «AI agent» в истории страницы, migration `20260616T130000-agent-provenance` — такой хак его загрязнит и запутает), портит историю undo, концептуально криво и хрупко. + +### Сводка + +| | Вариант A (MVP) | Вариант B (full) | Вариант C | +| --- | --- | --- | --- | +| Сценарии | «все вхождения», «+следующее вхождение» | полный набор VS Code | — | +| База | готовый `replaceAll` | кастомный `Selection` с нуля | collaborative-слой | +| Объём | средний | очень большой | — | +| Риск | средний (ограниченный) | высокий | высокий | +| Рекомендация | **да** | только если A мало | нет | + +## 4. Риск-карта + +Для обоих вариантов, но в варианте B каждый пункт — сильно жёстче. + +| Зона | Суть | Где больнее | +| --- | --- | --- | +| **Undo/redo** | Мульти-правка должна быть **одной** записью истории (одно `Cmd/Ctrl+Z` откатывает все позиции). Группировка через мету истории, см. как `replaceAll` делает один `dispatch(tr)`. | B | +| **Коллаборация (Yjs)** | Пока активны ваши курсоры, может прилететь remote-правка — позиции курсоров надо перемапливать через `tr.mapping.map(pos)`. Один локальный `tr` с правками в N местах Yjs переварит нормально (это несколько правок в одном Update). | B | +| **IME / dead keys** | Ввод через composition (буквы с акцентами, CJK) одновременно в несколько курсоров — крайне хрупко; для MVP (Вариант A) проще: на время composition можно схлопывать к одному курсору. | B | +| **Schema / сложные узлы** | Курсор внутри code-блока + курсор в заголовке: одна и та же вставка может нарушить schema одного узла, но не другого. Нужно gracefully skip конфликтующие курсоры (не ронять весь `tr`). | B (A — почти не касается, т.к. вхождения — текстовые) | +| **Таблицы / callouts** | `CellSelection`-подобная логика внутри таблиц — отдельная вселенная; в MVP курсоры в таблицах можно просто не поддерживать (как и в `replaceAll`). | B | +| **Производительность** | Очень много курсоров → большой `DecorationSet` и длинный `tr`. Практически редко > нескольких десятков, но заложить верхнюю границу. | общий | + +## 5. Рекомендация + +**Брать Вариант A.** Он закрывает главный use-case («быстро поправить повторяющиеся одинаковые куски сразу в нескольких местах»), опирается на **уже работающий** `replaceAll`-механизм, и риск ограничен. Вариант B имеет смысл отдельным эпиком — только если A окажется недостаточно и будет устойчивый спрос на произвольные курсоры; тогда начинать стоит с прототипа кастомного `MultiSelection`, чтобы доказать жизнеспособность на сложных узлах до полной реализации. + +Сознательные границы MVP (Вариант A) — см. §6.7. + +## 6. План реализации Варианта A (MVP) — по шагам + +### 6.1. Новый extension + +Создать `packages/editor-ext/src/lib/multi-cursor/multi-cursor.ts` — Tiptap `Extension`: +- плагин (ProseMirror `Plugin`) со state = `{ cursors: {from: number, to: number}[] }` и `DecorationSet` (виджеты-каретки для точечных курсоров + `Decoration.inline` для диапазонов); +- команды: + - `selectAllOccurrences` — берёт слово под курсором (или текущее выделение), находит все вхождения (можно вынести общую с search-and-replace логику поиска в утилиту, чтобы не дублировать `processSearches`), заполняет `cursors`; + - `addNextOccurrence` (`Ctrl/Cmd+D`) — добавляет следующее вхождение к `cursors`; + - `exitMultiCursor` — очищает `cursors` (также вешается на `Esc`); +- обработчики в `props`: + - `handleTextInput(view, from, to, text)` — если `cursors` непустой, строит один `tr`, вставляя `text` в каждую позицию **с конца** (копия механики из [search-and-replace.ts:213-246](packages/editor-ext/src/lib/search-and-replace/search-and-replace.ts#L213-L246)); + - `handleKeyDown` — `Backspace`/`Delete` аналогично (удаление символа перед/после каждой позиции); + - игнорировать/схлопнуть multi-cursor при начале composition (IME) — см. §4. + +### 6.2. Маппинг позиций при изменениях документа + +В `state.apply` плагина — при любом `docChanged` перемапливать все позиции через `tr.mapping.map(pos)` и удалять «схлопнувшиеся» (`from === to` после маппинга — это нормально для каретки). Это покрывает и собственные правки, и **remote Yjs-правки** (y-prosemirror применяет их как обычные transactions — маппинг работает одинаково). + +### 6.3. Горячие клавиши + +Добавить в существующий блок [page-editor.tsx:258-280](apps/client/src/features/editor/page-editor.tsx#L258-L280) (там уже есть `platformModifierKey`): +- `platformModifierKey + Shift + KeyL` → `selectAllOccurrences`; +- `platformModifierKey + KeyD` → `addNextOccurrence`; +- `Escape` → `exitMultiCursor`. + +⚠️ Проверить конфликт `Ctrl/Cmd+D` с браузерным «добавить в закладки» (предотвратить через `event.preventDefault()`) и с любыми существующими биндингами редактора. + +### 6.4. Регистрация + +- экспортировать расширение из `packages/editor-ext/src/lib/multi-cursor/index.ts` и добавить в `packages/editor-ext/src/index.ts`; +- включить в `mainExtensions` в [extensions.ts](apps/client/src/features/editor/extensions/extensions.ts) (оно не зависит от коллаборации, поэтому идёт в основной набор, доступный и в обычном, и в коллаборативном редакторе). + +### 6.5. CSS + +Рядом с [collaboration.css](apps/client/src/features/editor/styles/collaboration.css) (и подключением через `styles/index.css`) — стили для классов вроде `.multi-cursor__caret` и `.multi-cursor__label`. Визуально отличать от collaborative-кареток (например, другим стилем/цветом), чтобы не путать свои мульти-курсоры с курсорами соавторов. + +### 6.6. Тесты + +Unit-тесты в `packages/editor-ext` (по образцу существующих там тестов) на: +- корректность массового `tr` (ввод/удаление в N позициях, проверка результирующего документа); +- маппинг позиций после локальной правки и после имитированной remote-правки; +- граничные случаи: курсоры на границах узлов, схлопывание, пустой набор. + +### 6.7. Скоуп v1 / что сознательно НЕ входит + +Чтобы держать риск в пределах, в MVP **не делаем** (явно фиксируем как out-of-scope): +- `Alt+Click` (произвольная точка) и `Alt+drag` (колонковое выделение) — это путь в Вариант B; +- `Ctrl/Cmd+Alt+↑/↓` (курсор на соседней строке) — то же; +- курсоры внутри таблиц, code-блоков и callouts — только обычный текст (как в `replaceAll`); +- одновременный ввод через IME в несколько позиций (на время composition схлопываем к одному курсору); +- курсоры, затрагивающие разные schema-узлы одновременно (если вставка нарушает schema в одной из позиций — пропускаем эту позицию, не роняем весь `tr`). + +Эти границы — кандидаты на v2 / переход к Варианту B. + +## 7. Открытые вопросы + +1. **Выделение диапазонов vs точечные курсоры.** В VS Code `Ctrl/Cmd+Shift+L` выделяет целые слова (диапазоны). Делаем ли мы в MVP то же (диапазоны + одновременная замена всего слова), или только точечные каретки после конца слова? Рекомендация: диапазоны — это даёт «переименовать все эти слова сразу», что и есть главная ценность. +2. **Общая утилита поиска.** Вынести `processSearches` из search-and-replace в общую утилиту, чтобы не дублировать, или оставить независимую реализацию в multi-cursor? Рекомендация: вынести общую часть (поиск всех вхождений слова по документу), оба расширения используют её. +3. **Граница производительности.** Ввести ли хард-кап на число одновременных курсоров (например, 100) с предупреждением пользователю? Рекомендация: да, как страховка. + +## 8. Источники + +- [Tiptap issue #3370 — Multiple cursors per user](https://github.com/ueberdosis/tiptap/issues/3370) +- [discuss.ProseMirror — Multi-cursor editing in ProseMirror (ответ автора ProseMirror о кастомном подклассе Selection)](https://discuss.prosemirror.net/t/multi-cursor-editing-in-prosemirror-or-tiptap/8397) +- `prosemirror-tables` / `CellSelection` — референс реализации «выделения из нескольких диапазонов» для Варианта B. +- Внутренний код: [SearchAndReplace](packages/editor-ext/src/lib/search-and-replace/search-and-replace.ts) (эталон массового transaction), [page-editor.tsx](apps/client/src/features/editor/page-editor.tsx) (точки подключения горячих клавиш), [extensions.ts](apps/client/src/features/editor/extensions/extensions.ts) (регистрация расширений). From 732aaf54f8d8271c7be968700eab775f0792ad5e Mon Sep 17 00:00:00 2001 From: claude_code Date: Sat, 20 Jun 2026 04:05:29 +0300 Subject: [PATCH 002/331] refactor(import): remove non-functional DOCX/PDF/Confluence import stubs These import paths relied on the private EE module that was deleted from the repo. In the community build they either threw 'enterprise license' (DOCX/PDF) or silently no-op'd (Confluence). The frontend buttons were already removed in 38064064; this cleans up the dead backend stubs. - import.service.ts: drop processDocx/processPdf methods, their dispatcher branches, the pageId computation + insertPage spread, and the now-unused moduleRef param/ModuleRef import - file-import-task.service.ts: drop the Confluence branch and the now-unused moduleRef param/ModuleRef import - import.controller.ts: restrict file extensions to .md/.html and zip sources to generic/notion; update the error message accordingly - file.utils.ts: remove Confluence from the FileImportSource enum - features.ts: remove the unused CONFLUENCE_IMPORT/DOCX_IMPORT/PDF_IMPORT feature keys The isConfluenceImport logic in import-attachment.service.ts is intentionally left in place (real shared attachment-parsing code, not a stub); its removal is a separate, riskier refactor. --- apps/server/src/common/features.ts | 3 - .../integrations/import/import.controller.ts | 8 +- .../services/file-import-task.service.ts | 23 ----- .../import/services/import.service.ts | 97 ------------------- .../integrations/import/utils/file.utils.ts | 1 - 5 files changed, 3 insertions(+), 129 deletions(-) diff --git a/apps/server/src/common/features.ts b/apps/server/src/common/features.ts index c5fd9a20..4a2439d2 100644 --- a/apps/server/src/common/features.ts +++ b/apps/server/src/common/features.ts @@ -6,9 +6,6 @@ export const Feature = { COMMENT_RESOLUTION: 'comment:resolution', PAGE_PERMISSIONS: 'page:permissions', AI: 'ai', - CONFLUENCE_IMPORT: 'import:confluence', - DOCX_IMPORT: 'import:docx', - PDF_IMPORT: 'import:pdf', ATTACHMENT_INDEXING: 'attachment:indexing', SECURITY_SETTINGS: 'security:settings', MCP: 'mcp', diff --git a/apps/server/src/integrations/import/import.controller.ts b/apps/server/src/integrations/import/import.controller.ts index cd2341ea..c47e87ea 100644 --- a/apps/server/src/integrations/import/import.controller.ts +++ b/apps/server/src/integrations/import/import.controller.ts @@ -51,7 +51,7 @@ export class ImportController { @AuthUser() user: User, @AuthWorkspace() workspace: Workspace, ) { - const validFileExtensions = ['.md', '.html', '.docx', '.pdf']; + const validFileExtensions = ['.md', '.html']; const maxFileSize = bytes('30mb'); @@ -101,8 +101,6 @@ export class ImportController { const sourceMap: Record = { '.md': 'markdown', '.html': 'html', - '.docx': 'docx', - '.pdf': 'pdf', }; if (createdPage) { @@ -161,10 +159,10 @@ export class ImportController { const spaceId = file.fields?.spaceId?.value; const source = file.fields?.source?.value; - const validZipSources = ['generic', 'notion', 'confluence']; + const validZipSources = ['generic', 'notion']; if (!validZipSources.includes(source)) { throw new BadRequestException( - 'Invalid import source. Import source must either be generic, notion or confluence.', + 'Invalid import source. Import source must either be generic or notion.', ); } diff --git a/apps/server/src/integrations/import/services/file-import-task.service.ts b/apps/server/src/integrations/import/services/file-import-task.service.ts index 59447b27..40525ddf 100644 --- a/apps/server/src/integrations/import/services/file-import-task.service.ts +++ b/apps/server/src/integrations/import/services/file-import-task.service.ts @@ -32,7 +32,6 @@ import { import { executeTx } from '@docmost/db/utils'; import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo'; import { ImportAttachmentService } from './import-attachment.service'; -import { ModuleRef } from '@nestjs/core'; import { PageService } from '../../../core/page/services/page.service'; import { ImportPageNode } from '../dto/file-task-dto'; import { EventEmitter2 } from '@nestjs/event-emitter'; @@ -54,7 +53,6 @@ export class FileImportTaskService { private readonly backlinkRepo: BacklinkRepo, @InjectKysely() private readonly db: KyselyDB, private readonly importAttachmentService: ImportAttachmentService, - private moduleRef: ModuleRef, private eventEmitter: EventEmitter2, @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, ) {} @@ -115,27 +113,6 @@ export class FileImportTaskService { }); } - if (fileTask.source === FileImportSource.Confluence) { - let ConfluenceModule: any; - try { - // eslint-disable-next-line @typescript-eslint/no-require-imports - ConfluenceModule = require('./../../../ee/confluence-import/confluence-import.service'); - } catch (err) { - this.logger.error( - 'Confluence import requested but EE module not bundled in this build', - ); - return; - } - const confluenceImportService = this.moduleRef.get( - ConfluenceModule.ConfluenceImportService, - { strict: false }, - ); - - await confluenceImportService.processConfluenceImport({ - extractDir: tmpExtractDir, - fileTask, - }); - } try { await this.updateTaskStatus(fileTaskId, FileTaskStatus.Success, null); await cleanupTmpFile(); diff --git a/apps/server/src/integrations/import/services/import.service.ts b/apps/server/src/integrations/import/services/import.service.ts index c0e6c878..9182dcf1 100644 --- a/apps/server/src/integrations/import/services/import.service.ts +++ b/apps/server/src/integrations/import/services/import.service.ts @@ -28,7 +28,6 @@ import { StorageService } from '../../storage/storage.service'; import { InjectQueue } from '@nestjs/bullmq'; import { Queue } from 'bullmq'; import { QueueJob, QueueName } from '../../queue/constants'; -import { ModuleRef } from '@nestjs/core'; import { load } from 'cheerio'; import { normalizeImportHtml } from '../utils/import-formatter'; @@ -42,7 +41,6 @@ export class ImportService { @InjectKysely() private readonly db: KyselyDB, @InjectQueue(QueueName.FILE_TASK_QUEUE) private readonly fileTaskQueue: Queue, - private moduleRef: ModuleRef, ) {} async importPage( @@ -62,33 +60,11 @@ export class ImportService { let prosemirrorState = null; let createdPage = null; - // For DOCX, we need the page ID upfront so images can reference it - const pageId = - fileExtension === '.docx' || fileExtension === '.pdf' - ? uuid7() - : undefined; - try { if (fileExtension.endsWith('.md')) { prosemirrorState = await this.processMarkdown(fileContent); } else if (fileExtension.endsWith('.html')) { prosemirrorState = await this.processHTML(fileContent); - } else if (fileExtension.endsWith('.docx')) { - prosemirrorState = await this.processDocx( - fileBuffer, - workspaceId, - spaceId, - pageId, - userId, - ); - } else if (fileExtension.endsWith('.pdf')) { - prosemirrorState = await this.processPdf( - fileBuffer, - workspaceId, - spaceId, - pageId, - userId, - ); } } catch (err) { // Surface the real cause instead of a generic mask, so the failure is @@ -117,7 +93,6 @@ export class ImportService { const pagePosition = await this.getNewPagePosition(spaceId); createdPage = await this.pageRepo.insertPage({ - ...(pageId ? { id: pageId } : {}), slugId: generateSlugId(), title: pageTitle, content: prosemirrorJson, @@ -165,78 +140,6 @@ export class ImportService { } } - async processDocx( - fileBuffer: Buffer, - workspaceId: string, - spaceId: string, - pageId: string, - userId: string, - ): Promise { - let DocxImportModule: any; - try { - // eslint-disable-next-line @typescript-eslint/no-require-imports - DocxImportModule = require('./../../../ee/document-import/docx-import.service'); - } catch (err) { - this.logger.error( - 'DOCX import requested but EE module not bundled in this build', - ); - throw new BadRequestException( - 'This feature requires a valid enterprise license.', - ); - } - - const docxImportService = this.moduleRef.get( - DocxImportModule.DocxImportService, - { strict: false }, - ); - - const html = await docxImportService.convertDocxToHtml( - fileBuffer, - workspaceId, - spaceId, - pageId, - userId, - ); - - return this.processHTML(html); - } - - async processPdf( - fileBuffer: Buffer, - workspaceId: string, - spaceId: string, - pageId: string, - userId: string, - ): Promise { - let PdfImportModule: any; - try { - // eslint-disable-next-line @typescript-eslint/no-require-imports - PdfImportModule = require('./../../../ee/document-import/pdf-import.service'); - } catch (err) { - this.logger.error( - 'PDF import requested but EE module not bundled in this build', - ); - throw new BadRequestException( - 'This feature requires a valid enterprise license.', - ); - } - - const pdfImportService = this.moduleRef.get( - PdfImportModule.PdfImportService, - { strict: false }, - ); - - const html = await pdfImportService.convertPdfToHtml( - fileBuffer, - workspaceId, - spaceId, - pageId, - userId, - ); - - return this.processHTML(html); - } - async createYdoc(prosemirrorJson: any): Promise { if (prosemirrorJson) { // this.logger.debug(`Converting prosemirror json state to ydoc`); diff --git a/apps/server/src/integrations/import/utils/file.utils.ts b/apps/server/src/integrations/import/utils/file.utils.ts index 0b27554b..6f804210 100644 --- a/apps/server/src/integrations/import/utils/file.utils.ts +++ b/apps/server/src/integrations/import/utils/file.utils.ts @@ -10,7 +10,6 @@ export enum FileTaskType { export enum FileImportSource { Generic = 'generic', Notion = 'notion', - Confluence = 'confluence', } export enum FileTaskStatus { From 9fcec4d2953fd46820c3fc7a967c1cdc330a6bec Mon Sep 17 00:00:00 2001 From: vvzvlad Date: Sat, 20 Jun 2026 04:11:19 +0300 Subject: [PATCH 003/331] docs: remove backlog doc for broken import formats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete the backlog documentation that described the removal of non‑functional DOCX, PDF, and Confluence import features now that the code changes have been merged. --- docs/backlog/remove-broken-import-formats.md | 86 -------------------- 1 file changed, 86 deletions(-) delete mode 100644 docs/backlog/remove-broken-import-formats.md diff --git a/docs/backlog/remove-broken-import-formats.md b/docs/backlog/remove-broken-import-formats.md deleted file mode 100644 index 28209d0a..00000000 --- a/docs/backlog/remove-broken-import-formats.md +++ /dev/null @@ -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-модуля? From ddb93525d67f420a49175d5a7c0820629e140d26 Mon Sep 17 00:00:00 2001 From: claude_code Date: Sat, 20 Jun 2026 04:19:03 +0300 Subject: [PATCH 004/331] docs: replace CLAUDE.md with AGENTS.md, codify agent workflow (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Renames the agent-guidance file `CLAUDE.md` → `AGENTS.md` (the opencode-standard name) and prepends a process layer on top of the existing technical/architecture content. ## Changes - **Rename** `CLAUDE.md` → `AGENTS.md` (git detects 64% similarity, history preserved). - **New top section "Жизненный цикл задачи"** codifies the workflow we just went through: 1. Sync with `develop`, branch off 2. Implement (per the system-prompt workflow + subagents) 3. **Commit ONLY to Gitea and ONLY as `claude_code`** — never as `vvzvlad`, never push to `origin`/`upstream` 4. Push + PR to `develop` 5. User merges; agent deletes the task's `docs/backlog/.md` - **New release-cycle section**: before cutting a version, run the three orchestrator skills (test-orchestrator, review-orchestrator, red-team-orchestrator), fix their findings, then tag per the existing procedure. - **Credentials cheat-sheet**: agent identity, keychain service name (`gitea-claude-code`), Gitea PR API endpoint, base branch, and do-not-push warnings for `origin`/`upstream`. - **Fix typo**: repo slug is `gitmost`, not `gtimost` (the remote was redirecting on every push). Local `gitea` remote URL is updated to the canonical form. ## Out of scope No code changes — docs only. Reviewed-on: https://gitea.vvzvlad.xyz/vvzvlad/gitmost/pulls/2 Co-authored-by: claude_code Co-committed-by: claude_code --- CLAUDE.md => AGENTS.md | 163 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 160 insertions(+), 3 deletions(-) rename CLAUDE.md => AGENTS.md (64%) diff --git a/CLAUDE.md b/AGENTS.md similarity index 64% rename from CLAUDE.md rename to AGENTS.md index 7e2713f1..ed200604 100644 --- a/CLAUDE.md +++ b/AGENTS.md @@ -1,6 +1,164 @@ -# CLAUDE.md +# AGENTS.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +This file guides AI agents (Claude Code, opencode, …) working in this +repository. It has two layers: **how to run a task end-to-end** (the +sections below), and **how the codebase is built** (the technical sections +further down, formerly in `CLAUDE.md`). + +## Жизненный цикл задачи + +### 1. Старт: синхронизация с develop + +Перед началом **любой** работы обнови локальный `develop` и ветвись от него: + +```bash +git checkout develop +git fetch gitea +git pull --ff-only gitea develop +git checkout -b <короткое-имя-фичи> +``` + +Никогда не пилит фичу прямо в `develop` и не ветвись от устаревшего +`develop` — иначе PR будет содержать лишние коммиты или конфликтовать. + +### 2. Реализация + +Веди задачу по workflow из системного промпта (Phase 1 анализ → Phase 3 +реализация → Phase 4 review → Phase 5 верификация → Phase 6 отчёт). Большие +изменения делегируй в general subagent, ревьюй через review subagent. + +### 3. Коммит — ТОЛЬКО в Gitea и ТОЛЬКО от `claude_code` + +Это правило без исключений: + +- **Куда:** единственный remote для коммитов/пушей — **`gitea`** + (`gitea.vvzvlad.xyz`). **Никогда** не пушь в `origin` (GitHub-зеркало) и + тем более в `upstream` (оригинальный Docmost). GitHub-зеркало обновляется + CI-процессом владельца, не агентом. +- **От кого:** коммить **только** от агентского identity. Любой коммит, + у которого author или committer — `vvzvlad`, считается ошибкой и должен + быть переписан. + - **name:** `claude_code` + - **email:** `claude_code@vvzvlad.xyz` + +Используй `--reset-author` при amend, иначе git оставит оригинального +автора (по умолчанию config на этой машине — `vvzvlad`, поэтому проверяй +после каждого коммита): + +```bash +GIT_AUTHOR_NAME="claude_code" \ +GIT_AUTHOR_EMAIL="claude_code@vvzvlad.xyz" \ +GIT_COMMITTER_NAME="claude_code" \ +GIT_COMMITTER_EMAIL="claude_code@vvzvlad.xyz" \ +git commit --amend --no-edit --reset-author +``` + +Для обычного нового коммита достаточно один раз выставить локальный +config ветки и коммитить штатно: + +```bash +git config user.name "claude_code" +git config user.email "claude_code@vvzvlad.xyz" +``` + +Проверка перед push: + +```bash +git log -1 --format='Author: %an <%ae>%nCommitter: %cn <%ce>' +# обе строки должны показать claude_code +``` + +### 4. Push и PR в develop + +PR всегда в `develop`. Пароль `claude_code` лежит в macOS keychain как +**generic password** под service `gitea-claude-code` (не дублируй его как +internet-password для `gitea.vvzvlad.xyz` — это создаст конфликт с учёткой +владельца в git credential helper): + +```bash +AGENT_PASS=$(security find-generic-password -s gitea-claude-code -w) +``` + +Push — через временную подстановку кредов в remote URL, после чего URL +обязательно возвращается в чистый вид (пароль не должен оседать в git +config / reflog): + +```bash +ORIG_URL=$(git remote get-url gitea) +SAFE_PASS=$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1]))" "$AGENT_PASS") +git remote set-url gitea "https://claude_code:${SAFE_PASS}@gitea.vvzvlad.xyz/vvzvlad/gitmost.git" +git push -u gitea +git remote set-url gitea "$ORIG_URL" +unset AGENT_PASS SAFE_PASS +``` + +PR создаётся через Gitea REST API (Basic Auth от `claude_code`): + +```bash +curl -s -X POST \ + -u "claude_code:$(security find-generic-password -s gitea-claude-code -w)" \ + -H "Content-Type: application/json" \ + -d @pr_body.json \ + "https://gitea.vvzvlad.xyz/api/v1/repos/vvzvlad/gitmost/pulls" +``` + +`base: develop`, `head: `. В теле PR — что сделано, что вне scope, +результаты верификации (tsc/lint/tests). + +> Если push падает с `User permission denied for writing` — значит у +> `claude_code` нет коллабораторских прав на репо. Попроси владельца +> добавить (один раз, через Gitea UI или +> `PUT /api/v1/repos/vvzvlad/gitmost/collaborators/claude_code` с +> `{"permission":"write"}` от его учётки). + +### 5. Мерж и cleanup + +- **Мерж PR в develop делает пользователь** (не агент). Агент не жмёт + кнопку merge. +- **После реализации задачи удали её план из `docs/backlog/.md`** — + это часть закрытия задачи, не пользовательская работа. Файлы в + `docs/backlog/` — это очередь работы, выполненное из неё вычищается. + Сделай это в отдельном коммите от того же `claude_code` в той же ветке + (или попроси пользователя удалить, если PR уже открыт и ты не хочешь + его перепушивать). +- Не закоммичен ли мусор в рабочем дереве? Проверь `git status` перед + финальным отчётом. + +## Релизный цикл: набор на новую версию + +Когда в `develop` накопилось достаточно изменений для релиза, запускается +**финальное ревью тремя скиллами-оркестраторами** перед мержем/тегом: + +1. **test-orchestrator** (skill `code-review-orchestrator` с фокусом на + тестовом покрытии) — проверяет, что новый код покрыт тестами и нет + регрессий в существующих. +2. **review-orchestrator** (skill `code-review-orchestrator`) — + мульти-аспектный код-ревью: безопасность, стабильность, соответствие + конвенциям, регрессии, перегруженность. +3. **red-team-orchestrator** (red-team скилл) — адверсариальный анализ + атакующих сценариев на затронутые компоненты. + +Порядок: оркестраторы возвращают списки находок → агент правит всё, что +они нашли (через subagent или сам, по правилам делегирования) → повторно +прогоняет ревью затронутых мест → режет тег по процедуре «Cutting a +release» ниже. + +## Шпаргалка по учёткам и endpoint'ам + +| Что | Значение | +| --- | --- | +| Единственный remote для коммитов | `gitea` → `https://vvzvlad@gitea.vvzvlad.xyz/vvzvlad/gitmost.git` | +| Агентский user (Gitea/git) | `claude_code` | +| Агентский email | `claude_code@vvzvlad.xyz` | +| Пароль в keychain | `security find-generic-password -s gitea-claude-code -w` | +| PR API | `https://gitea.vvzvlad.xyz/api/v1/repos/vvzvlad/gitmost/pulls` (тут `gitmost` — реальный slug репо на сервере) | +| Базовая ветка | `develop` | +| `origin` | GitHub-зеркало `vvzvlad/gitmost` — **не пушить**, обновляется CI владельца | +| `upstream` | Оригинальный Docmost — **не пушить никогда** | + +--- + +# Архитектура и кодовая база ## What this is @@ -120,7 +278,6 @@ The git tag is the source of truth for the displayed version (UI reads `git desc 5. Tag the release commit with a **lightweight** tag (existing release tags are lightweight): `git tag vX.Y.Z`. 6. Push commit and tag: `git push origin main && git push origin vX.Y.Z`. Pushing the `v*` tag triggers `release.yml` (multi-arch GHCR images + a draft GitHub Release). - ## Planning docs `docs/*.md` hold design plans for in-progress / planned features (mobile app, offline sync, RAG improvements, voice dictation, arbitrary HTML embed). `docs/backlog/*.md` track known issues / follow-ups (e.g. AI-chat review follow-ups). Consult the relevant plan before working on one of those areas. From b81819ef6388c0885ca3460f9f9737e7dd90f430 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sat, 20 Jun 2026 05:31:34 +0300 Subject: [PATCH 005/331] feat(tree): Expand all / Collapse all for the space page tree Adds a server-authoritative whole-tree endpoint and sidebar menu commands so a deep space tree can be expanded in one request instead of a per-level BFS storm. Server: - POST /pages/tree (SidebarPageTreeDto: spaceId | pageId), same CASL space scoping as /sidebar-pages. Returns the whole space tree / subtree as a flat list in the sidebar item shape (id, slugId, title, icon, position, parentPageId, spaceId, hasChildren, canEdit), ordered by position (collate C byte order), content never fetched. - page.service.getSidebarPagesTree reproduces getSidebarPages' two-branch permission model: open space -> spaceCanEdit; restricted space -> seed the full descendant set then prune via filterAccessibleTreePages + filterAccessiblePageIdsWithPermissions (keeps restricted-but-granted pages, prunes inaccessible subtrees). hasChildren is derived from the final filtered set so it can never reveal inaccessible children. - page.repo.getSpaceDescendants: recursive CTE seeded by space roots. Client: - SpaceTree is forwardRef exposing expandAll/collapseAll/isExpanding; expandAll fetches the whole tree once, replaces current-space nodes, opens every branch (current space only), aborts on space switch, surfaces real errors; collapseAll collapses only current-space ids (shared open-map). - SpaceMenu gains Expand all / Collapse all items (no admin gate). Implements docs/backlog/tree-expand-collapse-all.md. Co-Authored-By: Claude Opus 4.8 --- .../public/locales/en-US/translation.json | 3 + .../features/page/services/page-service.ts | 8 + .../page/tree/components/space-tree.tsx | 106 ++++++++++- .../components/sidebar/space-sidebar.tsx | 45 ++++- .../src/core/page/dto/sidebar-page.dto.ts | 10 + apps/server/src/core/page/page.controller.ts | 45 ++++- .../src/core/page/services/page.service.ts | 132 ++++++++++++- .../page/services/sidebar-pages-tree.spec.ts | 179 ++++++++++++++++++ .../src/database/repos/page/page.repo.ts | 54 ++++++ 9 files changed, 573 insertions(+), 9 deletions(-) create mode 100644 apps/server/src/core/page/services/sidebar-pages-tree.spec.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 21f7c5f7..c2c8255e 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -977,6 +977,9 @@ "Page menu": "Page menu", "Expand": "Expand", "Collapse": "Collapse", + "Expand all": "Expand all", + "Collapse all": "Collapse all", + "Couldn't expand the tree: {{reason}}": "Couldn't expand the tree: {{reason}}", "Comment menu": "Comment menu", "Group menu": "Group menu", "Show hidden breadcrumbs": "Show hidden breadcrumbs", diff --git a/apps/client/src/features/page/services/page-service.ts b/apps/client/src/features/page/services/page-service.ts index 146da7dd..6434ec7c 100644 --- a/apps/client/src/features/page/services/page-service.ts +++ b/apps/client/src/features/page/services/page-service.ts @@ -92,6 +92,14 @@ export async function getAllSidebarPages( }; } +export async function getSpaceTree(params: { + spaceId: string; + pageId?: string; +}): Promise { + const req = await api.post("/pages/tree", params); + return req.data.items; +} + export async function getPageBreadcrumbs( pageId: string, ): Promise> { diff --git a/apps/client/src/features/page/tree/components/space-tree.tsx b/apps/client/src/features/page/tree/components/space-tree.tsx index 1c3aab8e..e3e339c9 100644 --- a/apps/client/src/features/page/tree/components/space-tree.tsx +++ b/apps/client/src/features/page/tree/components/space-tree.tsx @@ -1,8 +1,17 @@ import { useAtom } from "jotai"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from "react"; import { useParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Text } from "@mantine/core"; +import { notifications } from "@mantine/notifications"; import { fetchAllAncestorChildren, useGetRootSidebarPagesQuery, @@ -19,7 +28,10 @@ import { } from "@/features/page/tree/utils/utils.ts"; import { SpaceTreeNode } from "@/features/page/tree/types.ts"; import { treeModel } from "@/features/page/tree/model/tree-model"; -import { getPageBreadcrumbs } from "@/features/page/services/page-service.ts"; +import { + getPageBreadcrumbs, + getSpaceTree, +} from "@/features/page/services/page-service.ts"; import { IPage } from "@/features/page/types/page.types.ts"; import { extractPageSlugId } from "@/lib"; import { DocTree } from "./doc-tree"; @@ -30,10 +42,20 @@ interface SpaceTreeProps { readOnly: boolean; } -export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) { +export type SpaceTreeApi = { + expandAll: () => Promise; + collapseAll: () => void; + isExpanding: boolean; +}; + +const SpaceTree = forwardRef(function SpaceTree( + { spaceId, readOnly }, + ref, +) { const { t } = useTranslation(); const { pageSlug } = useParams(); const [data, setData] = useAtom(treeDataAtom); + const [isExpanding, setIsExpanding] = useState(false); const { handleMove } = useTreeMutation(spaceId); const { data: pagesData, @@ -186,6 +208,80 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) { [data, spaceId], ); + 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 }); + // Space switched mid-flight — abort merge/expand. + if (spaceIdRef.current !== startSpaceId) return; + + const fullTree = buildTreeWithChildren(buildTree(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, ...fullTree]; + }); + + // Open every branch node (node with children) of the current space only. + const branchIds: string[] = []; + const collectBranchIds = (nodes: SpaceTreeNode[]) => { + for (const n of nodes) { + if (n.children && n.children.length > 0) { + branchIds.push(n.id); + collectBranchIds(n.children); + } + } + }; + collectBranchIds(fullTree); + + setOpenTreeNodes((prev) => { + const next = { ...prev }; + for (const id of branchIds) next[id] = true; + return next; + }); + } catch (err: any) { + // Never swallow: log full error + surface the real reason. + 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]); + + const collapseAll = useCallback(() => { + // The open-map is shared across spaces; collapse only current-space ids so + // other spaces' expanded state is left intact. + const ids = new Set(); + 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]); + + useImperativeHandle( + ref, + () => ({ expandAll, collapseAll, isExpanding }), + [expandAll, collapseAll, isExpanding], + ); + // Stable callbacks for DocTree. Without these, every parent render recreates // the props and tears down every row's draggable/dropTarget subscription, // defeating memo(DocTreeRow). @@ -228,4 +324,6 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) { )} ); -} +}); + +export default SpaceTree; diff --git a/apps/client/src/features/space/components/sidebar/space-sidebar.tsx b/apps/client/src/features/space/components/sidebar/space-sidebar.tsx index 1786d84e..9ee98a50 100644 --- a/apps/client/src/features/space/components/sidebar/space-sidebar.tsx +++ b/apps/client/src/features/space/components/sidebar/space-sidebar.tsx @@ -7,6 +7,8 @@ import { } from "@mantine/core"; import { IconArrowDown, + IconChevronsDown, + IconChevronsUp, IconDots, IconEye, IconEyeOff, @@ -23,14 +25,16 @@ import { useUnwatchSpaceMutation, } from "@/features/space/queries/space-watcher-query.ts"; import classes from "./space-sidebar.module.css"; -import React from "react"; +import React, { useRef } from "react"; import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts"; import { Link, useParams } from "react-router-dom"; import clsx from "clsx"; import { useDisclosure } from "@mantine/hooks"; import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx"; import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts"; -import SpaceTree from "@/features/page/tree/components/space-tree.tsx"; +import SpaceTree, { + SpaceTreeApi, +} from "@/features/page/tree/components/space-tree.tsx"; import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts"; import { SpaceCaslAction, @@ -57,6 +61,7 @@ export function SpaceSidebar() { const spaceRules = space?.membership?.permissions; const spaceAbility = useSpaceAbility(spaceRules); const { handleCreate } = useTreeMutation(space?.id ?? ""); + const treeRef = useRef(null); if (!space) { return <>; @@ -100,6 +105,7 @@ export function SpaceSidebar() { SpaceCaslSubject.Page, )} onSpaceSettings={openSettings} + treeRef={treeRef} /> {spaceAbility.can( @@ -122,6 +128,7 @@ export function SpaceSidebar() {
void; + treeRef: React.RefObject; } function SpaceMenu({ spaceId, canManagePages, onSpaceSettings, + treeRef, }: SpaceMenuProps) { const { t } = useTranslation(); + const [isExpanding, setIsExpanding] = React.useState(false); + + const handleExpandAll = async () => { + setIsExpanding(true); + try { + await treeRef.current?.expandAll(); + } finally { + setIsExpanding(false); + } + }; + + const handleCollapseAll = () => { + treeRef.current?.collapseAll(); + }; const { spaceSlug } = useParams(); const [importOpened, { open: openImportModal, close: closeImportModal }] = useDisclosure(false); @@ -201,6 +224,24 @@ function SpaceMenu({ + } + > + {t("Expand all")} + + + } + > + {t("Collapse all")} + + + + ( pages: T[], - rootPageId: string, + rootPageId: string | null, userId: string, spaceId?: string, ): Promise { @@ -1153,6 +1153,15 @@ export class PageService { ); const accessibleSet = new Set(accessibleIds); + // When no explicit root is given (whole-space tree), every page whose + // parent is outside the returned set acts as a root (space root pages have + // parentPageId === null). This mirrors the single-root case below. + const pageIdSet = new Set(pageIds); + const isRoot = (page: T): boolean => { + if (rootPageId !== null) return page.id === rootPageId; + return !page.parentPageId || !pageIdSet.has(page.parentPageId); + }; + // Prune: include a page only if it's accessible AND its parent chain to root is included const includedIds = new Set(); @@ -1166,7 +1175,7 @@ export class PageService { if (!accessibleSet.has(page.id)) continue; // Root page: include if accessible - if (page.id === rootPageId) { + if (isRoot(page)) { includedIds.add(page.id); changed = true; continue; @@ -1182,4 +1191,123 @@ export class PageService { return pages.filter((p) => includedIds.has(p.id)); } + + /** + * 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 (id, slugId, title, icon, position, parentPageId, spaceId, + * hasChildren, canEdit) ordered by position. content is never fetched. + * + * Reproduces the exact two-branch permission logic of getSidebarPages(): + * - open space (no restrictions): every returned page is visible, canEdit = + * spaceCanEdit, hasChildren derived from the returned set. + * - restricted space: full descendant set is loaded, then per-page + * permissions applied via filterAccessibleTreePages (restricted-but-granted + * pages are kept; inaccessible subtrees pruned); canEdit is per-page AND + * spaceCanEdit; + * hasChildren is derived from the FINAL (post-prune, post-filter) set, so + * a node never advertises children the user cannot access — the same + * correction getSidebarPages does via getParentIdsWithAccessibleChildren. + */ + async getSidebarPagesTree( + spaceId: string, + userId: string, + spaceCanEdit?: boolean, + pageId?: string, + ): Promise< + Array< + Pick< + Page, + | 'id' + | 'slugId' + | 'title' + | 'icon' + | 'position' + | 'parentPageId' + | 'spaceId' + > & { hasChildren: boolean; canEdit: boolean } + > + > { + const hasRestrictions = + await this.pagePermissionRepo.hasRestrictedPagesInSpace(spaceId); + + // Seed: a single page subtree, or all root pages of the space. + // Always seed with the FULL (non-excluding) descendant set — in a restricted + // space the per-page filtering below (filterAccessibleTreePages) does the + // pruning, exactly like getSidebarPages. Seeding with *ExcludingRestricted + // would wrongly drop restricted pages the user has an explicit grant for + // (and never recurse into their children), diverging from the sidebar. + let pages: Array<{ + id: string; + slugId: string; + title: string; + icon: string; + position: string; + parentPageId: string | null; + spaceId: string; + }>; + + if (pageId) { + pages = await this.pageRepo.getPageAndDescendants(pageId, { + includeContent: false, + }); + } else { + pages = await this.pageRepo.getSpaceDescendants(spaceId, { + includeContent: false, + }); + } + + let permissionMap: Map | undefined; + + if (hasRestrictions) { + // Fine-grained per-page permissions on top of restricted pruning. + pages = await this.filterAccessibleTreePages( + pages, + pageId ?? null, + userId, + spaceId, + ); + + // Per-page canEdit, same source as getSidebarPages. + const accessiblePages = + await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions( + pages.map((p) => p.id), + userId, + ); + permissionMap = new Map(accessiblePages.map((p) => [p.id, p.canEdit])); + } + + // Derive hasChildren from the FINAL set: a node has children iff some + // returned row points to it as parent. In a restricted space this set is + // already pruned/filtered, so inaccessible children are not revealed. + const parentIds = new Set(); + for (const p of pages) { + if (p.parentPageId) parentIds.add(p.parentPageId); + } + + const shaped = pages.map((p) => ({ + id: p.id, + slugId: p.slugId, + title: p.title, + icon: p.icon, + position: p.position, + parentPageId: p.parentPageId, + spaceId: p.spaceId, + hasChildren: parentIds.has(p.id), + canEdit: hasRestrictions + ? Boolean(permissionMap?.get(p.id)) && (spaceCanEdit ?? true) + : (spaceCanEdit ?? true), + })); + + // Order by position with byte order, matching the sidebar's + // `position collate "C"` SQL ordering. position is non-null in returned + // rows; treat a null defensively as sorting last. + shaped.sort((a, b) => { + if (a.position == null) return b.position == null ? 0 : 1; + if (b.position == null) return -1; + return Buffer.compare(Buffer.from(a.position), Buffer.from(b.position)); + }); + + return shaped; + } } diff --git a/apps/server/src/core/page/services/sidebar-pages-tree.spec.ts b/apps/server/src/core/page/services/sidebar-pages-tree.spec.ts new file mode 100644 index 00000000..0c3a43c9 --- /dev/null +++ b/apps/server/src/core/page/services/sidebar-pages-tree.spec.ts @@ -0,0 +1,179 @@ +/** + * Pure-logic test for getSidebarPagesTree's shaping/permission logic. + * + * NOTE: We cannot import PageService directly here — its dependency chain + * imports `src/collaboration/collaboration.util` via a bare `src/...` path, and + * the server's jest config (package.json "jest".moduleNameMapper) has no + * `^src/(.*)$` mapping, so the module fails to resolve under jest. That is a + * pre-existing config gap unrelated to this feature. To still cover the + * load-bearing logic we replicate the exact shaping algorithm from + * PageService.getSidebarPagesTree below and assert against it. If the service + * logic changes, keep this mirror in sync. + */ + +type RawPage = { + id: string; + slugId: string; + title: string; + icon: string; + position: string; + parentPageId: string | null; + spaceId: string; +}; + +// Mirror of the shaping/branch logic in PageService.getSidebarPagesTree. +function shapeTree( + pages: RawPage[], + opts: { + hasRestrictions: boolean; + spaceCanEdit?: boolean; + permissionMap?: Map; + }, +) { + const parentIds = new Set(); + for (const p of pages) { + if (p.parentPageId) parentIds.add(p.parentPageId); + } + + const shaped = pages.map((p) => ({ + id: p.id, + slugId: p.slugId, + title: p.title, + icon: p.icon, + position: p.position, + parentPageId: p.parentPageId, + spaceId: p.spaceId, + hasChildren: parentIds.has(p.id), + canEdit: opts.hasRestrictions + ? Boolean(opts.permissionMap?.get(p.id)) && (opts.spaceCanEdit ?? true) + : (opts.spaceCanEdit ?? true), + })); + + shaped.sort((a, b) => { + if (a.position == null) return b.position == null ? 0 : 1; + if (b.position == null) return -1; + return Buffer.compare(Buffer.from(a.position), Buffer.from(b.position)); + }); + + return shaped; +} + +const page = ( + id: string, + parentPageId: string | null, + position: string, +): RawPage => ({ + id, + slugId: `slug-${id}`, + title: `Page ${id}`, + icon: '', + position, + parentPageId, + spaceId: 'space-1', +}); + +describe('getSidebarPagesTree shaping logic', () => { + it('open space: canEdit = spaceCanEdit, hasChildren derived from set', () => { + const pages = [ + page('root', null, 'a0'), + page('child', 'root', 'a0'), + page('leaf', 'child', 'a0'), + ]; + + const result = shapeTree(pages, { + hasRestrictions: false, + spaceCanEdit: true, + }); + + const byId = new Map(result.map((p) => [p.id, p])); + expect(byId.get('root')!.hasChildren).toBe(true); + expect(byId.get('child')!.hasChildren).toBe(true); + expect(byId.get('leaf')!.hasChildren).toBe(false); + expect(result.every((p) => p.canEdit === true)).toBe(true); + }); + + it('open space: spaceCanEdit=false makes every node read-only', () => { + const pages = [page('root', null, 'a0'), page('child', 'root', 'a0')]; + const result = shapeTree(pages, { + hasRestrictions: false, + spaceCanEdit: false, + }); + expect(result.every((p) => p.canEdit === false)).toBe(true); + }); + + it('restricted space: hasChildren does not reveal pruned children', () => { + // Simulates the filterAccessibleTreePages result: "child" was pruned, so + // the returned set has no row with parent === root. + const prunedPages = [page('root', null, 'a0')]; + const result = shapeTree(prunedPages, { + hasRestrictions: true, + spaceCanEdit: true, + permissionMap: new Map([['root', true]]), + }); + expect(result).toHaveLength(1); + // root no longer advertises children the user cannot access. + expect(result[0].hasChildren).toBe(false); + }); + + it('restricted space: canEdit is per-page AND spaceCanEdit', () => { + const pages = [ + page('root', null, 'a0'), + page('child', 'root', 'a0'), + ]; + const result = shapeTree(pages, { + hasRestrictions: true, + spaceCanEdit: true, + permissionMap: new Map([ + ['root', true], + ['child', false], + ]), + }); + const byId = new Map(result.map((p) => [p.id, p])); + expect(byId.get('root')!.canEdit).toBe(true); + expect(byId.get('child')!.canEdit).toBe(false); + expect(byId.get('root')!.hasChildren).toBe(true); + }); + + it('restricted space: spaceCanEdit=false overrides per-page canEdit', () => { + const pages = [page('root', null, 'a0')]; + const result = shapeTree(pages, { + hasRestrictions: true, + spaceCanEdit: false, + permissionMap: new Map([['root', true]]), + }); + expect(result[0].canEdit).toBe(false); + }); + + it('orders by position (collate-C style ascending)', () => { + const pages = [ + page('b', null, 'a1'), + page('c', null, 'a2'), + page('a', null, 'a0'), + ]; + const result = shapeTree(pages, { + hasRestrictions: false, + spaceCanEdit: true, + }); + expect(result.map((p) => p.id)).toEqual(['a', 'b', 'c']); + }); + + it('shape contains exactly the sidebar item fields', () => { + const result = shapeTree([page('root', null, 'a0')], { + hasRestrictions: false, + spaceCanEdit: true, + }); + expect(Object.keys(result[0]).sort()).toEqual( + [ + 'canEdit', + 'hasChildren', + 'icon', + 'id', + 'parentPageId', + 'position', + 'slugId', + 'spaceId', + 'title', + ].sort(), + ); + }); +}); diff --git a/apps/server/src/database/repos/page/page.repo.ts b/apps/server/src/database/repos/page/page.repo.ts index b2884603..639cf57b 100644 --- a/apps/server/src/database/repos/page/page.repo.ts +++ b/apps/server/src/database/repos/page/page.repo.ts @@ -672,4 +672,58 @@ export class PageRepo { .execute() ); } + + /** + * Whole space tree (all root pages and their descendants) in a single + * recursive query. Mirrors getPageAndDescendants but seeded by every root + * page of the space (parentPageId IS NULL) instead of a single parent. + */ + async getSpaceDescendants( + spaceId: string, + opts: { includeContent: boolean }, + ) { + return this.db + .withRecursive('page_hierarchy', (db) => + db + .selectFrom('pages') + .select([ + 'id', + 'slugId', + 'title', + 'icon', + 'position', + 'parentPageId', + 'spaceId', + 'workspaceId', + 'createdAt', + 'updatedAt', + ]) + .$if(opts?.includeContent, (qb) => qb.select('content')) + .where('spaceId', '=', spaceId) + .where('parentPageId', 'is', null) + .where('deletedAt', 'is', null) + .unionAll((exp) => + exp + .selectFrom('pages as p') + .select([ + 'p.id', + 'p.slugId', + 'p.title', + 'p.icon', + 'p.position', + 'p.parentPageId', + 'p.spaceId', + 'p.workspaceId', + 'p.createdAt', + 'p.updatedAt', + ]) + .$if(opts?.includeContent, (qb) => qb.select('p.content')) + .innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id') + .where('p.deletedAt', 'is', null), + ), + ) + .selectFrom('page_hierarchy') + .selectAll() + .execute(); + } } From b38b71eb51bf587a064e7d871e62268a13a9ec92 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sat, 20 Jun 2026 05:31:34 +0300 Subject: [PATCH 006/331] docs: remove implemented tree-expand-collapse-all backlog plan Co-Authored-By: Claude Opus 4.8 --- docs/backlog/tree-expand-collapse-all.md | 301 ----------------------- 1 file changed, 301 deletions(-) delete mode 100644 docs/backlog/tree-expand-collapse-all.md diff --git a/docs/backlog/tree-expand-collapse-all.md b/docs/backlog/tree-expand-collapse-all.md deleted file mode 100644 index 0fce6da1..00000000 --- a/docs/backlog/tree-expand-collapse-all.md +++ /dev/null @@ -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` (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 { - 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; - 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(); - 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(null)`, передать -в ``, и подвесить команды в шапке. **Без -`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. **Потолок размера ответа:** отдавать дерево любого размера или ограничить - (число узлов) и как сообщать про усечение. From b197cbedef9e2539b7cd53cf8c8e003ce9c12af7 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sat, 20 Jun 2026 05:38:13 +0300 Subject: [PATCH 007/331] feat(ai-chat): raise agent step cap 8->20, force a final text answer A narrow research question could burn all 8 steps on tool calls and end the turn with no assistant text (empty turn). Two changes: - MAX_AGENT_STEPS = 20 (was a magic stepCountIs(8)) so multi-search turns aren't cut off mid-investigation. - prepareStep reserves the LAST allowed step for a text-only synthesis: toolChoice 'none' + a FINAL_STEP_INSTRUCTION appended to (not replacing) the system prompt, so a tool-heavy turn always ends with a real answer. Logic extracted into the pure, exported prepareAgentStep(stepNumber, system) for unit testing; earlier steps return undefined (default behavior). Implements docs/backlog/ai-chat-step-limit-and-forced-final-answer.md. Co-Authored-By: Claude Opus 4.8 --- .../src/core/ai-chat/ai-chat.service.spec.ts | 34 ++++++++++++++- .../src/core/ai-chat/ai-chat.service.ts | 41 ++++++++++++++++++- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/apps/server/src/core/ai-chat/ai-chat.service.spec.ts b/apps/server/src/core/ai-chat/ai-chat.service.spec.ts index f1f3461a..d007c546 100644 --- a/apps/server/src/core/ai-chat/ai-chat.service.spec.ts +++ b/apps/server/src/core/ai-chat/ai-chat.service.spec.ts @@ -1,4 +1,9 @@ -import { compactToolOutput } from './ai-chat.service'; +import { + compactToolOutput, + prepareAgentStep, + MAX_AGENT_STEPS, + FINAL_STEP_INSTRUCTION, +} from './ai-chat.service'; /** * Unit tests for compactToolOutput: the pure helper that shrinks LARGE tool @@ -66,3 +71,30 @@ describe('compactToolOutput', () => { expect(compactedBytes).toBeLessThan(originalBytes / 10); }); }); + +/** + * Unit tests for prepareAgentStep: the pure helper that decides per-step + * overrides for the agent loop. Early steps return undefined (default + * behavior); the final allowed step (stepNumber === MAX_AGENT_STEPS - 1) forces + * a text-only synthesis answer (toolChoice 'none') with the FINAL_STEP_INSTRUCTION + * appended onto — not replacing — the original system prompt. + */ +describe('prepareAgentStep', () => { + it('returns undefined for the first step', () => { + expect(prepareAgentStep(0, 'SYS')).toBeUndefined(); + }); + + it('returns undefined for a non-final step (just before the last)', () => { + expect(prepareAgentStep(MAX_AGENT_STEPS - 2, 'SYS')).toBeUndefined(); + }); + + it('forces a text-only synthesis on the final allowed step', () => { + const result = prepareAgentStep(MAX_AGENT_STEPS - 1, 'SYS'); + expect(result).toBeDefined(); + expect(result?.toolChoice).toBe('none'); + // The original persona is preserved (prefix), not replaced. + expect(result?.system.startsWith('SYS')).toBe(true); + // The synthesis instruction is appended. + expect(result?.system).toContain(FINAL_STEP_INSTRUCTION); + }); +}); diff --git a/apps/server/src/core/ai-chat/ai-chat.service.ts b/apps/server/src/core/ai-chat/ai-chat.service.ts index 3119c3c4..1b274238 100644 --- a/apps/server/src/core/ai-chat/ai-chat.service.ts +++ b/apps/server/src/core/ai-chat/ai-chat.service.ts @@ -17,6 +17,39 @@ import { AiChatToolsService } from './tools/ai-chat-tools.service'; import { McpClientsService } from './external-mcp/mcp-clients.service'; import { buildSystemPrompt } from './ai-chat.prompt'; +// 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 prepareAgentStep). +// 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.'; + +// Pure, unit-testable: decide per-step overrides. Returns undefined for normal +// steps; on the final allowed step forces a text-only synthesis answer. +// `system` is the in-scope system prompt; we CONCATENATE so the original +// persona/context is preserved — a bare `system` override would REPLACE the +// whole system prompt for the 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; +} + +export { MAX_AGENT_STEPS, FINAL_STEP_INSTRUCTION }; + /** * Payload accepted from the client `useChat` POST body. We do NOT bind a strict * DTO (the global ValidationPipe whitelist would strip the useChat-specific @@ -244,7 +277,13 @@ export class AiChatService { // cap would truncate complex tool calls mid-argument. Let the model use its // natural per-step budget. (Cost/credit limits are an account concern, not // something to enforce by silently breaking the agent.) - stopWhen: stepCountIs(8), + stopWhen: stepCountIs(MAX_AGENT_STEPS), + // 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). prepareAgentStep forbids + // further tool calls and appends a synthesis instruction on that step, + // concatenated onto the original `system` so the persona is preserved. + prepareStep: ({ stepNumber }) => prepareAgentStep(stepNumber, system), abortSignal: signal, onFinish: async ({ text, finishReason, totalUsage, usage, steps }) => { await persistAssistant({ From fb01c07b71908f5197bb732f2024985848693e4c Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sat, 20 Jun 2026 05:38:13 +0300 Subject: [PATCH 008/331] docs: remove implemented ai-chat-step-limit backlog plan Co-Authored-By: Claude Opus 4.8 --- ...chat-step-limit-and-forced-final-answer.md | 199 ------------------ 1 file changed, 199 deletions(-) delete mode 100644 docs/backlog/ai-chat-step-limit-and-forced-final-answer.md diff --git a/docs/backlog/ai-chat-step-limit-and-forced-final-answer.md b/docs/backlog/ai-chat-step-limit-and-forced-final-answer.md deleted file mode 100644 index fb2f4a86..00000000 --- a/docs/backlog/ai-chat-step-limit-and-forced-final-answer.md +++ /dev/null @@ -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`, где - `ToolChoice = 'auto' | 'none' | 'required' | { type:'tool', toolName }` - (строка 126) — значит `toolChoice: 'none'` валидно и заставляет модель - отвечать текстом. -- `system?: string | SystemModelMessage | Array` — override - системного сообщения **для шага**; это полная замена, поэтому конкатенируем с - исходным `system`, а не пишем голую инструкцию. -- `stepNumber` нумеруется с нуля (док. пример: `if (stepNumber === 0) {...}`). - -> ⚠️ При апгрейде до AI SDK v7 поле `system` в `prepareStep` переименовано в -> `instructions` (см. migration guide 7.0). На v6 (`^6.0.134`, фактически -> 6.0.207) корректно именно `system`. Учесть при будущем bump. - -## Тонкие моменты / edge cases - -- **Резерв ровно одного шага** — на 20-м шаге модель не сможет сделать ещё один - «дозапрос». Это осознанный компромисс: гарантированный ответ важнее одного - лишнего инструмента. Если захочется буфера — форсить на `stepNumber >= - MAX_AGENT_STEPS - 2` (зарезервировать 2 шага), но это режет полезную работу. -- **Естественное завершение** до последнего шага — не затрагивается: override - применяется только при `stepNumber >= MAX_AGENT_STEPS - 1`. -- **finishReason** последнего шага: при `toolChoice:'none'` модель выдаёт текст - без tool-calls → цикл завершается как `stop` (а не «оборвался на лимите»). - Пустых ходов больше не будет; `onFinish` получит непустой `text`. -- **Замена system** override-ом — единственная ловушка: НЕ потерять исходный - промпт. Переменная `system` (`ai-chat.service.ts:146`) в замыкании — берём её. -- **maxOutputTokens** на агенте намеренно не задан (коммент `:242-246`) — это - изменение его не трогает; токенов на финальный текстовый шаг достаточно. -- **Клиент не меняется**: рендер шагов и текста уже есть в - `apps/client/src/features/ai-chat/components/message-list.tsx`. Раньше пустой - ход показывался как ход без текста — после фикса будет нормальный ответ. -- **Внешние MCP-клиенты** (tavily и пр.) закрываются в терминальных колбэках - (`closeExternalClients`) — путь завершения не меняется, ликов не добавляем. - -## Тестирование - -- Цикл `streamText` целиком юнит-тестировать дорого. Рекомендуется вынести - логику выбора шага в чистую экспортируемую функцию (по образцу - `compactToolOutput`, который уже тестируется в `ai-chat.service.spec.ts`): - -```ts -// Pure, unit-testable: decide per-step overrides. Returns undefined for normal -// steps, and forces a text-only synthesis on the final step. -export function prepareAgentStep( - stepNumber: number, - system: string, -): { toolChoice: 'none'; system: string } | undefined { - if (stepNumber >= MAX_AGENT_STEPS - 1) { - return { toolChoice: 'none', system: `${system}\n\n${FINAL_STEP_INSTRUCTION}` }; - } - return undefined; -} -``` - - Тогда `prepareStep: ({ stepNumber }) => prepareAgentStep(stepNumber, system)`, - а тест проверяет: для `stepNumber < 19` → `undefined`; для `19` → объект с - `toolChoice === 'none'` и `system`, начинающимся с исходного промпта и - содержащим `FINAL_STEP_INSTRUCTION`. - -## Альтернативы / возможные расширения (вне базового объёма) - -- **Конфигурируемый лимит** — вынести `MAX_AGENT_STEPS` в настройку воркспейса - (admin → AI), как системный промпт (`AiSettingsService.resolve`). Сейчас же — - просто константа в коде. -- **UI-метка «ответ по неполным данным»** — если последний шаг был принудительным, - можно прокинуть флажок в metadata и показать бейдж в `message-list.tsx`. Не - обязательно для базовой фичи. - -## Открытые вопросы (согласовать перед реализацией) - -- [ ] Значение лимита: 20 — ок? (компромисс «глубина исследования» vs стоимость - токенов на ход.) -- [ ] Текст `FINAL_STEP_INSTRUCTION` — устраивает формулировка? Язык ответа - модель выбирает сама по контексту; инструкция на английском как и весь - системный промпт. -- [ ] Выносить ли логику шага в чистую функцию ради юнит-теста (рекомендуется), - или оставить инлайн в `prepareStep` без отдельного теста. - -## Процесс - -- Сейчас это только план; код НЕ менялся. -- Реализация — режим делегирования (по умолчанию): изменение логическое - (новый `prepareStep` + константы, >5 строк) → general-purpose кодеру, затем - обязательный прогон `review`. -- Не коммитить; в конце предложить сообщение коммита. From 30c31892202d03719ce6cdc7c4e4e97d1cc8e077 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sat, 20 Jun 2026 06:30:06 +0300 Subject: [PATCH 009/331] feat(ai-chat): agent roles (admin-defined persona + optional model) Reusable, workspace-shared agent roles for the built-in AI chat. A role is a named persona (system-prompt instructions) + optional model override; a chat is bound to a role at creation and applies it every turn. Backend: - migration 20260620T120000: ai_agent_roles table + ai_chats.role_id (FK ON DELETE SET NULL); hand-merged types into db.d.ts/entity.types.ts (db.d.ts is hand-curated here, full codegen would clobber it). - core/ai-chat/roles: CRUD module. list = any workspace member; create/ update/delete = admin (Manage Settings ability, like ai-settings/mcp). All repo queries scoped by workspace_id; soft-delete (deleted_at). - buildSystemPrompt gains roleInstructions: role REPLACES the persona base (admin prompt / DEFAULT_PROMPT) but SAFETY_FRAMEWORK + context are always still appended. - stream(): role resolved from ai_chats.role_id for existing chats (never the request body -> no per-turn role swap); body.roleId only on creation. Disabled (enabled=false) and soft-deleted roles fall back to universal. - getChatModel(workspaceId, override): role model_config can swap model id / driver; a driver without configured creds throws 503 with a clear message naming the driver+role, resolved BEFORE response hijack. Client: - new-chat role picker (enabled roles only, default Universal assistant), roleId sent only on the first message; role badge (emoji+name) in the chat header and conversation list; admin Agent-roles management section in Settings -> AI (add/edit/delete, MCP-form pattern). Tests: ai-chat.prompt.spec (role layering + safety always present, incl. jailbreak); ai.service.spec (override on unconfigured driver -> 503). Implements docs/ai-agent-roles-plan.md. Co-Authored-By: Claude Opus 4.8 --- .../public/locales/en-US/translation.json | 23 +- .../features/ai-chat/atoms/ai-chat-atom.ts | 9 + .../ai-chat/components/ai-chat-window.tsx | 54 ++++- .../ai-chat/components/chat-thread.tsx | 14 ++ .../ai-chat/components/conversation-list.tsx | 13 +- .../features/ai-chat/queries/ai-chat-query.ts | 84 +++++++ .../ai-chat/services/ai-chat-service.ts | 33 +++ .../features/ai-chat/types/ai-chat.types.ts | 53 +++++ .../components/ai-agent-role-form.tsx | 209 ++++++++++++++++++ .../settings/components/ai-agent-roles.tsx | 175 +++++++++++++++ .../pages/settings/workspace/ai-settings.tsx | 8 + .../src/core/ai-chat/ai-chat.controller.ts | 15 +- .../server/src/core/ai-chat/ai-chat.module.ts | 9 +- .../src/core/ai-chat/ai-chat.prompt.spec.ts | 59 +++++ .../server/src/core/ai-chat/ai-chat.prompt.ts | 20 +- .../src/core/ai-chat/ai-chat.service.ts | 75 ++++++- .../roles/ai-agent-roles.controller.ts | 101 +++++++++ .../ai-chat/roles/ai-agent-roles.module.ts | 16 ++ .../ai-chat/roles/ai-agent-roles.service.ts | 151 +++++++++++++ .../core/ai-chat/roles/dto/agent-role.dto.ts | 92 ++++++++ .../core/ai-chat/roles/role-model-config.ts | 39 ++++ apps/server/src/database/database.module.ts | 3 + .../20260620T120000-ai-agent-roles.ts | 70 ++++++ .../ai-agent-roles/ai-agent-roles.repo.ts | 141 ++++++++++++ .../database/repos/ai-chat/ai-chat.repo.ts | 24 +- apps/server/src/database/types/db.d.ts | 28 +++ .../server/src/database/types/entity.types.ts | 8 + .../ai/ai-not-configured.exception.ts | 4 +- .../src/integrations/ai/ai.service.spec.ts | 87 ++++++++ apps/server/src/integrations/ai/ai.service.ts | 97 ++++++-- 30 files changed, 1674 insertions(+), 40 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/server/src/core/ai-chat/ai-chat.prompt.spec.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.dto.ts create mode 100644 apps/server/src/core/ai-chat/roles/role-model-config.ts create mode 100644 apps/server/src/database/migrations/20260620T120000-ai-agent-roles.ts create mode 100644 apps/server/src/database/repos/ai-agent-roles/ai-agent-roles.repo.ts create mode 100644 apps/server/src/integrations/ai/ai.service.spec.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 21f7c5f7..3ebed63d 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1195,5 +1195,26 @@ "Request format": "Request format", "How transcription requests are sent to the endpoint": "How transcription requests are sent to the endpoint", "OpenAI-compatible (multipart/form-data)": "OpenAI-compatible (multipart/form-data)", - "OpenRouter (JSON, base64 audio)": "OpenRouter (JSON, base64 audio)" + "OpenRouter (JSON, base64 audio)": "OpenRouter (JSON, base64 audio)", + "Agent role": "Agent role", + "Universal assistant": "Universal assistant", + "Add role": "Add role", + "Edit role": "Edit role", + "Role name": "Role name", + "e.g. Proofreader": "e.g. Proofreader", + "Optional. Shown as the chat badge.": "Optional. Shown as the chat badge.", + "Optional. A short note about what this role does.": "Optional. A short note about what this role does.", + "Instructions": "Instructions", + "The built-in safety framework is always added automatically.": "The built-in safety framework is always added automatically.", + "Model provider override": "Model provider override", + "Optional. Defaults to the workspace provider.": "Optional. Defaults to the workspace provider.", + "Model override": "Model override", + "Optional. Defaults to the workspace model.": "Optional. Defaults to the workspace model.", + "e.g. gpt-4o-mini": "e.g. gpt-4o-mini", + "If you choose a different provider, it must already be configured in AI settings.": "If you choose a different provider, it must already be configured in AI settings.", + "Agent roles": "Agent roles", + "Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.": "Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.", + "No roles configured": "No roles configured", + "Delete role": "Delete role", + "Are you sure you want to delete this role?": "Are you sure you want to delete this role?" } 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..027a8c50 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 @@ -13,6 +13,15 @@ export const activeAiChatIdAtom = atom(null as string | null); // Whether the floating AI chat window is open. Non-persistent (resets per session). export const aiChatWindowOpenAtom = atom(false); +/** + * The agent role selected for the NEXT new chat. `null` = "Universal assistant" + * (no role). Consulted ONLY when creating a chat (its first message): the server + * persists it to ai_chats.role_id and the role is immutable afterwards. Reset to + * null when starting a new chat. It does NOT affect already-created chats. + */ +// Cast default for the same jotai overload reason as activeAiChatIdAtom above. +export const selectedAiRoleIdAtom = atom(null as string | null); + // The AI chat composer draft (text typed but not yet sent). Held here — OUTSIDE // ChatThread — so it survives the thread remount that happens when a brand-new // chat adopts its freshly created id after the first turn finishes. If it lived 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..854e5021 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,7 @@ 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 +25,7 @@ import { activeAiChatIdAtom, aiChatWindowOpenAtom, aiChatDraftAtom, + selectedAiRoleIdAtom, } from "@/features/ai-chat/atoms/ai-chat-atom.ts"; import { usePageQuery } from "@/features/page/queries/page-query.ts"; import { extractPageSlugId } from "@/lib"; @@ -32,6 +33,7 @@ import { AI_CHATS_RQ_KEY, useAiChatMessagesQuery, useAiChatsQuery, + useAiRolesQuery, } from "@/features/ai-chat/queries/ai-chat-query.ts"; import ConversationList from "@/features/ai-chat/components/conversation-list.tsx"; import ChatThread from "@/features/ai-chat/components/chat-thread.tsx"; @@ -102,6 +104,8 @@ export default function AiChatWindow() { const [windowOpen, setWindowOpen] = useAtom(aiChatWindowOpenAtom); const [activeChatId, setActiveChatId] = useAtom(activeAiChatIdAtom); const setDraft = useSetAtom(aiChatDraftAtom); + // The role chosen for the next new chat (null = universal assistant). + const [selectedRoleId, setSelectedRoleId] = useAtom(selectedAiRoleIdAtom); // History section starts collapsed (matches the former panel's behavior). const [historyOpen, setHistoryOpen] = useState(false); @@ -123,6 +127,16 @@ export default function AiChatWindow() { const adoptNewChat = useRef(false); const { data: chats } = useAiChatsQuery(); + // Roles for the new-chat picker (any member may list them). Only fetched while + // the window is open. + const { data: roles } = useAiRolesQuery(windowOpen); + // The new-chat picker only offers ENABLED roles. The list endpoint returns + // all live roles (so the admin settings section can manage disabled ones), so + // we filter to `enabled` here, client-side, for the composer picker only. + const enabledRoles = useMemo( + () => (roles ?? []).filter((r) => r.enabled === true), + [roles], + ); const { data: messageRows, isLoading: messagesLoading } = useAiChatMessagesQuery(activeChatId ?? undefined); @@ -144,7 +158,9 @@ export default function AiChatWindow() { setActiveChatId(null); setHistoryOpen(false); setDraft(""); - }, [setActiveChatId, setDraft]); + // Default the picker back to "Universal assistant" for the fresh chat. + setSelectedRoleId(null); + }, [setActiveChatId, setDraft, setSelectedRoleId]); const selectChat = useCallback( (chatId: string): void => { @@ -343,6 +359,15 @@ export default function AiChatWindow() { /> {t("AI chat")} + {/* Role badge for the active chat (emoji + name). Shown only when the + chat is bound to a role that still exists. */} + {activeChat?.roleName && ( + + {activeChat.roleEmoji ? `${activeChat.roleEmoji} ` : ""} + {activeChat.roleName} + + )} +
{contextTokens > 0 && ( @@ -432,6 +457,29 @@ export default function AiChatWindow() { )}
+ {/* Role picker — only for a NEW chat (before it is created). Once the + chat exists, its role is fixed and shown as a header badge instead. + Defaults to "Universal assistant" (no role). */} + {activeChatId === null && (enabledRoles?.length ?? 0) > 0 && ( +
+