feat(ai-chat): getCurrentPage отдаёт текущее выделение пользователя в редакторе («вот тут исправь») #388
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Зачем
Пользователь выделяет фрагмент текста на странице и говорит агенту «вот тут исправь» / «переформулируй это». Сегодня агент знает только страницу (
getCurrentPage→{ page: { id, title } }), но не место: ему приходится переспрашивать или угадывать.Цель: снимать выделение редактора в момент отправки сообщения, возить его тем же путём, что и
openPage, и отдавать модели через существующую core-тулуgetCurrentPage— что означают «this» / «here» / «вот тут».Принятые решения (не пересматривать)
prepareSendMessagesRequest(тот же live-ref-паттерн, чтоopenPageRef). Каждый ход шлёт свежий снапшот → multi-turn работает сам.getCurrentPage, не отдельная тула. Она core (всегда активна,tool-tiers.ts), нулевой рост каталога; «страница + выделение» — один вопрос «где пользователь».openPage. Если страница не резолвится (resolveOpenPageContext→ null: чужая / недоступная / удалённая), выделение умирает вместе с ней автоматически. Отдельного pageId у selection нет.searchInPage/getNode) перед правкой; правки всё равно идут через CASL-защищённые тулы.from/to) НЕ шлём. Серверные тулы ими не оперируют, протухают мгновенно. Мостик к тулам —blockIds(attrs.id блоков, unique-id extension) +before/afterконтекст для дизамбигуацииeditPageTextfind/replace.state.selectionпосле потери фокуса; прецедент в кодовой базе —comment-dialog.tsx:54-55(кнопка комментария читает выделение после клика вне редактора).⚠️ Правила для исполнителя
openedPage-пайплайн — за скоупом), shared-реестр@docmost/mcp(getCurrentPage — inline-тула), схему БД, транспортuseChat.Wire-формат (клиент → сервер, body
POST /api/ai-chat/stream)Капы: клиент
text≤ 2000, сервер re-captext≤ 4000;before/after≤ 200 (сервер);blockIds≤ 20 элементов × ≤ 64 символа. Пустой после тримаtext→selection: null.Задачи
1. Клиент
1.1.
apps/client/src/features/editor/utils/get-editor-selection.ts(новый)Семантика:
state.selection.empty→null(дефолтная каретка-в-начале никогда не даёт ложного срабатывания);text = doc.textBetween(from, to, '\n'), кап 2000 +truncated: true;blockIds:doc.nodesBetween(from, to, (node) => { if (node.isBlock && node.attrs?.id) ids.push(node.attrs.id) }), дедуп, кап 20;before = doc.textBetween(max(0, from - 160), from, '\n'),after = doc.textBetween(to, min(doc.content.size, to + 160), '\n');text→null.1.2.
components/ai-chat-window.tsxЧитать оба атома (
useAtomValue):pageEditorAtom,readOnlyEditorAtom(@/features/editor/atoms/editor-atoms.ts). Собрать снапшоттер и передать в<ChatThread>новым пропом:1.3.
components/chat-thread.tsxgetEditorSelection?: () => EditorSelectionContext | null; хранить в ref (getEditorSelectionRef) по образцуopenPageRef— транспортныйuseMemo([])его замыкает, идентичность пропа не важна.prepareSendMessagesRequest(~:298-309) заменить сборкуopenPage:OpenPageContext(chat-thread.tsx:46) НЕ менять — selection приклеивается только на проводе.2. Сервер
2.1.
ai-chat.service.ts— тип bodyAiChatStreamBody.openPage(~:191) расширить:selection?: unknown+ комментарий, что поле клиентское/недоверенное и санируетсяsanitizeSelection(body — loose shape, парсится сreq.bodyбез DTO).2.2.
tools/current-page.util.ts— санация + новый контракт результатаresolveCurrentPageResult(openedPage)— селекшн берётся с резолвнутого контекста (см. 2.3);page: null⇒ всегдаselection: null.2.3.
ai-chat.service.ts—resolveOpenPageContext(~:362)openPage— расширенный тип сselection?: unknown.{ id, title, updatedAt, selection: SelectionContext | null }—selection: sanitizeSelection(openPage.selection)приклеивается ТОЛЬКО на успешном резолве страницы (fail-closed ветки возвращают null целиком, как сейчас).detectPageChange,snapshotOpenPage) лишнее поле игнорирует — по прецедентуupdatedAt(«The system-prompt / tool consumers ignore the extra field»).2.4.
tools/ai-chat-tools.service.ts—forUser+getCurrentPage(~:157, :309-317)openedPageрасширить полемselection?: SelectionContext | null.executeне меняется (resolveCurrentPageResult(openedPage)— селекшн уже внутри контекста).2.5.
ai-chat.prompt.ts— флаг в контекст (~:301-312)BuildSystemPromptInput.openedPage(~:160) расширитьselection?: object | null. После существующей строки про страницу, при наличии selection добавить ФИКСИРОВАННУЮ строку (без текста выделения — untrusted):2.6.
tools/tool-tiers.ts(~:101)catalogLine: 'getCurrentPage — the page the user is currently viewing and their current text selection on it.'3. Тесты
Клиент:
get-editor-selection.test.ts: headless-редактор (по образцу тестовpackages/editor-ext) — пустое выделение → null; однопараграфное → text без blockSeparator-артефактов; мультиблочное →'\n'между блоками + blockIds всех накрытых блоков; кап текста → truncated; before/after у границ дока не выходят за 0/size; дефолтная каретка нового редактора → null.chat-thread.test.tsx(мок-харнесс уже ловит транспорт): body содержитopenPage.selectionиз геттера в момент send; геттер вернул null →selection: null;openPage === null(не-страничный роут) → selection не шлётся вовсе.Сервер:
current-page.util.spec.ts:sanitizeSelection— валидный payload проходит; не-объект/пустой text/мусорные типы полей → null или отбрасывание поля; серверные капы (text 4000 → truncated, blockIds > 20 → срез, элемент > 64 симв. → отбрасывание);resolveCurrentPageResult—{ page, selection }контракт,page: null⇒selection: null.ai-chat.service.spec.ts:resolveOpenPageContextприклеивает санированный selection на успешном резолве; foreign/inaccessible страница → null целиком (selection не выживает).ai-chat-tools.service.spec.ts:getCurrentPageвозвращает selection из переданного контекста; без selection —selection: null.ai-chat.prompt.spec.ts: строка-подсказка присутствует ⇔ selection есть; ТЕКСТ выделения в промпт не попадает никогда.Ожидаемое поведение
getCurrentPage→{ page, selection: { text, blockIds, before, after } }; агент локализует через searchInPage/getNode и правит.selection: null, промпт без строки-подсказки — байт-в-байт сегодняшнее поведение.truncated: true; blockIds позволяют дочитать через getNode.openPage.idresolveOpenPageContext→ null, selection умирает вместе со страницей (решение 3).readOnlyEditorAtom(fallback в 1.2).DoD
selectionв результатеgetCurrentPage; клиентскийtool-parts.tsxчитаетo.id/o.pageIdи на вложенныйpageне реагирует уже сегодня — additive-ключ безопасен).buildSystemPromptни одним путём.Не входит в объём
Связанное