feat(frontend): M9 模态 UI(mode 系统+共享 store+WS 客户端 + chat(思维链/工具回显/情绪徽标)/code/Agent 监控/后台思考页 + Miuix 设计 token + PWA);tsc+vite build 通过

This commit is contained in:
Aska committed 2026-09-06 07:44:40 +08:00
1 parent b3ffd51736
commit 4c48e94659
40 files changed
+5321

No files matched your search

+275
View File
@@ -0,0 +1,275 @@
import { useAppStore } from "../store/useAppStore";
import { useModeStore } from "../store/useModeStore";
import { getAuthToken } from "./api";
import type { AgentInfo, FlowGraph, ToolUsed, WsUpEvent } from "./types";
import { now, uid } from "./util";
/**
* Realtime client for `/ws/chat`. Reconnects with exponential backoff and
* decodes the downstream event stream into the app store. All `data` payloads
* are `unknown` and narrowed here; nothing leaks `any` into components.
*/
let ws: WebSocket | null = null;
let shouldRun = false;
let reconnectTimer: number | null = null;
let attempt = 0;
const MAX_BACKOFF = 30_000;
const BASE_BACKOFF = 1_000;
function wsUrl(): string {
const proto = window.location.protocol === "https:" ? "wss" : "ws";
const token = getAuthToken() ?? "";
const qs = token ? `?token=${encodeURIComponent(token)}` : "";
return `${proto}://${window.location.host}/ws/chat${qs}`;
}
function toRecord(v: unknown): Record<string, unknown> | null {
if (v !== null && typeof v === "object" && !Array.isArray(v)) {
return v as Record<string, unknown>;
}
return null;
}
function parseTool(v: unknown): ToolUsed | null {
const r = toRecord(v);
if (!r) return null;
if (typeof r.name !== "string") return null;
return {
name: r.name,
summary: typeof r.summary === "string" ? r.summary : undefined,
detail: typeof r.detail === "string" ? r.detail : undefined,
ok: typeof r.ok === "boolean" ? r.ok : undefined,
at: now(),
};
}
function parseAgent(payload: unknown): AgentInfo | null {
const r = toRecord(payload);
if (!r) return null;
const id = typeof r.id === "string" ? r.id : uid();
const status =
typeof r.status === "string"
? (r.status as AgentInfo["status"])
: typeof r.state === "string"
? (r.state as AgentInfo["status"])
: "running";
const tool = parseTool(r.tool);
return {
id,
name: typeof r.name === "string" ? r.name : `子代理 ${id.slice(0, 4)}`,
role: typeof r.role === "string" ? r.role : undefined,
mode: typeof r.mode === "string" ? r.mode : undefined,
status,
progress: typeof r.progress === "number" ? r.progress : undefined,
thinking: typeof r.thinking === "string" ? r.thinking : undefined,
message: typeof r.message === "string" ? r.message : undefined,
tools: tool ? [tool] : undefined,
};
}
function parseGraph(payload: unknown): FlowGraph | null {
const r = toRecord(payload);
if (!r || !Array.isArray(r.nodes)) return null;
const nodes = r.nodes
.map((n) => toRecord(n))
.filter((n): n is Record<string, unknown> => n !== null)
.map((n) => ({
id: typeof n.id === "string" ? n.id : uid(),
label: typeof n.label === "string" ? n.label : "节点",
kind: typeof n.kind === "string" ? n.kind : undefined,
status:
typeof n.status === "string"
? (n.status as FlowGraph["nodes"][number]["status"])
: undefined,
}));
const edges = Array.isArray(r.edges)
? r.edges
.map((e) => toRecord(e))
.filter((e): e is Record<string, unknown> => e !== null)
.map((e) => ({
from: typeof e.from === "string" ? e.from : "",
to: typeof e.to === "string" ? e.to : "",
}))
: [];
return { id: typeof r.id === "string" ? r.id : "graph", nodes, edges };
}
function dispatch(ev: { type: string; data: unknown }): void {
const s = useAppStore.getState();
const r = toRecord(ev.data);
switch (ev.type) {
case "stream_chunk": {
if (r && typeof r.delta === "string") s.appendChunk(r.delta);
break;
}
case "stream_end": {
s.endStream();
if (r && typeof r.stopReason === "string") {
s.setContext({ lastAgentMode: s.context.lastAgentMode });
}
break;
}
case "thinking": {
if (typeof ev.data === "string") {
s.setThinking(ev.data);
} else if (r && typeof r.text === "string") {
s.setThinking(r.text);
}
break;
}
case "mood": {
if (typeof ev.data === "string") s.setMood(ev.data);
else if (r && typeof r.mood === "string") s.setMood(r.mood);
break;
}
case "tool_used": {
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;
}
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}`);
}
}
}
function scheduleReconnect(): void {
if (!shouldRun) return;
if (reconnectTimer !== null) return;
const delay = Math.min(BASE_BACKOFF * 2 ** attempt, MAX_BACKOFF);
attempt += 1;
useAppStore.getState().setWsStatus("reconnecting");
reconnectTimer = window.setTimeout(() => {
reconnectTimer = null;
openSocket();
}, delay);
}
function openSocket(): void {
const s = useAppStore.getState();
try {
ws = new WebSocket(wsUrl());
} catch {
s.setWsStatus("disconnected");
scheduleReconnect();
return;
}
s.setWsStatus(attempt === 0 ? "connecting" : "reconnecting");
ws.onopen = () => {
attempt = 0;
useAppStore.getState().setWsStatus("connected");
// Sync the current mode on every (re)connect so the agent's shell matches.
const mode = useModeStore.getState().mode;
sendUp({ type: "mode_switch", data: { mode } });
};
ws.onmessage = (msg) => {
try {
const parsed = JSON.parse(String(msg.data)) as unknown;
const r = toRecord(parsed);
if (!r) return;
dispatch({ type: typeof r.type === "string" ? r.type : "", data: r.data });
} catch {
// ignore malformed frames
}
};
ws.onerror = () => {
useAppStore.getState().setWsStatus("disconnected");
};
ws.onclose = () => {
ws = null;
useAppStore.getState().setWsStatus("disconnected");
scheduleReconnect();
};
}
/** Open (or reopen) the realtime channel. Safe to call repeatedly. */
export function connectWs(): void {
if (shouldRun && (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING)) {
return;
}
shouldRun = true;
openSocket();
}
/** Close the channel and stop reconnecting. */
export function disconnectWs(): void {
shouldRun = false;
if (reconnectTimer !== null) {
window.clearTimeout(reconnectTimer);
reconnectTimer = null;
}
const cur = ws;
ws = null;
if (cur) {
cur.onclose = null;
cur.close();
}
useAppStore.getState().setWsStatus("idle");
}
/** Send an upstream frame; returns false when the socket is not open. */
export function sendUp(ev: WsUpEvent): boolean {
const cur = ws;
if (!cur || cur.readyState !== WebSocket.OPEN) return false;
cur.send(JSON.stringify(ev));
return true;
}
/** Is the realtime channel open? */
export function isWsOpen(): boolean {
return ws?.readyState === WebSocket.OPEN;
}