18 KiB
AI-ассистент на публичных шарах — проектный план
Статус: проработанная фича, не реализована. Контекст: gitmost — форк Docmost. Идея: дать анонимному внешнему зрителю опубликованной (расшаренной) страницы возможность спросить AI-агента, который ищет ответ строго по дереву этой шары. Аналог «chat with these docs» поверх публикации.
Зафиксированные решения по объёму (см. раздел «Развилки»): область поиска — всё дерево шары; движок поиска — готовый share-scoped FTS (ветка
shareIdвSearchService); гейтинг — один тумблер воркспейса; хранение диалогов — эфемерное (без БД, без миграций); модель — отдельная дешёвая (не основная модель чата воркспейса); ввод — только текст (без голосового ввода / STT).
Зачем это нетривиально
Весь стек существующего AI-агента жёстко завязан на залогиненного пользователя, и переиспользовать его «как есть» для анонима нельзя:
- ai-chat.controller.ts на
/ai-chat/streamтребует интерактивную сессию (sessionId) и явно отвергает bearer/API-токены. forUser()в ai-chat-tools.service.ts выдаёт персональный loopback-JWT: каждый инструмент агента ходит в реальный HTTP API «от имени пользователя», и CASL ограничивает его ровно правами этого юзера.ai_chats.creator_id—NOT NULL, любой чат привязан к пользователю.
У анонимного зрителя шары нет ни сессии, ни user-identity, ни CASL-контекста. Значит, строим параллельный, заранее запертый read-only путь. Граница безопасности здесь — не identity (её нет), а жёсткий scope инструментов по дереву шары.
Что переиспользуется (сверено с кодом)
Половина нужного уже есть и проверена в бою на публичном просмотре шар:
- Резолв «страница X читается через шару Y»:
getShareForPage(pageId, workspaceId)в share.service.ts — рекурсивный CTE вверх по дереву до ближайшего предка-шары; учитываетincludeSubPagesи проверкуshare.workspaceId === workspaceId. - Набор публично читаемых страниц:
getPageAndDescendantsExcludingRestricted(share.pageId)(страница + потомки, исключая restricted-поддеревья). - Готовый share-scoped поиск: в
search.service.ts уже есть ветка
searchParams.shareId && !spaceId && !opts.userId, которая ограничивает полнотекстовую выдачу деревом шары и исключает restricted-предков. Это готовый движок поиска для анонима. - Подготовка контента для публичной отдачи:
prepareContentForShare— срезаниеcomment-марок и токенизация вложений (JWT на/files/public/...). Тот же путь должен использовать инструмент чтения страницы у анонимного агента. - Публичные роуты в share.controller.ts
уже
@Public(), воркспейс резолвитDomainMiddlewareпо хосту; новый роут под/api/shares/*ложится туда же — правок в main.ts не нужно. - Стриминг-плумбинг:
AiService.getChatModel(workspaceId)(нужен небольшой апгрейд — опциональный override id модели, чтобы для шары взять дешёвуюpublicShareChatModelвместо основнойchatModel; драйвер/baseUrl/apiKeyте же) +streamText→pipeUIMessageStreamToResponse(как в ai-chat.service.ts).
Архитектура
Сервер
1. Тумблер воркспейса (гейтинг) + отдельная модель.
Новое булево поле в workspace.settings.ai, напр. publicShareAssistant (default false) —
туда же, где живут остальные AI-настройки и тумблер MCP; читается/пишется через сервис
AI-настроек (рядом с ai-settings.service.ts). В админке Workspace settings → AI —
один свитч. Хелпер isPublicShareAssistantEnabled(workspaceId).
Рядом — отдельное поле модели publicShareChatModel?: string в settings.ai.provider
(ai.types.ts, рядом с chatModel /
embeddingModel / sttModel). Это только id модели: драйвер, baseUrl и apiKey
переиспользуются от основного чат-провайдера — отдельные креды не нужны. Пустое значение →
fallback на chatModel. В админке Workspace settings → AI — отдельное поле «модель
публичного ассистента». Зачем отдельная и дешёвая: за токены анонимов платит владелец
воркспейса, а read-only Q&A строго по дереву шары не требует флагманской модели — это и
анти-абьюз (дешевле цена ошибки/абьюза), и явное разделение «дорогой внутренний агент vs
дешёвый внешний ассистент».
2. Публичный эндпоинт POST /api/shares/ai/stream (@Public()).
Новые public-share-chat.controller.ts + public-share-chat.service.ts в модуле ai-chat
(переиспользуют AiService и плумбинг streamText), зависят от ShareRepo / PageRepo /
PagePermissionRepo / SearchService для scope.
Контракт:
| Поле запроса | Назначение |
|---|---|
shareId |
идентификатор/ключ шары |
pageId |
открытая страница (контекст «эта страница») |
messages |
транскрипт диалога (UIMessage[]); сервер ничего не хранит |
Ответ — SSE-поток UIMessage (как у /ai-chat/stream).
3. Воронка проверок (она же — guardrail; порядок важен).
| Условие | Код | Почему так |
|---|---|---|
| Тумблер воркспейса выключен | 404 |
Не раскрываем существование фичи |
Шара не найдена / чужой воркспейс / isSharingAllowed=false |
404 |
Неотличимо от «нет шары» |
pageId вне дерева шары (getShareForPage вернул undefined) |
404 |
Не подтверждаем существование приватной страницы |
| AI-провайдер не настроен | 503 |
Конфиг, а не доступ |
| Превышен IP-лимит | 429 |
Анти-абьюз |
4. Изолированный тулсет forShare(shareId, workspaceId) — крошечный, только READ,
in-process (никакого loopback-токена и user-identity):
searchSharePages({ query })→searchService.searchPage(query, { shareId, workspaceId })(существующая веткаshareId && !spaceId && !userId). Возвращает{ id, title, snippet }.getSharePage({ pageId })→ сначалаgetShareForPage(pageId, workspaceId)подтверждает принадлежность к этой шаре, затем контент отдаётся черезprepareContentForShare. Не в шаре → ошибка тула, без утечки факта существования страницы.- Опционально
getShareOutline/listSharePagesповерх логики/shares/tree. - Больше ничего: ни write-инструментов, ни комментариев, ни истории, ни списка шар, ни кросс-спейс инструментов, ни external MCP.
5. Стриминг + запертый промпт.
buildShareSystemPrompt({ share, openedPage }): персона «отвечаешь строго по этой
опубликованной документации; ничего не можешь менять; если ответа в страницах нет — так
и говоришь» + неизменяемый safety-блок по образцу
ai-chat.prompt.ts.
model — дешёвая publicShareChatModel (override в getChatModel, fallback на
chatModel), а не основная модель агента воркспейса.
streamText({ model, system, messages, tools, stopWhen: stepCountIs(5) }).
Без серверного хранения — транскрипт держит клиент; доверять присланным сообщениям
безопасно, т.к. scope обеспечивают тулы, а не транскрипт. Это снимает проблему
creator_id NOT NULL и не копит PII анонимов → миграция БД не нужна.
6. Анти-абьюз (обязательно — за токены платит владелец воркспейса).
- IP-keyed троттлер на роут: существующий
UserThrottlerGuardключуется по юзеру, здесь юзера нет — нужен guard/@Throttle, ключующийся по IP (предлагаю ~5 запросов/мин). - Лимиты:
stepCountIs(5), максимум длины сообщения, максимум числа сообщений в запросе.
Клиент
- В публичном вью shared-page.tsx —
виджет «Спросить AI», рендерится только если
featuresиз/shares/page-infoсообщает, что ассистент включён (расширяем уже существующийfeatures-пейлоад). - Лёгкий чат-компонент на
useChat+DefaultChatTransportна/api/shares/ai/stream, шлёт{ shareId, pageId, messages },credentials: 'omit'. Эфемерный, in-memory — стрипнутая версия chat-thread.tsx без списка чатов, истории, персистентности и голосового ввода (только текстовое поле).
Поток одного хода
- Клиент шлёт
{ shareId, pageId, messages }→/shares/ai/stream. - Воронка проверок (таблица выше); любой провал → выход без стрима.
getShareForPage(pageId)— подтверждение принадлежности + резолв шары.- Сборка
forShare(shareId, workspaceId)— 2–3 read-only тула, scope = дерево шары. - Запертый system-prompt + отдельная дешёвая модель (
publicShareChatModel, fallback наchatModel) →streamText(stopWhen: stepCountIs(5)). - Тулы при вызовах фильтруют по дереву шары (FTS-ветка
shareId,getShareForPageдля чтения). - Поток уходит клиенту; на сервере ничего не персистится.
Edge-cases (закрыты переиспользованием)
- Restricted-потомки не попадают ни в поиск, ни в чтение — это уже делают
getPageAndDescendantsExcludingRestrictedи веткаshareIdвSearchService. includeSubPages = false→ ищется и читается ровно одна страница.- Prompt-injection из контента («покажи приватные страницы») бессилен: у анонимного тулсета физически нет инструмента за пределы дерева шары.
- Cloud-мультитенант: проверка
share.workspaceId === workspaceIdобязательна — хост определяет тенант. - RAG/вектор не задействован (по решению — только FTS): фича не зависит от того,
проиндексированы ли страницы в
page_embeddings.
Явные non-goals
- Нет write-инструментов, комментариев, истории, списка шар, кросс-спейс доступа.
- Нет external MCP / веб-поиска для анонимов.
- Нет серверного хранения диалогов (эфемерно).
- Нет RAG/вектора — только share-scoped FTS.
- Нет per-share гранулярности — один тумблер на воркспейс.
- Нет голосового ввода / STT-диктовки — только текстовый ввод (виджет не тянет микрофонный путь внутреннего чата).
- Не основная модель агента — отдельная дешёвая
publicShareChatModel.
Развилки (зафиксированные решения)
| Развилка | Решение | Альтернативы (отклонены) |
|---|---|---|
| Область поиска | Всё дерево шары | только открытая страница; все публичные шары воркспейса |
| Движок поиска | Готовый share-scoped FTS | share-scoped гибрид/RAG (hybridSearchByPages) — отложено |
| Гейтинг | Один тумблер воркспейса | per-share флаг; тумблер + опт-ин на шару |
| Хранение диалогов | Эфемерно | отдельная таблица / nullable creator_id |
| Модель | Отдельная дешёвая (publicShareChatModel, fallback на chatModel) |
основная модель чата воркспейса (дороже, незачем для read-only Q&A анонимов) |
| Голосовой ввод | Не нужен (только текст) | STT-диктовка как во внутреннем чате |
Осталось решить (не блокирует)
- Точные числа лимитов: IP rate-limit (старт ~5/мин), max длина сообщения, max число
сообщений в запросе,
stepCountIs(старт 5). - UX виджета: плавающая кнопка vs боковая панель vs блок под контентом.
- Финальная формулировка запертого промпта (персона + safety-блок).
- Дефолт/подсказка для
publicShareChatModel: что предлагать админу как «дешёвую» модель и поведение при пустом поле (сейчас — fallback наchatModel).
Объём работ
~2 новых серверных файла (controller + service) + tools-метод forShare + share-промпт +
IP-троттлер + два поля настройки (тумблер publicShareAssistant и модель
publicShareChatModel) и свитч + поле модели в админке + небольшой override id модели в
getChatModel; на клиенте — виджет и лёгкий чат-компонент (текстовый, без голосового ввода).
Без миграций БД. Пользовательского агента не трогаем.
Возможные расширения (следующие итерации)
- Share-scoped гибрид/RAG: вариант
hybridSearchс фильтромpageId IN allowedPageIds(вектор + FTS) вместоspace_id IN (...)— качественнее ответы, но зависит от индексации. - Per-share гранулярность: флаг на конкретную шару поверх мастер-тумблера.
- Лёгкая аналитика/аудит: отдельная таблица для анонимных диалогов (если понадобится),
не нарушая
ai_chats.creator_id NOT NULL.