ИИ-чат: привязка чатов к документам (авто-открытие последнего чата документа) #191
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?
Задача
При открытии плавающего окна ИИ-чата на странице документа должен автоматически открываться последний чат, привязанный к этому документу. Привязка сохраняется до тех пор, пока на документе не будет создан новый чат — тогда привязанным становится он.
Согласованные решения по дизайну:
ai_chats.page_id(без миграции).Как устроено сейчас
Модель данных. В таблице
ai_chatsуже естьpage_id(apps/server/src/database/types/db.d.ts): «страница, на которой чат был создан (open page at first message)», иммутабельная,ON DELETE SET NULL. Чаты приватны для пользователя (creator_id), скоупятся воркспейсом.Создание чата (сервер). В
apps/server/src/core/ai-chat/ai-chat.service.ts(~строки 288–303) при первом сообщении нового чатаpage_idберётся из провалидированного открытого документа (resolveOpenPageContext). Клиент уже шлётopenPageв теле стрима (apps/client/src/features/ai-chat/components/chat-thread.tsx, ~227–234). → «привязка нового чата» уже работает, серверных изменений для неё не требуется.Выбор активного чата (клиент).
activeAiChatIdAtom— id текущего чата (null= новый). Сейчас при открытии окна нет логики выбора чата по странице.Две точки открытия окна:
apps/client/src/components/layouts/global/app-header.tsx— общий вход, просто ставитaiChatWindowOpenAtom = true.apps/client/src/components/ui/ai-agent-badge.tsx— диплинк в конкретный чат.Список чатов (
useAiChatsQuery) уже отдаётpageIdи сортированcreatedAt DESC, но грузит лишь первые 50 (без авто-догрузки всех страниц) → чисто клиентский поиск может промахнуться мимо старого привязанного чата → нужен серверный эндпоинт.Ключевая идея
«Привязанный чат документа X» = самый свежий по
created_atнепросмотренный (deleted_at IS NULL) чат этого пользователя в этом воркспейсе, у которогоpage_id = X.Из этого определения обе половины ТЗ выпадают сами собой:
page_id = Xи максимальныйcreated_at→ автоматически «последний привязанный». Никакого отдельного UPDATE привязки.Ручной выбор старого чата не меняет
created_at/page_id→ привязка не сдвигается.Точки изменений
Сервер (read-only логика)
1. Репозиторий —
apps/server/src/database/repos/ai-chat/ai-chat.repo.ts:2. DTO —
apps/server/src/core/ai-chat/dto/ai-chat.dto.ts:3. Контроллер —
apps/server/src/core/ai-chat/ai-chat.controller.ts:Клиент
4. Сервис —
apps/client/src/features/ai-chat/services/ai-chat-service.ts:5. Хук открытия — новый
apps/client/src/features/ai-chat/hooks/use-open-ai-chat.ts. Резолв живёт в хуке общего входа (шапки), а не внутри окна → диплинк-бейдж естественно идёт в обход привязки, без флага-подавления и без async-гонок внутри окна:6. Шапка —
apps/client/src/components/layouts/global/app-header.tsx: заменить прямойsetAiChatWindowOpen(true)на вызовuseOpenAiChatForCurrentPage().7. Бейдж —
apps/client/src/components/ui/ai-agent-badge.tsx: не трогаем (диплинк в конкретный чат в обход привязки).Окно
ai-chat-window.tsxне меняется — оно уже показывает любойactiveChatId(грузит историю, реконсилит идентичность треда). Механика адопта id, гонок двух вкладок (#137), латча истории остаётся нетронутой.Краевые случаи
if (windowOpen).resolved !== activeChatId).deleted_at IS NULL→ следующий по свежести илиnull.page_idобнуляется (ON DELETE SET NULL), чаты отвязываются.activeChatIdнезависимо; бейдж роли в шапке окна может не отрисоваться, пока не догрузится нужная страница списка (косметика; митигируется добавлениемroleName/roleEmojiв ответ эндпоинта).(creator_id, workspace_id, page_id, created_at DESC)ускорил бы запрос, но это миграция → опциональный follow-up (запрос редкий, раз на открытие окна, уже узко скоупится).Контракт эндпоинта
Тесты
findLatestByPage— самый свежий; фильтрыdeleted_at/creator_id/workspace_id/page_id;undefinedкогда нет; тай-брейк поid.bound-chat—chatIdвладельца;nullдля чужого/несуществующегоpageId; не отдаёт чужие чаты.activeChatId; вне страницы — не трогает; при открытом окне — не резолвит; драфт чистится только при смене; fail-soft →null.План реализации
findLatestByPageв репозитории.BoundChatDto+ маршрутPOST /ai-chat/bound-chat.getBoundChatв сервисе.useOpenAiChatForCurrentPage.app-header.tsx.Половина «привязка при создании» отдельной задачи не требует — обеспечена существующей записью
page_id.Вне рамок
(user_id, page_id) → chat_id).