diff --git a/docs/public-share-assistant-plan.md b/docs/public-share-assistant-plan.md deleted file mode 100644 index c14c90f9..00000000 --- a/docs/public-share-assistant-plan.md +++ /dev/null @@ -1,211 +0,0 @@ -# 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`.