Chat State and WS 深度解析
这一页看 cumora 如何把 agent 队友、conversation、message、doc 协作和 WebSocket 事件放进同一前端/服务端模型。
导入链与契约
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。
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}
核心实现,逐段拆
▸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 数据结构。
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 永久字段。
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 属于哪些租户。
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 })
关键流程
doc 协作路径展示了同一 WS 如何服务非聊天状态:server/src/ws.ts:163 的 handleDocFrame 处理 subscribe/update/awareness,server/src/ws.ts:213 到 server/src/ws.ts:235 把 Yjs update 和 awareness 分开广播。
边界 / 失败模式
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,避免泄露不可见文档是否存在。
关联模块
src/App.tsx:132 懒加载消息并标记已读。server/src/documents/rooms.ts:1 管理 Yjs room。