From 6e0323b446b713a15f766f062b732525e8738a2c Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 4 Oct 2026 10:54:06 +0800 Subject: [PATCH] =?UTF-8?q?refactor(frontend):=20=E6=8B=86=20types.ts(871)?= =?UTF-8?q?=20/=20ws.ts::dispatchFrame(266)=EF=BC=88=E7=BA=AF=E6=90=AC?= =?UTF-8?q?=E7=A7=BB=20+=20barrel=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 执行方案 DevDocs/audit/_plan.md 阶段 0 · G0-D: - lib/types.ts(871) → lib/types/ 11 个域文件 + 14 行 `export *` barrel(仿 lib/api.ts 先例) chat / ws / session / agent / flow / persona / api / admin(269) / adminWrite(176) / kook / notification - lib/ws.ts(494) → 203 行入口 + lib/ws/handlers/ 8 文件;dispatchFrame(266 行) 改为按组 分发链(7 个 handler 各持一组 case,命中 return true,未命中走原 default 兜底), 33 个 case 标签一一守恒 机械证明:type 导出集 86 个守恒(拆后并集 diff 为空);去掉空行与 import/export 语句后 **784=784 行、排序 diff 为空**;dispatchFrame 的 case 标签集 33=33 守恒;ws.ts 的 scheduleReconnect 至文件尾与 HEAD 字节级一致。 65 个 lib/types 消费方 + 3 个 lib/ws 消费方**零改动**通过 —— tsc 0 错误正是这一点的强判据。 验证:npx tsc --noEmit 0 错误;npm run build 成功;9 个前端逻辑测试全绿(含真实驱动 拆后 dispatchFrame 的 messageSync 54 checks 与 personaSession 23 checks); 最大文件 admin.ts 269 行、最大函数 openSocket 72 行(均达标)。 --- frontend/web/src/lib/types.ts | 879 +------------------ frontend/web/src/lib/types/admin.ts | 269 ++++++ frontend/web/src/lib/types/adminWrite.ts | 176 ++++ frontend/web/src/lib/types/agent.ts | 41 + frontend/web/src/lib/types/api.ts | 16 + frontend/web/src/lib/types/chat.ts | 79 ++ frontend/web/src/lib/types/flow.ts | 29 + frontend/web/src/lib/types/kook.ts | 94 ++ frontend/web/src/lib/types/notification.ts | 25 + frontend/web/src/lib/types/persona.ts | 44 + frontend/web/src/lib/types/session.ts | 33 + frontend/web/src/lib/types/ws.ts | 57 ++ frontend/web/src/lib/ws.ts | 339 +------ frontend/web/src/lib/ws/handlers/agent.ts | 29 + frontend/web/src/lib/ws/handlers/device.ts | 57 ++ frontend/web/src/lib/ws/handlers/message.ts | 92 ++ frontend/web/src/lib/ws/handlers/session.ts | 65 ++ frontend/web/src/lib/ws/handlers/shared.ts | 14 + frontend/web/src/lib/ws/handlers/stream.ts | 47 + frontend/web/src/lib/ws/handlers/thinking.ts | 47 + frontend/web/src/lib/ws/handlers/tool.ts | 39 + 21 files changed, 1288 insertions(+), 1183 deletions(-) create mode 100644 frontend/web/src/lib/types/admin.ts create mode 100644 frontend/web/src/lib/types/adminWrite.ts create mode 100644 frontend/web/src/lib/types/agent.ts create mode 100644 frontend/web/src/lib/types/api.ts create mode 100644 frontend/web/src/lib/types/chat.ts create mode 100644 frontend/web/src/lib/types/flow.ts create mode 100644 frontend/web/src/lib/types/kook.ts create mode 100644 frontend/web/src/lib/types/notification.ts create mode 100644 frontend/web/src/lib/types/persona.ts create mode 100644 frontend/web/src/lib/types/session.ts create mode 100644 frontend/web/src/lib/types/ws.ts create mode 100644 frontend/web/src/lib/ws/handlers/agent.ts create mode 100644 frontend/web/src/lib/ws/handlers/device.ts create mode 100644 frontend/web/src/lib/ws/handlers/message.ts create mode 100644 frontend/web/src/lib/ws/handlers/session.ts create mode 100644 frontend/web/src/lib/ws/handlers/shared.ts create mode 100644 frontend/web/src/lib/ws/handlers/stream.ts create mode 100644 frontend/web/src/lib/ws/handlers/thinking.ts create mode 100644 frontend/web/src/lib/ws/handlers/tool.ts diff --git a/frontend/web/src/lib/types.ts b/frontend/web/src/lib/types.ts index f970ad7..7278279 100644 --- a/frontend/web/src/lib/types.ts +++ b/frontend/web/src/lib/types.ts @@ -1,871 +1,14 @@ // Shared wire & UI model types for the Cyrene v2 user frontend (M9). // Strict mode: no `any`; unknown payloads are narrowed at the parse boundary. -import type { PersonaThinking, PersonaThinkingRequest } from "./personaThinking"; - -/** The 9 behavior modes. Switching only changes the presentation layer. */ -export type Mode = - | "chat" - | "code" - | "home" - | "work" - | "docs" - | "voice" - | "vision" - | "agents" - | "settings"; - -export const MODES: ReadonlyArray = [ - "chat", - "code", - "home", - "work", - "docs", - "voice", - "vision", - "agents", - "settings", -]; - -export type WsStatus = - | "idle" - | "connecting" - | "connected" - | "disconnected" - | "reconnecting" - | "expired"; - -export interface ToolUsed { - name: string; - summary?: string; - ok?: boolean; - detail?: string; - at: number; -} - -/** 唤醒式能力三态(design/14 §1):静息·温柔陪伴 / 唤醒·办事 / 全能·复杂任务。 */ -export type AwakenState = "quiet" | "awake" | "full"; - -/** capacity_awaken 下行事件 payload(design/14 §5):状态切换 + 本轮点亮的工具。 - * `tool` 可能是工具名字符串,也可能是含 `name` 的对象,解析层统一为字符串。 */ -export interface AwakenPayload { - fromState?: AwakenState; - toState?: AwakenState; - tool?: string | { name: string }; -} - -/** session_busy / session_pending 下行事件 payload(design/15 §5):她是否在忙 + 挂起消息数。 */ -export interface SessionStatePayload { - busy?: boolean; - pendingCount?: number; -} - -export interface ChatMessage { - id: string; - /** The owning session (backend `session_id`). Set when loaded via the sessions - * API; absent on a client-side optimistic bubble until it is persisted. */ - sessionId?: string; - role: "user" | "assistant" | "system"; - content: string; - /** Behavior mode this message was produced in — drives its bubble rendering. - * Each message carries its own mode, so a single session can mix bubble shapes. */ - mode?: Mode; - streaming?: boolean; - thinking?: string; - mood?: string; - tools?: ToolUsed[]; - /** Optional image attached to the turn (base64 data: URI or http(s) URL). The - * user bubble renders it as a thumb; vision-mode assistant bodies render it - * as an image card. Absent for legacy persisted messages, so renders fall back - * to plain text. */ - image?: string; - /** Source-platform tag of the message (design/16 §4.4): "web" for messages that - * originate on the Web main session, or an IM channel (e.g. "telegram", - * "weixin-ilink", "qq-obv11") for messages mirrored in from an IM adapter. The - * Web client renders the full session; an IM adapter filters to its own platform. - * Absent on legacy/client-optimistic messages. */ - platform?: string; - /** Concrete client/device that produced the message (design/16 §4.5), e.g. - * "web:chrome:a1b2c3" for a browser or "weixin-ilink:conf0:wx_uid" for an IM - * channel. Complements platform (which only names the platform). The Web client - * defaults to labeling the bubble source when this is present; absent on - * legacy/client-optimistic messages kept without it. */ - deviceId?: string; - /** Human-readable sender display name of a third-party message (group/stranger, - * design/16 §4.6). Blank for the owner's private chat, so present only when a - * non-owner is speaking; the recipient renders it as the sender label. */ - senderName?: string; - /** The role that produced this message (design/22 §7-5, `chat_messages - * .persona_id`, served by the message DTO). A session may switch roles without - * losing its context, so the authoring role is recorded per turn: a historical - * bubble keeps the role of *its own* turn instead of being re-attributed to the - * role the session speaks as now. - * - * Absent on a client-side optimistic bubble and on an older backend; `''` means - * "inherit the current global default role". Consumers fall back to the active - * session's role only in those cases — never to a name they cannot resolve. */ - personaId?: string; - createdAt: number; -} - -/** The two web session kinds (design/16 §2.1): the fixed main session that every - * turn falls into by default, and a user-created secondary session that shares - * the same persona memory. */ -export type SessionType = "main" | "sub"; - -/** A web conversation session as returned by GET /api/v1/sessions. The main - * session has a fixed id; a secondary session's id is minted on creation. The - * message truth lives server-side, so this is metadata only. */ -export interface SessionMeta { - id: string; - type: SessionType; - title?: string; - createdAt: number; - /** Messages newer than this account's read cursor for the session (Android - * 协同 B3/D4). Optional so an older backend stays compatible — absent means - * "unknown", which the UI and the read path both treat as 0. */ - unreadCount?: number; - /** The role this session talks to (design/22 §2 option (a): the session is the - * single source of truth). `''`/absent means "inherit the global default role", - * which is the pre-multi-persona behaviour, so an older backend keeps working - * unchanged. Resolved in `lib/persona.ts#resolveSessionPersona`. */ - personaId?: string; -} - -export type AgentStatus = - | "idle" - | "running" - | "blocked" - | "done" - | "error"; - -export interface AgentInfo { - id: string; - name: string; - role?: string; - mode?: string; - status: AgentStatus; - progress?: number; - thinking?: string; - tools?: ToolUsed[]; - message?: string; -} - -export interface SubagentEventPayload { - id: string; - name?: string; - state?: string; - status?: AgentStatus; - progress?: number; - message?: string; - thinking?: string; - tool?: ToolUsed; -} - -/** A "backthink" (后台思考) item — agent reasoning done in the background. */ -export interface BackthinkItem { - id: string; - trigger: string; - conclusion?: string; - shouldNotify?: boolean; - pushed?: boolean; - at: number; -} - -export interface FlowNode { - id: string; - label: string; - kind?: string; - status?: AgentStatus; - /** Optional structured metadata (params/result summaries) for the node detail panel. */ - props?: Record; - /** Optional accumulated span events (log) for the node detail panel. */ - log?: TraceEvent[]; - startedAt?: string; - endedAt?: string; -} -export interface FlowEdge { - from: string; - to: string; -} -export interface FlowGraph { - id: string; - nodes: FlowNode[]; - edges: FlowEdge[]; - /** The graph's owning session id (backend `sessionId`). */ - sessionId?: string; - /** Round start/end times (RFC3339), when the backend provides them. */ - startedAt?: string; - endedAt?: string; -} - -export interface SessionRecord { - platform: string; - channelId: string; - sessionId: string; - actorRole: string; - owner: boolean; - isGroup: boolean; - memWritable: boolean; -} - -export interface PersonaRecord { - id: string; - name: string; - character: string; - isDefault: boolean; - mode: string; - memoryNamespace: string; - /** This persona's own avatar image path. Omitted when unset (omitempty). */ - avatar?: string; - /** Per-orientation chat wallpaper; a role's own, never another's. Omitted when unset. */ - chatBackground?: { landscape?: string; portrait?: string }; - /** The role's current mood (v0.43, role-level: design/01 §2 — emotion follows - * the role, never a session, so every session of a role shows the same value). - * `""`/absent = the role has no tracked emotion; the UI keeps its neutral - * placeholder rather than inventing one. Used to seed the mood badge on load. */ - mood?: string; - /** The optional `thinking` block (v0.43). Admin list (`GET /admin/personas`) - * only; absent when the persona configures none. */ - thinking?: PersonaThinking; -} - -export interface LoginResult { - token: string; - expiresAt: number; - role: string; -} - -export interface ChatSyncResult { - reply: string; - steps: number; - stopReason: string; -} - -/** - * Downstream WS frame (server → client). `data` is intentionally `unknown`; - * every consumer narrows it. The backend emits `{type, data, turnId?, seq?, - * sessionId?}` and types include the structured stream events message.start/delta/end, - * thinking.start/delta/end, tool_call.start/delta/end, tool_execution.update - * (design/04 §1.1), plus the legacy stream_chunk / stream_end / thinking / mood / - * tool_used / subagent_event / graph_update / reminder / device_update / auth / - * error / task_output, and the capacity/session events from design/14 §5 + 15 §5: - * capacity_awaken (状态切换+点亮工具), session_busy / session_pending (她是否在忙+挂起消息数). - * - * `sessionId` (design/16 §4.2) scopes the frame to one conversation. The hub - * broadcasts globally — every connection of the owner sees every session — so the - * client filters by the active session: a frame belonging to another conversation - * (a concurrent stream, an edit, a delete) must never reach the rendered bubble. - * It is absent when the event is not conversation-scoped (panel/device events) or - * when the backend could not resolve the conversation; consumers then keep the old - * permissive behavior rather than dropping the frame. - */ -export interface WsDownEvent { - type: string; - data: unknown; - turnId?: string; - seq?: number; - sessionId?: string; -} - -/** Upstream WS frame (client → server). */ -export interface WsUpEvent { - type: "mode_switch" | "chat" | "voice_input" | "subagent_ask" | "task_output_query"; - reqId?: string; - data?: unknown; -} - -export interface ApiErrorShape { - code?: string; - message?: string; -} - -/** RBAC roles for the admin panel (owner > admin > viewer). */ -export type Role = "owner" | "admin" | "viewer"; - -/** A memory entry as returned by GET /api/v1/admin/memories. */ -export interface MemoryRecord { - id: string; - scope: string; - text: string; -} - -/** A capability/plugin as returned by GET /api/v1/admin/plugins. */ -export interface AdminPluginRecord { - name: string; - kind: string; - description: string; - allowInGroup: boolean; - allowInStranger: boolean; -} - -/** One runtime-installed plugin (GET /api/v1/admin/plugins/installed, v0.28). - * A v1 plugin is an external MCP server; `live` = connected and its tools are - * registered, `toolCount` = tools it contributed. The backend masks apiKey and - * sensitive env values before they reach the client. */ -export interface InstalledPlugin { - id: string; - name: string; - kind: string; - transport: string; - endpoint?: string; - command?: string; - args?: string[]; - apiKey?: string; - env?: Record; - createdAt?: string; - live: boolean; - toolCount: number; -} - -/** A channel adapter as returned by GET /api/v1/admin/channels. */ -export interface AdminChannelRecord { - name: string; - connected: boolean; - /** Configured accounts for this channel's platform (token masked). */ - accounts?: AdminChannelAccount[]; -} - -/** One configured IM channel account (admin view, token masked). */ -export interface AdminChannelAccount { - id: string; - config: Record; - allowGroups?: string[]; - denyGroups?: string[]; - allowUsers?: string[]; - denyUsers?: string[]; - groupMsgPerMin?: number; - msgPerMin?: number; - maxMessages?: number; - /** The role this channel account talks as (design/22 §4.4, 批次 2). Absent/`''` - * means "跟随默认角色" — it only ever applies to the account's non-isolated - * sessions (群聊/陌生人强制默认角色). Written through the config PUT. */ - personaId?: string; - /** Connected bot identity (kook resolves it from /api/v3/user/me). */ - botId?: string; - botUsername?: string; - botNickname?: string; - /** Account-level admin operations the platform adapter exposes (e.g. kook's - * ["friends","user_chats","channels","guilds","message_test"]). */ - capabilities?: string[]; -} - -/** The weixin-iLink scan-login QR payload (GET /admin/channels/{platform}/login-qr). - * qrUrl is the QR image URL; qrImage is a base64 data URL the panel can embed - * directly. status is one of idle|waiting|confirmed|failed. The qrcode scan - * identifier is never exposed. */ -export interface WeixinLoginQRResponse { - /** The WeChat QR image URL (qrcode_img_content); the panel renders a QR from it. */ - qrUrl?: string; - /** The raw scan identifier (qrcode), used for status polling. */ - qrCode?: string; - status: string; -} - -/** The weixin-iLink scan-login status payload (GET /admin/channels/{platform}/login-status). - * botToken is always masked (never a raw secret). */ -export interface WeixinLoginStatusResponse { - status: string; - botId?: string; - botToken?: string; - err?: string; -} - -/** One message in the platform (IM channel) send/receive log. Mirrors the - * historyMessage wire shape from GET /api/v1/admin/channels/{platform}/messages. */ -export interface AdminChannelMessage { - id: string; - sessionId: string; - role: string; // "user" | "assistant" - content: string; - mode?: string; - platform?: string; - deviceId?: string; - /** Adapter (platform:account) that produced the message; blank for Web. */ - adapterId?: string; - /** Human sender display name for group/stranger messages; blank = owner. */ - senderName?: string; - createdAt: number; -} - -/** An IoT device as returned by GET /api/v1/admin/iot. */ -export interface AdminIoTDevice { - id: string; - name: string; - type: string; - state: Record; - online: boolean; -} - -/** A backend user / API key record (GET /api/v1/admin/users, planned M10). */ -export interface AdminUserRecord { - id: string; - username: string; - role: string; - createdAt?: string; -} - -/** LLM provider/model configuration (GET/PUT /api/v1/admin/llm, design/04 §3.1). - * API key is never returned by the backend; provider is the wire protocol. */ -export interface AdminLlmProvider { - id: string; - name: string; - provider: string; - baseUrl: string; - model: string; - multimodal: boolean; - enabled: boolean; - fallback?: string; - /** The group's token budget. Absent/0 = unset (the runtime falls back to - * LLM_FALLBACK_MAX_TOKENS on the last step, then the adapter default). */ - maxTokens?: number; -} -export interface AdminLlmConfig { - providers: AdminLlmProvider[]; -} - -/** An automation / reminder entry (GET/POST /api/v1/admin/automations, planned). */ -export interface AdminAutomationRecord { - id: string; - name: string; - kind: string; - cron?: string; - enabled: boolean; - lastRun?: string; - nextRun?: string; -} - -/** A structured log line (GET /api/v1/admin/logs, planned). */ -export interface AdminLogRecord { - ts: string; - level: string; - source: string; - msg: string; -} - -/** Severity filter of the live log tail (GET /api/v1/admin/observability/logs). - * An absent `level` means “all”. */ -export type AdminLogLevel = "debug" | "info" | "warn" | "error"; - -/** One line of the gateway's structured log ring buffer (newest first). - * `time` is RFC3339 on current builds but the field is typed as either that or - * epoch milliseconds so the renderer stays compatible with both; `source` is - * the `module/subfeature` tag every log line carries (never blank). */ -export interface AdminLogLine { - time: string | number; - level: string; - source: string; - msg: string; -} - -/** Query for GET /api/v1/admin/observability/logs. `limit` defaults to 200 and - * is clamped to 1000 by the backend. */ -export interface AdminLogQuery { - level?: AdminLogLevel; - limit?: number; -} - -/** A knowledge-base document (GET /api/v1/admin/rag). */ -export interface AdminRagDoc { - id: string; - name: string; - desc?: string; - chunks: number; - embedded: boolean; -} - -/** One RAG retrieval hit (GET /api/v1/admin/rag?query= / POST /rag/query). */ -export interface AdminRagHit { - docId: string; - docName: string; - chapter?: string; - text: string; - score: number; - semantic: boolean; -} - -/** The GET /api/v1/admin/rag response shape. */ -export interface AdminRagResponse { - documents: AdminRagDoc[]; - hits?: AdminRagHit[]; -} - -/** One audit entry: who / what / when / result. `id` is a string for the local - * (client) trail but a number for backend-persisted entries. */ -export interface AuditRecord { - id: string | number; - who: string; - what: string; - when: string; - result: string; -} - -/** Gateway request metrics (GET /api/v1/admin/observability/metrics). Token/cost - * is intentionally absent: the gateway does not track spend, and a fabricated - * number would violate the "不伪造指标" red line. */ -export interface AdminObservabilityMetrics { - totalRequests: number; - qps: number; - avgLatencyMs: number; - maxLatencyMs: number; - perMethod: Record; - perPath: Record; - statusCounts: Record; - llmCalls: number; - subagentEvents: number; -} - -/** One traced span (GET /api/v1/admin/observability/trace). */ -export interface AdminTraceSpan { - id: string; - parentId?: string; - name: string; - kind: string; - status: string; - startedAt: string; - endedAt?: string; - durationNs: number; - props?: Record; - events?: TraceEvent[]; -} - -/** A fine-grained event attached to a span. */ -export interface TraceEvent { - time: string; - type: string; - props?: Record; -} - -/** The owner profile (mirrors configs/users/owner.yaml). */ -export interface OwnerProfile { - id: string; - displayName: string; - defaultPersona: string; - identityFile: string; - notes: string; - accounts: OwnerAccount[]; -} -export interface OwnerAccount { - platform: string; - accountId: string; -} - -/* --------------------------------------------------------------------------- - * Admin write payloads / results (POST/PUT/DELETE /api/v1/admin/*). - * The backend persists to a real store or answers honestly with a 501 / clear - * error — never a fabricated success. - * -------------------------------------------------------------------------*/ - -/** POST/PUT /api/v1/admin/personas body. id required on create, path on update. */ -export interface PersonaUpsertRequest { - id?: string; - name: string; - characterRef?: string; - role: string; - systemPrompt?: string; - personality?: string; - speechStyle?: string; - catchphrase?: string; - background?: string; - values?: string[]; - modes?: string[]; - mode?: string; - memoryNamespace?: string; - /** Optional reasoning/presentation block (design/01 §2.1). **Omit the key when - * the form configures nothing**: an omitted object keeps the stored block, - * while an all-default object clears it. `showToUser` stays tri-state — omit it - * (= not configured) rather than sending `false`. */ - thinking?: PersonaThinkingRequest; -} - -/** POST /api/v1/admin/users/apikey — the plaintext key is returned exactly once. */ -export interface ApiKeyIssueResult { - id: string; - key: string; - role: string; -} - -/** POST /api/v1/admin/automations result. */ -export interface AutomationCreateResult { - id: string; - name: string; - kind: string; -} - -/** POST /api/v1/admin/automations/{id}/run result. */ -export interface AutomationRunResult { - id: string; - output: unknown; -} - -/** POST /api/v1/admin/llm body — saves a provider group to models.json and - * hot-reloads the running LLM stack (persisted + active). The backend merges - * the request over the stored group (read-modify-write): an omitted field keeps - * its stored value, so a partial edit never wipes a budget or key. */ -export interface LlmProviderRequest { - id?: string; - name?: string; - provider?: string; - baseUrl: string; - apiKey?: string; - model: string; - multimodal?: boolean; - fallback?: string; - /** Absent = keep the stored budget (or leave unset for a new group). */ - maxTokens?: number; -} - -/** POST /api/v1/admin/llm/test body. */ -export interface LlmTestRequest { - provider?: string; - baseUrl: string; - apiKey?: string; - model: string; - prompt?: string; -} - -/** POST /api/v1/admin/llm/test result. */ -export interface LlmTestResult { - ok: boolean; - provider: string; - model: string; - reply: string; - tokens: number; -} - -/** MCP server connection block for POST /api/v1/admin/plugins/install (v0.28). - * `endpoint` is for http/sse, `command` (+ `args`) for stdio. apiKey is sent - * once and persisted but never echoed back; env is the stdio child environment. */ -export interface PluginInstallConfig { - transport: string; - endpoint?: string; - command?: string; - args?: string[]; - apiKey?: string; - env?: Record; -} - -/** POST /api/v1/admin/plugins/install body. v1 installs MCP servers only. */ -export interface PluginInstallRequest { - kind: string; - name: string; - config: PluginInstallConfig; -} - -/** POST /api/v1/admin/plugins/install result — the live server's registered tool count. */ -export interface PluginInstallResult { - installed: boolean; - name: string; - kind: string; - transport: string; - toolCount: number; -} - -/** POST /api/v1/admin/channels/test result (structural identity verdict). */ -export interface ChannelTestResult { - role: string; - owner: boolean; - isGroup: boolean; - env: string; - verdict: string; -} - -/** POST /api/v1/admin/iot/{id}/control body (params keys depend on device type). */ -export interface IoTControlRequest { - action: string; - params?: Record; -} - -/** POST /api/v1/admin/memories/search body. */ -export interface MemorySearchRequest { - query: string; - personaId?: string; - topK?: number; -} - -/** One memory retrieval hit. */ -export interface MemorySearchHit { - id: string; - scope: string; - text: string; - score: number; -} - -/** POST /api/v1/admin/memories/search result. */ -export interface MemorySearchResult { - query: string; - personaId: string; - semantic: boolean; - hits: MemorySearchHit[]; -} - -/** POST /api/v1/admin/memories body — the manual "添加记忆" write. Scope is - * optional and defaults to persona; it must be one of user|persona|project. */ -export interface MemoryAddRequest { - personaId?: string; - content: string; - scope?: "user" | "persona" | "project"; -} - -/** POST /api/v1/admin/memories result (the stored item's identity + text). */ -export interface MemoryAddResult { - id: string; - scope: string; - text: string; -} - -/** POST /api/v1/admin/rag body — the admin "导入文档" write. */ -export interface RagIndexRequest { - name: string; - desc?: string; - content: string; -} - -/* --- System management writes (design/11 §4 + design/10 §3 模块14; owner-only, audited). - * Field names mirror the gateway DTOs in internal/services/gateway/admin_system.go. --- */ - -/** POST /api/v1/admin/system/revoke-keys — how many API keys were revoked. */ -export interface SystemRevokeKeysResp { - revoked: number; -} - -/** POST /api/v1/admin/system/rotate-jwt — `note` warns that every existing token is now - * invalid. The new secret is never echoed back by the backend. */ -export interface SystemRotateJwtResp { - rotated: true; - note: string; -} - -/** POST /api/v1/admin/system/backup — the snapshot file path + byte size. */ -export interface SystemBackupResp { - path: string; - size: number; -} - -/** POST /api/v1/admin/system/restore — a pre-restore snapshot path + a restart notice. */ -export interface SystemRestoreResp { - restored: true; - backup: string; - note: string; -} - -/* --- KOOK per-adapter panel data (design/17 §2; GET /admin/channels/kook/view). --- */ - -/** One friend / blocked user in GET /admin/channels/{platform}/friends. */ -export interface KookFriendEntry { - user_id: string; - username?: string; - nickname?: string; - avatar?: string; - online?: boolean; -} - -/** One pending friend request (has a top-level request id). */ -export interface KookFriendRequestEntry { - id: number; - friend: KookFriendEntry; -} - -/** GET /admin/channels/{platform}/friends parsed data. */ -export interface KookFriendList { - friends?: KookFriendEntry[]; - requests?: KookFriendRequestEntry[]; - blocked?: KookFriendEntry[]; -} - -/** One private-chat session (GET /admin/channels/kook/view userChats). */ -export interface KookUserChat { - code: string; - last_read_time: number; - latest_msg_time: number; - unread_count: number; - target_id: string; - target_name?: string; - is_friend?: boolean; - is_blocked?: boolean; -} - -/** One server in the guild list. */ -export interface KookGuild { - id: string; - name: string; - icon?: string; - topic?: string; -} - -/** One channel in a guild. */ -export interface KookChannel { - id: string; - user_id?: string; - guild_id: string; - parent_id?: string; - name: string; - type: number; - level?: number; - is_category?: boolean; -} - -/** One guild member for the aggregate view. */ -export interface KookGuildUser { - user_id: string; - username?: string; - nickname?: string; - avatar?: string; - online?: boolean; - bot?: boolean; -} - -/** One channel message (GET /admin/channels/kook/view, not currently fetched). */ -export interface KookChannelMessage { - id: string; - type: number; - content: string; - author_id: string; - author_name?: string; - create_at: number; -} - -/** POST /admin/channels/{platform}/message/test result. */ -export interface KookMessageTestResult { - msg_id: string; - msg_timestamp: number; -} - -/** GET /api/v1/admin/channels/{platform}/view — the KOOK aggregate panel payload. - * Sub-fetches that fail are recorded in errors rather than aborting the view. */ -export interface KookViewResponse { - platform: string; - accountId?: string; - friends?: KookFriendList; - userChats?: KookUserChat[]; - guilds?: KookGuild[]; - channels?: KookChannel[]; - members?: KookGuildUser[]; - errors?: Record; -} - -/** Notification families of the unified feed (design/10 §3 #16). The endpoint - * aggregates pre-existing sources instead of a new model: audit = 系统/安全审计, - * channel = 渠道连接/断开/重连, backthink = 主动推送历史, system = 其余系统事件. */ -export type AdminNotificationType = "system" | "channel" | "backthink" | "audit"; - -/** One item of GET /api/v1/admin/notifications (newest first). `createdAt` is - * epoch milliseconds; `source` records which source produced it (audit / - * backthink / a channel name), so the page can show provenance honestly. */ -export interface AdminNotification { - id: string; - type: AdminNotificationType; - title: string; - body?: string; - source: string; - createdAt: number; -} - -/** Query for GET /api/v1/admin/notifications. An absent `type` means “all”; - * `before` is a `createdAt` (ms) cursor walking toward older items; `limit` - * defaults to 50 and is clamped to 200 by the backend. */ -export interface AdminNotificationQuery { - type?: AdminNotificationType; - before?: number; - limit?: number; -} +export * from "./types/chat"; +export * from "./types/ws"; +export * from "./types/session"; +export * from "./types/agent"; +export * from "./types/flow"; +export * from "./types/persona"; +export * from "./types/api"; +export * from "./types/admin"; +export * from "./types/adminWrite"; +export * from "./types/kook"; +export * from "./types/notification"; diff --git a/frontend/web/src/lib/types/admin.ts b/frontend/web/src/lib/types/admin.ts new file mode 100644 index 0000000..c147882 --- /dev/null +++ b/frontend/web/src/lib/types/admin.ts @@ -0,0 +1,269 @@ +/** RBAC roles for the admin panel (owner > admin > viewer). */ +export type Role = "owner" | "admin" | "viewer"; + +/** A memory entry as returned by GET /api/v1/admin/memories. */ +export interface MemoryRecord { + id: string; + scope: string; + text: string; +} + +/** A capability/plugin as returned by GET /api/v1/admin/plugins. */ +export interface AdminPluginRecord { + name: string; + kind: string; + description: string; + allowInGroup: boolean; + allowInStranger: boolean; +} + +/** One runtime-installed plugin (GET /api/v1/admin/plugins/installed, v0.28). + * A v1 plugin is an external MCP server; `live` = connected and its tools are + * registered, `toolCount` = tools it contributed. The backend masks apiKey and + * sensitive env values before they reach the client. */ +export interface InstalledPlugin { + id: string; + name: string; + kind: string; + transport: string; + endpoint?: string; + command?: string; + args?: string[]; + apiKey?: string; + env?: Record; + createdAt?: string; + live: boolean; + toolCount: number; +} + +/** A channel adapter as returned by GET /api/v1/admin/channels. */ +export interface AdminChannelRecord { + name: string; + connected: boolean; + /** Configured accounts for this channel's platform (token masked). */ + accounts?: AdminChannelAccount[]; +} + +/** One configured IM channel account (admin view, token masked). */ +export interface AdminChannelAccount { + id: string; + config: Record; + allowGroups?: string[]; + denyGroups?: string[]; + allowUsers?: string[]; + denyUsers?: string[]; + groupMsgPerMin?: number; + msgPerMin?: number; + maxMessages?: number; + /** The role this channel account talks as (design/22 §4.4, 批次 2). Absent/`''` + * means "跟随默认角色" — it only ever applies to the account's non-isolated + * sessions (群聊/陌生人强制默认角色). Written through the config PUT. */ + personaId?: string; + /** Connected bot identity (kook resolves it from /api/v3/user/me). */ + botId?: string; + botUsername?: string; + botNickname?: string; + /** Account-level admin operations the platform adapter exposes (e.g. kook's + * ["friends","user_chats","channels","guilds","message_test"]). */ + capabilities?: string[]; +} + +/** The weixin-iLink scan-login QR payload (GET /admin/channels/{platform}/login-qr). + * qrUrl is the QR image URL; qrImage is a base64 data URL the panel can embed + * directly. status is one of idle|waiting|confirmed|failed. The qrcode scan + * identifier is never exposed. */ +export interface WeixinLoginQRResponse { + /** The WeChat QR image URL (qrcode_img_content); the panel renders a QR from it. */ + qrUrl?: string; + /** The raw scan identifier (qrcode), used for status polling. */ + qrCode?: string; + status: string; +} + +/** The weixin-iLink scan-login status payload (GET /admin/channels/{platform}/login-status). + * botToken is always masked (never a raw secret). */ +export interface WeixinLoginStatusResponse { + status: string; + botId?: string; + botToken?: string; + err?: string; +} + +/** One message in the platform (IM channel) send/receive log. Mirrors the + * historyMessage wire shape from GET /api/v1/admin/channels/{platform}/messages. */ +export interface AdminChannelMessage { + id: string; + sessionId: string; + role: string; // "user" | "assistant" + content: string; + mode?: string; + platform?: string; + deviceId?: string; + /** Adapter (platform:account) that produced the message; blank for Web. */ + adapterId?: string; + /** Human sender display name for group/stranger messages; blank = owner. */ + senderName?: string; + createdAt: number; +} + +/** An IoT device as returned by GET /api/v1/admin/iot. */ +export interface AdminIoTDevice { + id: string; + name: string; + type: string; + state: Record; + online: boolean; +} + +/** A backend user / API key record (GET /api/v1/admin/users, planned M10). */ +export interface AdminUserRecord { + id: string; + username: string; + role: string; + createdAt?: string; +} + +/** LLM provider/model configuration (GET/PUT /api/v1/admin/llm, design/04 §3.1). + * API key is never returned by the backend; provider is the wire protocol. */ +export interface AdminLlmProvider { + id: string; + name: string; + provider: string; + baseUrl: string; + model: string; + multimodal: boolean; + enabled: boolean; + fallback?: string; + /** The group's token budget. Absent/0 = unset (the runtime falls back to + * LLM_FALLBACK_MAX_TOKENS on the last step, then the adapter default). */ + maxTokens?: number; +} +export interface AdminLlmConfig { + providers: AdminLlmProvider[]; +} + +/** An automation / reminder entry (GET/POST /api/v1/admin/automations, planned). */ +export interface AdminAutomationRecord { + id: string; + name: string; + kind: string; + cron?: string; + enabled: boolean; + lastRun?: string; + nextRun?: string; +} + +/** A structured log line (GET /api/v1/admin/logs, planned). */ +export interface AdminLogRecord { + ts: string; + level: string; + source: string; + msg: string; +} + +/** Severity filter of the live log tail (GET /api/v1/admin/observability/logs). + * An absent `level` means “all”. */ +export type AdminLogLevel = "debug" | "info" | "warn" | "error"; + +/** One line of the gateway's structured log ring buffer (newest first). + * `time` is RFC3339 on current builds but the field is typed as either that or + * epoch milliseconds so the renderer stays compatible with both; `source` is + * the `module/subfeature` tag every log line carries (never blank). */ +export interface AdminLogLine { + time: string | number; + level: string; + source: string; + msg: string; +} + +/** Query for GET /api/v1/admin/observability/logs. `limit` defaults to 200 and + * is clamped to 1000 by the backend. */ +export interface AdminLogQuery { + level?: AdminLogLevel; + limit?: number; +} + +/** A knowledge-base document (GET /api/v1/admin/rag). */ +export interface AdminRagDoc { + id: string; + name: string; + desc?: string; + chunks: number; + embedded: boolean; +} + +/** One RAG retrieval hit (GET /api/v1/admin/rag?query= / POST /rag/query). */ +export interface AdminRagHit { + docId: string; + docName: string; + chapter?: string; + text: string; + score: number; + semantic: boolean; +} + +/** The GET /api/v1/admin/rag response shape. */ +export interface AdminRagResponse { + documents: AdminRagDoc[]; + hits?: AdminRagHit[]; +} + +/** One audit entry: who / what / when / result. `id` is a string for the local + * (client) trail but a number for backend-persisted entries. */ +export interface AuditRecord { + id: string | number; + who: string; + what: string; + when: string; + result: string; +} + +/** Gateway request metrics (GET /api/v1/admin/observability/metrics). Token/cost + * is intentionally absent: the gateway does not track spend, and a fabricated + * number would violate the "不伪造指标" red line. */ +export interface AdminObservabilityMetrics { + totalRequests: number; + qps: number; + avgLatencyMs: number; + maxLatencyMs: number; + perMethod: Record; + perPath: Record; + statusCounts: Record; + llmCalls: number; + subagentEvents: number; +} + +/** One traced span (GET /api/v1/admin/observability/trace). */ +export interface AdminTraceSpan { + id: string; + parentId?: string; + name: string; + kind: string; + status: string; + startedAt: string; + endedAt?: string; + durationNs: number; + props?: Record; + events?: TraceEvent[]; +} + +/** A fine-grained event attached to a span. */ +export interface TraceEvent { + time: string; + type: string; + props?: Record; +} + +/** The owner profile (mirrors configs/users/owner.yaml). */ +export interface OwnerProfile { + id: string; + displayName: string; + defaultPersona: string; + identityFile: string; + notes: string; + accounts: OwnerAccount[]; +} +export interface OwnerAccount { + platform: string; + accountId: string; +} diff --git a/frontend/web/src/lib/types/adminWrite.ts b/frontend/web/src/lib/types/adminWrite.ts new file mode 100644 index 0000000..6770546 --- /dev/null +++ b/frontend/web/src/lib/types/adminWrite.ts @@ -0,0 +1,176 @@ +/* --------------------------------------------------------------------------- + * Admin write payloads / results (POST/PUT/DELETE /api/v1/admin/*). + * The backend persists to a real store or answers honestly with a 501 / clear + * error — never a fabricated success. + * -------------------------------------------------------------------------*/ + +/** POST /api/v1/admin/users/apikey — the plaintext key is returned exactly once. */ +export interface ApiKeyIssueResult { + id: string; + key: string; + role: string; +} + +/** POST /api/v1/admin/automations result. */ +export interface AutomationCreateResult { + id: string; + name: string; + kind: string; +} + +/** POST /api/v1/admin/automations/{id}/run result. */ +export interface AutomationRunResult { + id: string; + output: unknown; +} + +/** POST /api/v1/admin/llm body — saves a provider group to models.json and + * hot-reloads the running LLM stack (persisted + active). The backend merges + * the request over the stored group (read-modify-write): an omitted field keeps + * its stored value, so a partial edit never wipes a budget or key. */ +export interface LlmProviderRequest { + id?: string; + name?: string; + provider?: string; + baseUrl: string; + apiKey?: string; + model: string; + multimodal?: boolean; + fallback?: string; + /** Absent = keep the stored budget (or leave unset for a new group). */ + maxTokens?: number; +} + +/** POST /api/v1/admin/llm/test body. */ +export interface LlmTestRequest { + provider?: string; + baseUrl: string; + apiKey?: string; + model: string; + prompt?: string; +} + +/** POST /api/v1/admin/llm/test result. */ +export interface LlmTestResult { + ok: boolean; + provider: string; + model: string; + reply: string; + tokens: number; +} + +/** MCP server connection block for POST /api/v1/admin/plugins/install (v0.28). + * `endpoint` is for http/sse, `command` (+ `args`) for stdio. apiKey is sent + * once and persisted but never echoed back; env is the stdio child environment. */ +export interface PluginInstallConfig { + transport: string; + endpoint?: string; + command?: string; + args?: string[]; + apiKey?: string; + env?: Record; +} + +/** POST /api/v1/admin/plugins/install body. v1 installs MCP servers only. */ +export interface PluginInstallRequest { + kind: string; + name: string; + config: PluginInstallConfig; +} + +/** POST /api/v1/admin/plugins/install result — the live server's registered tool count. */ +export interface PluginInstallResult { + installed: boolean; + name: string; + kind: string; + transport: string; + toolCount: number; +} + +/** POST /api/v1/admin/channels/test result (structural identity verdict). */ +export interface ChannelTestResult { + role: string; + owner: boolean; + isGroup: boolean; + env: string; + verdict: string; +} + +/** POST /api/v1/admin/iot/{id}/control body (params keys depend on device type). */ +export interface IoTControlRequest { + action: string; + params?: Record; +} + +/** POST /api/v1/admin/memories/search body. */ +export interface MemorySearchRequest { + query: string; + personaId?: string; + topK?: number; +} + +/** One memory retrieval hit. */ +export interface MemorySearchHit { + id: string; + scope: string; + text: string; + score: number; +} + +/** POST /api/v1/admin/memories/search result. */ +export interface MemorySearchResult { + query: string; + personaId: string; + semantic: boolean; + hits: MemorySearchHit[]; +} + +/** POST /api/v1/admin/memories body — the manual "添加记忆" write. Scope is + * optional and defaults to persona; it must be one of user|persona|project. */ +export interface MemoryAddRequest { + personaId?: string; + content: string; + scope?: "user" | "persona" | "project"; +} + +/** POST /api/v1/admin/memories result (the stored item's identity + text). */ +export interface MemoryAddResult { + id: string; + scope: string; + text: string; +} + +/** POST /api/v1/admin/rag body — the admin "导入文档" write. */ +export interface RagIndexRequest { + name: string; + desc?: string; + content: string; +} + +/* --- System management writes (design/11 §4 + design/10 §3 模块14; owner-only, audited). + * Field names mirror the gateway DTOs in internal/services/gateway/admin_system.go. --- */ + +/** POST /api/v1/admin/system/revoke-keys — how many API keys were revoked. */ +export interface SystemRevokeKeysResp { + revoked: number; +} + +/** POST /api/v1/admin/system/rotate-jwt — `note` warns that every existing token is now + * invalid. The new secret is never echoed back by the backend. */ +export interface SystemRotateJwtResp { + rotated: true; + note: string; +} + +/** POST /api/v1/admin/system/backup — the snapshot file path + byte size. */ +export interface SystemBackupResp { + path: string; + size: number; +} + +/** POST /api/v1/admin/system/restore — a pre-restore snapshot path + a restart notice. */ +export interface SystemRestoreResp { + restored: true; + backup: string; + note: string; +} diff --git a/frontend/web/src/lib/types/agent.ts b/frontend/web/src/lib/types/agent.ts new file mode 100644 index 0000000..295bbb9 --- /dev/null +++ b/frontend/web/src/lib/types/agent.ts @@ -0,0 +1,41 @@ +import type { ToolUsed } from "./chat"; + +export type AgentStatus = + | "idle" + | "running" + | "blocked" + | "done" + | "error"; + +export interface AgentInfo { + id: string; + name: string; + role?: string; + mode?: string; + status: AgentStatus; + progress?: number; + thinking?: string; + tools?: ToolUsed[]; + message?: string; +} + +export interface SubagentEventPayload { + id: string; + name?: string; + state?: string; + status?: AgentStatus; + progress?: number; + message?: string; + thinking?: string; + tool?: ToolUsed; +} + +/** A "backthink" (后台思考) item — agent reasoning done in the background. */ +export interface BackthinkItem { + id: string; + trigger: string; + conclusion?: string; + shouldNotify?: boolean; + pushed?: boolean; + at: number; +} diff --git a/frontend/web/src/lib/types/api.ts b/frontend/web/src/lib/types/api.ts new file mode 100644 index 0000000..dcdf0b7 --- /dev/null +++ b/frontend/web/src/lib/types/api.ts @@ -0,0 +1,16 @@ +export interface LoginResult { + token: string; + expiresAt: number; + role: string; +} + +export interface ChatSyncResult { + reply: string; + steps: number; + stopReason: string; +} + +export interface ApiErrorShape { + code?: string; + message?: string; +} diff --git a/frontend/web/src/lib/types/chat.ts b/frontend/web/src/lib/types/chat.ts new file mode 100644 index 0000000..e11994a --- /dev/null +++ b/frontend/web/src/lib/types/chat.ts @@ -0,0 +1,79 @@ +/** The 9 behavior modes. Switching only changes the presentation layer. */ +export type Mode = + | "chat" + | "code" + | "home" + | "work" + | "docs" + | "voice" + | "vision" + | "agents" + | "settings"; + +export const MODES: ReadonlyArray = [ + "chat", + "code", + "home", + "work", + "docs", + "voice", + "vision", + "agents", + "settings", +]; + +export interface ToolUsed { + name: string; + summary?: string; + ok?: boolean; + detail?: string; + at: number; +} + +export interface ChatMessage { + id: string; + /** The owning session (backend `session_id`). Set when loaded via the sessions + * API; absent on a client-side optimistic bubble until it is persisted. */ + sessionId?: string; + role: "user" | "assistant" | "system"; + content: string; + /** Behavior mode this message was produced in — drives its bubble rendering. + * Each message carries its own mode, so a single session can mix bubble shapes. */ + mode?: Mode; + streaming?: boolean; + thinking?: string; + mood?: string; + tools?: ToolUsed[]; + /** Optional image attached to the turn (base64 data: URI or http(s) URL). The + * user bubble renders it as a thumb; vision-mode assistant bodies render it + * as an image card. Absent for legacy persisted messages, so renders fall back + * to plain text. */ + image?: string; + /** Source-platform tag of the message (design/16 §4.4): "web" for messages that + * originate on the Web main session, or an IM channel (e.g. "telegram", + * "weixin-ilink", "qq-obv11") for messages mirrored in from an IM adapter. The + * Web client renders the full session; an IM adapter filters to its own platform. + * Absent on legacy/client-optimistic messages. */ + platform?: string; + /** Concrete client/device that produced the message (design/16 §4.5), e.g. + * "web:chrome:a1b2c3" for a browser or "weixin-ilink:conf0:wx_uid" for an IM + * channel. Complements platform (which only names the platform). The Web client + * defaults to labeling the bubble source when this is present; absent on + * legacy/client-optimistic messages kept without it. */ + deviceId?: string; + /** Human-readable sender display name of a third-party message (group/stranger, + * design/16 §4.6). Blank for the owner's private chat, so present only when a + * non-owner is speaking; the recipient renders it as the sender label. */ + senderName?: string; + /** The role that produced this message (design/22 §7-5, `chat_messages + * .persona_id`, served by the message DTO). A session may switch roles without + * losing its context, so the authoring role is recorded per turn: a historical + * bubble keeps the role of *its own* turn instead of being re-attributed to the + * role the session speaks as now. + * + * Absent on a client-side optimistic bubble and on an older backend; `''` means + * "inherit the current global default role". Consumers fall back to the active + * session's role only in those cases — never to a name they cannot resolve. */ + personaId?: string; + createdAt: number; +} diff --git a/frontend/web/src/lib/types/flow.ts b/frontend/web/src/lib/types/flow.ts new file mode 100644 index 0000000..baea80c --- /dev/null +++ b/frontend/web/src/lib/types/flow.ts @@ -0,0 +1,29 @@ +import type { AgentStatus } from "./agent"; +import type { TraceEvent } from "./admin"; + +export interface FlowNode { + id: string; + label: string; + kind?: string; + status?: AgentStatus; + /** Optional structured metadata (params/result summaries) for the node detail panel. */ + props?: Record; + /** Optional accumulated span events (log) for the node detail panel. */ + log?: TraceEvent[]; + startedAt?: string; + endedAt?: string; +} +export interface FlowEdge { + from: string; + to: string; +} +export interface FlowGraph { + id: string; + nodes: FlowNode[]; + edges: FlowEdge[]; + /** The graph's owning session id (backend `sessionId`). */ + sessionId?: string; + /** Round start/end times (RFC3339), when the backend provides them. */ + startedAt?: string; + endedAt?: string; +} diff --git a/frontend/web/src/lib/types/kook.ts b/frontend/web/src/lib/types/kook.ts new file mode 100644 index 0000000..74ef5d2 --- /dev/null +++ b/frontend/web/src/lib/types/kook.ts @@ -0,0 +1,94 @@ +/* --- KOOK per-adapter panel data (design/17 §2; GET /admin/channels/kook/view). --- */ + +/** One friend / blocked user in GET /admin/channels/{platform}/friends. */ +export interface KookFriendEntry { + user_id: string; + username?: string; + nickname?: string; + avatar?: string; + online?: boolean; +} + +/** One pending friend request (has a top-level request id). */ +export interface KookFriendRequestEntry { + id: number; + friend: KookFriendEntry; +} + +/** GET /admin/channels/{platform}/friends parsed data. */ +export interface KookFriendList { + friends?: KookFriendEntry[]; + requests?: KookFriendRequestEntry[]; + blocked?: KookFriendEntry[]; +} + +/** One private-chat session (GET /admin/channels/kook/view userChats). */ +export interface KookUserChat { + code: string; + last_read_time: number; + latest_msg_time: number; + unread_count: number; + target_id: string; + target_name?: string; + is_friend?: boolean; + is_blocked?: boolean; +} + +/** One server in the guild list. */ +export interface KookGuild { + id: string; + name: string; + icon?: string; + topic?: string; +} + +/** One channel in a guild. */ +export interface KookChannel { + id: string; + user_id?: string; + guild_id: string; + parent_id?: string; + name: string; + type: number; + level?: number; + is_category?: boolean; +} + +/** One guild member for the aggregate view. */ +export interface KookGuildUser { + user_id: string; + username?: string; + nickname?: string; + avatar?: string; + online?: boolean; + bot?: boolean; +} + +/** One channel message (GET /admin/channels/kook/view, not currently fetched). */ +export interface KookChannelMessage { + id: string; + type: number; + content: string; + author_id: string; + author_name?: string; + create_at: number; +} + +/** POST /admin/channels/{platform}/message/test result. */ +export interface KookMessageTestResult { + msg_id: string; + msg_timestamp: number; +} + +/** GET /api/v1/admin/channels/{platform}/view — the KOOK aggregate panel payload. + * Sub-fetches that fail are recorded in errors rather than aborting the view. */ +export interface KookViewResponse { + platform: string; + accountId?: string; + friends?: KookFriendList; + userChats?: KookUserChat[]; + guilds?: KookGuild[]; + channels?: KookChannel[]; + members?: KookGuildUser[]; + errors?: Record; +} diff --git a/frontend/web/src/lib/types/notification.ts b/frontend/web/src/lib/types/notification.ts new file mode 100644 index 0000000..f4cb39a --- /dev/null +++ b/frontend/web/src/lib/types/notification.ts @@ -0,0 +1,25 @@ +/** Notification families of the unified feed (design/10 §3 #16). The endpoint + * aggregates pre-existing sources instead of a new model: audit = 系统/安全审计, + * channel = 渠道连接/断开/重连, backthink = 主动推送历史, system = 其余系统事件. */ +export type AdminNotificationType = "system" | "channel" | "backthink" | "audit"; + +/** One item of GET /api/v1/admin/notifications (newest first). `createdAt` is + * epoch milliseconds; `source` records which source produced it (audit / + * backthink / a channel name), so the page can show provenance honestly. */ +export interface AdminNotification { + id: string; + type: AdminNotificationType; + title: string; + body?: string; + source: string; + createdAt: number; +} + +/** Query for GET /api/v1/admin/notifications. An absent `type` means “all”; + * `before` is a `createdAt` (ms) cursor walking toward older items; `limit` + * defaults to 50 and is clamped to 200 by the backend. */ +export interface AdminNotificationQuery { + type?: AdminNotificationType; + before?: number; + limit?: number; +} diff --git a/frontend/web/src/lib/types/persona.ts b/frontend/web/src/lib/types/persona.ts new file mode 100644 index 0000000..9582992 --- /dev/null +++ b/frontend/web/src/lib/types/persona.ts @@ -0,0 +1,44 @@ +import type { PersonaThinking, PersonaThinkingRequest } from "../personaThinking"; + +export interface PersonaRecord { + id: string; + name: string; + character: string; + isDefault: boolean; + mode: string; + memoryNamespace: string; + /** This persona's own avatar image path. Omitted when unset (omitempty). */ + avatar?: string; + /** Per-orientation chat wallpaper; a role's own, never another's. Omitted when unset. */ + chatBackground?: { landscape?: string; portrait?: string }; + /** The role's current mood (v0.43, role-level: design/01 §2 — emotion follows + * the role, never a session, so every session of a role shows the same value). + * `""`/absent = the role has no tracked emotion; the UI keeps its neutral + * placeholder rather than inventing one. Used to seed the mood badge on load. */ + mood?: string; + /** The optional `thinking` block (v0.43). Admin list (`GET /admin/personas`) + * only; absent when the persona configures none. */ + thinking?: PersonaThinking; +} + +/** POST/PUT /api/v1/admin/personas body. id required on create, path on update. */ +export interface PersonaUpsertRequest { + id?: string; + name: string; + characterRef?: string; + role: string; + systemPrompt?: string; + personality?: string; + speechStyle?: string; + catchphrase?: string; + background?: string; + values?: string[]; + modes?: string[]; + mode?: string; + memoryNamespace?: string; + /** Optional reasoning/presentation block (design/01 §2.1). **Omit the key when + * the form configures nothing**: an omitted object keeps the stored block, + * while an all-default object clears it. `showToUser` stays tri-state — omit it + * (= not configured) rather than sending `false`. */ + thinking?: PersonaThinkingRequest; +} diff --git a/frontend/web/src/lib/types/session.ts b/frontend/web/src/lib/types/session.ts new file mode 100644 index 0000000..e5a6b8c --- /dev/null +++ b/frontend/web/src/lib/types/session.ts @@ -0,0 +1,33 @@ +/** The two web session kinds (design/16 §2.1): the fixed main session that every + * turn falls into by default, and a user-created secondary session that shares + * the same persona memory. */ +export type SessionType = "main" | "sub"; + +/** A web conversation session as returned by GET /api/v1/sessions. The main + * session has a fixed id; a secondary session's id is minted on creation. The + * message truth lives server-side, so this is metadata only. */ +export interface SessionMeta { + id: string; + type: SessionType; + title?: string; + createdAt: number; + /** Messages newer than this account's read cursor for the session (Android + * 协同 B3/D4). Optional so an older backend stays compatible — absent means + * "unknown", which the UI and the read path both treat as 0. */ + unreadCount?: number; + /** The role this session talks to (design/22 §2 option (a): the session is the + * single source of truth). `''`/absent means "inherit the global default role", + * which is the pre-multi-persona behaviour, so an older backend keeps working + * unchanged. Resolved in `lib/persona.ts#resolveSessionPersona`. */ + personaId?: string; +} + +export interface SessionRecord { + platform: string; + channelId: string; + sessionId: string; + actorRole: string; + owner: boolean; + isGroup: boolean; + memWritable: boolean; +} diff --git a/frontend/web/src/lib/types/ws.ts b/frontend/web/src/lib/types/ws.ts new file mode 100644 index 0000000..1de4fd7 --- /dev/null +++ b/frontend/web/src/lib/types/ws.ts @@ -0,0 +1,57 @@ +export type WsStatus = + | "idle" + | "connecting" + | "connected" + | "disconnected" + | "reconnecting" + | "expired"; + +/** 唤醒式能力三态(design/14 §1):静息·温柔陪伴 / 唤醒·办事 / 全能·复杂任务。 */ +export type AwakenState = "quiet" | "awake" | "full"; + +/** capacity_awaken 下行事件 payload(design/14 §5):状态切换 + 本轮点亮的工具。 + * `tool` 可能是工具名字符串,也可能是含 `name` 的对象,解析层统一为字符串。 */ +export interface AwakenPayload { + fromState?: AwakenState; + toState?: AwakenState; + tool?: string | { name: string }; +} + +/** session_busy / session_pending 下行事件 payload(design/15 §5):她是否在忙 + 挂起消息数。 */ +export interface SessionStatePayload { + busy?: boolean; + pendingCount?: number; +} + +/** + * Downstream WS frame (server → client). `data` is intentionally `unknown`; + * every consumer narrows it. The backend emits `{type, data, turnId?, seq?, + * sessionId?}` and types include the structured stream events message.start/delta/end, + * thinking.start/delta/end, tool_call.start/delta/end, tool_execution.update + * (design/04 §1.1), plus the legacy stream_chunk / stream_end / thinking / mood / + * tool_used / subagent_event / graph_update / reminder / device_update / auth / + * error / task_output, and the capacity/session events from design/14 §5 + 15 §5: + * capacity_awaken (状态切换+点亮工具), session_busy / session_pending (她是否在忙+挂起消息数). + * + * `sessionId` (design/16 §4.2) scopes the frame to one conversation. The hub + * broadcasts globally — every connection of the owner sees every session — so the + * client filters by the active session: a frame belonging to another conversation + * (a concurrent stream, an edit, a delete) must never reach the rendered bubble. + * It is absent when the event is not conversation-scoped (panel/device events) or + * when the backend could not resolve the conversation; consumers then keep the old + * permissive behavior rather than dropping the frame. + */ +export interface WsDownEvent { + type: string; + data: unknown; + turnId?: string; + seq?: number; + sessionId?: string; +} + +/** Upstream WS frame (client → server). */ +export interface WsUpEvent { + type: "mode_switch" | "chat" | "voice_input" | "subagent_ask" | "task_output_query"; + reqId?: string; + data?: unknown; +} diff --git a/frontend/web/src/lib/ws.ts b/frontend/web/src/lib/ws.ts index a63aa84..791a866 100644 --- a/frontend/web/src/lib/ws.ts +++ b/frontend/web/src/lib/ws.ts @@ -1,21 +1,15 @@ -import { messageEventApplies, useAppStore } from "../store/useAppStore"; +import { useAppStore } from "../store/useAppStore"; import { useModeStore } from "../store/useModeStore"; import { getAuthToken, isTokenExpired, onAuthExpired, setAuthToken } from "./api"; -import { - parseAdminChannelMessage, - parseAgent, - parseAwakenTool, - parseBackthinkItem, - parseGraph, - parsePersistedMessage, - parseTool, - parseToolCall, - playVoiceAudio, - toRecord, -} from "./wsParse"; -import { MODES, type AwakenState, type ChatMessage, type Mode, type WsUpEvent } from "./types"; -import { sessionReadTarget } from "./sessionRead"; -import { streamFrameApplies } from "./streamScope"; +import { toRecord } from "./wsParse"; +import type { WsUpEvent } from "./types"; +import { handleStreamEvent } from "./ws/handlers/stream"; +import { handleThinkingEvent } from "./ws/handlers/thinking"; +import { handleToolEvent } from "./ws/handlers/tool"; +import { handleMessageEvent } from "./ws/handlers/message"; +import { handleAgentEvent } from "./ws/handlers/agent"; +import { handleSessionEvent } from "./ws/handlers/session"; +import { handleDeviceEvent } from "./ws/handlers/device"; /** * Realtime client for `/ws/chat`. Reconnects with exponential backoff and @@ -28,11 +22,6 @@ let shouldRun = false; let reconnectTimer: number | null = null; let attempt = 0; -/** Accumulator for a streamed thinking segment (thinking.* events). Reset on - * thinking.start so a mid-turn reconnect can rebuild the collapsed block - * without relying on prior state. */ -let pendingThinking = ""; - const MAX_BACKOFF = 30_000; const BASE_BACKOFF = 1_000; @@ -43,307 +32,27 @@ function wsUrl(): string { return `${proto}://${window.location.host}/ws/chat${qs}`; } -/** The fields of a `message_edited` payload the chat needs: the edited message's - * id and its new content. `sessionId` is carried inside the broadcast DTO (and on - * the frame envelope) so the frame can be filtered; `mode`/`mood` are optional — - * an edit never changes them, but the DTO carries them for completeness. */ -interface MessageEdited { - id: string; - content: string; - sessionId?: string; - mode?: Mode; - mood?: string; -} - -/** Narrow a `message_edited` payload (the updated message DTO), or null when it is - * unusable. `content` is required: an edit always carries the new text, and a - * frame without it must not blank out a rendered bubble. */ -function parseMessageEdited(data: unknown): MessageEdited | null { - const r = toRecord(data); - if (!r || typeof r.id !== "string" || r.id === "" || typeof r.content !== "string") { - return null; - } - return { - id: r.id, - content: r.content, - sessionId: typeof r.sessionId === "string" ? r.sessionId : undefined, - mode: - typeof r.mode === "string" && MODES.includes(r.mode as Mode) - ? (r.mode as Mode) - : undefined, - mood: typeof r.mood === "string" ? r.mood : undefined, - }; -} - /** Route one decoded downstream frame into the store. Exported (with a `frame` * shaped first argument) so the frame→store mapping can be exercised as a pure * logic test without a browser: this project has no jsdom/test runner, so the - * socket lifecycle around it stays untested while the mapping does not have to. */ + * socket lifecycle around it stays untested while the mapping does not have to. + * 帧按类型分组在 lib/ws/handlers/* 中处理:任一组命中(含组内主动丢弃)即结束, + * 全部未命中走下方未知帧兜底 —— 与拆分前的单个 switch 等价。 */ export function dispatchFrame(ev: { type: string; sessionId?: string; data: unknown }): void { const s = useAppStore.getState(); const r = toRecord(ev.data); - switch (ev.type) { - case "stream_chunk": { - if (!streamFrameApplies(ev, r, s.activeSessionId)) break; - if (r && typeof r.delta === "string") s.appendChunk(r.delta); - break; - } - case "stream_end": { - // Another session's turn ending must not close the streaming bubble on - // screen or clear this conversation's busy badge (both are single-slot - // state: the last message's flag, and one shell-wide `sessionBusy`). - if (!streamFrameApplies(ev, r, s.activeSessionId)) break; - s.endStream(); - // The assistant reply stopped streaming, so the immediate turn is over: - // close the busy/pending window so the badge does not stick. If the backend - // keeps a task/subagent running it re-emits session_busy (design/15 §5). - s.setSessionBusy(false, 0); - if (r && typeof r.stopReason === "string") { - s.setContext({ lastAgentMode: s.context.lastAgentMode }); - } - break; - } - // Structured stream events (design/04 §1.1, aligned with OpenClaw). These - // arrive in place of the legacy stream_chunk when the loop streams events. - case "message.start": { - // The assistant bubble is already opened by useChat; nothing to do here. - break; - } - case "message.delta": { - if (!streamFrameApplies(ev, r, s.activeSessionId)) break; - if (r && typeof r.delta === "string") s.appendChunk(r.delta); - break; - } - case "message.end": { - // Body-text segment closed; the turn may continue with tool calls, so we - // do NOT end the stream here (stream_end owns that). - break; - } - case "message_persisted": { - // design/16 §4.2 — a user or assistant message was persisted and broadcast - // with its backend id. Only surface messages for the active session; the - // store dedups by id and adopts the local optimistic bubble (uid → backend - // id), so both the originating and remote browsers converge on one message. - const msg = parsePersistedMessage(r?.message); - if (msg && msg.sessionId === s.activeSessionId) { - s.appendOrUpdateMessage(msg); - } - break; - } - case "message_edited": { - // design/16 §4.2 (D3 edit path) — the stored message's content was changed - // and broadcast with the updated DTO. The backend row stays the single source - // of truth; this only re-renders the bubble, and only in the session it - // belongs to (same filter as message_persisted). Patch by id — an id we do not - // hold is another session's (or an already-gone) message, never a new row. - const edit = parseMessageEdited(ev.data); - if (!edit) break; - if (messageEventApplies(edit.sessionId ?? ev.sessionId, s.activeSessionId)) { - const patch: Partial = { content: edit.content }; - if (edit.mode) patch.mode = edit.mode; - if (edit.mood) patch.mood = edit.mood; - s.updateMessageById(edit.id, patch); - } - break; - } - case "message_deleted": { - // design/16 §4.2 (D3 delete path) — the message was removed server-side, so - // drop it by id instead of waiting for a manual re-read. The id is globally - // unique; sessionId is advisory and, when present, must be the active one. - const id = r && typeof r.id === "string" ? r.id : ""; - if (id && messageEventApplies(ev.sessionId, s.activeSessionId)) { - s.removeMessageById(id); - } - break; - } - case "channel_persisted": { - // design/16 §4.6 §4 — an isolated (group/stranger) channel message was - // persisted and broadcast for the IM adapter log page. The web chat has no - // handler for this type, so it never reaches the active conversation; the - // admin adapter log consumes it via `channelEvents` to append in realtime. - const msg = parseAdminChannelMessage(r?.message); - if (msg) s.pushChannelEvent(msg); - break; - } - case "backthink": { - // design/12 §4 — a background-think pass result was persisted. Head-insert into - // the store so the admin backthink panel appends it in realtime (rows keyed by - // id → React reconciles incrementally, no whole-page re-render). - const item = parseBackthinkItem(r); - if (item) s.addBackthink(item); - break; - } - case "thinking.start": { - // Guarded like the deltas: only the active conversation may reset the - // accumulator (which also bounds a switched-away session's stale text). - if (!streamFrameApplies(ev, r, s.activeSessionId)) break; - pendingThinking = ""; - break; - } - case "thinking.delta": { - if (!streamFrameApplies(ev, r, s.activeSessionId)) break; - if (r && typeof r.delta === "string") { - pendingThinking += r.delta; - s.setThinking(pendingThinking); - } - break; - } - case "thinking.end": { - if (!streamFrameApplies(ev, r, s.activeSessionId)) break; - if (pendingThinking) s.setThinking(pendingThinking); - pendingThinking = ""; - break; - } - case "tool_call.start": - case "tool_call.end": { - if (!streamFrameApplies(ev, r, s.activeSessionId)) break; - const tool = parseToolCall(ev.data); - if (tool) s.addTool(tool); - break; - } - case "tool_call.delta": { - // Argument fragments stream between start/end; the card is added at the - // boundary so mid-stream reconnects do not drop it. - break; - } - case "tool_execution.update": { - // Same card as tool_call.* — same guard. - if (!streamFrameApplies(ev, r, s.activeSessionId)) break; - const tool = parseToolCall(ev.data); - if (tool) s.addTool(tool); - break; - } - case "thinking": { - // Legacy single-shot CoT frame — renders into the same bubble. - if (!streamFrameApplies(ev, r, s.activeSessionId)) break; - if (typeof ev.data === "string") { - s.setThinking(ev.data); - } else if (r && typeof r.text === "string") { - s.setThinking(r.text); - } - break; - } - case "mood": { - // Role-scoped (design/22 §4.6): the frame names the persona the change - // belongs to and carries no sessionId, so the bucket key is personaId — - // each role's mood stays with its own conversations. A legacy string frame - // has nowhere to put it, so it lands in the unscoped "" bucket. - if (typeof ev.data === "string") { - s.setMood(ev.data); - } else if (r && typeof r.mood === "string") { - s.setMood(r.mood, typeof r.personaId === "string" ? r.personaId : ""); - } - break; - } - case "tool_used": { - // Legacy tool echo — same card as tool_call.*. - if (!streamFrameApplies(ev, r, s.activeSessionId)) break; - const tool = parseTool(ev.data); - if (tool) s.addTool(tool); - break; - } - case "subagent_event": { - const agent = parseAgent(ev.data ?? r); - if (agent) s.upsertAgent(agent); - break; - } - case "graph_update": { - const g = parseGraph(ev.data); - if (g) s.setFlowGraph(g); - break; - } - case "task_output": { - if (r) { - const taskId = typeof r.taskId === "string" ? r.taskId : "main"; - const output = - typeof r.output === "string" - ? r.output - : typeof r.line === "string" - ? r.line - : typeof ev.data === "string" - ? ev.data - : JSON.stringify(ev.data); - s.appendTaskLog(taskId, output + "\n"); - } - break; - } - case "device_update": { - if (typeof ev.data === "string") s.addDeviceUpdate(ev.data); - else if (r && typeof r.message === "string") s.addDeviceUpdate(r.message); - break; - } - case "reminder": { - if (typeof ev.data === "string") s.addDeviceUpdate(`提醒:${ev.data}`); - else if (r && typeof r.message === "string") s.addDeviceUpdate(`提醒:${r.message}`); - break; - } - case "error": { - const msg = r && typeof r.message === "string" ? r.message : "未知错误"; - s.addDeviceUpdate(`WS 错误:${msg}`); - break; - } - case "auth": { - // handshake acknowledgement; nothing to persist yet. - break; - } - case "mode_switch": { - // The agent drives the behavior mode (design/01 §3). Apply it to the shell - // so newly begun message bubbles render per that mode. - if (r && typeof r.mode === "string") { - const m = r.mode as Mode; - if (MODES.includes(m)) useModeStore.getState().setMode(m); - } - break; - } - case "voice_output": { - // Voice turn reply: the backend sends back the synthesized audio plus the - // recognized transcript and the assistant reply. Playback is best-effort - // (autoplay may be blocked); the transcript/reply is surfaced as a log line. - playVoiceAudio(ev.data); - if (r && typeof r.reply === "string") { - s.addDeviceUpdate(`语音回复:${r.reply}`); - } else if (r && typeof r.text === "string") { - s.addDeviceUpdate(`语音识别:${r.text}`); - } - break; - } - case "capacity_awaken": { - // design/14 §5 — 能力点亮:静息 → 唤醒 → 全能 + 本轮点亮工具。前端据此更新唤醒徽标/唤醒路径。 - const fromState = - typeof r?.fromState === "string" - ? (r.fromState as AwakenState) - : undefined; - const toState = - (typeof r?.toState === "string" ? (r.toState as AwakenState) : undefined) ?? - "quiet"; - s.setAwaken(fromState, toState, parseAwakenTool(r?.tool)); - break; - } - case "session_busy": - case "session_pending": { - // design/15 §5 — 她忙/有挂起消息时的轻状态(不阻塞输入框)。两者同构:都是 {busy, pendingCount}。 - const busy = typeof r?.busy === "boolean" ? r.busy : true; - const pendingCount = - typeof r?.pendingCount === "number" ? r.pendingCount : s.pendingCount; - s.setSessionBusy(busy, pendingCount); - break; - } - case "session_read": { - // 协同 B3/D4 — one end marked the conversation read, so the unread badge goes - // out on every end. Only the addressed session is touched (a frame naming - // another conversation must not clear the dot of the one on screen), and an - // unattributable frame is dropped rather than guessed. - const id = sessionReadTarget(ev.sessionId, r); - if (id) s.clearUnread(id); - break; - } - default: { - // Unknown future event: keep it in the context log so no frame is lost. - const text = typeof ev.data === "string" ? ev.data : JSON.stringify(ev.data); - s.addDeviceUpdate(`[${ev.type}] ${text}`); - } - } + if (handleStreamEvent(ev, r, s)) return; + if (handleThinkingEvent(ev, r, s)) return; + if (handleToolEvent(ev, r, s)) return; + if (handleMessageEvent(ev, r, s)) return; + if (handleAgentEvent(ev, r, s)) return; + if (handleSessionEvent(ev, r, s)) return; + if (handleDeviceEvent(ev, r, s)) return; + + // Unknown future event: keep it in the context log so no frame is lost. + const text = typeof ev.data === "string" ? ev.data : JSON.stringify(ev.data); + s.addDeviceUpdate(`[${ev.type}] ${text}`); } function scheduleReconnect(): void { diff --git a/frontend/web/src/lib/ws/handlers/agent.ts b/frontend/web/src/lib/ws/handlers/agent.ts new file mode 100644 index 0000000..9a36378 --- /dev/null +++ b/frontend/web/src/lib/ws/handlers/agent.ts @@ -0,0 +1,29 @@ +import { parseAgent, parseBackthinkItem, parseGraph } from "../../wsParse"; +import type { FrameEvent, FramePayload, StoreState } from "./shared"; + +/** 子代理/任务帧处理组:subagent_event / graph_update / backthink。 + * 命中返回 true,未命中返回 false 交给下一组。 */ +export function handleAgentEvent(ev: FrameEvent, r: FramePayload, s: StoreState): boolean { + switch (ev.type) { + case "subagent_event": { + const agent = parseAgent(ev.data ?? r); + if (agent) s.upsertAgent(agent); + return true; + } + case "graph_update": { + const g = parseGraph(ev.data); + if (g) s.setFlowGraph(g); + return true; + } + case "backthink": { + // design/12 §4 — a background-think pass result was persisted. Head-insert into + // the store so the admin backthink panel appends it in realtime (rows keyed by + // id → React reconciles incrementally, no whole-page re-render). + const item = parseBackthinkItem(r); + if (item) s.addBackthink(item); + return true; + } + default: + return false; + } +} diff --git a/frontend/web/src/lib/ws/handlers/device.ts b/frontend/web/src/lib/ws/handlers/device.ts new file mode 100644 index 0000000..e57c5ed --- /dev/null +++ b/frontend/web/src/lib/ws/handlers/device.ts @@ -0,0 +1,57 @@ +import { playVoiceAudio } from "../../wsParse"; +import type { FrameEvent, FramePayload, StoreState } from "./shared"; + +/** 输出/设备侧帧处理组:task_output / device_update / reminder / error / auth / + * voice_output。命中返回 true,未命中返回 false(由 dispatchFrame 走未知帧兜底)。 */ +export function handleDeviceEvent(ev: FrameEvent, r: FramePayload, s: StoreState): boolean { + switch (ev.type) { + case "task_output": { + if (r) { + const taskId = typeof r.taskId === "string" ? r.taskId : "main"; + const output = + typeof r.output === "string" + ? r.output + : typeof r.line === "string" + ? r.line + : typeof ev.data === "string" + ? ev.data + : JSON.stringify(ev.data); + s.appendTaskLog(taskId, output + "\n"); + } + return true; + } + case "device_update": { + if (typeof ev.data === "string") s.addDeviceUpdate(ev.data); + else if (r && typeof r.message === "string") s.addDeviceUpdate(r.message); + return true; + } + case "reminder": { + if (typeof ev.data === "string") s.addDeviceUpdate(`提醒:${ev.data}`); + else if (r && typeof r.message === "string") s.addDeviceUpdate(`提醒:${r.message}`); + return true; + } + case "error": { + const msg = r && typeof r.message === "string" ? r.message : "未知错误"; + s.addDeviceUpdate(`WS 错误:${msg}`); + return true; + } + case "auth": { + // handshake acknowledgement; nothing to persist yet. + return true; + } + case "voice_output": { + // Voice turn reply: the backend sends back the synthesized audio plus the + // recognized transcript and the assistant reply. Playback is best-effort + // (autoplay may be blocked); the transcript/reply is surfaced as a log line. + playVoiceAudio(ev.data); + if (r && typeof r.reply === "string") { + s.addDeviceUpdate(`语音回复:${r.reply}`); + } else if (r && typeof r.text === "string") { + s.addDeviceUpdate(`语音识别:${r.text}`); + } + return true; + } + default: + return false; + } +} diff --git a/frontend/web/src/lib/ws/handlers/message.ts b/frontend/web/src/lib/ws/handlers/message.ts new file mode 100644 index 0000000..f20111d --- /dev/null +++ b/frontend/web/src/lib/ws/handlers/message.ts @@ -0,0 +1,92 @@ +import { messageEventApplies } from "../../../store/useAppStore"; +import { MODES, type ChatMessage, type Mode } from "../../types"; +import { parseAdminChannelMessage, parsePersistedMessage, toRecord } from "../../wsParse"; +import type { FrameEvent, FramePayload, StoreState } from "./shared"; + +/** The fields of a `message_edited` payload the chat needs: the edited message's + * id and its new content. `sessionId` is carried inside the broadcast DTO (and on + * the frame envelope) so the frame can be filtered; `mode`/`mood` are optional — + * an edit never changes them, but the DTO carries them for completeness. */ +interface MessageEdited { + id: string; + content: string; + sessionId?: string; + mode?: Mode; + mood?: string; +} + +/** Narrow a `message_edited` payload (the updated message DTO), or null when it is + * unusable. `content` is required: an edit always carries the new text, and a + * frame without it must not blank out a rendered bubble. */ +function parseMessageEdited(data: unknown): MessageEdited | null { + const r = toRecord(data); + if (!r || typeof r.id !== "string" || r.id === "" || typeof r.content !== "string") { + return null; + } + return { + id: r.id, + content: r.content, + sessionId: typeof r.sessionId === "string" ? r.sessionId : undefined, + mode: + typeof r.mode === "string" && MODES.includes(r.mode as Mode) + ? (r.mode as Mode) + : undefined, + mood: typeof r.mood === "string" ? r.mood : undefined, + }; +} + +/** 消息落库/编辑/删除帧处理组:message_persisted / message_edited / + * message_deleted / channel_persisted。命中(含组内主动丢弃)返回 true, + * 未命中返回 false 交给下一组。 */ +export function handleMessageEvent(ev: FrameEvent, r: FramePayload, s: StoreState): boolean { + switch (ev.type) { + case "message_persisted": { + // design/16 §4.2 — a user or assistant message was persisted and broadcast + // with its backend id. Only surface messages for the active session; the + // store dedups by id and adopts the local optimistic bubble (uid → backend + // id), so both the originating and remote browsers converge on one message. + const msg = parsePersistedMessage(r?.message); + if (msg && msg.sessionId === s.activeSessionId) { + s.appendOrUpdateMessage(msg); + } + return true; + } + case "message_edited": { + // design/16 §4.2 (D3 edit path) — the stored message's content was changed + // and broadcast with the updated DTO. The backend row stays the single source + // of truth; this only re-renders the bubble, and only in the session it + // belongs to (same filter as message_persisted). Patch by id — an id we do not + // hold is another session's (or an already-gone) message, never a new row. + const edit = parseMessageEdited(ev.data); + if (!edit) return true; + if (messageEventApplies(edit.sessionId ?? ev.sessionId, s.activeSessionId)) { + const patch: Partial = { content: edit.content }; + if (edit.mode) patch.mode = edit.mode; + if (edit.mood) patch.mood = edit.mood; + s.updateMessageById(edit.id, patch); + } + return true; + } + case "message_deleted": { + // design/16 §4.2 (D3 delete path) — the message was removed server-side, so + // drop it by id instead of waiting for a manual re-read. The id is globally + // unique; sessionId is advisory and, when present, must be the active one. + const id = r && typeof r.id === "string" ? r.id : ""; + if (id && messageEventApplies(ev.sessionId, s.activeSessionId)) { + s.removeMessageById(id); + } + return true; + } + case "channel_persisted": { + // design/16 §4.6 §4 — an isolated (group/stranger) channel message was + // persisted and broadcast for the IM adapter log page. The web chat has no + // handler for this type, so it never reaches the active conversation; the + // admin adapter log consumes it via `channelEvents` to append in realtime. + const msg = parseAdminChannelMessage(r?.message); + if (msg) s.pushChannelEvent(msg); + return true; + } + default: + return false; + } +} diff --git a/frontend/web/src/lib/ws/handlers/session.ts b/frontend/web/src/lib/ws/handlers/session.ts new file mode 100644 index 0000000..b61e993 --- /dev/null +++ b/frontend/web/src/lib/ws/handlers/session.ts @@ -0,0 +1,65 @@ +import { useModeStore } from "../../../store/useModeStore"; +import { sessionReadTarget } from "../../sessionRead"; +import { MODES, type AwakenState, type Mode } from "../../types"; +import { parseAwakenTool } from "../../wsParse"; +import type { FrameEvent, FramePayload, StoreState } from "./shared"; + +/** 会话/角色状态帧处理组:capacity_awaken / session_busy / session_pending / + * session_read / mode_switch / mood。命中返回 true,未命中返回 false 交给下一组。 */ +export function handleSessionEvent(ev: FrameEvent, r: FramePayload, s: StoreState): boolean { + switch (ev.type) { + case "capacity_awaken": { + // design/14 §5 — 能力点亮:静息 → 唤醒 → 全能 + 本轮点亮工具。前端据此更新唤醒徽标/唤醒路径。 + const fromState = + typeof r?.fromState === "string" + ? (r.fromState as AwakenState) + : undefined; + const toState = + (typeof r?.toState === "string" ? (r.toState as AwakenState) : undefined) ?? + "quiet"; + s.setAwaken(fromState, toState, parseAwakenTool(r?.tool)); + return true; + } + case "session_busy": + case "session_pending": { + // design/15 §5 — 她忙/有挂起消息时的轻状态(不阻塞输入框)。两者同构:都是 {busy, pendingCount}。 + const busy = typeof r?.busy === "boolean" ? r.busy : true; + const pendingCount = + typeof r?.pendingCount === "number" ? r.pendingCount : s.pendingCount; + s.setSessionBusy(busy, pendingCount); + return true; + } + case "session_read": { + // 协同 B3/D4 — one end marked the conversation read, so the unread badge goes + // out on every end. Only the addressed session is touched (a frame naming + // another conversation must not clear the dot of the one on screen), and an + // unattributable frame is dropped rather than guessed. + const id = sessionReadTarget(ev.sessionId, r); + if (id) s.clearUnread(id); + return true; + } + case "mode_switch": { + // The agent drives the behavior mode (design/01 §3). Apply it to the shell + // so newly begun message bubbles render per that mode. + if (r && typeof r.mode === "string") { + const m = r.mode as Mode; + if (MODES.includes(m)) useModeStore.getState().setMode(m); + } + return true; + } + case "mood": { + // Role-scoped (design/22 §4.6): the frame names the persona the change + // belongs to and carries no sessionId, so the bucket key is personaId — + // each role's mood stays with its own conversations. A legacy string frame + // has nowhere to put it, so it lands in the unscoped "" bucket. + if (typeof ev.data === "string") { + s.setMood(ev.data); + } else if (r && typeof r.mood === "string") { + s.setMood(r.mood, typeof r.personaId === "string" ? r.personaId : ""); + } + return true; + } + default: + return false; + } +} diff --git a/frontend/web/src/lib/ws/handlers/shared.ts b/frontend/web/src/lib/ws/handlers/shared.ts new file mode 100644 index 0000000..0cedb84 --- /dev/null +++ b/frontend/web/src/lib/ws/handlers/shared.ts @@ -0,0 +1,14 @@ +import { useAppStore } from "../../../store/useAppStore"; + +/** dispatchFrame 处理的单个下行帧(信封形状,与 lib/ws.ts 导出的 dispatchFrame 签名一致)。 */ +export interface FrameEvent { + type: string; + sessionId?: string; + data: unknown; +} + +/** toRecord(ev.data) 解析后的帧 payload(与 lib/wsParse.ts#toRecord 的返回形状一致)。 */ +export type FramePayload = Record | null; + +/** dispatchFrame 入口处取到的 store 快照类型,按参数传给各帧处理组。 */ +export type StoreState = ReturnType; diff --git a/frontend/web/src/lib/ws/handlers/stream.ts b/frontend/web/src/lib/ws/handlers/stream.ts new file mode 100644 index 0000000..31b55e2 --- /dev/null +++ b/frontend/web/src/lib/ws/handlers/stream.ts @@ -0,0 +1,47 @@ +import { streamFrameApplies } from "../../streamScope"; +import type { FrameEvent, FramePayload, StoreState } from "./shared"; + +/** 流式正文帧处理组:stream_chunk / stream_end / message.start / message.delta / + * message.end。命中(含组内主动丢弃)返回 true,未命中返回 false 交给下一组。 */ +export function handleStreamEvent(ev: FrameEvent, r: FramePayload, s: StoreState): boolean { + switch (ev.type) { + case "stream_chunk": { + if (!streamFrameApplies(ev, r, s.activeSessionId)) return true; + if (r && typeof r.delta === "string") s.appendChunk(r.delta); + return true; + } + case "stream_end": { + // Another session's turn ending must not close the streaming bubble on + // screen or clear this conversation's busy badge (both are single-slot + // state: the last message's flag, and one shell-wide `sessionBusy`). + if (!streamFrameApplies(ev, r, s.activeSessionId)) return true; + s.endStream(); + // The assistant reply stopped streaming, so the immediate turn is over: + // close the busy/pending window so the badge does not stick. If the backend + // keeps a task/subagent running it re-emits session_busy (design/15 §5). + s.setSessionBusy(false, 0); + if (r && typeof r.stopReason === "string") { + s.setContext({ lastAgentMode: s.context.lastAgentMode }); + } + return true; + } + // Structured stream events (design/04 §1.1, aligned with OpenClaw). These + // arrive in place of the legacy stream_chunk when the loop streams events. + case "message.start": { + // The assistant bubble is already opened by useChat; nothing to do here. + return true; + } + case "message.delta": { + if (!streamFrameApplies(ev, r, s.activeSessionId)) return true; + if (r && typeof r.delta === "string") s.appendChunk(r.delta); + return true; + } + case "message.end": { + // Body-text segment closed; the turn may continue with tool calls, so we + // do NOT end the stream here (stream_end owns that). + return true; + } + default: + return false; + } +} diff --git a/frontend/web/src/lib/ws/handlers/thinking.ts b/frontend/web/src/lib/ws/handlers/thinking.ts new file mode 100644 index 0000000..5ea2551 --- /dev/null +++ b/frontend/web/src/lib/ws/handlers/thinking.ts @@ -0,0 +1,47 @@ +import { streamFrameApplies } from "../../streamScope"; +import type { FrameEvent, FramePayload, StoreState } from "./shared"; + +/** Accumulator for a streamed thinking segment (thinking.* events). Reset on + * thinking.start so a mid-turn reconnect can rebuild the collapsed block + * without relying on prior state. */ +let pendingThinking = ""; + +/** 思考流帧处理组:thinking.start / thinking.delta / thinking.end + 遗留单帧 thinking。 + * 命中(含组内主动丢弃)返回 true,未命中返回 false 交给下一组。 */ +export function handleThinkingEvent(ev: FrameEvent, r: FramePayload, s: StoreState): boolean { + switch (ev.type) { + case "thinking.start": { + // Guarded like the deltas: only the active conversation may reset the + // accumulator (which also bounds a switched-away session's stale text). + if (!streamFrameApplies(ev, r, s.activeSessionId)) return true; + pendingThinking = ""; + return true; + } + case "thinking.delta": { + if (!streamFrameApplies(ev, r, s.activeSessionId)) return true; + if (r && typeof r.delta === "string") { + pendingThinking += r.delta; + s.setThinking(pendingThinking); + } + return true; + } + case "thinking.end": { + if (!streamFrameApplies(ev, r, s.activeSessionId)) return true; + if (pendingThinking) s.setThinking(pendingThinking); + pendingThinking = ""; + return true; + } + case "thinking": { + // Legacy single-shot CoT frame — renders into the same bubble. + if (!streamFrameApplies(ev, r, s.activeSessionId)) return true; + if (typeof ev.data === "string") { + s.setThinking(ev.data); + } else if (r && typeof r.text === "string") { + s.setThinking(r.text); + } + return true; + } + default: + return false; + } +} diff --git a/frontend/web/src/lib/ws/handlers/tool.ts b/frontend/web/src/lib/ws/handlers/tool.ts new file mode 100644 index 0000000..b3432ef --- /dev/null +++ b/frontend/web/src/lib/ws/handlers/tool.ts @@ -0,0 +1,39 @@ +import { streamFrameApplies } from "../../streamScope"; +import { parseTool, parseToolCall } from "../../wsParse"; +import type { FrameEvent, FramePayload, StoreState } from "./shared"; + +/** 工具帧处理组:tool_call.start / tool_call.end / tool_call.delta / + * tool_execution.update + 遗留 tool_used。命中(含组内主动丢弃)返回 true, + * 未命中返回 false 交给下一组。 */ +export function handleToolEvent(ev: FrameEvent, r: FramePayload, s: StoreState): boolean { + switch (ev.type) { + case "tool_call.start": + case "tool_call.end": { + if (!streamFrameApplies(ev, r, s.activeSessionId)) return true; + const tool = parseToolCall(ev.data); + if (tool) s.addTool(tool); + return true; + } + case "tool_call.delta": { + // Argument fragments stream between start/end; the card is added at the + // boundary so mid-stream reconnects do not drop it. + return true; + } + case "tool_execution.update": { + // Same card as tool_call.* — same guard. + if (!streamFrameApplies(ev, r, s.activeSessionId)) return true; + const tool = parseToolCall(ev.data); + if (tool) s.addTool(tool); + return true; + } + case "tool_used": { + // Legacy tool echo — same card as tool_call.*. + if (!streamFrameApplies(ev, r, s.activeSessionId)) return true; + const tool = parseTool(ev.data); + if (tool) s.addTool(tool); + return true; + } + default: + return false; + } +}