refactor(frontend): 拆 types.ts(871) / ws.ts::dispatchFrame(266)(纯搬移 + barrel)
执行方案 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 行(均达标)。
This commit is contained in:
1 parent
e9afca9b29
commit
6e0323b446
21 files changed
+1288
-1183
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<ChatMessage> = { 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> | null;
|
||||
|
||||
/** dispatchFrame 入口处取到的 store 快照类型,按参数传给各帧处理组。 */
|
||||
export type StoreState = ReturnType<typeof useAppStore.getState>;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user