feat: JWT过期自动重置 + 用户端登录页 + 系统设置前三个真实实现
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

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:
Aska committed 2026-09-07 13:57:36 +08:00
1 parent 7e39153a69
commit 1ebc82fffd
19 files changed
+1164 -245

No files matched your search

+9
View File
@@ -3,6 +3,7 @@ import { TopBar } from "./components/TopBar";
import { Sidebar, MobileNav } from "./components/Nav";
import { ModeRouter } from "./modes/ModeRouter";
import { AdminApp } from "./admin/AdminApp";
import { Login } from "./components/Login";
import { parseAdminHash } from "./admin/router";
import { connectWs, disconnectWs } from "./lib/ws";
import { initTheme } from "./lib/theme";
@@ -121,6 +122,14 @@ export default function App(): React.ReactNode {
return <AdminApp section={route.section} />;
}
// No credential: never render the chat shell (it would silently show an
// "未登录" shell with no way in). Show the user-side login card instead; its
// onSuccess reloads the page so connectWs + session/persona effects re-run
// with the fresh token. Admin shell keeps its own LoginGate (#/admin).
if (!getAuthToken()) {
return <Login onSuccess={() => window.location.reload()} />;
}
return (
<div className="flex h-full flex-col">
<TopBar onMenu={() => setMenuOpen(true)} />
+1 -1
View File
@@ -170,7 +170,7 @@ function LoginGate(): React.ReactNode {
</div>
<div className="text-lg font-semibold">需要登录</div>
<p className="max-w-md text-sm text-[var(--mu-text-lo)]">
管理面板需持有 JWT。请先到用户端 <span className="text-[var(--mu-text-hi)]">设置 → 连接</span> 登录获取令牌后,再返回本页。
管理面板需持有 JWT。请先在用户端 <span className="text-[var(--mu-text-hi)]">登录页</span> 完成登录后,再返回本页。
</p>
<Button variant="primary" onClick={() => { window.location.hash = ""; }}>前往用户端登录</Button>
</div>
+19 -51
View File
@@ -1,86 +1,54 @@
import { useState } from "react";
import { getStoredRole } from "../../lib/api";
import { useAuditStore } from "../audit";
import { KeyValueRow, Notice, PageHeader } from "../components";
import { Button, Surface, Tag } from "../../components/primitives";
import { Surface, Tag } from "../../components/primitives";
import { Icon } from "../../components/Icon";
/**
* 系统设置 (design/10)。后端 M10+ 才提供真实的密钥轮换/备份/环境变量写入等
* 系统级操作;在此之前只呈现**诚实的只读配置事实**,并把尚未实现的操作合并成
* 一条「未实现」说明——不做满屏点击后 `alert` 的占位按钮。
*/
export function SystemModule(): React.ReactNode {
const isOwner = getStoredRole() === "owner";
const log = useAuditStore((s) => s.log);
const [logLevel, setLogLevel] = useState("info");
const sensitive = (what: string, note: string) => {
if (!isOwner) {
alert(`仅 owner 可执行:${what}。当前角色 ${getStoredRole()}。`);
return;
}
log(`system.${what}`, "ok");
alert(`${note}(占位):后端 M10+ 提供真实执行。`);
};
return (
<div className="space-y-5">
<PageHeader title="系统设置" subtitle="环境变量 + 安全(密钥/JWT) + 存储 + 备份/恢复 + 重启/更新 + 日志级别" />
<PageHeader title="系统设置" subtitle="安全(密钥/JWT) + 运行时 + 存储 + 环境变量(只读);可写系统操作见末条说明" />
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Surface className="space-y-2">
<Surface className="space-y-1">
<SectionTitle icon={<Icon name="shield" size={18} />} title="安全 & 密钥" ownerOnly />
<KeyValueRow label="JWT 密钥" value="••••••••" mono />
<KeyValueRow label="鉴权" value="JWT + RBAC + API key" />
<KeyValueRow label="审计" value="who/what/when/result" />
<div className="flex gap-2 pt-2">
<Button size="sm" variant="outline" disabled={!isOwner} onClick={() => sensitive("rotateJwt", "轮换 JWT 密钥")}>轮换 JWT 密钥</Button>
<Button size="sm" variant="outline" disabled={!isOwner} onClick={() => sensitive("revokeAllKeys", "吊销全部 API key")}>吊销全部 API key</Button>
</div>
<KeyValueRow label="审计" value="who / what / when / result" />
</Surface>
<Surface className="space-y-2">
<Surface className="space-y-1">
<SectionTitle icon={<Icon name="storage" size={18} />} title="存储" />
<KeyValueRow label="默认" value="SQLite" />
<KeyValueRow label="可选增强" value="PG / pgvector(语义/向量路径)" />
<KeyValueRow label="单二进制 / 单端口" value=":8080 · 零跨进程 IPC" />
<div className="flex gap-2 pt-2">
<Button size="sm" variant="outline" disabled={!isOwner} onClick={() => sensitive("backup", "备份数据")}>备份</Button>
<Button size="sm" variant="outline" disabled={!isOwner} onClick={() => sensitive("restore", "恢复数据")}>恢复</Button>
</div>
</Surface>
<Surface className="space-y-2">
<Surface className="space-y-1">
<SectionTitle icon={<Icon name="package" size={18} />} title="环境变量" />
<KeyValueRow label="SERVER_HOST" value="0.0.0.0" mono />
<KeyValueRow label="SERVER_PORT" value="8080" mono />
<KeyValueRow label="AUTH_ADMIN_USER/PASS" value="••••••••" mono />
<div className="pt-2">
<Button size="sm" variant="outline" disabled={!isOwner} onClick={() => sensitive("editEnv", "编辑环境变量")}>编辑环境变量</Button>
</div>
</Surface>
<Surface className="space-y-2">
<Surface className="space-y-1">
<SectionTitle icon={<Icon name="refresh" size={18} />} title="运行时" />
<div className="flex items-center justify-between py-1.5">
<span className="text-xs text-[var(--mu-text-lo)]">日志级别</span>
<span className="flex gap-1.5">
{["debug", "info", "warn", "error"].map((lvl) => (
<button
key={lvl}
onClick={() => setLogLevel(lvl)}
className={`rounded-[var(--mu-radius)] px-2 py-1 text-xs ${
logLevel === lvl ? "bg-[var(--mu-accent)]/20 text-[var(--mu-accent-hi)]" : "bg-[var(--mu-surface-4)] text-[var(--mu-text-mid)]"
}`}
>
{lvl}
</button>
))}
</span>
</div>
<div className="flex flex-wrap gap-2 pt-2">
<Button size="sm" variant="outline" disabled={!isOwner} onClick={() => sensitive("restart", "重启服务")}>重启服务</Button>
<Button size="sm" variant="outline" disabled={!isOwner} onClick={() => sensitive("update", "检查/安装更新")}>检查更新</Button>
</div>
<KeyValueRow label="部署形态" value="单二进制 · 单端口 · 零跨进程 IPC" />
<KeyValueRow label="日志级别" value="后端配置,面板暂不可改(M10+)" />
</Surface>
</div>
<Notice tone="warn">
以下系统操作后端暂未实现(M10+ 落地),当前不可用:轮换 JWT 密钥 · 吊销全部 API
key · 备份/恢复 · 编辑环境变量 · 重启服务 · 检查更新。均为真实读写,未实现前不做占位。
</Notice>
{!isOwner ? <Notice tone="warn">安全 / 密钥 / 系统设置类敏感操作仅 owner 可执行;当前角色 {getStoredRole()}。</Notice> : null}
</div>
);
+136
View File
@@ -0,0 +1,136 @@
import { useState } from "react";
import type { FormEvent, ReactNode } from "react";
import { ApiError, getStoredRole, login, setAuthToken } from "../lib/api";
import type { Role } from "../lib/types";
import { Button, Spinner, Surface, Tag } from "./primitives";
import { Icon } from "./Icon";
/** localStorage key for the optional "记住用户名" toggle. */
const USERNAME_KEY = "cyrene.username";
export interface LoginProps {
/** Called after a successful sign-in; the parent decides how to reflect the
* new session (typically reload so connectWs + session/persona fetch re-run
* with the fresh credential). */
onSuccess: () => void;
}
function roleTone(role: Role): "accent" | "ok" | "neutral" {
return role === "owner" ? "accent" : role === "admin" ? "ok" : "neutral";
}
/** Map a login failure to a user-safe message. Never echoes credentials, and
* collapses server detail into generic wording so nothing sensitive leaks. */
function toLoginMessage(err: unknown): string {
if (err instanceof ApiError) {
if (err.status === 401 || err.code === "unauthorized") return "用户名或密码错误";
if (err.code === "network_error" || err.status === 0) return "网络连接失败,请确认服务已启动";
return err.message || "登录失败";
}
return "登录失败";
}
const inputCls =
"w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm text-[var(--mu-text-hi)] outline-none transition-all duration-200 focus:ring-1 focus:ring-[var(--mu-accent)] placeholder:text-[var(--mu-text-lo)]";
/**
* Miuix user-facing login card. Rendered by App when no JWT is present, so the
* user shell (TopBar/Sidebar/chat) stays unreachable until authenticated. It
* carries no routing dependency — App owns what happens after onSuccess.
*/
export function Login({ onSuccess }: LoginProps): ReactNode {
const [username, setUsername] = useState(() => localStorage.getItem(USERNAME_KEY) ?? "");
const [password, setPassword] = useState("");
const [remember, setRemember] = useState(() => localStorage.getItem(USERNAME_KEY) !== null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const role = getStoredRole();
const submit = async (e: FormEvent): Promise<void> => {
e.preventDefault();
if (busy) return;
setBusy(true);
setError(null);
try {
const res = await login(username.trim(), password);
// Persist the JWT (and decoded role) immediately so App's next render sees
// a real credential and proceeds to the main shell.
setAuthToken(res.token);
if (remember) localStorage.setItem(USERNAME_KEY, username.trim());
else localStorage.removeItem(USERNAME_KEY);
setPassword(""); // never keep the secret in component state after submit
onSuccess();
} catch (err: unknown) {
setError(toLoginMessage(err));
} finally {
setBusy(false);
}
};
return (
<div className="flex h-full items-center justify-center p-6">
<Surface className="w-full max-w-sm">
<div className="flex flex-col items-center gap-1 pb-6 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-[var(--mu-radius-lg)] bg-[var(--mu-surface-4)] text-[var(--mu-accent)]">
<Icon name="lock" size={28} />
</div>
<div className="text-lg font-semibold">登录 Cyrene</div>
<div className="text-xs text-[var(--mu-text-lo)]">输入账号凭证以继续使用用户端</div>
{role ? (
<Tag tone={roleTone(role)} className="mt-1">
{role}
</Tag>
) : null}
</div>
<form className="flex flex-col gap-3" onSubmit={submit}>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">用户名</span>
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
placeholder="用户名"
disabled={busy}
className={inputCls}
/>
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">密码</span>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
placeholder="密码"
disabled={busy}
className={inputCls}
/>
</label>
<label className="flex cursor-pointer select-none items-center gap-2 text-sm text-[var(--mu-text-mid)]">
<input
type="checkbox"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
disabled={busy}
className="h-4 w-4 accent-[var(--mu-accent)]"
/>
记住用户名
</label>
{error ? (
<div className="rounded-[var(--mu-radius)] bg-[var(--mu-err)]/15 px-3 py-2 text-xs text-[var(--mu-err)]">
{error}
</div>
) : null}
<Button type="submit" variant="primary" disabled={busy} className="mt-1 w-full">
{busy ? <Spinner size={16} label="登录中…" /> : "登录"}
</Button>
</form>
</Surface>
</div>
);
}
+2 -1
View File
@@ -27,6 +27,7 @@ function NavContent({ onNavigate }: { onNavigate: () => void }): React.ReactNode
const [theme, setTheme] = useState<ThemeMode>(readTheme);
const [creating, setCreating] = useState(false);
const connected = wsStatus === "connected";
const wsLabel = connected ? "实时连接" : wsStatus === "expired" ? "登录过期" : "未连接";
const hasToken = !!getAuthToken();
const toggleTheme = () => {
@@ -78,7 +79,7 @@ function NavContent({ onNavigate }: { onNavigate: () => void }): React.ReactNode
<NavItem
active={connected}
icon="channel"
label={connected ? "实时连接" : "未连接"}
label={wsLabel}
onClick={onNavigate}
hint={wsStatus}
/>
+2 -1
View File
@@ -23,6 +23,7 @@ const wsMeta: Record<WsStatus, { label: string; tone: "ok" | "warn" | "neutral"
connected: { label: "实时", tone: "ok" },
reconnecting: { label: "重连中", tone: "warn" },
disconnected: { label: "已断开", tone: "err" },
expired: { label: "登录过期", tone: "err" },
};
export function TopBar({ onMenu }: { onMenu: () => void }): React.ReactNode {
@@ -71,7 +72,7 @@ export function TopBar({ onMenu }: { onMenu: () => void }): React.ReactNode {
<AwakenBadge />
<Tag tone={meta.tone} className="hidden sm:inline-flex">
<span className={`inline-block h-1.5 w-1.5 rounded-full ${wsStatus === "connected" ? "bg-[var(--mu-ok)]" : "mu-blink"}`} />
<span className={`inline-block h-1.5 w-1.5 rounded-full ${wsStatus === "connected" ? "bg-[var(--mu-ok)]" : wsStatus === "expired" ? "bg-[var(--mu-err)]" : "mu-blink"}`} />
{meta.label}
</Tag>
+53 -6
View File
@@ -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. */
+2 -1
View File
@@ -30,7 +30,8 @@ export type WsStatus =
| "connecting"
| "connected"
| "disconnected"
| "reconnecting";
| "reconnecting"
| "expired";
export interface ToolUsed {
name: string;
+61 -154
View File
@@ -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();
});
+160
View File
@@ -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(),
};
}