cumoraAI
§ · Nested detail page

Chat State and WS 深度解析

这一页看 cumora 如何把 agent 队友、conversation、message、doc 协作和 WebSocket 事件放进同一前端/服务端模型。

§ 00 · IMPORT CHAIN

导入链与契约

src/types.ts:1 到 src/types.ts:10 定义 agent role、participant kind、status、computer kind 和 engine id;这些类型随后贯穿 src/types.ts:31 的 Participant、src/types.ts:64 的 Conversation 和 src/types.ts:193 的 Message。

src/types.ts · L1-62TypeScript
1export type AgentRole = 'researcher' | 'designer' | 'engineer' | 'pm' | 'brand' | 'ops'\n2export type ParticipantKind = 'agent' | 'human'\n3export type Status = 'avail' | 'working' | 'thinking' | 'waiting' | 'resting'\n4\n5/** Where an agent runs. 'cloud' = the built-in Cumora Cloud (managed engine);\n6 *  'local'/'vps' = a computer the user paired, running the BYOA daemon. */\n7export type ComputerKind = 'cloud' | 'local' | 'vps'\n8export type ComputerStatus = 'online' | 'offline' | 'busy'\n9/** Engine an agent's host runs it on. 'managed' = Cumora's server-side loop. */\n10export type EngineId = 'managed' | 'claude' | 'codex'\n11\n12export interface Computer {\n13  id: string\n14  name: string\n15  kind: ComputerKind\n16  status: ComputerStatus\n17  availableEngines: EngineId[]\n18  lastSeenAt?: string | null\n19  pairedAt?: string | null\n20  /** The cumora daemon version this computer is running (null = cloud / unknown). */\n21  daemonVersion?: string | null\n22  /** How the daemon runs: true = installed service (launchd/systemd),\n23   *  false = manually-run foreground command, null = cloud / unknown. */\n24  daemonSupervised?: boolean | null\n25  /** Newest published daemon version (for the upgrade banner). */\n26  latestDaemonVersion?: string | null\n27  /** True when the daemon is behind the latest version → show the upgrade banner. */\n28  daemonOutdated?: boolean\n29}\n30\n31export interface Participant {\n32  id: string\n33  kind: ParticipantKind\n34  name: string\n35  role?: string\n36  initial: string\n37  /** linear-gradient or any css background — fallback when avatarUrl is empty */\n38  avatarBg: string\n39  /** AI-generated portrait URL (preferred over avatarBg when set) */\n40  avatarUrl?: string | null\n41  status: Status\n42  statusUpdatedAt?: string\n43  bio?: string\n44  tools?: string[]\n45  /** the agent's distinctive style — only set for agents */\n46  systemPrompt?: string\n47  /** big-brain (main) model override; null/undefined = use system default */\n48  model?: string | null\n49  /** small-brain (fast/auxiliary) model override */\n50  fastModel?: string | null\n51  /** id of the Computer this agent runs on (null/undefined = Cumora Cloud) */\n52  computerId?: string | null\n53  /** engine the agent's host runs it on ('managed' for cloud agents) */\n54  engine?: EngineId | null\n55  /** Real external email address. Agents get one of the form\n56   *  `<id>@<companySlug>.<EMAIL_DOMAIN>` (auto-minted on first use);\n57   *  humans carry their auth email here for the renderer's contact\n58   *  picker. Null when the email feature isn't configured. */\n59  email?: string | null\n60  /** non-null = agent has been off-boarded; ISO timestamp of when */\n61  departedAt?: string | null\n62}
§ 01 · CORE IMPL

核心实现,逐段拆

▸Conversation 把 agent convene 和 whisper 放进同一模型

src/types.ts:64 到 src/types.ts:104 里,conversation kind 包括 group、direct、whisper、email,members 是 participant id,pulledBy 记录 agent 发起拉群的原因。这个模型让 agent-only whisper 和 human-agent group 不需要两套 UI 数据结构。

src/types.ts · L64-104TypeScript
64export type ConversationKind = 'group' | 'direct' | 'whisper' | 'email'\n65\n66export interface Conversation {\n67  id: string\n68  kind: ConversationKind\n69  title: string\n70  /** display subtitle - members or whisper pair */\n71  subtitle?: string\n72  /** free-form purpose / topic line, editable by any member */\n73  topic?: string | null\n74  /** participant ids */\n75  members: string[]\n76  /** for whispers: the two agents in private chat */\n77  whisperPair?: [string, string]\n78  pinned?: boolean\n79  /** Per-user mute. When true, the conversation suppresses notifications and\n80   *  is excluded from the global unread total (but its per-row badge still\n81   *  shows). Pair with `mutedUntil` to know when the mute auto-expires. */\n82  muted?: boolean\n83  /** ISO timestamp when the mute auto-expires; null/undefined = forever. */\n84  mutedUntil?: string | null\n85  unread?: number\n86  /** Latest persisted message id from the conversation list payload. Used to\n87   *  detect when the sidebar preview has advanced past the open transcript. */\n88  lastMessageId?: string | null\n89  lastAt: string\n90  /** Raw ISO timestamp the row was last touched (last message time, or\n91   *  the conversation's own updatedAt when there are no messages yet).\n92   *  Server returns the list in this order; we keep the raw value so any\n93   *  client-side re-sort uses real time rather than the display label. */\n94  lastAtIso: string\n95  preview: string\n96  /** optional special tag */\n97  tag?: 'team' | 'whisper' | 'human' | 'fresh-pulled'\n98  /** if pulled by an agent: the convener id and reason */\n99  pulledBy?: { agentId: string; at: string; reason: string }\n100  /** when this conversation belongs to a project, the project's id + name + tint */\n101  projectId?: string | null\n102  projectName?: string | null\n103  projectColor?: string | null\n104}

▸Message 统一 text、tool、email、poll 与 optimistic state

src/types.ts:106 到 src/types.ts:255 定义 MessageKind、EmailFields、ReactionEntry、QuotedSummary 和 Message。注意 src/types.ts:247 的 pending/failed/clientId 是前端 optimistic render 所需,不是 API 永久字段。

src/types.ts · L193-255TypeScript
193export interface Message {\n194  id: string\n195  conversationId: string\n196  authorId: string\n197  kind: MessageKind\n198  body: string\n199  at: string\n200  reactions?: ReactionEntry[]\n201  /** for tool messages */\n202  tool?: {\n203    name: string\n204    arg: string\n205    status: string\n206    detail: string\n207    icon?: 'web' | 'github' | 'figma' | 'db'\n208  }\n209  /** for attachments */\n210  attachment?: {\n211    name: string\n212    /** kind 'img' renders inline; others render as a file card */\n213    kind: 'img' | 'pdf' | 'file' | 'fig'\n214    /** real assets carry a URL; mock/legacy data may not */\n215    url?: string\n216    mime?: string\n217    size?: number\n218    /** legacy descriptor — fallback when no real file is present (e.g. mock data) */\n219    meta?: string\n220  }\n221  /** for whisper-link cards in main chat */\n222  whisperLink?: {\n223    pair: [string, string]\n224    snippet: string\n225    count: number\n226  }\n227  /** Populated by the server when kind === 'email'. Carries headers,\n228   *  direction, and transport status so the email bubble can render the\n229   *  "subject + from / to / cc + sent/failed" chrome. */\n230  email?: EmailFields\n231  /** Populated by the server when kind === 'poll'. */\n232  poll?: PollPayload\n233  /** Per-option aggregated tallies for kind === 'poll'. Empty array for\n234   *  any other message kind. Updated in place by `poll.updated` WS events. */\n235  pollTallies?: PollTally[]\n236  /** Reply-to / quote pointer: the id of another message in this same\n237   *  conversation that this one is quoting. Null for non-reply messages. */\n238  quotedMessageId?: string\n239  /** Inlined summary of the quoted-original, resolved server-side so the\n240   *  renderer can draw the quote card without a second roundtrip. Missing\n241   *  when the original was deleted — bubble renders "[deleted]". */\n242  quoted?: QuotedSummary\n243  /** Number of OTHER messages quoting this one. Drives the "N 条回复" link\n244   *  under the bubble that opens the thread drawer. Server-computed on\n245   *  fetch; 0 / undefined means no replies. */\n246  replyCount?: number\n247  /** Optimistic-render flags. Only set on locally-inserted messages awaiting\n248   *  the server round-trip; never returned from the API. */\n249  pending?: boolean\n250  failed?: boolean\n251  /** Stable identity that survives the temp-id → real-id rename so React\n252   *  keeps the same DOM node (avoids re-firing the entry animation). Set on\n253   *  optimistic insert; carried onto the server echo in `applyEvent`. */\n254  clientId?: string\n255}

▸WebSocket 连接用 membership 过滤事件

server/src/ws.ts:26 到 server/src/ws.ts:45 的 AuthedSocket 保存 userId、originId、companies、doc subscriptions 和 heartbeat 标记;server/src/ws.ts:135 到 server/src/ws.ts:155 通过 company_members 查询 membership。实时广播不是裸推,而是先知道这个 socket 属于哪些租户。

server/src/ws.ts · L135-201TypeScript
135async function loadMemberships(userId: string): Promise<Set<string>> {\n136  const { rows } = await pool.query<{ company_id: string }>(\n137    `SELECT company_id FROM company_members WHERE user_id = $1`,\n138    [userId],\n139  )\n140  return new Set(rows.map((r) => r.company_id))\n141}\n142\n143/** Look up a doc + verify the caller's tenant membership in one shot.\n144 *  Returns null when the doc doesn't exist OR the caller can't see it —\n145 *  same opaque posture the chat handlers use to avoid leaking existence. */\n146async function docCompanyFor(documentId: string, userId: string): Promise<string | null> {\n147  const { rows } = await pool.query<{ company_id: string }>(\n148    `SELECT d.company_id\n149       FROM documents d\n150       JOIN company_members m ON m.company_id = d.company_id AND m.user_id = $2\n151      WHERE d.id = $1\n152      LIMIT 1`,\n153    [documentId, userId],\n154  )\n155  return rows[0]?.company_id ?? null\n156}\n157\n158function sendJson(ws: WebSocket, payload: unknown): void {\n159  if (ws.readyState !== ws.OPEN) return\n160  try { ws.send(JSON.stringify(payload)) } catch { /* ignore */ }\n161}\n162\n163async function handleDocFrame(c: AuthedSocket, msg: Record<string, unknown>): Promise<void> {\n164  const type = msg.type as string | undefined\n165  const documentId = typeof msg.documentId === 'string' ? msg.documentId : null\n166  if (!documentId) return\n167\n168  if (type === 'doc.subscribe') {\n169    if (c.docSubs.has(documentId)) return  // idempotent\n170    const companyId = await docCompanyFor(documentId, c.userId)\n171    if (!companyId) {\n172      sendJson(c.ws, { type: 'doc.error', documentId, error: 'not found' })\n173      return\n174    }\n175    const subRec: DocSubscriber = {\n176      originId: c.originId,\n177      onUpdate: (update, originId) => {\n178        sendJson(c.ws, {\n179          type: 'doc.update',\n180          documentId,\n181          updateB64: Buffer.from(update).toString('base64'),\n182          originId,\n183        })\n184      },\n185      onAwareness: (update, originId) => {\n186        sendJson(c.ws, {\n187          type: 'doc.awareness',\n188          documentId,\n189          updateB64: Buffer.from(update).toString('base64'),\n190          originId,\n191        })\n192      },\n193    }\n194    const { initialState } = await docSubscribe(documentId, companyId, subRec)\n195    c.docSubs.set(documentId, subRec)\n196    sendJson(c.ws, {\n197      type: 'doc.sync',\n198      documentId,\n199      stateB64: Buffer.from(initialState).toString('base64'),\n200      originId: c.originId,\n201    })
§ 02 · KEY FLOW

关键流程

REST list conversations → WebSocket ticket auth → tenant-filtered Redis bridge → message / reaction / poll / doc events → zustand stores render one team inbox

doc 协作路径展示了同一 WS 如何服务非聊天状态:server/src/ws.ts:163 的 handleDocFrame 处理 subscribe/update/awareness,server/src/ws.ts:213 到 server/src/ws.ts:235 把 Yjs update 和 awareness 分开广播。

§ 03 · EDGE CASES

边界 / 失败模式

  • server/src/ws.ts:49 的 WS_MAX_BUFFERED_BYTES 与 TERMINATE cap 防止慢客户端造成 OOM。
  • server/src/ws.ts:65 的 resetHumanPresenceOnBoot 清理 stale avail,人类 presence 不依赖旧进程状态。
  • server/src/ws.ts:143 的 docCompanyFor 返回 opaque null,避免泄露不可见文档是否存在。
§ 04 · RELATED

关联模块

React shell
src/App.tsx:132 懒加载消息并标记已读。
Document rooms
server/src/documents/rooms.ts:1 管理 Yjs room。
Sub-page · 2026-08-18↑ cumora 主页面