[feature][ui][ai-chat] Аватар-стек: спереди аватар AI-агента, за ним — аккаунт запустившего человека #300

Open
opened 2026-07-03 04:08:23 +03:00 by agent_vscode · 0 comments
Collaborator

Проблема / мотивация

Сейчас у контента, написанного AI-агентом (комментарии, история страницы), основная аватарка — это человек, а участие агента показано маленьким фиолетовым текстовым бейджем AI-agent рядом с именем. Иерархия неверная: действие совершил агент, а человек — тот, кто его запустил. Переворачиваем:

  • основная (передняя) аватарка = AI-агент;
  • за ней, со смещением и меньше = аккаунт человека, который его запустил.
Сейчас:                          Хотим:
🧑 vvzvlad [✦ AI-AGENT]          [✦]◗🧑  «Proofreader» · vvzvlad
человек основной, агент — подпись  агент спереди, человек — сзади, меньше

Модель провенанса (ключ к отрисовке)

Провенанс-токен агента ВСЕГДА привязан к логину, и логин резолвится из токена (sub) — это ровно comment.creator. Вопрос лишь в том, ЧЬЯ это личность:

  • Внутренний AI-чат: токен минтится для человекаapps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts:73generateAccessToken(user, sessionId, { actor:'agent', aiChatId }), где user = владелец чата. Значит sub=человек, creator=человек. Коммент реально несёт aiChatId (стампится через agentSourceFields(provenance,'createdSource','aiChatId')), а у чата есть role_id (ai-chat.service.ts:487). «Агент» здесь — это роль чата (ai_agent_roles.name + опц. emoji), у неё нет логина и картинки.
  • Внешний MCP: sub = аккаунт, под которым зашли (resolveMcpSessionConfig, apps/server/src/integrations/mcp/mcp-auth.helpers.ts:560). Единственный способ получить agent-метку снаружи — залогиниться как выделенный is_agent-аккаунт (external MCP не может подсунуть actor:'agent' — этот claim ставит только сервер). Значит creator = сам агент-аккаунт (name+avatarUrl), а отдельного человека за ним НЕТ. На users связи owner/created_by нет.

Дискриминатор на клиенте — один: наличие aiChatId (для истории — lastUpdatedAiChatId).

Решение

1. Backend: нормализуем в две личности (клиент не ветвится)

На agent-контенте добавить два под-объекта. Коммент (comment.repo.ts, рядом с withCreator): резолв через left join comments.aiChatId → ai_chats.role_id → ai_agent_roles (name, emoji).

agent:    { name: string; emoji?: string | null; avatarUrl?: string | null } // ПЕРЕД
launcher: { name: string; avatarUrl?: string } | null                         // ЗАД (человек)
Кейс agent (перед) launcher (зад) Имя берётся из
Внутр. чат, роль есть (aiChatId != null) { name: role.name, emoji: role.emoji, avatarUrl: null } creator (человек) ai_agent_roles.name/emoji
Внутр. чат, роли нет (role_id = null) { name: "AI agent", avatarUrl: null } creator (человек) fallback
Внешний MCP (aiChatId == null) { name: creator.name, avatarUrl: creator.avatarUrl } null сам агент-аккаунт

Имя/эмодзи из роли — это ровно то, чем приложение уже подписывает ассистента в окне чата (ai-chat-window.tsx:882assistantName={currentRole?.name}; message-item.tsx:148resolveAssistantName(...) ?? "AI agent"). В самих комментах/истории сейчас это НЕ выводится — фича это и добавляет.

Важно: join ai_agent_roles для подписи делать БЕЗ фильтра enabled/deleted_at (роль могли позже выключить/soft-delete, а подпись исторического контента должна сохраниться) — использовать AiAgentRoleRepo.findById, НЕ findLiveEnabled.

2. Backend: история страницы (тот же паттерн, другие колонки)

У истории модель иная — lastUpdatedBy + группа contributors, а не один creator, но нормализация идентична. В apps/server/src/database/repos/page/page-history.repo.ts добавить withAgent (по аналогии с withLastUpdatedBy/withContributors).

Коммент История
Триггер createdSource === 'agent' lastUpdatedSource === 'agent'history-item.tsx уже isAgentEdit)
Дискриминатор aiChatId lastUpdatedAiChatId
Роль (перед) aiChatId → ai_chats.role_id → ai_agent_roles lastUpdatedAiChatId → ai_chats.role_id → ai_agent_roles
Человек (зад) creator lastUpdatedBy

lastUpdatedAiChatId/lastUpdatedBy в IPageHistory уже есть — не хватает только резолва роли (name/emoji).

3. Frontend: компонент AgentAvatarStackcomponents/ui/, один на комменты и историю)

<Box pos="relative" w={38} h={38}>
  {launcher && (                                   // ЗАД: человек-инициатор, если есть
    <CustomAvatar
      size={22} avatarUrl={launcher.avatarUrl} name={launcher.name}
      style={{ position: "absolute", right: -5, bottom: -5, zIndex: 0,
               border: "2px solid var(--mantine-color-body)" }} />
  )}
  <AgentGlyph agent={agent} size={38} style={{ position: "relative", zIndex: 1 }} /> // ПЕРЕД: агент
</Box>

AgentGlyph — приоритет источника картинки агента:

  1. agent.avatarUrl (внешний MCP-аккаунт) → CustomAvatar с картинкой;
  2. иначе agent.emoji (роль с эмодзи) → Avatar с эмодзи на фиолетовом кружке;
  3. иначе → IconSparkles на фиолетовом (тот же фиолет, что у нынешнего бейджа).

Подпись: жирным agent.name, при наличии — приглушённым · {launcher.name}. Текстовый бейдж AI-AGENT убираем (заменён стеком); клик-переход в чат по aiChatId переносим на стек (логика из ai-agent-badge.tsx). Tooltip: внутр. чат — «AI-агент «{роль}» от имени {человек}»; MCP — «AI-агент {имя}».

История: группу contributors (Avatar.Group) оставляем как есть; AgentAvatarStack встаёт РОВНО на место текстового бейджа. Небольшая избыточность (lastUpdatedBy может встречаться и в contributors, и «сзади») допустима.

Fallback'и: launcher == null (MCP) → только передняя аватарка агента; createdSource !== 'agent'lastUpdatedSource !== 'agent') → прежнее поведение (одна аватарка человека).

Принятые решения

  • Аватар агента без картинки (внутр. чат): эмодзи роли, иначе sparkles-глиф.
  • Текстовый бейдж AI-AGENT: заменить стеком (клик в чат сохранить на стеке).
  • Применять: комментарии + история страницы (везде, где сейчас AiAgentBadge).
  • Внешний MCP: только агент (launcher=null) — второго субъекта в модели нет.

Затрагиваемые файлы

  • Backend: apps/server/src/database/repos/comment/comment.repo.ts (withAgent); apps/server/src/database/repos/page/page-history.repo.ts (withAgent); DTO/типы коммента.
  • Types: apps/client/src/features/comment/types/comment.types.ts (IComment.agent, IComment.launcher), apps/client/src/features/page-history/types/page.types.ts.
  • Frontend: новый apps/client/src/components/ui/agent-avatar-stack.tsx (+AgentGlyph); интеграция в comment-list-item.tsx:122-140 и history-item.tsx; удаление/сворачивание ai-agent-badge.tsx (deep-link переносится).
  • i18n-ключи (tooltip, alt).
  • Тесты: юнит на AgentAvatarStack (внутр. чат с ролью / без роли / MCP / non-agent); backend-тест на withAgent (коммент и история).

Критерии приёмки (DoD)

  • Agent-коммент из внутреннего чата: спереди аватар агента (эмодзи роли/sparkles), за ним меньшая аватарка человека-инициатора (creator).
  • Внешний MCP: спереди аватар агент-аккаунта, задней аватарки нет.
  • Tooltip раскрывает обе личности; клик по стеку открывает AI-чат при наличии aiChatId.
  • createdSource !== 'agent' → прежний одиночный аватар человека.
  • Единообразно в комментариях и истории страницы; текстовый бейдж убран.
  • Backend отдаёт agent/launcher из подписанного провенанса (без спуфинга); роль резолвится через findById (не отфильтровывается по enabled/deleted_at).
  • Тесты на компонент и на серверные select'ы; i18n добавлен.

Вне scope (опционально, отдельные тикеты)

  • Связь is_agent-аккаунт → человек-владелец (users.owner_user_id), чтобы показывать человека и за внешним MCP-агентом. Сейчас не нужно: launcher=null честно отражает модель.
  • Загружаемые аватарки для ролей агента (пока — эмодзи/sparkles).

Постановка: рисовать «наоборот» — основная аватарка это AI-агент, а за ним аккаунт пользователя, который его запустил.

## Проблема / мотивация Сейчас у контента, написанного AI-агентом (комментарии, история страницы), **основная аватарка — это человек**, а участие агента показано маленьким фиолетовым текстовым бейджем `AI-agent` рядом с именем. Иерархия неверная: действие совершил **агент**, а человек — тот, кто его запустил. Переворачиваем: - **основная (передняя) аватарка = AI-агент**; - **за ней, со смещением и меньше = аккаунт человека**, который его запустил. ``` Сейчас: Хотим: 🧑 vvzvlad [✦ AI-AGENT] [✦]◗🧑 «Proofreader» · vvzvlad человек основной, агент — подпись агент спереди, человек — сзади, меньше ``` ## Модель провенанса (ключ к отрисовке) Провенанс-токен агента ВСЕГДА привязан к логину, и логин резолвится из токена (`sub`) — это ровно `comment.creator`. Вопрос лишь в том, ЧЬЯ это личность: - **Внутренний AI-чат**: токен минтится для **человека** — `apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts:73` → `generateAccessToken(user, sessionId, { actor:'agent', aiChatId })`, где `user` = владелец чата. Значит `sub`=человек, `creator`=человек. Коммент реально несёт `aiChatId` (стампится через `agentSourceFields(provenance,'createdSource','aiChatId')`), а у чата есть `role_id` (`ai-chat.service.ts:487`). «Агент» здесь — это **роль чата** (`ai_agent_roles.name` + опц. `emoji`), у неё нет логина и картинки. - **Внешний MCP**: `sub` = аккаунт, под которым зашли (`resolveMcpSessionConfig`, `apps/server/src/integrations/mcp/mcp-auth.helpers.ts:560`). Единственный способ получить agent-метку снаружи — залогиниться **как выделенный `is_agent`-аккаунт** (external MCP не может подсунуть `actor:'agent'` — этот claim ставит только сервер). Значит `creator` = **сам агент-аккаунт** (`name`+`avatarUrl`), а отдельного человека за ним НЕТ. На `users` связи owner/created_by нет. Дискриминатор на клиенте — один: **наличие `aiChatId`** (для истории — `lastUpdatedAiChatId`). ## Решение ### 1. Backend: нормализуем в две личности (клиент не ветвится) На agent-контенте добавить два под-объекта. **Коммент** (`comment.repo.ts`, рядом с `withCreator`): резолв через left join `comments.aiChatId → ai_chats.role_id → ai_agent_roles (name, emoji)`. ```ts agent: { name: string; emoji?: string | null; avatarUrl?: string | null } // ПЕРЕД launcher: { name: string; avatarUrl?: string } | null // ЗАД (человек) ``` | Кейс | `agent` (перед) | `launcher` (зад) | Имя берётся из | |---|---|---|---| | Внутр. чат, роль есть (`aiChatId != null`) | `{ name: role.name, emoji: role.emoji, avatarUrl: null }` | `creator` (человек) | `ai_agent_roles.name`/`emoji` | | Внутр. чат, роли нет (`role_id = null`) | `{ name: "AI agent", avatarUrl: null }` | `creator` (человек) | fallback | | Внешний MCP (`aiChatId == null`) | `{ name: creator.name, avatarUrl: creator.avatarUrl }` | `null` | сам агент-аккаунт | Имя/эмодзи из роли — это ровно то, чем приложение уже подписывает ассистента в окне чата (`ai-chat-window.tsx:882` → `assistantName={currentRole?.name}`; `message-item.tsx:148` → `resolveAssistantName(...) ?? "AI agent"`). В самих комментах/истории сейчас это НЕ выводится — фича это и добавляет. **Важно:** join `ai_agent_roles` для подписи делать БЕЗ фильтра `enabled`/`deleted_at` (роль могли позже выключить/soft-delete, а подпись исторического контента должна сохраниться) — использовать `AiAgentRoleRepo.findById`, НЕ `findLiveEnabled`. ### 2. Backend: история страницы (тот же паттерн, другие колонки) У истории модель иная — `lastUpdatedBy` + группа `contributors`, а не один `creator`, но нормализация идентична. В `apps/server/src/database/repos/page/page-history.repo.ts` добавить `withAgent` (по аналогии с `withLastUpdatedBy`/`withContributors`). | | Коммент | История | |---|---|---| | Триггер | `createdSource === 'agent'` | `lastUpdatedSource === 'agent'` (в `history-item.tsx` уже `isAgentEdit`) | | Дискриминатор | `aiChatId` | `lastUpdatedAiChatId` | | Роль (перед) | `aiChatId → ai_chats.role_id → ai_agent_roles` | `lastUpdatedAiChatId → ai_chats.role_id → ai_agent_roles` | | Человек (зад) | `creator` | `lastUpdatedBy` | `lastUpdatedAiChatId`/`lastUpdatedBy` в `IPageHistory` уже есть — не хватает только резолва роли (`name`/`emoji`). ### 3. Frontend: компонент `AgentAvatarStack` (в `components/ui/`, один на комменты и историю) ```tsx <Box pos="relative" w={38} h={38}> {launcher && ( // ЗАД: человек-инициатор, если есть <CustomAvatar size={22} avatarUrl={launcher.avatarUrl} name={launcher.name} style={{ position: "absolute", right: -5, bottom: -5, zIndex: 0, border: "2px solid var(--mantine-color-body)" }} /> )} <AgentGlyph agent={agent} size={38} style={{ position: "relative", zIndex: 1 }} /> // ПЕРЕД: агент </Box> ``` `AgentGlyph` — приоритет источника картинки агента: 1. `agent.avatarUrl` (внешний MCP-аккаунт) → `CustomAvatar` с картинкой; 2. иначе `agent.emoji` (роль с эмодзи) → `Avatar` с эмодзи на фиолетовом кружке; 3. иначе → `IconSparkles` на фиолетовом (тот же фиолет, что у нынешнего бейджа). Подпись: жирным `agent.name`, при наличии — приглушённым `· {launcher.name}`. **Текстовый бейдж `AI-AGENT` убираем** (заменён стеком); **клик-переход в чат по `aiChatId` переносим на стек** (логика из `ai-agent-badge.tsx`). Tooltip: внутр. чат — «AI-агент «{роль}» от имени {человек}»; MCP — «AI-агент {имя}». **История:** группу `contributors` (Avatar.Group) оставляем как есть; `AgentAvatarStack` встаёт РОВНО на место текстового бейджа. Небольшая избыточность (`lastUpdatedBy` может встречаться и в contributors, и «сзади») допустима. Fallback'и: `launcher == null` (MCP) → только передняя аватарка агента; `createdSource !== 'agent'` (и `lastUpdatedSource !== 'agent'`) → прежнее поведение (одна аватарка человека). ## Принятые решения - Аватар агента без картинки (внутр. чат): **эмодзи роли, иначе sparkles-глиф**. - Текстовый бейдж `AI-AGENT`: **заменить стеком** (клик в чат сохранить на стеке). - Применять: **комментарии + история страницы** (везде, где сейчас `AiAgentBadge`). - Внешний MCP: **только агент** (`launcher=null`) — второго субъекта в модели нет. ## Затрагиваемые файлы - Backend: `apps/server/src/database/repos/comment/comment.repo.ts` (`withAgent`); `apps/server/src/database/repos/page/page-history.repo.ts` (`withAgent`); DTO/типы коммента. - Types: `apps/client/src/features/comment/types/comment.types.ts` (`IComment.agent`, `IComment.launcher`), `apps/client/src/features/page-history/types/page.types.ts`. - Frontend: новый `apps/client/src/components/ui/agent-avatar-stack.tsx` (+`AgentGlyph`); интеграция в `comment-list-item.tsx:122-140` и `history-item.tsx`; удаление/сворачивание `ai-agent-badge.tsx` (deep-link переносится). - i18n-ключи (tooltip, alt). - Тесты: юнит на `AgentAvatarStack` (внутр. чат с ролью / без роли / MCP / non-agent); backend-тест на `withAgent` (коммент и история). ## Критерии приёмки (DoD) - [ ] Agent-коммент из внутреннего чата: спереди аватар агента (эмодзи роли/sparkles), за ним меньшая аватарка человека-инициатора (`creator`). - [ ] Внешний MCP: спереди аватар агент-аккаунта, задней аватарки нет. - [ ] Tooltip раскрывает обе личности; клик по стеку открывает AI-чат при наличии `aiChatId`. - [ ] `createdSource !== 'agent'` → прежний одиночный аватар человека. - [ ] Единообразно в комментариях и истории страницы; текстовый бейдж убран. - [ ] Backend отдаёт `agent`/`launcher` из подписанного провенанса (без спуфинга); роль резолвится через `findById` (не отфильтровывается по `enabled`/`deleted_at`). - [ ] Тесты на компонент и на серверные select'ы; i18n добавлен. ## Вне scope (опционально, отдельные тикеты) - Связь `is_agent`-аккаунт → человек-владелец (`users.owner_user_id`), чтобы показывать человека и за внешним MCP-агентом. Сейчас не нужно: `launcher=null` честно отражает модель. - Загружаемые аватарки для ролей агента (пока — эмодзи/sparkles). --- _Постановка: рисовать «наоборот» — основная аватарка это AI-агент, а за ним аккаунт пользователя, который его запустил._
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#300