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,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");
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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