feat(frontend): M9 模态 UI(mode 系统+共享 store+WS 客户端 + chat(思维链/工具回显/情绪徽标)/code/Agent 监控/后台思考页 + Miuix 设计 token + PWA);tsc+vite build 通过
This commit is contained in:
1 parent
b3ffd51736
commit
4c48e94659
40 files changed
+5321
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user