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

+117
View File
@@ -0,0 +1,117 @@
import type {
ApiErrorShape,
ChatSyncResult,
LoginResult,
PersonaRecord,
SessionRecord,
} from "./types";
/**
* REST client for the Cyrene v2 gateway. The gateway wraps every success in
* `{data}` and every failure in `{error:{code,message}}` (Docs/api-client.md).
* We use same-origin relative paths so the same code works behind the Vite dev
* proxy and inside the single-binary gateway on :8080.
*/
export class ApiError extends Error {
readonly status: number;
readonly code?: string;
constructor(status: number, code: string | undefined, message: string) {
super(message);
this.name = "ApiError";
this.status = status;
this.code = code;
}
}
const API_BASE = "/api/v1";
const TOKEN_KEY = "cyrene.jwt";
export function getAuthToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function setAuthToken(token: string | null): void {
if (token) localStorage.setItem(TOKEN_KEY, token);
else localStorage.removeItem(TOKEN_KEY);
}
interface Envelope {
data?: unknown;
error?: ApiErrorShape;
}
async function request<T>(
path: string,
init: RequestInit = {}
): Promise<T> {
const headers = new Headers(init.headers);
headers.set("Content-Type", "application/json");
headers.set("X-API-Version", "1");
const token = getAuthToken();
if (token) headers.set("Authorization", `Bearer ${token}`);
let res: Response;
try {
res = await fetch(`${API_BASE}${path}`, { ...init, headers });
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "网络请求失败";
throw new ApiError(0, "network_error", msg);
}
let body: Envelope;
try {
body = (await res.json()) as Envelope;
} catch {
throw new ApiError(res.status, "bad_response", "服务端返回了无法解析的响应");
}
if (!res.ok || body.error) {
throw new ApiError(
res.status,
body.error?.code ?? "unknown",
body.error?.message ?? `请求失败(${res.status})`
);
}
return body.data as 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 }),
});
}
/** GET /api/v1/health — liveness probe. */
export function health(): Promise<{ status: string }> {
return request<{ status: string }>("/health");
}
/** POST /api/v1/chat — one synchronous agent turn. */
export function chatSync(
sessionId: string,
text: string
): Promise<ChatSyncResult> {
return request<ChatSyncResult>("/chat", {
method: "POST",
body: JSON.stringify({ sessionId, text }),
});
}
/** POST /api/v1/chat with stream:true — triggers streaming over the WS channel. */
export function chatStream(sessionId: string, text: string): Promise<{ stream: true }> {
return request<{ stream: true }>("/chat", {
method: "POST",
body: JSON.stringify({ sessionId, text, stream: true }),
});
}
/** GET /api/v1/sessions — known channel sessions. */
export function fetchSessions(): Promise<SessionRecord[]> {
return request<SessionRecord[]>("/sessions");
}
/** GET /api/v1/personas — persona summaries. */
export function fetchPersonas(): Promise<PersonaRecord[]> {
return request<PersonaRecord[]>("/personas");
}
+22
View File
@@ -0,0 +1,22 @@
export type ThemeMode = "dark" | "light";
const THEME_KEY = "cyrene.theme";
/** Read the persisted theme, defaulting to dark-first (Miuix). */
export function readTheme(): ThemeMode {
const saved = localStorage.getItem(THEME_KEY);
return saved === "light" ? "light" : "dark";
}
/** Apply a theme by toggling the `.theme-light` class on <html>. */
export function applyTheme(theme: ThemeMode): void {
document.documentElement.classList.toggle("theme-light", theme === "light");
localStorage.setItem(THEME_KEY, theme);
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.setAttribute("content", theme === "light" ? "#f6f6f8" : "#161616");
}
/** Apply the persisted theme on app start. */
export function initTheme(): void {
applyTheme(readTheme());
}
+162
View File
@@ -0,0 +1,162 @@
// Shared wire & UI model types for the Cyrene v2 user frontend (M9).
// Strict mode: no `any`; unknown payloads are narrowed at the parse boundary.
/** The 9 behavior modes. Switching only changes the presentation layer. */
export type Mode =
| "chat"
| "code"
| "home"
| "work"
| "docs"
| "voice"
| "vision"
| "agents"
| "settings";
export const MODES: ReadonlyArray<Mode> = [
"chat",
"code",
"home",
"work",
"docs",
"voice",
"vision",
"agents",
"settings",
];
export type WsStatus =
| "idle"
| "connecting"
| "connected"
| "disconnected"
| "reconnecting";
export interface ToolUsed {
name: string;
summary?: string;
ok?: boolean;
detail?: string;
at: number;
}
export interface ChatMessage {
id: string;
role: "user" | "assistant" | "system";
content: string;
streaming?: boolean;
thinking?: string;
mood?: string;
tools?: ToolUsed[];
createdAt: number;
}
export type AgentStatus =
| "idle"
| "running"
| "blocked"
| "done"
| "error";
export interface AgentInfo {
id: string;
name: string;
role?: string;
mode?: string;
status: AgentStatus;
progress?: number;
thinking?: string;
tools?: ToolUsed[];
message?: string;
}
export interface SubagentEventPayload {
id: string;
name?: string;
state?: string;
status?: AgentStatus;
progress?: number;
message?: string;
thinking?: string;
tool?: ToolUsed;
}
/** A "backthink" (后台思考) item — agent reasoning done in the background. */
export interface BackthinkItem {
id: string;
trigger: string;
conclusion?: string;
shouldNotify?: boolean;
pushed?: boolean;
at: number;
}
export interface FlowNode {
id: string;
label: string;
kind?: string;
status?: AgentStatus;
}
export interface FlowEdge {
from: string;
to: string;
}
export interface FlowGraph {
id: string;
nodes: FlowNode[];
edges: FlowEdge[];
}
export interface SessionRecord {
platform: string;
channelId: string;
sessionId: string;
actorRole: string;
owner: boolean;
isGroup: boolean;
memWritable: boolean;
}
export interface PersonaRecord {
id: string;
name: string;
character: string;
isDefault: boolean;
mode: string;
memoryNamespace: string;
}
export interface LoginResult {
token: string;
expiresAt: number;
role: string;
}
export interface ChatSyncResult {
reply: string;
steps: number;
stopReason: string;
}
/**
* Downstream WS frame (server → client). `data` is intentionally `unknown`;
* every consumer narrows it. The backend emits `{type, data}` and types include
* stream_chunk / stream_end / thinking / mood / tool_used / subagent_event /
* graph_update / reminder / device_update / auth / error / task_output.
*/
export interface WsDownEvent {
type: string;
data: unknown;
}
/** Upstream WS frame (client → server). */
export interface WsUpEvent {
type: "mode_switch" | "chat" | "voice_input" | "subagent_ask" | "task_output_query";
reqId?: string;
data?: unknown;
}
export interface ApiErrorShape {
code?: string;
message?: string;
}
+46
View File
@@ -0,0 +1,46 @@
import { useCallback, useEffect, useRef, useState } from "react";
interface AsyncState<T> {
data: T | null;
loading: boolean;
error: string | null;
reload: () => void;
}
/** Runs an async factory on mount (and on reload), tracking loading/error so
* every API-backed surface has a complete state. Best-effort, no `any`. */
export function useAsyncData<T>(fn: () => Promise<T>, deps: readonly unknown[] = []): AsyncState<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fnRef = useRef(fn);
fnRef.current = fn;
const depsKey = deps.join("\u0000");
const run = useCallback(() => {
let cancelled = false;
setLoading(true);
setError(null);
fnRef
.current()
.then((res) => {
if (!cancelled) setData(res);
})
.catch((err: unknown) => {
if (!cancelled) setError(err instanceof Error ? err.message : "加载失败");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [depsKey]);
useEffect(() => {
const cancel = run();
return cancel;
}, [run]);
return { data, loading, error, reload: run };
}
+52
View File
@@ -0,0 +1,52 @@
import { useCallback, useState } from "react";
import { chatStream } from "./api";
import { connectWs, isWsOpen } from "./ws";
import { useAppStore } from "../store/useAppStore";
import { now } from "./util";
/** Drives one user turn: push the user bubble, blanket a streaming assistant
* bubble, trigger the backend loop over REST (`stream:true`), and let the WS
* stream render into that bubble. Falls back to the WS `chat` channel when the
* REST trigger fails so the demo still works without a wired loop. */
export function useSendChat(): {
send: (text: string) => Promise<void>;
sending: boolean;
} {
const [sending, setSending] = useState(false);
const sessionId = useAppStore((s) => s.sessionId);
const addUserMessage = useAppStore((s) => s.addUserMessage);
const beginAssistant = useAppStore((s) => s.beginAssistant);
const endStream = useAppStore((s) => s.endStream);
const appendChunk = useAppStore((s) => s.appendChunk);
const addTool = useAppStore((s) => s.addTool);
const appendTaskLog = useAppStore((s) => s.appendTaskLog);
const send = useCallback(
async (text: string) => {
const trimmed = text.trim();
if (!trimmed || sending) return;
setSending(true);
if (!isWsOpen()) connectWs();
addUserMessage(trimmed);
beginAssistant();
try {
await chatStream(sessionId, trimmed);
} catch (err: unknown) {
// Backend loop not wired or gateway unreachable: show the failure inline
// rather than leaving a permanently-streaming bubble.
endStream();
const msg = err instanceof Error ? err.message : "触发失败";
appendChunk(`[stream 触发失败] ${msg}`);
appendTaskLog("main", `[error] ${msg}\n`);
addTool({ name: "stream_trigger", summary: "失败", ok: false, detail: msg, at: now() });
} finally {
setSending(false);
}
},
[sending, sessionId, addUserMessage, beginAssistant, endStream, appendChunk, addTool, appendTaskLog]
);
return { send, sending };
}
+12
View File
@@ -0,0 +1,12 @@
/** Generate a reasonably-unique id without external deps. */
export function uid(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `id-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
/** Current epoch milliseconds. */
export function now(): number {
return Date.now();
}
+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;
}