feat: JWT过期自动重置 + 用户端登录页 + 系统设置前三个真实实现
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密码仅登录验证不打印
This commit is contained in:
1 parent
7e39153a69
commit
1ebc82fffd
19 files changed
+1164
-245
No files matched your search
@@ -101,6 +101,38 @@ export function setStoredRole(role: Role | null): void {
|
||||
else localStorage.removeItem(ROLE_KEY);
|
||||
}
|
||||
|
||||
/** True when the JWT's `exp` (unix seconds) is already in the past. Used client-side
|
||||
* to recognise a handshake rejection as "token expired" before the browser ever
|
||||
* exposes the 401 (WS handshake failures surface only as a generic close). */
|
||||
export function isTokenExpired(token: string): boolean {
|
||||
const parts = token.split(".");
|
||||
if (parts.length < 2) return false;
|
||||
try {
|
||||
const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
|
||||
const payload = JSON.parse(atob(padded)) as { exp?: number };
|
||||
const exp = payload.exp;
|
||||
return typeof exp === "number" && exp > 0 && Math.floor(Date.now() / 1000) >= exp;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Subscribe to an "auth expired" event. The REST layer fires this exactly once
|
||||
* when a non-login request comes back 401/unauthorized, after already clearing
|
||||
* the stored token; the WS layer subscribes to stop reconnecting + flag the UI. */
|
||||
type AuthExpiredHandler = () => void;
|
||||
const authExpiredHandlers = new Set<AuthExpiredHandler>();
|
||||
export function onAuthExpired(handler: AuthExpiredHandler): () => void {
|
||||
authExpiredHandlers.add(handler);
|
||||
return () => {
|
||||
authExpiredHandlers.delete(handler);
|
||||
};
|
||||
}
|
||||
function fireAuthExpired(): void {
|
||||
authExpiredHandlers.forEach((h) => h());
|
||||
}
|
||||
|
||||
/** Optional API key for admin endpoints (X-API-Key). */
|
||||
export function getApiKey(): string | null {
|
||||
return localStorage.getItem(API_KEY_KEY);
|
||||
@@ -125,7 +157,8 @@ export interface ChannelConfigSaveResult {
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
init: RequestInit = {}
|
||||
init: RequestInit = {},
|
||||
opts: { skipAuthExpired?: boolean } = {}
|
||||
): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
@@ -151,9 +184,19 @@ async function request<T>(
|
||||
}
|
||||
|
||||
if (!res.ok || body.error) {
|
||||
const code = body.error?.code ?? (res.status === 401 ? "unauthorized" : "unknown");
|
||||
// A 401/unauthorized on a logged-in (non-login) call means the stored token is
|
||||
// gone/expired: clear it and notify the WS layer so it stops reconnecting, then
|
||||
// throw a dedicated code so the UI can prompt re-login. The login call itself is
|
||||
// exempt — a bad password is a per-request failure, not a stale session.
|
||||
if (!opts.skipAuthExpired && (res.status === 401 || code === "unauthorized")) {
|
||||
setAuthToken(null);
|
||||
fireAuthExpired();
|
||||
throw new ApiError(res.status, "auth_expired", "登录已过期,请重新登录");
|
||||
}
|
||||
throw new ApiError(
|
||||
res.status,
|
||||
body.error?.code ?? "unknown",
|
||||
code,
|
||||
body.error?.message ?? `请求失败(${res.status})`
|
||||
);
|
||||
}
|
||||
@@ -162,10 +205,14 @@ async function request<T>(
|
||||
|
||||
/** POST /api/v1/auth/login — exchanges user/password for a JWT. */
|
||||
export function login(username: string, password: string): Promise<LoginResult> {
|
||||
return request<LoginResult>("/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
return request<LoginResult>(
|
||||
"/auth/login",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
},
|
||||
{ skipAuthExpired: true }
|
||||
);
|
||||
}
|
||||
|
||||
/** GET /api/v1/health — liveness probe. */
|
||||
|
||||
@@ -30,7 +30,8 @@ export type WsStatus =
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "disconnected"
|
||||
| "reconnecting";
|
||||
| "reconnecting"
|
||||
| "expired";
|
||||
|
||||
export interface ToolUsed {
|
||||
name: string;
|
||||
|
||||
+61
-154
@@ -1,8 +1,17 @@
|
||||
import { useAppStore } from "../store/useAppStore";
|
||||
import { useModeStore } from "../store/useModeStore";
|
||||
import { getAuthToken } from "./api";
|
||||
import { MODES, type AgentInfo, type AwakenState, type ChatMessage, type FlowGraph, type Mode, type ToolUsed, type WsUpEvent } from "./types";
|
||||
import { now, uid } from "./util";
|
||||
import { getAuthToken, isTokenExpired, onAuthExpired, setAuthToken } from "./api";
|
||||
import {
|
||||
parseAgent,
|
||||
parseAwakenTool,
|
||||
parseGraph,
|
||||
parsePersistedMessage,
|
||||
parseTool,
|
||||
parseToolCall,
|
||||
playVoiceAudio,
|
||||
toRecord,
|
||||
} from "./wsParse";
|
||||
import { MODES, type AwakenState, type Mode, type WsUpEvent } from "./types";
|
||||
|
||||
/**
|
||||
* Realtime client for `/ws/chat`. Reconnects with exponential backoff and
|
||||
@@ -30,157 +39,6 @@ function wsUrl(): string {
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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). */
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
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. */
|
||||
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. */
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
||||
function dispatch(ev: { type: string; data: unknown }): void {
|
||||
const s = useAppStore.getState();
|
||||
const r = toRecord(ev.data);
|
||||
@@ -374,6 +232,13 @@ function dispatch(ev: { type: string; data: unknown }): void {
|
||||
function scheduleReconnect(): void {
|
||||
if (!shouldRun) return;
|
||||
if (reconnectTimer !== null) return;
|
||||
// Only a live, non-expired credential may retry; otherwise this isn't a network
|
||||
// blip but a dead session we should surface, not loop on.
|
||||
const token = getAuthToken();
|
||||
if (!token || isTokenExpired(token)) {
|
||||
markAuthExpired();
|
||||
return;
|
||||
}
|
||||
const delay = Math.min(BASE_BACKOFF * 2 ** attempt, MAX_BACKOFF);
|
||||
attempt += 1;
|
||||
useAppStore.getState().setWsStatus("reconnecting");
|
||||
@@ -385,6 +250,13 @@ function scheduleReconnect(): void {
|
||||
|
||||
function openSocket(): void {
|
||||
const s = useAppStore.getState();
|
||||
// A locally-expired token means the handshake will be rejected with 401 before
|
||||
// it ever upgrades; don't even attempt it — flag the expiry instead.
|
||||
const token = getAuthToken();
|
||||
if (token && isTokenExpired(token)) {
|
||||
markAuthExpired();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ws = new WebSocket(wsUrl());
|
||||
} catch {
|
||||
@@ -393,9 +265,15 @@ function openSocket(): void {
|
||||
return;
|
||||
}
|
||||
|
||||
// Tracks whether this socket ever completed the handshake (reached OPEN). A
|
||||
// browser surfaces a handshake 401 only as onerror+onclose (code 1006, no HTTP
|
||||
// status), so the only way to tell "auth rejected" from "network blip" is to
|
||||
// compare the staged close against the token's own expiry.
|
||||
let opened = false;
|
||||
s.setWsStatus(attempt === 0 ? "connecting" : "reconnecting");
|
||||
|
||||
ws.onopen = () => {
|
||||
opened = true;
|
||||
attempt = 0;
|
||||
useAppStore.getState().setWsStatus("connected");
|
||||
// Sync the current mode on every (re)connect so the agent's shell matches.
|
||||
@@ -415,11 +293,23 @@ function openSocket(): void {
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
// Handshake/auth rejects land here first. An expired credential is not a
|
||||
// transient outage, so stop the loop and prompt re-login rather than looping.
|
||||
const cur = getAuthToken();
|
||||
if (cur && isTokenExpired(cur)) {
|
||||
markAuthExpired();
|
||||
return;
|
||||
}
|
||||
useAppStore.getState().setWsStatus("disconnected");
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
ws = null;
|
||||
const cur = getAuthToken();
|
||||
if (!opened && cur && isTokenExpired(cur)) {
|
||||
markAuthExpired();
|
||||
return;
|
||||
}
|
||||
useAppStore.getState().setWsStatus("disconnected");
|
||||
scheduleReconnect();
|
||||
};
|
||||
@@ -450,6 +340,16 @@ export function disconnectWs(): void {
|
||||
useAppStore.getState().setWsStatus("idle");
|
||||
}
|
||||
|
||||
/** Terminal "auth expired" path: stop reconnecting, drop the stale token, and
|
||||
* flag the UI so it prompts re-login instead of burning backoff on a dead
|
||||
* credential. Fired from (a) a handshake reject on an expired token and (b) the
|
||||
* REST 401 handler (via onAuthExpired). */
|
||||
function markAuthExpired(): void {
|
||||
setAuthToken(null);
|
||||
disconnectWs();
|
||||
useAppStore.getState().setWsStatus("expired");
|
||||
}
|
||||
|
||||
/** Send an upstream frame; returns false when the socket is not open. */
|
||||
export function sendUp(ev: WsUpEvent): boolean {
|
||||
const cur = ws;
|
||||
@@ -462,3 +362,10 @@ export function sendUp(ev: WsUpEvent): boolean {
|
||||
export function isWsOpen(): boolean {
|
||||
return ws?.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
// When the REST layer hits a 401/unauthorized on a logged-in call it clears the
|
||||
// token and fires this handler; mirror it into the same terminal expiry path so
|
||||
// the reconnect loop stops and the UI shows "登录过期" for both WS and REST 401s.
|
||||
onAuthExpired(() => {
|
||||
markAuthExpired();
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
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(),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user