11 KiB
Хрупкая передача «текущей страницы» в AI-агента
Контекст: агент не понимает «эта/текущая страница». В разговоре через CLIProxyAPI он отвечает «я не вижу текущую страницу» и просит уточнить id/название. Пользователь сообщает: без CLIProxyAPI (прямой эндпоинт) работает. То есть проблема воспроизводится на прокси-пути, но сама механика передачи страницы хрупкая по двум независимым причинам (см. ниже), поэтому фиксируем в беклоге целиком.
Как сейчас инжектится текущая страница (цепочка)
Страница передаётся только текстом в системный промпт — отдельной строкой. Это единственная точка, где агент узнаёт pageId «этой страницы». Нет ни инструмента «get current page», ни поля в user-сообщении.
- Клиент вычисляет
openPageиз роута:apps/client/src/features/ai-chat/components/ai-chat-window.tsx:124-131—const { pageSlug } = useParams();→usePageQuery({ pageId: extractPageSlugId(pageSlug) })→openPage = openPageData ? { id, title } : null. Передаётся вChatThread(:391). - Транспорт кладёт
openPageв тело запроса:apps/client/src/features/ai-chat/components/chat-thread.tsx:107-127(prepareSendMessagesRequest, поле на:121), POST/api/ai-chat/stream. - Контроллер читает тело СЫРЫМ (намеренно без DTO, чтобы глобальный
ValidationPipe { whitelist: true }не выкинул незадекларированное поле):apps/server/src/core/ai-chat/ai-chat.controller.ts:103-135(const body = (req.body ?? {}) as AiChatStreamBody;). - Сервис прокидывает
body.openPage→openedPage:apps/server/src/core/ai-chat/ai-chat.service.ts:146-149(тип поля —:32,openPage?: { id?; title? } | null). buildSystemPromptдописывает строку контекста в системный промпт:apps/server/src/core/ai-chat/ai-chat.prompt.ts:94-101—The user is currently viewing the page "<title>" (pageId: <id>).... Добавляется в секцию контекста (после persona, ПЕРЕД safety-framework).- Уходит как роль
systemвstreamText({ system, ... }):apps/server/src/core/ai-chat/ai-chat.service.ts:237-239на OpenAI-совместимый/chat/completionsпо настроенномуbaseURL(это и есть CLIProxyAPI):apps/server/src/integrations/ai/ai.service.ts:46-52(createOpenAI({ apiKey, baseURL }).chat(model)).
Хрупкость №1 — клиентская: openPage по исходнику всегда null
AiChatWindow примонтирован в глобальной оболочке:
apps/client/src/components/layouts/global/global-app-shell.tsx:159,
которую рендерит Layout (apps/client/src/components/layouts/global/layout.tsx:7-19).
Layout — это pathless родительский layout-роут
(<Route element={<Layout/>}> без своего пути), а сегмент :pageSlug
матчится только дочерним роутом /s/:spaceSlug/p/:pageSlug → <Page/>
(apps/client/src/App.tsx:56-66).
В react-router-dom@7.13.1 useParams() возвращает
matches[matches.length-1].params (проверено в исходнике
node_modules/react-router/dist/development/chunk-XOLAXE2Z.js:6891-6895).
На уровне шелла последний матч — это pathless Layout (params {}),
параметры дочернего роута через <Outlet/> родителю НЕ видны. Значит в
AiChatWindow pageSlug === undefined → extractPageSlugId(undefined)
возвращает undefined (apps/client/src/lib/utils.tsx:14-23) →
usePageQuery отключён (enabled: !!pageInput.pageId,
apps/client/src/features/page/queries/page-query.ts:44-52) →
openPage = null.
Ловушка — комментарий «same source the breadcrumb uses». Хлебные крошки
используют ТОТ ЖЕ useParams() (apps/client/src/features/page/components/breadcrumbs/breadcrumb.tsx:37)
и работают — но лишь потому, что рендерятся ВНУТРИ <Page/> (дочерний роут,
где :pageSlug уже заматчен). Один хук, разная глубина в дереве → разный
результат.
Косвенное подтверждение того же антипаттерна рядом: Layout тоже делает
const { spaceSlug } = useParams() (layout.tsx:8) и тоже получает
undefined → SearchSpotlight получает spaceId={undefined} и тихо
работает без привязки к спейсу. Никем не замечено, потому что некритично.
ПРОТИВОРЕЧИЕ, которое надо разрешить перед фиксом: по исходнику
openPage должен быть null В ОБОИХ режимах (и через прокси, и напрямую),
а пользователь говорит, что напрямую РАБОТАЕТ. Значит либо рантайм/сборка
расходится с рабочим деревом, либо страница доезжает иным путём. Проверить
фактом (см. открытые вопросы) ДО того, как чинить клиент.
Хрупкость №2 — прокси: контекст живёт только в system-сообщении
Поскольку pageId передаётся ТОЛЬКО строкой в роли system, любой прокси,
который переписывает/дополняет системный промпт, может её потерять или
«утопить». gitmost формирует system одинаково независимо от эндпоинта —
строка идентична для direct и для прокси. Значит если напрямую работает, а
через CLIProxyAPI нет, расхождение возникает ВНУТРИ трансляции прокси
(CLIProxyAPI оборачивает CLI-бэкенды — Gemini CLI / Claude Code / Codex /
Qwen — у которых свой объёмный системный промпт; наш system может быть
склеен с их преамбулой, перенесён в systemInstruction, обрезан или
недооценён моделью). Пользователь ранее отмечал «она вроде не стирает
системный промпт, а просто дополняет» — это надо подтвердить захватом
реального запроса.
Открытые вопросы (проверить ДО реализации)
- Что реально уходит в
system? Залогировать строку передstreamText(ai-chat.service.ts:~237) и сравнить direct vs proxy — строка должна быть БАЙТ-В-БАЙТ одинаковой. - Долетает ли
openPageнепустым до сервера? Залогироватьbody.openPageвai-chat.service.ts:~149в обоих режимах. Если null даже на direct — проблема №1 реальна и для direct (тогда «работает» означало что-то иное). Если непустой — клиентская теория проuseParamsневерна для рантайма, надо понять почему (другая сборка? другой м压онт?). - Что CLIProxyAPI шлёт апстриму? Снять HTTP апстрим-запрос прокси
(логи прокси / mitmproxy) — присутствует ли строка
pageId: ...в системной инструкции, что отдаётся модели.
Варианты фикса (выбрать после разрешения противоречия)
Клиентская часть (проблема №1), если подтвердится:
- A. В
AiChatWindowзаменитьuseParams()наuseMatch("/s/:spaceSlug/p/:pageSlug")илиmatchPathпоuseLocation().pathname— матчится по полному URL независимо от позиции в дереве. Минимально и точечно. - B. Завести jotai-атом текущей страницы, который выставляет
Page(он внутри дочернего роута, видит params), и читать его в окне чата. Заодно чинит тот же баг вLayout/SearchSpotlight.
Прокси-устойчивость (проблема №2):
- C. Дублировать контекст страницы НЕ только в system: добавить короткий
скрытый префикс в user-сообщение, либо дать агенту инструмент
get_current_page(берёт pageId из серверной сессии запроса), чтобы идентичность страницы не зависела от сохранности system-промпта прокси. - D. Если CLIProxyAPI обрезает/переносит system — настроить его так, чтобы наш system сохранялся (вне кода gitmost; задокументировать требование).
Рекомендация: сначала разрешить противоречие логами (дёшево), потом A или B для клиента + C для устойчивости к прокси (C — единственное, что реально лечит исходный симптом «через прокси не видит страницу»).
Процесс
- Чистая диагностика на текущий момент, код НЕ менялся.
- Реализация — режим делегирования (по умолчанию): нетривиально (роутинг +
серверный промпт/инструмент) → general-purpose кодеру, затем обязательный
прогон
review. - Не коммитить; в конце предложить сообщение коммита.