agent-111 JWT过期重置+系统设置去占位: - ws.ts 识别401/过期 markAuthExpired(清token+停重连+expired态),isTokenExpired;wsParse.ts拆出压行371;scheduleReconnect有token未过期才重连 - api.ts REST 401→auth_expired+清token+引导重登;login走skipAuthExpired密码错不误清 - types.ts WsStatus加expired;TopBar/Nav expired显示登录过期;System.tsx去alert占位改4张事实卡+合并Notice agent-113 用户端登录页: - components/Login.tsx Miuix登录卡(用户名/密码→login()→setAuthToken);App !getAuthToken()→<Login>;AdminApp LoginGate指向 agent-112 系统设置前三个(owner+审计): - auth.APIKey.RevokeAll(吊销全部key);auth.JWTService.RotateSecret(≥32校验+并发安全+新密钥不回显) - gateway admin_system.go: POST revoke-keys/rotate-jwt/backup(VACUUM INTO)/restore(防路径穿越+恢复前自动备份+原子替换+需重启) - wire.go 传DatabasePath;全仓 build/vet/test绿+交叉编译;api-client v0.17系统设置契约 实机验证: 登录(role=owner)+revoke-keys 200+渠道列表含kook; 全仓绿+前端build绿; .env密码仅登录验证不打印
161 lines
6.3 KiB
TypeScript
161 lines
6.3 KiB
TypeScript
import { MODES, type AgentInfo, type ChatMessage, type FlowGraph, type Mode, type ToolUsed } from "./types";
|
|
import { now, uid } from "./util";
|
|
|
|
/**
|
|
* Pure frame decoders for the `/ws/chat` downstream stream (design/04 §1.1 +
|
|
* design/16 §4). Each takes an `unknown` `data` payload, narrows it, and returns a
|
|
* typed model (or `null` to drop the frame). None of these touch the store, so the
|
|
* socket client (`ws.ts`) stays focused on connection lifecycle.
|
|
*/
|
|
|
|
export 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;
|
|
}
|
|
|
|
/** Decode a base64 string into bytes (used for the voice audio reply). */
|
|
function decodeBase64(b64: string): Uint8Array {
|
|
const bin = atob(b64);
|
|
const bytes = new Uint8Array(bin.length);
|
|
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
return bytes;
|
|
}
|
|
|
|
/** Best-effort playback of a voice_output audio payload (blocked by autoplay
|
|
* policy, so sound may be silent until the user interacts). */
|
|
export function playVoiceAudio(payload: unknown): void {
|
|
const r = toRecord(payload);
|
|
if (!r || typeof r.audio !== "string" || !r.audio) return;
|
|
try {
|
|
const bytes = decodeBase64(r.audio);
|
|
const format = typeof r.format === "string" && r.format ? r.format : "wav";
|
|
const buf = new ArrayBuffer(bytes.byteLength);
|
|
new Uint8Array(buf).set(bytes);
|
|
const blob = new Blob([buf], { type: `audio/${format}` });
|
|
const url = URL.createObjectURL(blob);
|
|
const audio = new Audio(url);
|
|
void audio.play().catch(() => {});
|
|
audio.onended = () => URL.revokeObjectURL(url);
|
|
} catch {
|
|
// Ignore malformed audio payloads.
|
|
}
|
|
}
|
|
|
|
export 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(),
|
|
};
|
|
}
|
|
|
|
/** Normalize a capacity_awaken `tool` field into a tool-name string. The backend
|
|
* may send a bare name or a `{name}` object; both reduce to the name. */
|
|
export function parseAwakenTool(v: unknown): string | undefined {
|
|
if (typeof v === "string") return v || undefined;
|
|
const r = toRecord(v);
|
|
if (r && typeof r.name === "string" && r.name) return r.name;
|
|
return undefined;
|
|
}
|
|
|
|
/** Build a tool card from a tool_call.* payload. Unlike the legacy tool_used
|
|
* frame these carry `id` (provider block id) rather than a guaranteed `name`,
|
|
* so we label the card by id when no name is present. */
|
|
export function parseToolCall(v: unknown): ToolUsed | null {
|
|
const r = toRecord(v);
|
|
if (!r) return null;
|
|
const name = typeof r.name === "string" ? r.name : "";
|
|
const id = typeof r.id === "string" ? r.id : "";
|
|
if (!name && !id) return null;
|
|
return {
|
|
name: name || `tool_${id.slice(0, 4)}`,
|
|
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(),
|
|
};
|
|
}
|
|
|
|
export 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,
|
|
};
|
|
}
|
|
|
|
export 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 };
|
|
}
|
|
|
|
/** Build a ChatMessage from a `message_persisted` payload (design/16 §3.3/§4.2):
|
|
* the backend broadcast a fully-persisted message carrying the backend-generated
|
|
* id. mode is validated against the known modes so a bad value never reaches the
|
|
* UI; createdAt falls back to now when the wire omits it. */
|
|
export function parsePersistedMessage(v: unknown): ChatMessage | null {
|
|
const r = toRecord(v);
|
|
if (!r) return null;
|
|
const id = typeof r.id === "string" && r.id ? r.id : "";
|
|
const role = r.role;
|
|
if (!id || (role !== "user" && role !== "assistant" && role !== "system")) return null;
|
|
return {
|
|
id,
|
|
sessionId: typeof r.sessionId === "string" && r.sessionId ? r.sessionId : undefined,
|
|
role,
|
|
content: typeof r.content === "string" ? r.content : "",
|
|
mode: typeof r.mode === "string" && MODES.includes(r.mode as Mode) ? (r.mode as Mode) : undefined,
|
|
mood: typeof r.mood === "string" ? r.mood : undefined,
|
|
image: typeof r.image === "string" ? r.image : undefined,
|
|
platform: typeof r.platform === "string" && r.platform ? r.platform : undefined,
|
|
deviceId: typeof r.deviceId === "string" && r.deviceId ? r.deviceId : undefined,
|
|
senderName: typeof r.senderName === "string" && r.senderName ? r.senderName : undefined,
|
|
createdAt: typeof r.createdAt === "number" ? r.createdAt : now(),
|
|
};
|
|
}
|