212 lines
18 KiB
Markdown
212 lines
18 KiB
Markdown
# AI-ассистент на публичных шарах — проектный план
|
|
|
|
> Статус: проработанная фича, **не реализована**. Контекст: gitmost — форк Docmost.
|
|
> Идея: дать **анонимному внешнему зрителю** опубликованной (расшаренной) страницы
|
|
> возможность спросить AI-агента, который ищет ответ **строго по дереву этой шары**.
|
|
> Аналог «chat with these docs» поверх публикации.
|
|
>
|
|
> Зафиксированные решения по объёму (см. раздел «Развилки»):
|
|
> область поиска — **всё дерево шары**; движок поиска — **готовый share-scoped FTS**
|
|
> (ветка `shareId` в `SearchService`); гейтинг — **один тумблер воркспейса**;
|
|
> хранение диалогов — **эфемерное** (без БД, без миграций);
|
|
> модель — **отдельная дешёвая** (не основная модель чата воркспейса);
|
|
> ввод — **только текст** (без голосового ввода / STT).
|
|
|
|
## Зачем это нетривиально
|
|
|
|
Весь стек существующего AI-агента жёстко завязан на залогиненного пользователя, и
|
|
переиспользовать его «как есть» для анонима нельзя:
|
|
|
|
- [ai-chat.controller.ts](../apps/server/src/core/ai-chat/ai-chat.controller.ts) на
|
|
`/ai-chat/stream` требует **интерактивную сессию** (`sessionId`) и явно отвергает
|
|
bearer/API-токены.
|
|
- `forUser()` в
|
|
[ai-chat-tools.service.ts](../apps/server/src/core/ai-chat/tools/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](../apps/server/src/core/share/share.service.ts) — рекурсивный CTE
|
|
вверх по дереву до ближайшего предка-шары; учитывает `includeSubPages` и проверку
|
|
`share.workspaceId === workspaceId`.
|
|
- **Набор публично читаемых страниц**: `getPageAndDescendantsExcludingRestricted(share.pageId)`
|
|
(страница + потомки, **исключая** restricted-поддеревья).
|
|
- **Готовый share-scoped поиск**: в
|
|
[search.service.ts](../apps/server/src/core/search/search.service.ts) уже есть ветка
|
|
`searchParams.shareId && !spaceId && !opts.userId`, которая ограничивает полнотекстовую
|
|
выдачу деревом шары и исключает restricted-предков. Это **готовый движок поиска для анонима**.
|
|
- **Подготовка контента для публичной отдачи**: `prepareContentForShare` — срезание
|
|
`comment`-марок и токенизация вложений (JWT на `/files/public/...`). Тот же путь должен
|
|
использовать инструмент чтения страницы у анонимного агента.
|
|
- **Публичные роуты** в [share.controller.ts](../apps/server/src/core/share/share.controller.ts)
|
|
уже `@Public()`, воркспейс резолвит `DomainMiddleware` по хосту; новый роут под `/api/shares/*`
|
|
ложится туда же — **правок в [main.ts](../apps/server/src/main.ts) не нужно**.
|
|
- **Стриминг-плумбинг**: `AiService.getChatModel(workspaceId)` (нужен небольшой апгрейд —
|
|
опциональный override id модели, чтобы для шары взять дешёвую `publicShareChatModel`
|
|
вместо основной `chatModel`; драйвер/`baseUrl`/`apiKey` те же) +
|
|
`streamText` → `pipeUIMessageStreamToResponse` (как в
|
|
[ai-chat.service.ts](../apps/server/src/core/ai-chat/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](../apps/server/src/integrations/ai/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](../apps/server/src/core/ai-chat/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](../apps/client/src/pages/share/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](../apps/client/src/features/ai-chat/components/chat-thread.tsx) без
|
|
списка чатов, истории, персистентности и **голосового ввода** (только текстовое поле).
|
|
|
|
## Поток одного хода
|
|
|
|
1. Клиент шлёт `{ shareId, pageId, messages }` → `/shares/ai/stream`.
|
|
2. Воронка проверок (таблица выше); любой провал → выход без стрима.
|
|
3. `getShareForPage(pageId)` — подтверждение принадлежности + резолв шары.
|
|
4. Сборка `forShare(shareId, workspaceId)` — 2–3 read-only тула, scope = дерево шары.
|
|
5. Запертый system-prompt + **отдельная дешёвая модель** (`publicShareChatModel`, fallback на `chatModel`) → `streamText(stopWhen: stepCountIs(5))`.
|
|
6. Тулы при вызовах фильтруют по дереву шары (FTS-ветка `shareId`, `getShareForPage` для чтения).
|
|
7. Поток уходит клиенту; на сервере ничего не персистится.
|
|
|
|
## 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`.
|