ИИ-чат: привязка чатов к документам (авто-открытие последнего чата документа) #191

Closed
opened 2026-06-25 22:38:40 +03:00 by Ghost · 0 comments

Задача

При открытии плавающего окна ИИ-чата на странице документа должен автоматически открываться последний чат, привязанный к этому документу. Привязка сохраняется до тех пор, пока на документе не будет создан новый чат — тогда привязанным становится он.

Согласованные решения по дизайну:

  • Хранение — переиспользуем существующее поле 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 = новый). Сейчас при открытии окна нет логики выбора чата по странице.

Две точки открытия окна:

  1. Кнопка в шапке apps/client/src/components/layouts/global/app-header.tsx — общий вход, просто ставит aiChatWindowOpenAtom = true.
  2. Бейдж авторства apps/client/src/components/ui/ai-agent-badge.tsx — диплинк в конкретный чат.

Список чатов (useAiChatsQuery) уже отдаёт pageId и сортирован createdAt DESC, но грузит лишь первые 50 (без авто-догрузки всех страниц) → чисто клиентский поиск может промахнуться мимо старого привязанного чата → нужен серверный эндпоинт.

Ключевая идея

«Привязанный чат документа X» = самый свежий по created_at непросмотренный (deleted_at IS NULL) чат этого пользователя в этом воркспейсе, у которого page_id = X.

Из этого определения обе половины ТЗ выпадают сами собой:

  • Авто-открытие — при открытии окна на X резолвим этот чат и делаем активным.
  • Перепривязка при создании — новый чат на 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:

/**
 * The "bound chat" for a document: the requesting user's most recently created,
 * non-deleted chat whose origin page is `pageId`. Auto-opened when the AI chat
 * window is opened on that page. Newest-by-createdAt wins, so a chat created
 * later on the same page supersedes earlier ones — exactly how "new chat ->
 * becomes the bound one" falls out for free. Scoped to the user + workspace.
 */
async findLatestByPage(
  creatorId: string,
  workspaceId: string,
  pageId: string,
): Promise<AiChat | undefined> {
  return this.db
    .selectFrom('aiChats')
    .selectAll('aiChats')
    .where('creatorId', '=', creatorId)
    .where('workspaceId', '=', workspaceId)
    .where('pageId', '=', pageId)
    .where('deletedAt', 'is', null)
    .orderBy('createdAt', 'desc')
    .orderBy('id', 'desc') // stable tiebreaker, mirrors findByCreator's cursor
    .limit(1)
    .executeTakeFirst();
}

2. DTOapps/server/src/core/ai-chat/dto/ai-chat.dto.ts:

/** Resolve the chat bound to a document (the page's most-recent owned chat). */
export class BoundChatDto {
  @IsString()
  pageId: string;
}

3. Контроллерapps/server/src/core/ai-chat/ai-chat.controller.ts:

/**
 * Resolve the chat bound to a document for the requesting user: the most-recent
 * non-deleted chat created on that page (ai_chats.page_id). Returns
 * { chatId: null } when the page has no owned chat (-> a fresh chat). No page
 * access check needed: only the caller's OWN chats are matched, so a foreign
 * pageId reveals nothing.
 */
@HttpCode(HttpStatus.OK)
@Post('bound-chat')
async boundChat(
  @Body() dto: BoundChatDto,
  @AuthUser() user: User,
  @AuthWorkspace() workspace: Workspace,
): Promise<{ chatId: string | null }> {
  const chat = await this.aiChatRepo.findLatestByPage(
    user.id,
    workspace.id,
    dto.pageId,
  );
  return { chatId: chat?.id ?? null };
}

Клиент

4. Сервисapps/client/src/features/ai-chat/services/ai-chat-service.ts:

/**
 * Resolve the chat bound to a document (the current user's most-recent chat
 * created on that page), or null when there is none. Drives auto-open-on-page.
 */
export async function getBoundChat(pageId: string): Promise<string | null> {
  const req = await api.post<{ chatId: string | null }>("/ai-chat/bound-chat", {
    pageId,
  });
  return req.data.chatId;
}

5. Хук открытия — новый apps/client/src/features/ai-chat/hooks/use-open-ai-chat.ts. Резолв живёт в хуке общего входа (шапки), а не внутри окна → диплинк-бейдж естественно идёт в обход привязки, без флага-подавления и без async-гонок внутри окна:

/**
 * The generic "open the AI chat" action, WITH document binding: when invoked
 * while viewing a page, it resolves that page's bound chat and selects it before
 * opening — so the last chat for this document re-opens by itself. With no bound
 * chat (or off a page) it keeps the current selection / opens a fresh chat. Used
 * by the app-header entry point; NOT by the provenance badge (which deep-links).
 */
export function useOpenAiChatForCurrentPage() {
  const [windowOpen, setWindowOpen] = useAtom(aiChatWindowOpenAtom);
  const [activeChatId, setActiveChatId] = useAtom(activeAiChatIdAtom);
  const setDraft = useSetAtom(aiChatDraftAtom);
  const setSelectedRoleId = useSetAtom(selectedAiRoleIdAtom);

  // Same route-match trick the window uses: read :pageSlug from the pathname.
  const match = useMatch("/s/:spaceSlug/p/:pageSlug");
  const pageId = extractPageSlugId(match?.params?.pageSlug);

  return useCallback(async () => {
    // Re-clicks while the window is already open (incl. minimized) must NOT
    // re-resolve and yank the user to another chat: resolve only on a genuine
    // closed -> open transition.
    if (windowOpen) {
      setWindowOpen(true);
      return;
    }
    let resolved: string | null = activeChatId; // off-a-page: keep current
    if (pageId) {
      try {
        resolved = await getBoundChat(pageId); // null => fresh chat
      } catch {
        resolved = null; // fail-soft: a fresh chat is always a safe fallback
      }
    }
    // Clear the composer draft / picked role ONLY on an actual switch, so
    // reopening the same chat does not wipe an in-progress draft.
    if (resolved !== activeChatId) {
      setActiveChatId(resolved);
      setDraft("");
      setSelectedRoleId(null);
    }
    setWindowOpen(true);
  }, [windowOpen, activeChatId, pageId, setWindowOpen, setActiveChatId, setDraft, setSelectedRoleId]);
}

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), латча истории остаётся нетронутой.

Краевые случаи

  1. Диплинк из бейджа не перебивается резолвом — решено архитектурно (резолв только в хуке шапки).
  2. Повторный клик по шапке при открытом окне (в т.ч. свёрнутом) — гард if (windowOpen).
  3. «Новый чат» без отправки сообщения не привязывается — чат создаётся на сервере только при первом сообщении.
  4. Привязка нового чата идёт по странице на момент отправки — если открыт чат с X, ушли на Y, создали новый чат → он привяжется к Y.
  5. Чистка драфта только при реальной смене чата (resolved !== activeChatId).
  6. Вне страницы документа — резолв не выполняется, текущий активный чат сохраняется.
  7. Удалённый привязанный чат — фильтр deleted_at IS NULL → следующий по свежести или null.
  8. Hard-delete страницыpage_id обнуляется (ON DELETE SET NULL), чаты отвязываются.
  9. Привязанный чат вне первой страницы списка — тред грузится по activeChatId независимо; бейдж роли в шапке окна может не отрисоваться, пока не догрузится нужная страница списка (косметика; митигируется добавлением roleName/roleEmoji в ответ эндпоинта).
  10. Fail-soft на ошибке резолва — открываем свежий чат.
  11. Индекс (creator_id, workspace_id, page_id, created_at DESC) ускорил бы запрос, но это миграция → опциональный follow-up (запрос редкий, раз на открытие окна, уже узко скоупится).

Контракт эндпоинта

POST /api/ai-chat/bound-chat
Auth:  JwtAuthGuard (interactive session), workspace-scoped
Body:  { "pageId": string }
200:   { "chatId": string | null }   // null => нет привязанного чата -> свежий

Тесты

  • Сервер (repo): findLatestByPage — самый свежий; фильтры deleted_at/creator_id/workspace_id/page_id; undefined когда нет; тай-брейк по id.
  • Сервер (controller): bound-chatchatId владельца; null для чужого/несуществующего pageId; не отдаёт чужие чаты.
  • Клиент (hook): на странице — резолвит и ставит activeChatId; вне страницы — не трогает; при открытом окне — не резолвит; драфт чистится только при смене; fail-soft → null.
  • Сценарий: создать чат A на X → открыть на X = A; «Новый чат» + сообщение на X → создаётся B → переоткрытие на X = B; ручной выбор A из истории не меняет привязку.

План реализации

  1. Сервер: findLatestByPage в репозитории.
  2. Сервер: BoundChatDto + маршрут POST /ai-chat/bound-chat.
  3. Клиент: getBoundChat в сервисе.
  4. Клиент: хук useOpenAiChatForCurrentPage.
  5. Клиент: подключить хук в app-header.tsx.
  6. Тесты по разделу выше.

Половина «привязка при создании» отдельной задачи не требует — обеспечена существующей записью page_id.

Вне рамок

  • Авто-переключение чата при навигации между документами с открытым окном.
  • Перепривязка при ручном выборе старого чата (потребовала бы мутабельной таблицы (user_id, page_id) → chat_id).
  • Общие (не per-user) привязки.
## Задача При открытии плавающего окна ИИ-чата на странице документа должен **автоматически открываться последний чат, привязанный к этому документу**. Привязка сохраняется до тех пор, пока на документе не будет создан новый чат — тогда привязанным становится он. **Согласованные решения по дизайну:** - **Хранение** — переиспользуем существующее поле `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` = новый). Сейчас при открытии окна **нет** логики выбора чата по странице. **Две точки открытия окна:** 1. Кнопка в шапке `apps/client/src/components/layouts/global/app-header.tsx` — общий вход, просто ставит `aiChatWindowOpenAtom = true`. 2. Бейдж авторства `apps/client/src/components/ui/ai-agent-badge.tsx` — диплинк в конкретный чат. **Список чатов** (`useAiChatsQuery`) уже отдаёт `pageId` и сортирован `createdAt DESC`, но грузит лишь первые 50 (без авто-догрузки всех страниц) → чисто клиентский поиск может промахнуться мимо старого привязанного чата → нужен серверный эндпоинт. ## Ключевая идея «Привязанный чат документа X» = самый свежий по `created_at` непросмотренный (`deleted_at IS NULL`) чат **этого пользователя** в **этом воркспейсе**, у которого `page_id = X`. Из этого определения обе половины ТЗ выпадают сами собой: - **Авто-открытие** — при открытии окна на X резолвим этот чат и делаем активным. - **Перепривязка при создании** — новый чат на 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`: ```ts /** * The "bound chat" for a document: the requesting user's most recently created, * non-deleted chat whose origin page is `pageId`. Auto-opened when the AI chat * window is opened on that page. Newest-by-createdAt wins, so a chat created * later on the same page supersedes earlier ones — exactly how "new chat -> * becomes the bound one" falls out for free. Scoped to the user + workspace. */ async findLatestByPage( creatorId: string, workspaceId: string, pageId: string, ): Promise<AiChat | undefined> { return this.db .selectFrom('aiChats') .selectAll('aiChats') .where('creatorId', '=', creatorId) .where('workspaceId', '=', workspaceId) .where('pageId', '=', pageId) .where('deletedAt', 'is', null) .orderBy('createdAt', 'desc') .orderBy('id', 'desc') // stable tiebreaker, mirrors findByCreator's cursor .limit(1) .executeTakeFirst(); } ``` **2. DTO** — `apps/server/src/core/ai-chat/dto/ai-chat.dto.ts`: ```ts /** Resolve the chat bound to a document (the page's most-recent owned chat). */ export class BoundChatDto { @IsString() pageId: string; } ``` **3. Контроллер** — `apps/server/src/core/ai-chat/ai-chat.controller.ts`: ```ts /** * Resolve the chat bound to a document for the requesting user: the most-recent * non-deleted chat created on that page (ai_chats.page_id). Returns * { chatId: null } when the page has no owned chat (-> a fresh chat). No page * access check needed: only the caller's OWN chats are matched, so a foreign * pageId reveals nothing. */ @HttpCode(HttpStatus.OK) @Post('bound-chat') async boundChat( @Body() dto: BoundChatDto, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace, ): Promise<{ chatId: string | null }> { const chat = await this.aiChatRepo.findLatestByPage( user.id, workspace.id, dto.pageId, ); return { chatId: chat?.id ?? null }; } ``` ### Клиент **4. Сервис** — `apps/client/src/features/ai-chat/services/ai-chat-service.ts`: ```ts /** * Resolve the chat bound to a document (the current user's most-recent chat * created on that page), or null when there is none. Drives auto-open-on-page. */ export async function getBoundChat(pageId: string): Promise<string | null> { const req = await api.post<{ chatId: string | null }>("/ai-chat/bound-chat", { pageId, }); return req.data.chatId; } ``` **5. Хук открытия** — новый `apps/client/src/features/ai-chat/hooks/use-open-ai-chat.ts`. Резолв живёт в хуке общего входа (шапки), а не внутри окна → диплинк-бейдж естественно идёт в обход привязки, без флага-подавления и без async-гонок внутри окна: ```ts /** * The generic "open the AI chat" action, WITH document binding: when invoked * while viewing a page, it resolves that page's bound chat and selects it before * opening — so the last chat for this document re-opens by itself. With no bound * chat (or off a page) it keeps the current selection / opens a fresh chat. Used * by the app-header entry point; NOT by the provenance badge (which deep-links). */ export function useOpenAiChatForCurrentPage() { const [windowOpen, setWindowOpen] = useAtom(aiChatWindowOpenAtom); const [activeChatId, setActiveChatId] = useAtom(activeAiChatIdAtom); const setDraft = useSetAtom(aiChatDraftAtom); const setSelectedRoleId = useSetAtom(selectedAiRoleIdAtom); // Same route-match trick the window uses: read :pageSlug from the pathname. const match = useMatch("/s/:spaceSlug/p/:pageSlug"); const pageId = extractPageSlugId(match?.params?.pageSlug); return useCallback(async () => { // Re-clicks while the window is already open (incl. minimized) must NOT // re-resolve and yank the user to another chat: resolve only on a genuine // closed -> open transition. if (windowOpen) { setWindowOpen(true); return; } let resolved: string | null = activeChatId; // off-a-page: keep current if (pageId) { try { resolved = await getBoundChat(pageId); // null => fresh chat } catch { resolved = null; // fail-soft: a fresh chat is always a safe fallback } } // Clear the composer draft / picked role ONLY on an actual switch, so // reopening the same chat does not wipe an in-progress draft. if (resolved !== activeChatId) { setActiveChatId(resolved); setDraft(""); setSelectedRoleId(null); } setWindowOpen(true); }, [windowOpen, activeChatId, pageId, setWindowOpen, setActiveChatId, setDraft, setSelectedRoleId]); } ``` **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), латча истории остаётся нетронутой. ## Краевые случаи 1. **Диплинк из бейджа не перебивается резолвом** — решено архитектурно (резолв только в хуке шапки). 2. **Повторный клик по шапке при открытом окне** (в т.ч. свёрнутом) — гард `if (windowOpen)`. 3. **«Новый чат» без отправки сообщения не привязывается** — чат создаётся на сервере только при первом сообщении. 4. **Привязка нового чата идёт по странице на момент отправки** — если открыт чат с X, ушли на Y, создали новый чат → он привяжется к Y. 5. **Чистка драфта только при реальной смене чата** (`resolved !== activeChatId`). 6. **Вне страницы документа** — резолв не выполняется, текущий активный чат сохраняется. 7. **Удалённый привязанный чат** — фильтр `deleted_at IS NULL` → следующий по свежести или `null`. 8. **Hard-delete страницы** → `page_id` обнуляется (`ON DELETE SET NULL`), чаты отвязываются. 9. **Привязанный чат вне первой страницы списка** — тред грузится по `activeChatId` независимо; бейдж роли в шапке окна может не отрисоваться, пока не догрузится нужная страница списка (косметика; митигируется добавлением `roleName/roleEmoji` в ответ эндпоинта). 10. **Fail-soft на ошибке резолва** — открываем свежий чат. 11. **Индекс** `(creator_id, workspace_id, page_id, created_at DESC)` ускорил бы запрос, но это миграция → опциональный follow-up (запрос редкий, раз на открытие окна, уже узко скоупится). ## Контракт эндпоинта ``` POST /api/ai-chat/bound-chat Auth: JwtAuthGuard (interactive session), workspace-scoped Body: { "pageId": string } 200: { "chatId": string | null } // null => нет привязанного чата -> свежий ``` ## Тесты - **Сервер (repo):** `findLatestByPage` — самый свежий; фильтры `deleted_at`/`creator_id`/`workspace_id`/`page_id`; `undefined` когда нет; тай-брейк по `id`. - **Сервер (controller):** `bound-chat` — `chatId` владельца; `null` для чужого/несуществующего `pageId`; не отдаёт чужие чаты. - **Клиент (hook):** на странице — резолвит и ставит `activeChatId`; вне страницы — не трогает; при открытом окне — не резолвит; драфт чистится только при смене; fail-soft → `null`. - **Сценарий:** создать чат A на X → открыть на X = A; «Новый чат» + сообщение на X → создаётся B → переоткрытие на X = B; ручной выбор A из истории не меняет привязку. ## План реализации 1. Сервер: `findLatestByPage` в репозитории. 2. Сервер: `BoundChatDto` + маршрут `POST /ai-chat/bound-chat`. 3. Клиент: `getBoundChat` в сервисе. 4. Клиент: хук `useOpenAiChatForCurrentPage`. 5. Клиент: подключить хук в `app-header.tsx`. 6. Тесты по разделу выше. Половина «привязка при создании» отдельной задачи не требует — обеспечена существующей записью `page_id`. ## Вне рамок - Авто-переключение чата при навигации между документами с открытым окном. - Перепривязка при ручном выборе старого чата (потребовала бы мутабельной таблицы `(user_id, page_id) → chat_id`). - Общие (не per-user) привязки.
vvzvlad added the feature label 2026-06-26 00:32:20 +03:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#191