;
+
+/** 进程/浏览器身份(cid、token)—— 由 core 注入,UI 只读。 */
+export interface ClientIdentity {
+ cid: Cid;
+ /** 是否远程客户端(影响本机/远程的展示差异)。 */
+ remote: boolean;
+}
+
+export type { WireClientState, ChatMessage, SessionSlice };
diff --git a/packages/webui-react/src/contracts/protocol.ts b/packages/webui-react/src/contracts/protocol.ts
new file mode 100644
index 00000000..e754610b
--- /dev/null
+++ b/packages/webui-react/src/contracts/protocol.ts
@@ -0,0 +1,244 @@
+/**
+ * contracts/protocol.ts —— 传输层契约(wire contract)
+ * ============================================================================
+ * 【这是"咬合面"的第一半】定义浏览器 ↔ webui server 之间的真实线上格式,
+ * 与 packages/webui/docs/API.md、server/lib/ws-server.js 严格一一对应。
+ *
+ * 规则(高内聚低耦合):
+ * 1. 本文件只放 **线上格式** 的类型与判别函数,不放任何业务逻辑、任何 import。
+ * 2. core/ 的 transport 实现负责把它翻译成 domain.ts 的领域对象。
+ * 3. ui/ **禁止** 直接 import 本文件(UI 只见 domain.ts)。
+ * 4. 修改线上格式 = 修改契约,必须同步 API.md 与服务端测试。
+ * ============================================================================
+ */
+
+// ── WebSocket 帧信封(server → client)─────────────────────────────────────
+export const PROTOCOL_VERSION = 1 as const;
+
+export type ServerFrameType =
+ | 'hello'
+ | 'state.snapshot'
+ | 'control'
+ | 'error'
+ | 'pong';
+
+export interface ServerFrame {
+ v: typeof PROTOCOL_VERSION;
+ type: ServerFrameType;
+ /** 单调递增,仅 state.snapshot / control 携带;用于断线 resume。 */
+ seq?: number;
+ ts?: number;
+ payload: P;
+}
+
+export interface HelloPayload {
+ cid: string;
+ resumeSupported: boolean;
+ latestSeq: number | null;
+ heartbeatMs: number;
+ ringCapacity: number;
+}
+
+export interface StreamErrorPayload {
+ code: 'resume-underrun' | (string & {});
+ message?: string;
+}
+
+/** 控制帧名字空间 —— 未知名字必须被静默忽略(向前兼容)。 */
+export type ControlName =
+ | 'auth.token_rotated'
+ | 'needs_authorization'
+ | 'authorization_decided'
+ | 'alerts.append'
+ | 'alerts.update'
+ | 'token.first_run';
+
+export interface ControlPayload {
+ name: ControlName | (string & {});
+ /** 恒为字符串;结构化数据以 JSON 文本承载。 */
+ data: string;
+}
+
+// ── WebSocket 帧(client → server)─────────────────────────────────────────
+export type ClientFrame =
+ | { v: typeof PROTOCOL_VERSION; type: 'resume'; payload: { lastSeq: number } }
+ | { v: typeof PROTOCOL_VERSION; type: 'ping' }
+ | { v: typeof PROTOCOL_VERSION; type: 'pong' }
+ | { v: typeof PROTOCOL_VERSION; type: 'close' };
+
+// ── 异常通道(alerts)──────────────────────────────────────────────────────
+export type AlertLevel = 'info' | 'warn' | 'error';
+
+export interface WireAlert {
+ id: string;
+ ts: number;
+ level: AlertLevel;
+ msg: string;
+ src: string;
+ cid?: string | null;
+ sessionId?: string | null;
+ data?: unknown;
+ count?: number;
+}
+
+export interface AlertsSnapshotFrame {
+ kind: 'snapshot';
+ alerts: WireAlert[];
+}
+
+export type AlertsDeltaFrame =
+ | { kind: 'append'; alert: WireAlert }
+ | { kind: 'update'; alert: WireAlert };
+
+// ── 每请求授权(authorize 门)──────────────────────────────────────────────
+export interface WireAuthRequest {
+ requestId: string;
+ action: string;
+ ctx: Record;
+ expiresAt: number;
+}
+
+export interface WireAuthDecided {
+ requestId: string;
+ approved: boolean;
+ decidedBy?: string;
+}
+
+// ── 全量状态快照(GET /api/state 与 state.snapshot 帧同构)─────────────────
+export interface WireModel {
+ name?: string;
+ provider?: string;
+}
+
+export interface WireRunning {
+ active: boolean;
+ startedAt?: number;
+}
+
+export interface WireUsage {
+ fiveHourPercent?: number;
+ weekly?: string | number;
+ resetAt?: number;
+ weeklyResetAt?: number;
+ fetchedAt?: number;
+ source?: string;
+ hidden?: boolean;
+ raw?: unknown;
+}
+
+export interface WireWorkspace {
+ dir?: string | null;
+ branch?: string | null;
+ tree?: string | null;
+}
+
+export interface WireMcodeSession {
+ id: string;
+ title?: string;
+ workspace?: string;
+ updatedAt?: number;
+}
+
+/** 每个浏览器 tab(cid)一份的服务端状态。 */
+export interface WireClientState {
+ version?: string;
+ running?: WireRunning;
+ model?: WireModel;
+ permissions?: string;
+ thinking?: string | null;
+ workspace?: WireWorkspace;
+ usage?: WireUsage;
+ chat?: unknown[];
+ sessions?: WireSessionRow[];
+ mcodeSessions?: WireMcodeSession[];
+ mcodeSessionsPending?: boolean;
+ settings?: WireSettings;
+ quotaEnabled?: boolean;
+ hasTokenPlanKey?: boolean;
+ tokenPlanApiKeyMasked?: string;
+ tokenPlanApiKeySource?: 'env' | 'file' | 'settings' | '';
+ tokenPlanApiKeyFilePath?: string;
+ askUserAnswers?: Record;
+ [k: string]: unknown;
+}
+
+export interface WireSessionRow {
+ id: string;
+ title?: string;
+ workspace?: string;
+ mcodeSessionId?: string;
+ titleCustom?: boolean;
+ updatedAt?: number;
+}
+
+export interface WireSettings {
+ lanBroadcast?: boolean;
+ lanBind?: boolean;
+ readOnly?: boolean;
+ tokenEnabled?: boolean;
+ currentToken?: string;
+ tokenAcknowledged?: boolean;
+ tokenRotatedAt?: number;
+ lanIp?: string | null;
+ lanUrl?: string | null;
+ lanUrlWithToken?: string | null;
+ localUrl?: string;
+ lanExposed?: boolean;
+ bindRestartPending?: boolean;
+ lanExposureNotice?: string;
+ trustedOrigins?: string[];
+ defaultModel?: string;
+ defaultWorkspace?: string;
+ mcodeCmd?: string;
+ mcodeVersion?: string;
+ port?: number;
+ host?: string;
+ bindHost?: string;
+ [k: string]: unknown;
+}
+
+// ── REST 通用响应信封 ──────────────────────────────────────────────────────
+export interface OkResponse {
+ ok: true;
+ [k: string]: unknown;
+}
+
+export interface ErrResponse {
+ ok: false;
+ error: string;
+ code?: string;
+}
+
+export type ApiResponse = (OkResponse & T) | ErrResponse;
+
+// ── 判别与归一化(容错:线上数据一律视为不可信)────────────────────────────
+export function isServerFrame(v: unknown): v is ServerFrame {
+ return (
+ typeof v === 'object' && v !== null &&
+ (v as ServerFrame).v === PROTOCOL_VERSION &&
+ typeof (v as ServerFrame).type === 'string'
+ );
+}
+
+export function isErrResponse(v: unknown): v is ErrResponse {
+ return typeof v === 'object' && v !== null && (v as ErrResponse).ok === false;
+}
+
+export const ALERT_LEVELS: readonly AlertLevel[] = ['info', 'warn', 'error'];
+
+/** 把不可信 wire alert 归一化;id 为空串表示"不可用",调用方须跳过。 */
+export function normalizeWireAlert(raw: unknown): WireAlert {
+ const o = (raw && typeof raw === 'object' ? raw : {}) as Record;
+ const level = ALERT_LEVELS.includes(o.level as AlertLevel) ? (o.level as AlertLevel) : 'info';
+ return {
+ id: typeof o.id === 'string' ? o.id : o.id != null ? String(o.id) : '',
+ ts: Number(o.ts) || 0,
+ level,
+ msg: typeof o.msg === 'string' ? o.msg : String(o.msg ?? ''),
+ src: typeof o.src === 'string' && o.src ? o.src : 'system',
+ cid: o.cid != null ? String(o.cid) : null,
+ sessionId: o.sessionId != null ? String(o.sessionId) : null,
+ data: o.data,
+ count: Number(o.count) || 1,
+ };
+}
diff --git a/packages/webui-react/src/core/defaults.ts b/packages/webui-react/src/core/defaults.ts
new file mode 100644
index 00000000..a6684152
--- /dev/null
+++ b/packages/webui-react/src/core/defaults.ts
@@ -0,0 +1,52 @@
+/**
+ * core/defaults.ts —— 默认端口实现的装配点
+ * ============================================================================
+ * 【热插拔】createRegistry() 的默认后端。每个端口的具体实现分散在
+ * core/transport/* 与 core/services/* —— 本文件只负责组装,
+ * 因此换供应商/换传输/换状态库只改对应子目录 + 这里的一行。
+ * ============================================================================
+ */
+
+import type { Registry } from '../contracts/ports';
+
+import { createHttpPort } from './transport/http-port';
+import { createStreamPort } from './transport/stream-port';
+import { createKvPort } from './store/kv-port';
+import { createNotifierPort } from './services/notifier-port';
+import { createSessionService } from './services/session-service';
+import { createChatService } from './services/chat-service';
+import { createModelService } from './services/model-service';
+import { createWorkspaceService } from './services/workspace-service';
+import { createSettingsService } from './services/settings-service';
+import { createUsageService } from './services/usage-service';
+import { createAlertsService } from './services/alerts-service';
+import { createAuthService } from './services/auth-service';
+import { createUploadService } from './services/upload-service';
+
+export function createDefaultRegistry(): Registry {
+ const kv = createKvPort();
+ const http = createHttpPort({ kv });
+ const stream = createStreamPort({ http, kv });
+ const alerts = createAlertsService({ http, stream });
+ const auth = createAuthService({ http, stream });
+ const sessions = createSessionService({ http, stream, kv });
+ // 注入 kv,使"自定义供应商"跨 reload 持久化(否则只活在当前页)。
+ const models = createModelService({ http, sessions, kv });
+
+ return {
+ clock: { now: () => Date.now() },
+ http,
+ stream,
+ kv,
+ notifier: createNotifierPort(),
+ sessions,
+ chat: createChatService({ http, sessions }),
+ models,
+ workspace: createWorkspaceService({ http, kv }),
+ settings: createSettingsService({ http, stream }),
+ usage: createUsageService({ http }),
+ alerts,
+ auth,
+ upload: createUploadService({ http }),
+ };
+}
diff --git a/packages/webui-react/src/core/registry.ts b/packages/webui-react/src/core/registry.ts
new file mode 100644
index 00000000..034fff47
--- /dev/null
+++ b/packages/webui-react/src/core/registry.ts
@@ -0,0 +1,43 @@
+/**
+ * core/registry.ts —— 组装根(composition root)= 热插拔的唯一替换点
+ * ============================================================================
+ * 【热插拔易迭代】所有模块不许自己 new 端口实现,一律从这里取。
+ * - 生产: createRegistry() -> core/defaults.ts 的默认实现
+ * - 测试: createRegistry({...}) -> 覆盖任意端口(注入 fake)
+ * - 运行期:replacePort('models', p) -> 换供应商实现,其余模块无感
+ *
+ * 【低耦合】依赖方向:features/ui -> 本文件 -> contracts/ports.ts。任何模块
+ * 都不反向依赖本文件的调用方。
+ * ============================================================================
+ */
+
+import type { Registry, RegistryOverrides } from '../contracts/ports';
+import { createDefaultRegistry } from './defaults';
+
+/** 用默认实现 + 覆盖项装配一套端口。纯函数,不写全局。 */
+export function createRegistry(overrides: RegistryOverrides = {}): Registry {
+ return { ...createDefaultRegistry(), ...overrides };
+}
+
+// 进程内单例 —— 仅供 React 组装根与非 React 代码取用。
+let current: Registry | null = null;
+
+export function getRegistry(): Registry {
+ if (!current) current = createRegistry();
+ return current;
+}
+
+/** 整套替换(例如切换到完全不同的后端实现)。 */
+export function setRegistry(next: Registry): void {
+ current = next;
+}
+
+/** 热插拔单个端口:换供应商 / 换传输 / 换状态库,其余模块无感。 */
+export function replacePort(key: K, impl: Registry[K]): void {
+ current = { ...getRegistry(), [key]: impl };
+}
+
+/** 测试用:丢弃单例,下次 getRegistry() 重新装配。 */
+export function resetRegistry(): void {
+ current = null;
+}
diff --git a/packages/webui-react/src/core/services/alerts-service.ts b/packages/webui-react/src/core/services/alerts-service.ts
new file mode 100644
index 00000000..f0454c35
--- /dev/null
+++ b/packages/webui-react/src/core/services/alerts-service.ts
@@ -0,0 +1,131 @@
+/**
+ * core/services/alerts-service.ts —— AlertsServicePort 实现(异常通道 / 铃铛)
+ * 【职责】GET /api/alerts 取快照;订阅 stream 的 alerts.append / alerts.update 控制帧
+ * 做增量合并;按 alert.id 去重 —— 断线重放的快照/事件不得重复计入未读。
+ * 提供 list / unread / markRead / clear / subscribe。
+ * 【接缝】实现 contracts/ports.ts 的 AlertsServicePort;wire alert 经
+ * contracts/protocol.ts 的 normalizeWireAlert 归一后才进 AlertItem。
+ */
+import type { AlertsServicePort, HttpPort, StreamPort } from '../../contracts/ports';
+import type { AlertItem } from '../../contracts/domain';
+import type { WireAlert } from '../../contracts/protocol';
+import { normalizeWireAlert } from '../../contracts/protocol';
+
+export interface AlertsServiceDeps {
+ http: HttpPort;
+ stream: StreamPort;
+}
+
+function asRecord(v: unknown): Record | null {
+ return typeof v === 'object' && v !== null ? (v as Record) : null;
+}
+
+function readControl(raw: unknown): { name: string; data: string } | null {
+ const f = asRecord(raw);
+ if (!f || f['type'] !== 'control') return null;
+ const p = asRecord(f['payload']);
+ if (!p) return null;
+ const name = p['name'];
+ const data = p['data'];
+ if (typeof name !== 'string' || typeof data !== 'string') return null;
+ return { name, data };
+}
+
+function toItem(wire: WireAlert): AlertItem | null {
+ if (!wire.id) return null; // id 为空串 = 不可用,跳过
+ return {
+ id: wire.id,
+ ts: wire.ts,
+ level: wire.level,
+ msg: wire.msg,
+ src: wire.src,
+ sessionId: wire.sessionId ?? null,
+ count: wire.count ?? 1,
+ };
+}
+
+export function createAlertsService(deps: AlertsServiceDeps): AlertsServicePort {
+ const { http, stream } = deps;
+ /** 到达顺序即环形缓冲顺序(最旧在前);Map.set 已存在 id 时保持原位置。 */
+ const byId = new Map();
+ const readIds = new Set();
+ const listeners = new Set<() => void>();
+
+ function emit(): void {
+ for (const l of [...listeners]) {
+ try {
+ l();
+ } catch {
+ // 监听方异常不打断分发
+ }
+ }
+ }
+
+ function merge(item: AlertItem | null): void {
+ if (!item) return;
+ byId.set(item.id, item); // 按 id 去重:重放不会新增条目
+ }
+
+ function handleDelta(data: string): void {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(data);
+ } catch {
+ return;
+ }
+ const o = asRecord(parsed);
+ if (!o) return;
+ const kind = o['kind'];
+ if (kind !== 'append' && kind !== 'update') return;
+ merge(toItem(normalizeWireAlert(o['alert'])));
+ emit();
+ }
+
+ stream.onFrame((raw) => {
+ const ctrl = readControl(raw);
+ if (!ctrl) return;
+ if (ctrl.name === 'alerts.append' || ctrl.name === 'alerts.update') {
+ handleDelta(ctrl.data);
+ }
+ });
+
+ return {
+ async snapshot(): Promise {
+ const res = await http.get<{ alerts?: unknown }>('/api/alerts');
+ const rows = Array.isArray(res.alerts) ? res.alerts : [];
+ for (const row of rows) {
+ merge(toItem(normalizeWireAlert(row)));
+ }
+ emit();
+ return this.list();
+ },
+
+ list(): AlertItem[] {
+ return [...byId.values()];
+ },
+
+ unread(): number {
+ let n = 0;
+ for (const id of byId.keys()) if (!readIds.has(id)) n += 1;
+ return n;
+ },
+
+ markRead(): void {
+ for (const id of byId.keys()) readIds.add(id);
+ emit();
+ },
+
+ clear(): void {
+ byId.clear();
+ readIds.clear();
+ emit();
+ },
+
+ subscribe(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+ },
+ };
+}
diff --git a/packages/webui-react/src/core/services/auth-service.ts b/packages/webui-react/src/core/services/auth-service.ts
new file mode 100644
index 00000000..66c92da2
--- /dev/null
+++ b/packages/webui-react/src/core/services/auth-service.ts
@@ -0,0 +1,121 @@
+/**
+ * core/services/auth-service.ts —— AuthServicePort 实现(每请求授权队列)
+ * 【职责】订阅 stream 的 needs_authorization / authorization_decided 控制帧维护
+ * 待确认队列(按 requestId 去重);decide() 发 POST /api/auth/decision
+ * (requestId + approve 严格布尔);返回 404 视为已在别处决定,本地移除。
+ * 提供 pending / decide / subscribe。
+ * 【接缝】实现 contracts/ports.ts 的 AuthServicePort;wire 数据经 contracts/
+ * protocol.ts 的 WireAuthRequest / WireAuthDecided 形状收窄后进 PendingAuth。
+ */
+import type { AuthServicePort, HttpPort, StreamPort } from '../../contracts/ports';
+import type { PendingAuth } from '../../contracts/domain';
+import { HttpError } from '../transport/http-port';
+
+export interface AuthServiceDeps {
+ http: HttpPort;
+ stream: StreamPort;
+}
+
+function asRecord(v: unknown): Record | null {
+ return typeof v === 'object' && v !== null ? (v as Record) : null;
+}
+
+function readControl(raw: unknown): { name: string; data: string } | null {
+ const f = asRecord(raw);
+ if (!f || f['type'] !== 'control') return null;
+ const p = asRecord(f['payload']);
+ if (!p) return null;
+ const name = p['name'];
+ const data = p['data'];
+ if (typeof name !== 'string' || typeof data !== 'string') return null;
+ return { name, data };
+}
+
+/** 404 = 该请求已在别处被决定(或已过期被服务端丢弃)。 */
+function isNotFound(e: unknown): boolean {
+ if (e instanceof HttpError) return e.status === 404;
+ if (typeof e === 'object' && e !== null && (e as { status?: unknown }).status === 404) return true;
+ return e instanceof Error && /\b404\b/.test(e.message);
+}
+
+export function createAuthService(deps: AuthServiceDeps): AuthServicePort {
+ const { http, stream } = deps;
+ /** 按 requestId 去重的待确认队列(到达序)。 */
+ const queue = new Map();
+ const listeners = new Set<() => void>();
+
+ function emit(): void {
+ for (const l of [...listeners]) {
+ try {
+ l();
+ } catch {
+ // 监听方异常不打断分发
+ }
+ }
+ }
+
+ function enqueue(data: string): void {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(data);
+ } catch {
+ return;
+ }
+ const o = asRecord(parsed);
+ if (!o) return;
+ const requestId = o['requestId'];
+ if (typeof requestId !== 'string' || !requestId) return;
+ if (queue.has(requestId)) return; // 去重:重放不重复弹窗
+ const ctxRaw = asRecord(o['ctx']);
+ const ctx: Record = ctxRaw ? { ...ctxRaw } : {};
+ queue.set(requestId, {
+ requestId,
+ action: typeof o['action'] === 'string' ? o['action'] : '',
+ ctx,
+ expiresAt: Number(o['expiresAt']) || 0,
+ receivedAt: Date.now(),
+ });
+ emit();
+ }
+
+ function dequeue(data: string): void {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(data);
+ } catch {
+ return;
+ }
+ const o = asRecord(parsed);
+ const requestId = o ? o['requestId'] : undefined;
+ if (typeof requestId === 'string' && queue.delete(requestId)) emit();
+ }
+
+ stream.onFrame((raw) => {
+ const ctrl = readControl(raw);
+ if (!ctrl) return;
+ if (ctrl.name === 'needs_authorization') enqueue(ctrl.data);
+ else if (ctrl.name === 'authorization_decided') dequeue(ctrl.data);
+ });
+
+ return {
+ pending(): PendingAuth[] {
+ return [...queue.values()];
+ },
+
+ async decide(requestId: string, approve: boolean): Promise {
+ try {
+ await http.post('/api/auth/decision', { requestId, approve: approve === true });
+ } catch (e) {
+ if (!isNotFound(e)) throw e; // 其它失败保留队列项,便于重试
+ }
+ if (queue.delete(requestId)) emit();
+ },
+
+ subscribe(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+ },
+ };
+}
diff --git a/packages/webui-react/src/core/services/chat-service.ts b/packages/webui-react/src/core/services/chat-service.ts
new file mode 100644
index 00000000..9281c832
--- /dev/null
+++ b/packages/webui-react/src/core/services/chat-service.ts
@@ -0,0 +1,57 @@
+/**
+ * core/services/chat-service.ts —— ChatServicePort 实现(发送 / 停止 / 斜杠命令)
+ * 【职责】POST /api/send(content + attachments)、POST /api/stop、POST /api/cmd;
+ * 发送成功后把用户消息追加进**该会话**切片的 messages 并置 inflightId。
+ * 【接缝】实现 contracts/ports.ts 的 ChatServicePort;只写目标会话的切片
+ * (经 SessionService.update),绝不影响其它会话。
+ */
+import type { HttpPort, ChatServicePort } from '../../contracts/ports';
+import type { ChatMessage, SessionId, TextBlock } from '../../contracts/domain';
+import type { SessionService } from './session-service';
+
+export interface ChatServiceDeps {
+ http: HttpPort;
+ sessions: SessionService;
+}
+
+function makeId(): string {
+ const c: unknown = typeof globalThis !== 'undefined' ? (globalThis as { crypto?: unknown }).crypto : undefined;
+ if (typeof c === 'object' && c !== null) {
+ const gen = (c as { randomUUID?: unknown }).randomUUID;
+ if (typeof gen === 'function') return String(gen.call(c));
+ }
+ return 'm-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10);
+}
+
+export function createChatService(deps: ChatServiceDeps): ChatServicePort {
+ const { http, sessions } = deps;
+
+ return {
+ async send(sessionId: SessionId, content: string, attachments?: string[]): Promise {
+ await http.post('/api/send', { content, attachments: attachments ?? [] });
+ const block: TextBlock = { id: makeId(), kind: 'text', text: content };
+ const msg: ChatMessage = {
+ id: makeId(),
+ role: 'user',
+ blocks: [block],
+ ts: Date.now(),
+ streaming: false,
+ };
+ sessions.update(sessionId, (prev) => ({
+ ...prev,
+ messages: [...prev.messages, msg],
+ inflightId: msg.id,
+ }));
+ },
+
+ async stop(sessionId: SessionId): Promise {
+ await http.post('/api/stop', {});
+ // 本地视图立即回到空闲;服务端 running 态随后由流帧校正
+ sessions.update(sessionId, (prev) => ({ ...prev, inflightId: null, running: false }));
+ },
+
+ async command(_sessionId: SessionId, cmd: string): Promise {
+ await http.post('/api/cmd', { cmd });
+ },
+ };
+}
diff --git a/packages/webui-react/src/core/services/model-service.ts b/packages/webui-react/src/core/services/model-service.ts
new file mode 100644
index 00000000..ad02dd59
--- /dev/null
+++ b/packages/webui-react/src/core/services/model-service.ts
@@ -0,0 +1,197 @@
+/**
+ * core/services/model-service.ts —— ModelServicePort 实现(供应商 / 模型 / 思考强度 三段切换)
+ * 【职责】GET /api/models 取模型目录 {models,current};providers() 由目录按 provider
+ * 去重派生并合并 kv 持久化的自定义供应商;setProvider/setModel 走 POST /api/set-model;
+ * setThinking 更新该会话切片 selection.thinking 并同样发一次 set-model(服务端暂未
+ * 实现 thinking,但契约保留)。三段选择按会话独立保存在各自切片里。
+ * 【接缝】实现 contracts/ports.ts 的 ModelServicePort;切片读写走 SessionService,
+ * 自定义供应商列表走 KeyValueStorePort(webui_custom_providers)。
+ */
+import type { HttpPort, ModelServicePort } from '../../contracts/ports';
+import type {
+ ModelOption,
+ ModelSelection,
+ ProviderId,
+ ProviderOption,
+ SessionId,
+ ThinkingEffort,
+} from '../../contracts/domain';
+import { THINKING_EFFORTS, splitModelId } from '../../contracts/domain';
+import type { KeyValueStorePort } from '../../contracts/ports';
+import type { SessionService } from './session-service';
+
+export interface ModelServiceDeps {
+ http: HttpPort;
+ sessions: SessionService;
+ /** 自定义供应商的持久化(defaults.ts 若未传则退化为内存,重启即失)。 */
+ kv?: KeyValueStorePort;
+}
+
+/** 比端口更宽:自定义供应商的管理留给设置面板。 */
+export interface ModelService extends ModelServicePort {
+ addCustomProvider(option: ProviderOption): void;
+ removeCustomProvider(id: ProviderId): void;
+ customProviders(): ProviderOption[];
+}
+
+interface Catalog {
+ models: ModelOption[];
+ current: string;
+ hint: string | undefined;
+}
+
+const CUSTOM_PROVIDERS_KEY = 'webui_custom_providers';
+
+function asRecord(v: unknown): Record | null {
+ return typeof v === 'object' && v !== null ? (v as Record) : null;
+}
+
+export function createModelService(deps: ModelServiceDeps): ModelService {
+ const { http, sessions } = deps;
+ // 无 kv 注入时降级为进程内存储(自定义供应商不跨 reload)
+ const memoryKv = new Map();
+ const kv: KeyValueStorePort = deps.kv ?? {
+ get: (k) => memoryKv.get(k) ?? null,
+ set: (k, v) => void memoryKv.set(k, v),
+ remove: (k) => void memoryKv.delete(k),
+ };
+
+ function readCustomProviders(): ProviderOption[] {
+ try {
+ const raw = kv.get(CUSTOM_PROVIDERS_KEY);
+ if (!raw) return [];
+ const arr: unknown = JSON.parse(raw);
+ if (!Array.isArray(arr)) return [];
+ const out: ProviderOption[] = [];
+ for (const item of arr) {
+ const o = asRecord(item);
+ if (!o || typeof o['id'] !== 'string' || !o['id']) continue;
+ out.push({
+ id: o['id'],
+ label: typeof o['label'] === 'string' && o['label'] ? o['label'] : o['id'],
+ hint: typeof o['hint'] === 'string' ? o['hint'] : undefined,
+ });
+ }
+ return out;
+ } catch {
+ return [];
+ }
+ }
+
+ function writeCustomProviders(list: ProviderOption[]): void {
+ try {
+ kv.set(CUSTOM_PROVIDERS_KEY, JSON.stringify(list));
+ } catch {
+ // 持久化失败不影响内存态
+ }
+ }
+
+ async function fetchCatalog(): Promise {
+ const res = await http.get<{ models?: unknown; current?: unknown; hint?: unknown }>('/api/models');
+ const models: ModelOption[] = [];
+ if (Array.isArray(res.models)) {
+ for (const raw of res.models) {
+ const o = asRecord(raw);
+ if (!o || typeof o['id'] !== 'string' || !o['id']) continue;
+ const id = o['id'];
+ const provider = typeof o['provider'] === 'string' && o['provider'] ? o['provider'] : splitModelId(id);
+ models.push({
+ id,
+ label: typeof o['label'] === 'string' && o['label'] ? o['label'] : id,
+ provider,
+ contextLimit: typeof o['contextLimit'] === 'number' ? o['contextLimit'] : undefined,
+ });
+ }
+ }
+ const current = typeof res.current === 'string' ? res.current : '';
+ const hint = typeof res.hint === 'string' ? res.hint : undefined;
+
+ // 目录带回的 current 补齐「还没选过模型」的会话切片(不覆盖已有选择)
+ if (current) {
+ for (const id of sessions.ids()) {
+ const s = sessions.slice(id);
+ if (!s.selection.model) {
+ sessions.update(id, {
+ selection: { ...s.selection, model: current, provider: splitModelId(current, s.selection.provider) },
+ });
+ }
+ }
+ }
+ return { models, current, hint };
+ }
+
+ return {
+ async providers(): Promise {
+ const catalog = await fetchCatalog();
+ const merged = new Map();
+ for (const m of catalog.models) {
+ if (!merged.has(m.provider)) merged.set(m.provider, { id: m.provider, label: m.provider, hint: catalog.hint });
+ }
+ for (const custom of readCustomProviders()) {
+ merged.set(custom.id, custom); // 自定义项可覆盖派生项的 label/hint
+ }
+ return [...merged.values()];
+ },
+
+ async models(provider?: string): Promise {
+ const catalog = await fetchCatalog();
+ if (!provider) return catalog.models;
+ return catalog.models.filter((m) => m.provider === provider);
+ },
+
+ current(sessionId: SessionId): ModelSelection {
+ return sessions.slice(sessionId).selection;
+ },
+
+ async setProvider(sessionId: SessionId, provider: string): Promise {
+ const sel = sessions.slice(sessionId).selection;
+ const list = await this.models(provider);
+ // 回落到该供应商第一个模型;该供应商暂无模型时保留原模型(仍发 set-model 保契约)
+ const modelId = list[0]?.id ?? sel.model;
+ await http.post('/api/set-model', { model: modelId });
+ const next: ModelSelection = { ...sel, provider, model: modelId };
+ sessions.update(sessionId, { selection: next });
+ return next;
+ },
+
+ async setModel(sessionId: SessionId, modelId: string): Promise {
+ const sel = sessions.slice(sessionId).selection;
+ await http.post('/api/set-model', { model: modelId });
+ const next: ModelSelection = {
+ ...sel,
+ model: modelId,
+ provider: splitModelId(modelId, sel.provider),
+ };
+ sessions.update(sessionId, { selection: next });
+ return next;
+ },
+
+ async setThinking(sessionId: SessionId, effort: ThinkingEffort): Promise {
+ const safe: ThinkingEffort = (THINKING_EFFORTS as readonly string[]).includes(effort) ? effort : 'medium';
+ const sel = sessions.slice(sessionId).selection;
+ // thinking 是本地契约字段:先落切片,服务端未实现也保留 set-model 调用
+ const next: ModelSelection = { ...sel, thinking: safe };
+ sessions.update(sessionId, { selection: next });
+ try {
+ await http.post('/api/set-model', { model: next.model });
+ } catch {
+ // 服务端暂不感知 thinking —— 不阻塞本地选择
+ }
+ return next;
+ },
+
+ addCustomProvider(option: ProviderOption): void {
+ const list = readCustomProviders().filter((p) => p.id !== option.id);
+ list.push(option);
+ writeCustomProviders(list);
+ },
+
+ removeCustomProvider(id: ProviderId): void {
+ writeCustomProviders(readCustomProviders().filter((p) => p.id !== id));
+ },
+
+ customProviders(): ProviderOption[] {
+ return readCustomProviders();
+ },
+ };
+}
diff --git a/packages/webui-react/src/core/services/notifier-port.ts b/packages/webui-react/src/core/services/notifier-port.ts
new file mode 100644
index 00000000..834b8d14
--- /dev/null
+++ b/packages/webui-react/src/core/services/notifier-port.ts
@@ -0,0 +1,85 @@
+/**
+ * core/services/notifier-port.ts —— NotifierPort 的默认实现(console + 极简事件发射器)
+ * 【职责】core 在不感知 antd 的前提下发出 toast / confirm 诉求:默认打印 console,
+ * 并把事件广播给订阅者;confirm 可被订阅者接管答复(返回 boolean 即生效)。
+ * 【接缝】实现 contracts/ports.ts 的 NotifierPort;ui 层稍后用 antd message/modal
+ * 实现同一个 NotifierPort 并经 replacePort('notifier', …) 热插拔注入 ——
+ * 因此默认实现必须能独立工作(无 UI 也能跑通全部 core 流程)。
+ */
+import type { NotifierPort } from '../../contracts/ports';
+
+export type NotifierLevel = 'info' | 'success' | 'warn' | 'error';
+
+export type NotifierEvent =
+ | { kind: 'toast'; message: string; level: NotifierLevel }
+ | { kind: 'confirm'; title: string; body: string };
+
+/**
+ * 事件监听者:返回 boolean(或 Promise)可接管 confirm 的答复,
+ * 第一个非 undefined 的布尔答复生效;其余返回值忽略。
+ */
+export type NotifierListener = (event: NotifierEvent) => unknown;
+
+export interface ObservableNotifierPort extends NotifierPort {
+ /** 订阅通知事件;返回退订函数。 */
+ on(listener: NotifierListener): () => void;
+}
+
+export interface NotifierPortOptions {
+ /** 无人接管 confirm 时的默认答复(无 UI 环境下让流程可继续)。 */
+ confirmDefault?: boolean;
+ /** 无人接管时的输出(默认 console)。 */
+ log?: (event: NotifierEvent) => void;
+}
+
+function defaultLog(event: NotifierEvent): void {
+ if (event.kind === 'toast') {
+ const prefix = '[webui:' + event.level + ']';
+ if (event.level === 'error') console.error(prefix, event.message);
+ else if (event.level === 'warn') console.warn(prefix, event.message);
+ else console.log(prefix, event.message);
+ } else {
+ console.log('[webui:confirm]', event.title, event.body);
+ }
+}
+
+export function createNotifierPort(options: NotifierPortOptions = {}): ObservableNotifierPort {
+ const confirmDefault = options.confirmDefault ?? true;
+ const log = options.log ?? defaultLog;
+ const listeners = new Set();
+
+ function publish(event: NotifierEvent): unknown[] {
+ const results: unknown[] = [];
+ for (const l of [...listeners]) {
+ try {
+ results.push(l(event));
+ } catch {
+ // 监听方异常不影响通知本身
+ }
+ }
+ return results;
+ }
+
+ return {
+ toast(message: string, kind?: 'info' | 'success' | 'warn' | 'error'): void {
+ const event: NotifierEvent = { kind: 'toast', message, level: kind ?? 'info' };
+ log(event);
+ publish(event);
+ },
+ async confirm(title: string, body: string): Promise {
+ const event: NotifierEvent = { kind: 'confirm', title, body };
+ log(event);
+ for (const r of publish(event)) {
+ const v = await r;
+ if (typeof v === 'boolean') return v; // 第一个接管者说了算
+ }
+ return confirmDefault;
+ },
+ on(listener: NotifierListener): () => void {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+ },
+ };
+}
diff --git a/packages/webui-react/src/core/services/session-service.ts b/packages/webui-react/src/core/services/session-service.ts
new file mode 100644
index 00000000..df79ab2c
--- /dev/null
+++ b/packages/webui-react/src/core/services/session-service.ts
@@ -0,0 +1,189 @@
+/**
+ * core/services/session-service.ts —— SessionServicePort 实现(会话生命周期 + 会话隔离切片)
+ * 【职责】REST 会话 CRUD(/api/sessions*)+ 维护 Map>:
+ * 每个会话一份独立 store(core/store/create-store.ts),消息 / 流式缓冲 / 上下文 /
+ * 模型三段选择互不串扰;切换会话绝不清空或污染其它会话的切片。
+ * 【接缝】实现 contracts/ports.ts 的 SessionServicePort,并额外暴露 store/update/ids
+ * 供 features 的流式翻译层写切片(stream -> domain 的接缝);每会话 selection
+ * 经 KeyValueStorePort 持久化(webui_sel:),reload 后仍是每会话独立保存。
+ */
+import { createStore } from '../store/create-store';
+import type { Store, Updater } from '../store/create-store';
+import type {
+ HttpPort,
+ KeyValueStorePort,
+ SessionServicePort,
+ StreamPort,
+} from '../../contracts/ports';
+import type { ModelSelection, SessionId, SessionSlice, SessionSummary, ThinkingEffort } from '../../contracts/domain';
+import { THINKING_EFFORTS, emptySessionSlice } from '../../contracts/domain';
+
+export interface SessionServiceDeps {
+ http: HttpPort;
+ /** 预留接缝:流式帧 -> 切片的翻译层将来挂在 features,这里不解析帧。 */
+ stream: StreamPort;
+ kv: KeyValueStorePort;
+ /** 每会话切片的默认模型选择(默认 minimax_api / medium)。 */
+ defaultSelection?: ModelSelection;
+}
+
+/** 比端口更宽的实现类型:store/update/ids 是流式翻译层的写入口。 */
+export interface SessionService extends SessionServicePort {
+ store(id: SessionId): Store;
+ update(id: SessionId, updater: Updater): void;
+ ids(): SessionId[];
+}
+
+export const DEFAULT_MODEL_SELECTION: ModelSelection = {
+ provider: 'minimax_api',
+ model: '',
+ thinking: 'medium',
+};
+
+function selKey(id: SessionId): string {
+ return 'webui_sel:' + id;
+}
+
+function toSummary(raw: unknown): SessionSummary | null {
+ if (typeof raw !== 'object' || raw === null) return null;
+ const o = raw as Record;
+ const id = typeof o['id'] === 'string' ? o['id'] : '';
+ if (!id) return null;
+ return {
+ id,
+ title: typeof o['title'] === 'string' ? o['title'] : '',
+ workspace: typeof o['workspace'] === 'string' ? o['workspace'] : null,
+ mcodeSessionId: typeof o['mcodeSessionId'] === 'string' ? o['mcodeSessionId'] : null,
+ titleCustom: o['titleCustom'] === true,
+ updatedAt: Number(o['updatedAt']) || 0,
+ };
+}
+
+function isEffort(v: unknown): v is ThinkingEffort {
+ return typeof v === 'string' && (THINKING_EFFORTS as readonly string[]).includes(v);
+}
+
+export function createSessionService(deps: SessionServiceDeps): SessionService {
+ const { http, kv } = deps;
+ const fallback: ModelSelection = deps.defaultSelection ?? DEFAULT_MODEL_SELECTION;
+ /** 会话隔离的核心结构:每个 sessionId 一份独立 store,互不共享。 */
+ const slices = new Map>();
+
+ function loadSelection(id: SessionId): ModelSelection {
+ try {
+ const raw = kv.get(selKey(id));
+ if (!raw) return { ...fallback };
+ const o = JSON.parse(raw) as unknown;
+ if (typeof o === 'object' && o !== null) {
+ const r = o as Record;
+ if (typeof r['provider'] === 'string' && typeof r['model'] === 'string' && isEffort(r['thinking'])) {
+ return { provider: r['provider'], model: r['model'], thinking: r['thinking'] };
+ }
+ }
+ } catch {
+ // 坏数据当没有
+ }
+ return { ...fallback };
+ }
+
+ function persistSelection(id: SessionId, sel: ModelSelection): void {
+ try {
+ kv.set(selKey(id), JSON.stringify(sel));
+ } catch {
+ // 持久化失败不影响内存态
+ }
+ }
+
+ function storeFor(id: SessionId): Store {
+ let s = slices.get(id);
+ if (!s) {
+ s = createStore(emptySessionSlice(id, loadSelection(id)));
+ slices.set(id, s);
+ }
+ return s;
+ }
+
+ function update(id: SessionId, updater: Updater): void {
+ const s = storeFor(id);
+ const prev = s.get();
+ const next = typeof updater === 'function' ? updater(prev) : { ...prev, ...updater };
+ if (next.selection !== prev.selection) persistSelection(id, next.selection);
+ s.set(next);
+ }
+
+ return {
+ async list(): Promise {
+ const res = await http.get<{ sessions?: unknown[] }>('/api/sessions');
+ const rows = Array.isArray(res.sessions) ? res.sessions : [];
+ const out: SessionSummary[] = [];
+ for (const row of rows) {
+ const s = toSummary(row);
+ if (!s) continue;
+ out.push(s);
+ // 只刷新 summary 字段,绝不触碰该会话(或任何其它会话)的消息
+ if (slices.has(s.id)) update(s.id, { summary: s });
+ }
+ return out;
+ },
+
+ async create(workspace?: string | null): Promise {
+ const body = workspace != null ? { workspace } : {};
+ const res = await http.post<{ id?: unknown }>('/api/sessions', body);
+ const id = typeof res.id === 'string' ? res.id : '';
+ if (!id) throw new Error('session create: missing id in response');
+ update(id, {
+ summary: {
+ id,
+ title: '',
+ workspace: workspace ?? null,
+ mcodeSessionId: null,
+ titleCustom: false,
+ updatedAt: Date.now(),
+ },
+ });
+ return id;
+ },
+
+ async switchTo(id: SessionId): Promise {
+ await http.post('/api/sessions/switch', { id });
+ // 会话隔离:切换只通知服务端,本地任何切片都不清空、不重建。
+ },
+
+ async rename(id: SessionId, title: string): Promise {
+ await http.post('/api/sessions/rename', { id, title });
+ const s = slices.get(id);
+ if (s) {
+ const prev = s.get();
+ update(id, {
+ summary: prev.summary
+ ? { ...prev.summary, title, titleCustom: true }
+ : { id, title, workspace: null, mcodeSessionId: null, titleCustom: true, updatedAt: Date.now() },
+ });
+ }
+ },
+
+ async remove(id: SessionId): Promise {
+ await http.del('/api/sessions/' + encodeURIComponent(id));
+ slices.delete(id); // 只丢这一份切片,其它会话原样保留
+ try {
+ kv.remove(selKey(id));
+ } catch {
+ // 忽略
+ }
+ },
+
+ slice(id: SessionId): SessionSlice {
+ return storeFor(id).get();
+ },
+
+ subscribe(id: SessionId, listener: () => void): () => void {
+ return storeFor(id).subscribe(listener);
+ },
+
+ store: storeFor,
+ update,
+ ids(): SessionId[] {
+ return [...slices.keys()];
+ },
+ };
+}
diff --git a/packages/webui-react/src/core/services/settings-service.ts b/packages/webui-react/src/core/services/settings-service.ts
new file mode 100644
index 00000000..6029c25a
--- /dev/null
+++ b/packages/webui-react/src/core/services/settings-service.ts
@@ -0,0 +1,109 @@
+/**
+ * core/services/settings-service.ts —— SettingsServicePort 实现(服务端设置)
+ * 【职责】GET /api/settings 取快照;POST /api/settings 更新白名单字段
+ * (lanBroadcast / lanBind / readOnly / tokenEnabled / resetToken /
+ * acknowledgeToken / trustedOrigins);resetToken 发 {resetToken:true},
+ * 拿到新 token 后调 http.setToken 同步本地。
+ * 【接缝】实现 contracts/ports.ts 的 SettingsServicePort;订阅 stream 的
+ * auth.token_rotated 控制帧,token 被服务端轮换时同步 http 身份(下一个请求生效)。
+ */
+import type { SettingsServicePort, StreamPort } from '../../contracts/ports';
+import type { WireSettings } from '../../contracts/protocol';
+import type { AuthedHttpPort } from '../transport/http-port';
+
+export interface SettingsServiceDeps {
+ http: AuthedHttpPort;
+ stream: StreamPort;
+}
+
+/** 可写字段白名单:其余字段只读透传。 */
+const PATCH_KEYS = [
+ 'lanBroadcast',
+ 'lanBind',
+ 'readOnly',
+ 'tokenEnabled',
+ 'resetToken',
+ 'acknowledgeToken',
+ 'trustedOrigins',
+] as const;
+
+function asRecord(v: unknown): Record | null {
+ return typeof v === 'object' && v !== null ? (v as Record) : null;
+}
+
+/** 取 control 帧的 {name, data};其它帧返回 null。 */
+function readControl(raw: unknown): { name: string; data: string } | null {
+ const f = asRecord(raw);
+ if (!f || f['type'] !== 'control') return null;
+ const p = asRecord(f['payload']);
+ if (!p) return null;
+ const name = p['name'];
+ const data = p['data'];
+ if (typeof name !== 'string' || typeof data !== 'string') return null;
+ return { name, data };
+}
+
+export function createSettingsService(deps: SettingsServiceDeps): SettingsServicePort {
+ const { http, stream } = deps;
+ let cache: WireSettings | null = null;
+
+ function mergeIntoCache(patch: Record): WireSettings {
+ const next: WireSettings = { ...(cache ?? {}) };
+ for (const [k, v] of Object.entries(patch)) {
+ if (k === 'ok') continue;
+ next[k] = v;
+ }
+ cache = next;
+ return next;
+ }
+
+ // token 轮换:服务端广播 auth.token_rotated,本地身份必须立刻跟上
+ stream.onFrame((raw) => {
+ const ctrl = readControl(raw);
+ if (!ctrl || ctrl.name !== 'auth.token_rotated') return;
+ let next = '';
+ try {
+ const parsed: unknown = JSON.parse(ctrl.data);
+ const o = asRecord(parsed);
+ if (o) {
+ const t = o['token'] ?? o['currentToken'];
+ if (typeof t === 'string') next = t;
+ } else if (typeof parsed === 'string') {
+ next = parsed;
+ }
+ } catch {
+ if (ctrl.data.trim()) next = ctrl.data.trim();
+ }
+ if (next) http.setToken(next);
+ });
+
+ return {
+ async get(): Promise {
+ const res = await http.get>('/api/settings');
+ return mergeIntoCache({ ...res });
+ },
+
+ async update(patch: Partial): Promise {
+ const body: Record = {};
+ for (const key of PATCH_KEYS) {
+ const v = (patch as Record)[key];
+ if (v !== undefined) body[key] = v;
+ }
+ const res = await http.post>('/api/settings', body);
+ return mergeIntoCache({ ...body, ...res });
+ },
+
+ async resetToken(): Promise<{ token: string }> {
+ const res = await http.post>('/api/settings', { resetToken: true });
+ const token = typeof res['currentToken'] === 'string' ? res['currentToken'] : '';
+ if (token) http.setToken(token); // 新 token 立即生效(含 WS 重连地址)
+ mergeIntoCache({ ...res });
+ return { token };
+ },
+
+ async acknowledgeToken(): Promise {
+ const res = await http.post>('/api/settings', { acknowledgeToken: true });
+ mergeIntoCache({ ...res });
+ },
+ };
+}
diff --git a/packages/webui-react/src/core/services/upload-service.ts b/packages/webui-react/src/core/services/upload-service.ts
new file mode 100644
index 00000000..b316c8f8
--- /dev/null
+++ b/packages/webui-react/src/core/services/upload-service.ts
@@ -0,0 +1,58 @@
+/**
+ * core/services/upload-service.ts —— UploadServicePort 实现(附件上传)
+ * 【职责】走 http.upload(FormData 的 file 字段)产出 Attachment:
+ * 成功 status='done'(带服务端 path/size);失败 status='error' 并带 error 文案,
+ * 不向调用方抛错(UI 需要把失败也渲染成一条附件记录)。
+ * 【接缝】实现 contracts/ports.ts 的 UploadServicePort。
+ */
+import type { HttpPort, UploadServicePort } from '../../contracts/ports';
+import type { Attachment } from '../../contracts/domain';
+
+export interface UploadServiceDeps {
+ http: HttpPort;
+}
+
+interface UploadResponse {
+ ok?: unknown;
+ path?: unknown;
+ name?: unknown;
+ size?: unknown;
+}
+
+function makeId(): string {
+ const c: unknown = typeof globalThis !== 'undefined' ? (globalThis as { crypto?: unknown }).crypto : undefined;
+ if (typeof c === 'object' && c !== null) {
+ const gen = (c as { randomUUID?: unknown }).randomUUID;
+ if (typeof gen === 'function') return String(gen.call(c));
+ }
+ return 'a-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10);
+}
+
+export function createUploadService(deps: UploadServiceDeps): UploadServicePort {
+ const { http } = deps;
+
+ return {
+ async upload(file: File | Blob, name: string): Promise {
+ const id = makeId();
+ try {
+ const res = (await http.upload('/api/upload', file, name)) as UploadResponse;
+ return {
+ id,
+ name: typeof res.name === 'string' && res.name ? res.name : name,
+ path: typeof res.path === 'string' ? res.path : '',
+ size: typeof res.size === 'number' ? res.size : file.size ?? 0,
+ status: 'done',
+ };
+ } catch (e) {
+ return {
+ id,
+ name,
+ path: '',
+ size: 0,
+ status: 'error',
+ error: e instanceof Error ? e.message : String(e),
+ };
+ }
+ },
+ };
+}
diff --git a/packages/webui-react/src/core/services/usage-service.ts b/packages/webui-react/src/core/services/usage-service.ts
new file mode 100644
index 00000000..21fa283f
--- /dev/null
+++ b/packages/webui-react/src/core/services/usage-service.ts
@@ -0,0 +1,73 @@
+/**
+ * core/services/usage-service.ts —— UsageServicePort 实现(套餐用量 + 上下文消耗)
+ * 【职责】GET /api/usage 取套餐额度快照 -> UsageInfo;POST /api/refresh 触发服务端重取;
+ * GET /api/usage-real 取每轮上下文消耗 -> ContextUsage(used/limit/percent/tps/
+ * cacheRead/model/source)。
+ * 【接缝】实现 contracts/ports.ts 的 UsageServicePort;线上响应字段容错解析
+ * (不可信数据一律收窄后才进 domain 对象)。
+ */
+import type { HttpPort, UsageServicePort } from '../../contracts/ports';
+import type { ContextUsage, SessionId, UsageInfo } from '../../contracts/domain';
+
+export interface UsageServiceDeps {
+ http: HttpPort;
+}
+
+function asRecord(v: unknown): Record | null {
+ return typeof v === 'object' && v !== null ? (v as Record) : null;
+}
+
+function num(v: unknown): number | null {
+ return typeof v === 'number' && Number.isFinite(v) ? v : null;
+}
+
+/** weekly 字段可能是 "91%" / 91 / "unlimited" —— 一律收窄为百分比或 null。 */
+function weeklyPercent(v: unknown): number | null {
+ if (typeof v === 'number' && Number.isFinite(v)) return v;
+ if (typeof v === 'string') {
+ const m = v.trim().replace(/%$/, '');
+ const n = Number(m);
+ return Number.isFinite(n) && m !== '' ? n : null;
+ }
+ return null;
+}
+
+export function createUsageService(deps: UsageServiceDeps): UsageServicePort {
+ const { http } = deps;
+
+ return {
+ async quota(): Promise {
+ const res = asRecord(await http.get('/api/usage')) ?? {};
+ return {
+ fiveHourPercent: num(res['fiveHourPercent'] ?? res['remaining']),
+ weeklyPercent: weeklyPercent(res['weeklyPercent'] ?? res['weekly']),
+ fetchedAt: num(res['fetchedAt']),
+ source: typeof res['source'] === 'string' ? res['source'] : undefined,
+ hidden: res['hidden'] === true ? true : undefined,
+ };
+ },
+
+ async refresh(): Promise {
+ await http.post('/api/refresh', {});
+ },
+
+ async context(_sessionId: SessionId): Promise {
+ // usage-real 按 cid(运行时)统计,暂不区分 sessionId;签名保留会话语义
+ const res = asRecord(await http.get('/api/usage-real')) ?? {};
+ const used = num(res['lastTurnContextTokens']);
+ const limit = num(res['contextLimit']);
+ if (used === null && limit === null) return null;
+ const u = used ?? 0;
+ const l = limit ?? 0;
+ return {
+ used: u,
+ limit: l,
+ percent: l > 0 ? Math.round((u * 100) / l) : 0,
+ tps: num(res['tps']) ?? 0,
+ cacheRead: num(res['lastCacheReadTokens']) ?? undefined,
+ model: typeof res['model'] === 'string' ? res['model'] : undefined,
+ source: typeof res['source'] === 'string' ? res['source'] : 'usage-real',
+ };
+ },
+ };
+}
diff --git a/packages/webui-react/src/core/services/workspace-service.ts b/packages/webui-react/src/core/services/workspace-service.ts
new file mode 100644
index 00000000..88a482bd
--- /dev/null
+++ b/packages/webui-react/src/core/services/workspace-service.ts
@@ -0,0 +1,134 @@
+/**
+ * core/services/workspace-service.ts —— WorkspaceServicePort 实现(工作区)
+ * 【职责】POST /api/workspace(dir + syncTui + action)切换/复位工作区;
+ * GET /api/workspace/browse 列目录;最近 5 个工作区经 kv 持久化。
+ * 【接缝】实现 contracts/ports.ts 的 WorkspaceServicePort,另暴露 useTui()
+ * (action:'useTui',跟随 mcode TUI 当前目录)作为端口的补充建议。
+ */
+import type { HttpPort, KeyValueStorePort, WorkspaceServicePort } from '../../contracts/ports';
+import type { WorkspaceEntry, WorkspaceInfo } from '../../contracts/domain';
+
+export interface WorkspaceServiceDeps {
+ http: HttpPort;
+ kv: KeyValueStorePort;
+}
+
+/** 比端口更宽:补齐 /api/workspace 的 action:'useTui'。 */
+export interface WorkspaceService extends WorkspaceServicePort {
+ useTui(): Promise;
+}
+
+const RECENTS_KEY = 'webui_recent_workspaces';
+const RECENTS_MAX = 5;
+
+function asRecord(v: unknown): Record | null {
+ return typeof v === 'object' && v !== null ? (v as Record) : null;
+}
+
+function baseName(path: string): string {
+ const parts = path.split(/[\\/]/).filter(Boolean);
+ return parts.length ? parts[parts.length - 1] : path;
+}
+
+function toInfo(raw: unknown): WorkspaceInfo {
+ const o = asRecord(raw) ?? {};
+ const dir = typeof o['dir'] === 'string' ? o['dir'] : null;
+ const tree = typeof o['treeState'] === 'string' ? o['treeState'] : typeof o['tree'] === 'string' ? o['tree'] : null;
+ return {
+ dir,
+ branch: typeof o['branch'] === 'string' ? o['branch'] : null,
+ tree,
+ };
+}
+
+export function createWorkspaceService(deps: WorkspaceServiceDeps): WorkspaceService {
+ const { http, kv } = deps;
+ let current: WorkspaceInfo | null = null;
+
+ function recentsRead(): WorkspaceEntry[] {
+ try {
+ const raw = kv.get(RECENTS_KEY);
+ if (!raw) return [];
+ const arr: unknown = JSON.parse(raw);
+ if (!Array.isArray(arr)) return [];
+ const out: WorkspaceEntry[] = [];
+ for (const item of arr) {
+ const o = asRecord(item);
+ if (!o || typeof o['path'] !== 'string' || !o['path']) continue;
+ out.push({
+ name: typeof o['name'] === 'string' && o['name'] ? o['name'] : baseName(o['path']),
+ path: o['path'],
+ isDir: true,
+ });
+ }
+ return out.slice(0, RECENTS_MAX);
+ } catch {
+ return [];
+ }
+ }
+
+ function recentsWrite(list: WorkspaceEntry[]): void {
+ try {
+ kv.set(RECENTS_KEY, JSON.stringify(list.slice(0, RECENTS_MAX)));
+ } catch {
+ // 持久化失败不影响内存态
+ }
+ }
+
+ async function postWorkspace(body: Record): Promise {
+ const res = await http.post('/api/workspace', body);
+ current = toInfo(res);
+ return current;
+ }
+
+ return {
+ current(): WorkspaceInfo | null {
+ return current;
+ },
+
+ async use(dir: string, syncTui?: boolean): Promise {
+ const info = await postWorkspace({ dir, syncTui: syncTui === true });
+ this.addRecent(dir);
+ return info;
+ },
+
+ async reset(): Promise {
+ return postWorkspace({ action: 'reset' });
+ },
+
+ async useTui(): Promise {
+ const info = await postWorkspace({ action: 'useTui' });
+ if (info.dir) this.addRecent(info.dir);
+ return info;
+ },
+
+ async browse(path?: string): Promise {
+ const query = path ? '?path=' + encodeURIComponent(path) : '';
+ const res = await http.get<{ children?: unknown }>('/api/workspace/browse' + query);
+ const out: WorkspaceEntry[] = [];
+ if (Array.isArray(res.children)) {
+ for (const raw of res.children) {
+ const o = asRecord(raw);
+ if (!o || typeof o['path'] !== 'string' || !o['path']) continue;
+ out.push({
+ name: typeof o['name'] === 'string' ? o['name'] : baseName(o['path']),
+ path: o['path'],
+ isDir: o['isDir'] === true,
+ });
+ }
+ }
+ return out;
+ },
+
+ recents(): WorkspaceEntry[] {
+ return recentsRead();
+ },
+
+ addRecent(path: string): void {
+ if (!path) return;
+ const list = recentsRead().filter((e) => e.path !== path);
+ list.unshift({ name: baseName(path), path, isDir: true });
+ recentsWrite(list.slice(0, RECENTS_MAX));
+ },
+ };
+}
diff --git a/packages/webui-react/src/core/store/create-store.ts b/packages/webui-react/src/core/store/create-store.ts
new file mode 100644
index 00000000..31826b50
--- /dev/null
+++ b/packages/webui-react/src/core/store/create-store.ts
@@ -0,0 +1,41 @@
+/**
+ * core/store/create-store.ts —— 极简可观察 store(零依赖)
+ * ============================================================================
+ * 【高内聚】只做一件事:一份状态 + 订阅 + 不可变更新。
+ * 【热插拔】签名刻意与 useSyncExternalStore 对齐,未来可直接换成 zustand /
+ * Redux / Jotai 而不动任何订阅方。core 与 ui 都只通过它读写状态。
+ * ============================================================================
+ */
+
+export type Updater = Partial | ((prev: S) => S);
+
+export interface Store {
+ get(): S;
+ set(update: Updater): void;
+ subscribe(listener: () => void): () => void;
+ /** 供 useSyncExternalStore 使用的快照读取。 */
+ snapshot(): S;
+}
+
+export function createStore(initial: S): Store {
+ let state = initial;
+ const listeners = new Set<() => void>();
+
+ return {
+ get: () => state,
+ snapshot: () => state,
+ set(update) {
+ const patch = typeof update === 'function' ? update(state) : update;
+ // 不可变更新:引用变了才通知,天然避免无意义的重渲染。
+ state = { ...state, ...patch };
+ for (const l of listeners) l();
+ },
+ subscribe(listener) {
+ listeners.add(listener);
+ return () => { listeners.delete(listener); };
+ },
+ };
+}
+
+// 注:React 绑定(useStore)刻意不放在本文件 —— core 必须零 UI 依赖,
+// 否则 headless 场景(Node 端 / 单测)无法复用本 store。绑定见 features/use-store.ts。
diff --git a/packages/webui-react/src/core/store/kv-port.ts b/packages/webui-react/src/core/store/kv-port.ts
new file mode 100644
index 00000000..8054ba61
--- /dev/null
+++ b/packages/webui-react/src/core/store/kv-port.ts
@@ -0,0 +1,53 @@
+/**
+ * core/store/kv-port.ts —— KeyValueStorePort 的 localStorage 实现
+ * 【职责】为 token / cid / 语言 / 最近工作区 / 每会话模型选择等轻量偏好提供持久化。
+ * 【接缝】实现 contracts/ports.ts 的 KeyValueStorePort;所有读写 try/catch 容错,
+ * 无 storage(隐私模式 / SSR / 配额溢出)时静默降级为进程内 Map,绝不向调用方抛错。
+ */
+import type { KeyValueStorePort } from '../../contracts/ports';
+
+export function createKvPort(): KeyValueStorePort {
+ const memory = new Map();
+ let storage: Storage | null = null;
+ try {
+ storage = typeof localStorage !== 'undefined' ? localStorage : null;
+ if (storage) {
+ const probe = '__webui_kv_probe__';
+ storage.setItem(probe, '1');
+ storage.removeItem(probe);
+ }
+ } catch {
+ // localStorage 存在但不可用(隐私模式 / 被禁用)——降级为内存
+ storage = null;
+ }
+
+ return {
+ get(key: string): string | null {
+ try {
+ if (storage) {
+ const v = storage.getItem(key);
+ if (v !== null) return v;
+ }
+ } catch {
+ // 读失败时落到内存镜像
+ }
+ return memory.get(key) ?? null;
+ },
+ set(key: string, value: string): void {
+ memory.set(key, value);
+ try {
+ if (storage) storage.setItem(key, value);
+ } catch {
+ // 写失败(配额满等)——内存镜像保底
+ }
+ },
+ remove(key: string): void {
+ memory.delete(key);
+ try {
+ if (storage) storage.removeItem(key);
+ } catch {
+ // 忽略
+ }
+ },
+ };
+}
diff --git a/packages/webui-react/src/core/transport/http-port.ts b/packages/webui-react/src/core/transport/http-port.ts
new file mode 100644
index 00000000..dbaaa00b
--- /dev/null
+++ b/packages/webui-react/src/core/transport/http-port.ts
@@ -0,0 +1,190 @@
+/**
+ * core/transport/http-port.ts —— HttpPort 的 fetch 实现(REST 传输的唯一出口)
+ * 【职责】拼 /api 前缀与 ?token=&cid= 查询串、注入 Authorization: Bearer 头,
+ * 把「非 2xx」与「响应体 ok:false」统一归一为抛 Error(响应的 error 字段)。
+ * 【接缝】实现 contracts/ports.ts 的 HttpPort,另暴露 setToken/getToken/cid 三个
+ * 身份方法(token 轮换与 WS 地址需要);token/cid 经 KeyValueStorePort 持久化
+ * (webui_token / webui_cid),token 优先读 URL ?token=,读到后立即
+ * history.replaceState 从地址栏抹掉(语义对齐 public/app/state.js 开头注释)。
+ */
+import type { HttpPort, KeyValueStorePort } from '../../contracts/ports';
+
+export const WEBUI_TOKEN_KEY = 'webui_token';
+export const WEBUI_CID_KEY = 'webui_cid';
+
+/** 身份操作:stream-port 拼 WS 地址、settings-service 落 token 轮换时使用。 */
+export interface HttpIdentity {
+ setToken(token: string): void;
+ getToken(): string;
+ cid(): string;
+}
+
+/** 带身份操作的 HttpPort。 */
+export type AuthedHttpPort = HttpPort & HttpIdentity;
+
+export interface HttpPortDeps {
+ kv: KeyValueStorePort;
+}
+
+/** 携带 HTTP 状态码的失败,调用方可按 status 精确分支(例如 404 = 已在别处决定)。 */
+export class HttpError extends Error {
+ readonly status: number;
+ readonly code: string | undefined;
+
+ constructor(message: string, status: number, code?: string) {
+ super(message);
+ this.name = 'HttpError';
+ this.status = status;
+ this.code = code;
+ }
+}
+
+/** 网络层错误(fetch 抛出)也归一为 HttpError,status 记 0。 */
+export function isHttpError(e: unknown): e is HttpError {
+ return e instanceof HttpError;
+}
+
+function asRecord(v: unknown): Record | null {
+ return typeof v === 'object' && v !== null ? (v as Record) : null;
+}
+
+function readErrorField(data: unknown, fallback: string): string {
+ const o = asRecord(data);
+ if (o && typeof o['error'] === 'string' && o['error']) return o['error'];
+ return fallback;
+}
+
+function readCode(data: unknown): string | undefined {
+ const o = asRecord(data);
+ const code = o ? o['code'] : undefined;
+ return typeof code === 'string' && code ? code : undefined;
+}
+
+function isErrBody(data: unknown): boolean {
+ const o = asRecord(data);
+ return o !== null && o['ok'] === false;
+}
+
+/** 读 URL ?token=(用户从带 token 的链接进来)。 */
+function readUrlToken(): string {
+ try {
+ if (typeof window === 'undefined') return '';
+ return new URLSearchParams(window.location.search).get('token') ?? '';
+ } catch {
+ return '';
+ }
+}
+
+/** 立刻把 ?token= 从地址栏抹掉,避免进 history / Referer(必须在任何请求之前跑)。 */
+function stripTokenFromUrl(): void {
+ try {
+ if (typeof window === 'undefined') return;
+ const params = new URLSearchParams(window.location.search);
+ if (!params.has('token')) return;
+ const clean = window.location.pathname + (window.location.hash || '');
+ window.history.replaceState(null, '', clean);
+ } catch {
+ // 隐私模式等 —— token 仍在 kv,reload 依旧可用
+ }
+}
+
+function createCid(): string {
+ const c: unknown = typeof globalThis !== 'undefined' ? (globalThis as { crypto?: unknown }).crypto : undefined;
+ if (typeof c === 'object' && c !== null) {
+ const gen = (c as { randomUUID?: unknown }).randomUUID;
+ if (typeof gen === 'function') return String(gen.call(c));
+ }
+ return 'c-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10);
+}
+
+export function createHttpPort(deps: HttpPortDeps): AuthedHttpPort {
+ const { kv } = deps;
+
+ // ── token 引导:URL ?token= 优先 → kv 兜底;随即抹掉地址栏 ────────────────
+ let token = '';
+ const urlToken = readUrlToken();
+ if (urlToken) {
+ token = urlToken;
+ kv.set(WEBUI_TOKEN_KEY, urlToken);
+ } else {
+ token = kv.get(WEBUI_TOKEN_KEY) ?? '';
+ }
+ stripTokenFromUrl();
+
+ // ── cid 引导:每浏览器一个稳定 client id,随所有请求上行 ───────────────────
+ let cid = kv.get(WEBUI_CID_KEY) ?? '';
+ if (!cid) {
+ cid = createCid();
+ kv.set(WEBUI_CID_KEY, cid);
+ }
+
+ function toUrl(path: string): string {
+ let p = path.trim();
+ if (!/^https?:\/\//i.test(p)) {
+ if (p.startsWith('/api/') || p === '/api') {
+ // 调用方已带前缀 —— 原样
+ } else if (p.startsWith('/')) {
+ p = '/api' + p;
+ } else {
+ p = '/api/' + p;
+ }
+ }
+ const parts: string[] = [];
+ if (token) parts.push('token=' + encodeURIComponent(token));
+ parts.push('cid=' + encodeURIComponent(cid));
+ const joiner = p.includes('?') ? (p.endsWith('?') || p.endsWith('&') ? '' : '&') : '?';
+ return p + joiner + parts.join('&');
+ }
+
+ async function request(method: string, path: string, body?: unknown, form?: FormData): Promise {
+ const headers: Record = {};
+ if (token) headers['Authorization'] = 'Bearer ' + token;
+ let payload: BodyInit | undefined;
+ if (form) {
+ payload = form; // multipart:Content-Type 由浏览器带 boundary 生成
+ } else if (body !== undefined) {
+ headers['Content-Type'] = 'application/json; charset=utf-8';
+ payload = JSON.stringify(body);
+ }
+
+ let res: Response;
+ try {
+ res = await fetch(toUrl(path), { method, headers, body: payload });
+ } catch (e) {
+ throw new HttpError(e instanceof Error ? e.message : String(e), 0);
+ }
+
+ let data: unknown = null;
+ const text = await res.text();
+ if (text) {
+ try {
+ data = JSON.parse(text);
+ } catch {
+ data = null; // 非 JSON 响应体:只看状态码
+ }
+ }
+
+ if (!res.ok) throw new HttpError(readErrorField(data, 'HTTP ' + res.status), res.status, readCode(data));
+ if (isErrBody(data)) throw new HttpError(readErrorField(data, 'request failed'), res.status, readCode(data));
+ const out: unknown = data ?? {};
+ return out as T;
+ }
+
+ return {
+ get: (path: string): Promise => request('GET', path),
+ post: (path: string, body?: unknown): Promise => request('POST', path, body),
+ del: (path: string): Promise => request('DELETE', path),
+ upload: (path: string, file: Blob, name: string): Promise => {
+ const form = new FormData();
+ form.append('file', file, name); // 服务端读 file 字段
+ return request('POST', path, undefined, form);
+ },
+ setToken(next: string): void {
+ token = typeof next === 'string' ? next : '';
+ if (token) kv.set(WEBUI_TOKEN_KEY, token);
+ else kv.remove(WEBUI_TOKEN_KEY);
+ },
+ getToken: (): string => token,
+ cid: (): string => cid,
+ };
+}
diff --git a/packages/webui-react/src/core/transport/stream-port.ts b/packages/webui-react/src/core/transport/stream-port.ts
new file mode 100644
index 00000000..33a75897
--- /dev/null
+++ b/packages/webui-react/src/core/transport/stream-port.ts
@@ -0,0 +1,187 @@
+/**
+ * core/transport/stream-port.ts —— StreamPort 的 WebSocket 实现(/api/stream 事件流)
+ * 【职责】按 location 协议连 ws(s)://host/api/stream(带 token & cid 查询串);
+ * 只收文本 JSON 帧并把原始对象**原样**经 onFrame 上抛(不做业务解析);
+ * 断线 3 秒重连;维护 seq 车票供 resume。
+ * 【接缝】实现 contracts/ports.ts 的 StreamPort,帧格式见 contracts/protocol.ts。
+ * resume 策略:本页连接史里有 lastSeq 才在 hello 后发 {type:'resume'},否则
+ * 什么都不发(基线由调用方拉 REST);error 帧 code=resume-underrun 时把
+ * lastSeq 回退为 hello 的 latestSeq。lastSeq 只活在本实例(跨 reload 会重新
+ * 拉 REST 基线,重放旧事件反而会回滚 UI)。二进制帧 / 非法 JSON / 未知帧类型
+ * 一律静默忽略(向前兼容)。
+ */
+import type { KeyValueStorePort, StreamPort, StreamStatus } from '../../contracts/ports';
+import type { ClientFrame, ServerFrame } from '../../contracts/protocol';
+import { PROTOCOL_VERSION, isServerFrame } from '../../contracts/protocol';
+import type { AuthedHttpPort } from './http-port';
+import { WEBUI_CID_KEY, WEBUI_TOKEN_KEY } from './http-port';
+
+export interface StreamPortDeps {
+ /** 取实时 token / cid(token 可能被 auth.token_rotated 轮换过,必须现读)。 */
+ http: AuthedHttpPort;
+ /** 兜底身份源(http 未注入身份时从 kv 读)。 */
+ kv: KeyValueStorePort;
+}
+
+const RECONNECT_MS = 3000;
+const KNOWN_FRAME_TYPES: ReadonlySet = new Set(['hello', 'state.snapshot', 'control', 'error', 'pong']);
+
+function asRecord(v: unknown): Record | null {
+ return typeof v === 'object' && v !== null ? (v as Record) : null;
+}
+
+export function createStreamPort(deps: StreamPortDeps): StreamPort {
+ const listeners = new Set<(raw: unknown) => void>();
+ let ws: WebSocket | null = null;
+ let status: StreamStatus = 'idle';
+ let stopped = true;
+ let timer: ReturnType | null = null;
+ /** 单调 seq 车票:断线重连时用于 resume。null = 没有可续的历史。 */
+ let lastSeq: number | null = null;
+ /** 最近一次 hello 的 latestSeq:resume-underrun 时回退到这里。 */
+ let helloLatestSeq: number | null = null;
+
+ function buildUrl(): string {
+ const loc = typeof location !== 'undefined' ? location : null;
+ const scheme = loc && loc.protocol === 'https:' ? 'wss' : 'ws';
+ const host = loc ? loc.host : '127.0.0.1:18090';
+ const token = deps.http.getToken() || deps.kv.get(WEBUI_TOKEN_KEY) || '';
+ const cid = deps.http.cid() || deps.kv.get(WEBUI_CID_KEY) || '';
+ const parts: string[] = [];
+ if (token) parts.push('token=' + encodeURIComponent(token));
+ if (cid) parts.push('cid=' + encodeURIComponent(cid));
+ const query = parts.length ? '?' + parts.join('&') : '';
+ return scheme + '://' + host + '/api/stream' + query;
+ }
+
+ function emit(raw: unknown): void {
+ for (const l of listeners) {
+ try {
+ l(raw);
+ } catch {
+ // 监听方异常不许打断帧分发
+ }
+ }
+ }
+
+ function sendFrame(frame: ClientFrame): void {
+ if (!ws || status !== 'open') return;
+ try {
+ ws.send(JSON.stringify(frame));
+ } catch {
+ // 发不进去就丢:下一次重连会用 resume 补
+ }
+ }
+
+ function handleMessage(text: string): void {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(text);
+ } catch {
+ return; // 非 JSON 文本帧:静默忽略
+ }
+ if (!isServerFrame(parsed)) return;
+ const frame: ServerFrame = parsed;
+ if (!KNOWN_FRAME_TYPES.has(frame.type)) return; // 未知帧类型:静默忽略
+
+ const seq: unknown = frame.seq;
+ if (typeof seq === 'number' && Number.isFinite(seq) && (lastSeq === null || seq > lastSeq)) {
+ lastSeq = seq;
+ }
+
+ if (frame.type === 'hello') {
+ const p = asRecord(frame.payload);
+ const latest: unknown = p ? p['latestSeq'] : null;
+ helloLatestSeq = typeof latest === 'number' && Number.isFinite(latest) ? latest : null;
+ // 本地有 lastSeq 才续传;否则什么都不做,基线由调用方拉 REST
+ if (lastSeq !== null) {
+ sendFrame({ v: PROTOCOL_VERSION, type: 'resume', payload: { lastSeq } });
+ }
+ } else if (frame.type === 'error') {
+ const p = asRecord(frame.payload);
+ if (p && p['code'] === 'resume-underrun') {
+ // 环形缓冲已覆盖不到 lastSeq —— 服务端补发了最新快照,车票回退到 hello 的水位
+ lastSeq = helloLatestSeq;
+ }
+ }
+
+ emit(frame); // 原样上抛,业务解析归 features / services
+ }
+
+ function scheduleReconnect(): void {
+ if (stopped || timer !== null) return;
+ timer = setTimeout(() => {
+ timer = null;
+ openSocket();
+ }, RECONNECT_MS);
+ }
+
+ function openSocket(): void {
+ if (stopped || ws) return;
+ if (typeof WebSocket === 'undefined') return; // 非浏览器环境(单测):保持 idle
+ status = 'connecting';
+ let socket: WebSocket;
+ try {
+ socket = new WebSocket(buildUrl());
+ } catch {
+ status = 'reconnecting';
+ scheduleReconnect();
+ return;
+ }
+ ws = socket;
+ socket.onopen = (): void => {
+ if (ws === socket) status = 'open';
+ };
+ socket.onmessage = (ev: MessageEvent): void => {
+ if (ws !== socket) return;
+ const data: unknown = ev.data;
+ if (typeof data !== 'string') return; // 二进制帧:静默忽略
+ handleMessage(data);
+ };
+ socket.onclose = (): void => {
+ if (ws !== socket) return;
+ ws = null;
+ if (stopped) {
+ status = 'closed';
+ return;
+ }
+ status = 'reconnecting';
+ scheduleReconnect();
+ };
+ socket.onerror = (): void => {
+ // 交给 onclose 收尾(close 紧随 error),这里不重复调度重连
+ };
+ }
+
+ return {
+ connect(): void {
+ stopped = false;
+ if (!ws && timer === null) openSocket(); // 幂等:已有连接或待重连时不再开新连接
+ },
+ close(): void {
+ stopped = true;
+ if (timer !== null) {
+ clearTimeout(timer);
+ timer = null;
+ }
+ const socket = ws;
+ ws = null;
+ status = 'closed';
+ if (socket) {
+ try {
+ socket.close();
+ } catch {
+ // 忽略
+ }
+ }
+ },
+ send: sendFrame,
+ onFrame(listener: (raw: unknown) => void): () => void {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+ },
+ status: (): StreamStatus => status,
+ };
+}
diff --git a/packages/webui-react/src/features/app-controller.ts b/packages/webui-react/src/features/app-controller.ts
new file mode 100644
index 00000000..9cf87d8d
--- /dev/null
+++ b/packages/webui-react/src/features/app-controller.ts
@@ -0,0 +1,376 @@
+/**
+ * features/app-controller.ts —— 咬合枢纽(the mesh hub)
+ * ============================================================================
+ * 【这是整套设计里最关键的一个文件】ui/ 与 core/ 在这里唯一地咬合。
+ *
+ * ui/ ──(只收 props)──┐
+ * ├──> 本文件:把端口翻译成快照 + 动作
+ * core/ ──(只走 Port)────┘
+ *
+ * 【高内聚】本文件只做编排:不渲染、不发 HTTP、不碰 WebSocket 细节 —— 那些
+ * 全在 core 的端口实现里。它把 N 个窄端口聚合成 1 个面向视图的宽接口,
+ * 因此 ui 层的每个组件都只需要自己那一小撮 props(低耦合)。
+ * 【会话隔离】所有会话相关的读写都经 activeSession() 取到对应切片,绝不共享
+ * 跨会话的可变状态 —— 这是本次修复的核心诉求。
+ * 【热插拔】只依赖 contracts/ports.ts 的接口。换供应商/换传输 = 换 Registry
+ * 里的一个实现,本文件零改动。
+ * ============================================================================
+ */
+
+import type {
+ AlertItem,
+ Attachment,
+ ChatMessage,
+ ContextUsage,
+ ModelSelection,
+ PendingAuth,
+ ProviderOption,
+ ModelOption,
+ SessionGroup,
+ SessionId,
+ SessionSlice,
+ SessionSummary,
+ ThinkingEffort,
+ UsageInfo,
+ WorkspaceEntry,
+ WorkspaceInfo,
+} from '../contracts/domain';
+import { groupSessionsByWorkspace } from '../contracts/domain';
+import type { WireSettings } from '../contracts/protocol';
+import type { Registry } from '../contracts/ports';
+
+export type ThemeMode = 'light' | 'dark';
+export type Lang = 'zh' | 'en';
+
+export interface AppSnapshot {
+ ready: boolean;
+ activeSessionId: SessionId | null;
+ sessions: SessionSummary[];
+ groups: SessionGroup[];
+ /** 当前会话的隔离切片;无会话时为 null。 */
+ slice: SessionSlice | null;
+ providers: ProviderOption[];
+ models: ModelOption[];
+ selection: ModelSelection | null;
+ settings: WireSettings | null;
+ usage: UsageInfo | null;
+ context: ContextUsage | null;
+ alerts: AlertItem[];
+ alertsUnread: number;
+ authQueue: PendingAuth[];
+ workspace: WorkspaceInfo | null;
+ recents: WorkspaceEntry[];
+ theme: ThemeMode;
+ lang: Lang;
+ leftOpen: boolean;
+ rightOpen: boolean;
+ searchQuery: string;
+ collapsedGroups: string[];
+}
+
+export interface AppActions {
+ // 会话
+ newChat(workspace?: string | null): Promise;
+ selectSession(id: SessionId): Promise;
+ renameSession(id: SessionId, title: string): Promise;
+ deleteSession(id: SessionId): Promise;
+ refreshSessions(): Promise;
+ // 对话
+ send(content: string): Promise;
+ stop(): Promise;
+ sendCommand(cmd: string): Promise;
+ // 模型三段式(本次新增能力)
+ setProvider(provider: string): Promise;
+ setModel(modelId: string): Promise;
+ setThinking(effort: ThinkingEffort): Promise;
+ submitCustomModel(expr: string): Promise;
+ // 工作区
+ useWorkspace(dir: string): Promise;
+ resetWorkspace(): Promise;
+ browseWorkspace(path?: string): Promise;
+ // 附件
+ uploadFiles(files: File[]): Promise;
+ removeAttachment(id: string): void;
+ // 设置与用量
+ updateSettings(patch: Partial): Promise;
+ resetToken(): Promise;
+ acknowledgeToken(): Promise;
+ refreshUsage(): Promise;
+ // 异常与授权
+ markAlertsRead(): void;
+ clearAlerts(): void;
+ decideAuth(requestId: string, approve: boolean): Promise;
+ // 纯 UI 状态
+ setTheme(theme: ThemeMode): void;
+ setLang(lang: Lang): void;
+ setLeftOpen(v: boolean): void;
+ setRightOpen(v: boolean): void;
+ setSearchQuery(q: string): void;
+ toggleGroup(key: string): void;
+}
+
+export interface AppController {
+ snapshot(): AppSnapshot;
+ subscribe(listener: () => void): () => void;
+ actions: AppActions;
+}
+
+export function createAppController(reg: Registry): AppController {
+ const listeners = new Set<() => void>();
+ const notify = () => { for (const l of listeners) l(); };
+
+ // ── 纯 UI 状态(不进服务端) ────────────────────────────────────────────
+ let ready = false;
+ let activeSessionId: SessionId | null = null;
+ let sessions: SessionSummary[] = [];
+ let providers: ProviderOption[] = [];
+ let models: ModelOption[] = [];
+ let settings: WireSettings | null = null;
+ let usage: UsageInfo | null = null;
+ let context: ContextUsage | null = null;
+ let workspace: WorkspaceInfo | null = null;
+ let recents: WorkspaceEntry[] = [];
+ let theme: ThemeMode = 'light';
+ let lang: Lang = 'zh';
+ let leftOpen = false;
+ let rightOpen = false;
+ let searchQuery = '';
+ let collapsedGroups: string[] = [];
+
+ const activeSlice = (): SessionSlice | null =>
+ activeSessionId ? reg.sessions.slice(activeSessionId) : null;
+
+ function snapshot(): AppSnapshot {
+ const slice = activeSlice();
+ return {
+ ready,
+ activeSessionId,
+ sessions,
+ groups: groupSessionsByWorkspace(sessions),
+ slice,
+ providers,
+ models,
+ selection: slice ? slice.selection : null,
+ settings,
+ usage,
+ context,
+ alerts: reg.alerts.list(),
+ alertsUnread: reg.alerts.unread(),
+ authQueue: reg.auth.pending(),
+ workspace,
+ recents,
+ theme,
+ lang,
+ leftOpen,
+ rightOpen,
+ searchQuery,
+ collapsedGroups,
+ };
+ }
+
+ // ── 订阅所有会话切片 + 各服务,统一通知 ────────────────────────────────
+ const subDeps: Array<() => void> = [];
+ subDeps.push(reg.alerts.subscribe(notify));
+ subDeps.push(reg.auth.subscribe(notify));
+ let sessionSub: (() => void) | null = null;
+ function resubscribeSession() {
+ if (sessionSub) { sessionSub(); sessionSub = null; }
+ if (activeSessionId) sessionSub = reg.sessions.subscribe(activeSessionId, notify);
+ }
+
+ async function loadCatalog() {
+ try {
+ providers = await reg.models.providers();
+ models = await reg.models.models();
+ } catch {
+ providers = [];
+ models = [];
+ }
+ }
+
+ async function bootstrap() {
+ try { sessions = await reg.sessions.list(); } catch { sessions = []; }
+ await loadCatalog();
+ try { settings = await reg.settings.get(); } catch { settings = null; }
+ try { usage = await reg.usage.quota(); } catch { usage = null; }
+ try { workspace = reg.workspace.current(); recents = reg.workspace.recents(); } catch { /* noop */ }
+ ready = true;
+ notify();
+ }
+
+ const actions: AppActions = {
+ async newChat(ws) {
+ const id = await reg.sessions.create(ws ?? null);
+ activeSessionId = id;
+ resubscribeSession();
+ await actions.refreshSessions();
+ },
+ async selectSession(id) {
+ if (activeSessionId === id) return;
+ activeSessionId = id; // 旧切片原样保留 —— 会话隔离
+ resubscribeSession();
+ context = null;
+ try { await reg.sessions.switchTo(id); } catch { /* 服务端切换失败不阻塞本地隔离 */ }
+ try { context = await reg.usage.context(id); } catch { context = null; }
+ notify();
+ },
+ async renameSession(id, title) {
+ await reg.sessions.rename(id, title);
+ await actions.refreshSessions();
+ },
+ async deleteSession(id) {
+ await reg.sessions.remove(id);
+ if (activeSessionId === id) activeSessionId = null;
+ resubscribeSession();
+ await actions.refreshSessions();
+ },
+ async refreshSessions() {
+ try { sessions = await reg.sessions.list(); } catch { /* 保留旧列表 */ }
+ notify();
+ },
+
+ async send(content) {
+ const id = activeSessionId;
+ if (!id) return;
+ const slice = reg.sessions.slice(id);
+ const refs = slice.attachments.filter((a) => a.status === 'done').map((a) => '@' + a.path);
+ const msg: ChatMessage = {
+ id: 'u-' + reg.clock.now(),
+ role: 'user',
+ ts: reg.clock.now(),
+ blocks: [{ id: 'b-' + reg.clock.now(), kind: 'text', text: content, markdown: false }],
+ };
+ reg.sessions.slice(id).messages.push(msg);
+ reg.sessions.slice(id).attachments = [];
+ notify();
+ await reg.chat.send(id, content, refs);
+ },
+ async stop() {
+ if (activeSessionId) await reg.chat.stop(activeSessionId);
+ },
+ async sendCommand(cmd) {
+ if (activeSessionId) await reg.chat.command(activeSessionId, cmd);
+ },
+
+ async setProvider(provider) {
+ const id = activeSessionId;
+ if (!id) return;
+ const sel = await reg.models.setProvider(id, provider);
+ try { models = await reg.models.models(sel.provider); } catch { models = []; }
+ notify();
+ },
+ async setModel(modelId) {
+ const id = activeSessionId;
+ if (!id) return;
+ await reg.models.setModel(id, modelId);
+ notify();
+ },
+ async setThinking(effort) {
+ const id = activeSessionId;
+ if (!id) return;
+ await reg.models.setThinking(id, effort);
+ notify();
+ },
+ async submitCustomModel(expr) {
+ const trimmed = expr.trim();
+ if (!trimmed) return;
+ const id = activeSessionId;
+ if (!id) return;
+ await reg.models.setModel(id, trimmed);
+ await loadCatalog();
+ notify();
+ },
+
+ async useWorkspace(dir) {
+ workspace = await reg.workspace.use(dir, true);
+ reg.workspace.addRecent(dir);
+ recents = reg.workspace.recents();
+ notify();
+ },
+ async resetWorkspace() {
+ workspace = await reg.workspace.reset();
+ notify();
+ },
+ browseWorkspace(path) {
+ return reg.workspace.browse(path);
+ },
+
+ async uploadFiles(files) {
+ const id = activeSessionId;
+ if (!id) return;
+ for (const f of files) {
+ const localId = 'att-' + reg.clock.now() + '-' + Math.random().toString(36).slice(2, 8);
+ const pending: Attachment = { id: localId, name: f.name, path: '', size: f.size, status: 'uploading' };
+ reg.sessions.slice(id).attachments.push(pending);
+ notify();
+ try {
+ const done = await reg.upload.upload(f, f.name);
+ const arr = reg.sessions.slice(id).attachments;
+ const i = arr.findIndex((a) => a.id === localId);
+ if (i >= 0) arr[i] = { ...done, id: localId };
+ } catch (e) {
+ const arr = reg.sessions.slice(id).attachments;
+ const i = arr.findIndex((a) => a.id === localId);
+ if (i >= 0) arr[i] = { ...pending, status: 'error', error: e instanceof Error ? e.message : String(e) };
+ }
+ notify();
+ }
+ },
+ removeAttachment(atId) {
+ const slice = activeSlice();
+ if (!slice) return;
+ slice.attachments = slice.attachments.filter((a) => a.id !== atId);
+ notify();
+ },
+
+ async updateSettings(patch) {
+ settings = await reg.settings.update(patch);
+ notify();
+ },
+ async resetToken() {
+ await reg.settings.resetToken();
+ settings = await reg.settings.get();
+ notify();
+ },
+ async acknowledgeToken() {
+ await reg.settings.acknowledgeToken();
+ settings = await reg.settings.get();
+ notify();
+ },
+ async refreshUsage() {
+ try { await reg.usage.refresh(); } catch { /* noop */ }
+ try { usage = await reg.usage.quota(); } catch { /* keep old */ }
+ if (activeSessionId) {
+ try { context = await reg.usage.context(activeSessionId); } catch { context = null; }
+ }
+ notify();
+ },
+
+ markAlertsRead() { reg.alerts.markRead(); notify(); },
+ clearAlerts() { reg.alerts.clear(); notify(); },
+ async decideAuth(requestId, approve) { await reg.auth.decide(requestId, approve); notify(); },
+
+ setTheme(t) { theme = t; notify(); },
+ setLang(l) { lang = l; notify(); },
+ setLeftOpen(v) { leftOpen = v; notify(); },
+ setRightOpen(v) { rightOpen = v; notify(); },
+ setSearchQuery(q) { searchQuery = q; notify(); },
+ toggleGroup(key) {
+ collapsedGroups = collapsedGroups.includes(key)
+ ? collapsedGroups.filter((k) => k !== key)
+ : [...collapsedGroups, key];
+ notify();
+ },
+ };
+
+ void bootstrap();
+
+ return {
+ snapshot,
+ subscribe(listener) {
+ listeners.add(listener);
+ return () => { listeners.delete(listener); for (const s of subDeps) s(); };
+ },
+ actions,
+ };
+}
diff --git a/packages/webui-react/src/features/registry-context.tsx b/packages/webui-react/src/features/registry-context.tsx
new file mode 100644
index 00000000..c6508bcf
--- /dev/null
+++ b/packages/webui-react/src/features/registry-context.tsx
@@ -0,0 +1,31 @@
+/**
+ * features/registry-context.tsx —— 把端口注入 React 树的唯一入口
+ * ============================================================================
+ * 【咬合】features 层是全项目唯一允许同时看见 core(Registry)与 ui 的层。
+ * ui 组件从不 import 本文件;它们只收 props。本文件提供的 useRegistry()
+ * 只被 features/ 内部与 App 组装根使用。
+ * 【热插拔】测试里用 即可整套替换。
+ * ============================================================================
+ */
+
+import { createContext, useContext, type ReactNode } from 'react';
+import type { Registry } from '../contracts/ports';
+import { getRegistry } from '../core/registry';
+
+const RegistryContext = createContext(null);
+
+export interface RegistryProviderProps {
+ value?: Registry;
+ children: ReactNode;
+}
+
+export function RegistryProvider({ value, children }: RegistryProviderProps) {
+ const reg = value ?? getRegistry();
+ return {children};
+}
+
+export function useRegistry(): Registry {
+ const reg = useContext(RegistryContext);
+ if (!reg) throw new Error('useRegistry 必须在 内使用');
+ return reg;
+}
diff --git a/packages/webui-react/src/features/use-app.ts b/packages/webui-react/src/features/use-app.ts
new file mode 100644
index 00000000..fcf12a27
--- /dev/null
+++ b/packages/webui-react/src/features/use-app.ts
@@ -0,0 +1,23 @@
+/**
+ * features/use-app.ts —— AppController 的 React 绑定
+ * ============================================================================
+ * 【低耦合】ui 组件不需要知道本文件的存在。只有 App 组装根用它把控制器的
+ * 快照 + 动作拆成 props 递给各哑组件。
+ * 【性能】按需取快照字段做选择器,避免任意状态变化引发整树重渲染。
+ * ============================================================================
+ */
+
+import { useSyncExternalStore } from 'react';
+import type { AppActions, AppController, AppSnapshot } from './app-controller';
+
+export function useAppSnapshot(controller: AppController): AppSnapshot {
+ return useSyncExternalStore(
+ controller.subscribe,
+ () => controller.snapshot(),
+ () => controller.snapshot(),
+ );
+}
+
+export function useAppActions(controller: AppController): AppActions {
+ return controller.actions;
+}
diff --git a/packages/webui-react/src/features/use-store.ts b/packages/webui-react/src/features/use-store.ts
new file mode 100644
index 00000000..ea18e2bd
--- /dev/null
+++ b/packages/webui-react/src/features/use-store.ts
@@ -0,0 +1,21 @@
+/**
+ * features/use-store.ts —— 极简 store 的 React 绑定
+ * ============================================================================
+ * 【为什么放这】core/store/create-store.ts 必须零 UI 依赖(headless 可复用),
+ * 而 useSyncExternalStore 是 React 专属。把这层桥接收进 features —— 正是它
+ * 作为 core↔ui 咬合层的职责。
+ * 【热插拔】换状态库时只改本文件与 core/store,订阅方无感。
+ * ============================================================================
+ */
+
+import { useSyncExternalStore } from 'react';
+import type { Store } from '../core/store/create-store';
+
+/** 组件里 useStore(store, s => s.x) 即可,选择器最小重渲染。 */
+export function useStore(store: Store, selector: (s: S) => T): T {
+ return useSyncExternalStore(
+ store.subscribe,
+ () => selector(store.snapshot()),
+ () => selector(store.snapshot()),
+ );
+}
diff --git a/packages/webui-react/src/i18n/en.ts b/packages/webui-react/src/i18n/en.ts
new file mode 100644
index 00000000..ff568fa9
--- /dev/null
+++ b/packages/webui-react/src/i18n/en.ts
@@ -0,0 +1,267 @@
+/**
+ * src/i18n/en.ts —— English 文案词典(键名 1:1 对齐 public/app/i18n.js)
+ * 【职责】只放文案数据,零逻辑、零 IO。
+ * 【接缝】类型 Record 强制与 zh.ts 键集合完全一致 ——
+ * 任何漏键 / 多键都是编译错误,保证中英双语永远同步。
+ */
+import type { I18nKey } from './zh';
+
+export const en: Record = {
+ title: 'Mcode Web UI',
+ new_chat: 'New Chat',
+ search_placeholder: 'Search sessions...',
+ workspace: 'Workspace',
+ recent: 'Recent',
+ no_sessions: 'No sessions yet',
+ model_picker_title: 'Switch Model',
+ model_picker_loading: 'Loading...',
+ model_picker_empty: 'No models available',
+ model_picker_hint: 'Press Enter to send /model command to mcode',
+ cancel: 'Cancel',
+ usage: 'Usage',
+ usage_loading: 'Click to load usage...',
+ quota_card_title: 'Quota (Token Plan)',
+ quota_card_enabled: 'Show usage',
+ quota_card_enabled_help: 'When off, the usage button disappears from the main UI',
+ quota_card_key: 'Subscription Key',
+ quota_card_key_help: 'Get from platform.minimaxi.com/user-center/token-plan. Stored in plain text in settings.json',
+ save: 'Save',
+ clear: 'Clear',
+ quota_no_data_hint: 'Quota feature is not enabled. Click the button below to open Appearance settings.',
+ quota_go_settings: 'Open Appearance settings',
+ quota_disabled: 'Disabled',
+ quota_saved: 'Saved',
+ quota_need_key: 'Enter Subscription Key',
+ quota_clear_confirm: 'Clear Subscription Key? Quota data will stop showing.',
+ quota_cleared: 'Cleared',
+ quota_status_configured: 'Configured',
+ quota_status_not_configured: 'Not configured',
+ quota_source_env: 'env',
+ quota_source_file: 'file',
+ quota_delete_disabled_external: 'Key is managed externally (env / file); cannot be deleted from the UI',
+ quota_input_placeholder_env: 'env takes priority — this value is ignored while the env var is set',
+ quota_input_placeholder_file: 'file takes priority — this value is ignored while the file is present',
+ quota_modal_title: 'Configure Subscription Key',
+ delete_key: 'Delete',
+ upgrade: 'Upgrade',
+ appearance: 'Appearance',
+ language: 'Language',
+ settings: 'Settings',
+ appearance_light: 'Light',
+ appearance_dark: 'Dark',
+ appearance_theme: 'Theme',
+ appearance_theme_help: 'Toggle light/dark theme',
+ appearance_quota_help: 'When off, the usage button disappears from the main UI',
+ language_zh: '中文',
+ language_en: 'English',
+ empty_hint_1: 'No messages yet — start typing below',
+ empty_hint_2: 'Press / for commands, Ctrl+V to paste image',
+ welcome_tagline: 'No messages yet — start typing below',
+ hint_help: 'Show commands',
+ hint_status: 'Show status',
+ hint_sessions: 'Sessions',
+ hint_usage: 'Usage',
+ input_placeholder: 'Type a message... (/ commands · @ files · Ctrl+V images)',
+ hint_footer: '/ commands · @ files · Ctrl+V images · Enter to send',
+ section_session: 'SESSION',
+ section_model: 'MODEL',
+ section_workspace: 'WORKSPACE',
+ section_context: 'CONTEXT',
+ section_goal: 'GOAL',
+ section_todo: 'TODO',
+ section_plan: 'Plan',
+ section_thinking: 'Thinking',
+ show_thinking: 'Show thinking',
+ lines: 'lines',
+ ask_title: 'Ask',
+ plan_review: 'Plan Review',
+ plan_view_btn: 'View full plan',
+ r_title: 'Title', r_dir: 'Directory', r_branch: 'Branch', r_tree: 'Status',
+ r_used: 'Used', r_percent: 'Percent', r_tps: 'Speed', r_cache: 'cache',
+ status_idle: 'Idle',
+ status_offline: 'Offline',
+ status_thinking: 'Thinking',
+ status_running: 'Running',
+ status_completed: 'Done',
+ tps_label: 'tok/s',
+ goal_active: 'Active',
+ goal_complete: 'Complete',
+ goal_paused: 'Paused',
+ todo_done: 'Done',
+ todo_pending: 'Pending',
+ user_loading: 'Mcode Web',
+ user_default: 'Mcode',
+ mode_default: 'Default',
+ mode_ask: 'Ask', mode_ask_desc: 'Confirm sensitive',
+ mode_auto: 'Auto', mode_auto_desc: 'Only high-risk',
+ mode_full: 'Full Access', mode_full_desc: 'No confirm',
+ mode_plan: 'Plan Mode', mode_plan_desc: 'Force mcode to output Plan: format',
+ drop_hint: 'Drop file to upload',
+ toggle_theme: 'Toggle theme',
+ toggle_lang: '中 / English',
+ network: 'Network access',
+ slash_no_results: 'No matching commands',
+ slash_section_cmd: 'Commands',
+ slash_section_skill: 'Skills',
+ perm_full: 'Full access',
+ perm_ask: 'Ask',
+ perm_read: 'Read-only',
+ plan_mode_continue: 'Continue with plan',
+ plan_mode_deny: 'Deny',
+ lan_access: 'LAN Access',
+ lan_on: 'On',
+ lan_off: 'Off',
+ lan_link_title: 'Click to copy LAN access URL',
+ copy_success: 'Copied',
+ copy_failed: 'Copy failed',
+ copy_failed_manual: 'Copy failed, please copy manually',
+ model_switched: 'Switched to',
+ sessions_list: 'Sessions',
+ refresh: 'Refresh',
+ quota_remaining: 'left',
+ quota_5h_limit: '5-Hour Limit',
+ quota_weekly_limit: 'Weekly Limit',
+ quota_next_reset: 'Next reset',
+ quota_loading_fail: 'Load failed',
+ quota_loading_idle: 'Click to load usage…',
+ usage_refreshed: 'Usage refreshed',
+ lan_title_on: 'LAN access on — other devices on this network can access',
+ lan_title_off: 'LAN access off — only this computer can access',
+ plan_mode_on: 'Plan mode on (next message will use Plan: format for mcode)',
+ plan_mode_off: 'Plan mode off',
+ perm_mode_note: 'Permission mode only updates the webui UI; mcode\'s actual mode comes from the --permission launch flag (no mid-session change in 0.1.5)',
+ ask_reopened: '✓ ask_user modal re-enabled (presentedKeys cleared)',
+ ask_no_reset: 'ask_user modal was never dismissed (nothing to reset)',
+ workspace_unset: '(Unset)',
+ workspace_unset_short: 'Unset',
+ workspace_current: 'Current',
+ workspace_switch: 'Click to switch workspace',
+ sidebar_loading: 'Loading sessions…',
+ workspace_picker_title: 'Switch Workspace',
+ workspace_picker_select: 'Select Workspace',
+ workspace_picker_current: 'Current: ',
+ session_delete: 'Delete this session',
+ session_delete_confirm: 'Delete?',
+ session_delete_yes: 'Delete',
+ session_delete_cancel: '×',
+ session_rename: 'Rename this session',
+ session_rename_ok: '✓ Renamed',
+ session_rename_fail: 'Rename failed',
+ btn_send_title: 'Send (Enter)',
+ btn_stop_title: 'Stop (/stop)',
+ workspace_unset_text: 'Unset (click to select)',
+ chip_online_title: 'WebUI tabs currently connected to server',
+ chip_online_single: '1 dev',
+ chip_online_plural: '{n} devs',
+ topbar_beta_title: 'Beta version',
+ force_reload: 'Force reload',
+ force_reload_title: 'Force reload (bypass browser cache)',
+ chip_offline: 'offline',
+ workspace_use_tui: "Use mcode TUI's current workspace",
+ workspace_reset: 'Reset to default workspace detected at startup',
+ workspace_locked_in_chat: 'Chat already started — workspace locked. Click "New Chat" in the sidebar to pick a new workspace.',
+ workspace_picker_hint: 'Click to select workspace',
+ native_candidates_title: 'Multiple matching directories found:',
+ native_candidates_none: 'No matching directory found — opened built-in tree',
+ native_picked_fill: 'Directory selection failed, try entering the path manually',
+ ws_nows_btn: 'No workspace needed',
+ ws_nows_unavailable: 'Temporary directory unavailable',
+ ws_add: 'Add workspace…',
+ ask_user_other_placeholder: 'Other...',
+ ask_user_clear: 'Clear',
+ ask_user_skip: 'Skip',
+ ask_user_send: 'Send',
+ ask_user_resend: 'Answered (click to resend)',
+ ask_user_step_count: 'of {n} steps',
+ ask_user_answered: 'Answered: {answer}',
+ ask_user_skipped: 'Skipped',
+ ask_user_no_options_hint: 'No preset options — type in "Other" below',
+ ask_user_send_count: 'Send ({n} questions)',
+ ask_user_resend_count: 'Answered ({n} questions, click to resend)',
+ topbar_readonly_zh: '只读',
+ topbar_readonly_en: 'READ ONLY',
+ topbar_readonly_title: 'webui is in read-only mode (remote clients cannot send or delete)',
+ lan_card_title: 'LAN Security',
+ lan_card_readonly: 'Read-only mode',
+ lan_card_readonly_help: 'Remote clients can only read; cannot send or delete',
+ lan_card_token_auth: 'Token auth',
+ lan_card_token_auth_help: 'Requires ?token= or Authorization header; loopback exempt',
+ lan_card_reset_token: 'Reset token',
+ lan_card_reset_token_confirm: 'Reset the token? The old token becomes invalid immediately.',
+ lan_card_token_value: 'Current token',
+ lan_card_token_show: 'Show',
+ lan_card_token_hide: 'Hide',
+ lan_card_token_saved: 'Saved — click "Reset" to view again',
+ lan_card_token_disabled: 'Token auth disabled',
+ lan_card_token_copy: 'Copy',
+ lan_card_token_copied: 'Copied to clipboard',
+ lan_card_token_new_warning: 'New token — open the URL on another device to test',
+ lan_card_token_ack: 'I have saved it',
+ lan_card_token_ack_help: 'After saving the token will not be shown again; reset to view again',
+ lan_card_token_rotated_toast: 'Token rotated, new value auto-synced',
+ auth_title: 'Authorization required',
+ auth_queue_pos: 'pending {i}/{n}',
+ auth_expires_in: 'Time limit',
+ auth_approve: 'Approve',
+ auth_deny: 'Deny',
+ auth_decision_failed: 'Failed to submit decision',
+ auth_action_session_delete: 'Delete session',
+ auth_action_sessions_cleanup_orphans: 'Clean up orphan sessions',
+ auth_action_session_cleanup_all: 'Delete all sessions',
+ auth_action_session_export: 'Export session',
+ auth_action_session_search: 'Cross-workspace session search',
+ auth_action_token_reset: 'Reset access token',
+ auth_action_slash_clear: 'Clear / restart chat',
+ auth_action_startup_cleanup: 'Startup cleanup',
+ auth_ctx_targetSessionId: 'target session',
+ auth_ctx_matchKind: 'match kind',
+ auth_ctx_isMcodeSid: 'mcode session id',
+ auth_ctx_isOrphan: 'orphan',
+ auth_ctx_chatLen: 'chat lines',
+ auth_ctx_q: 'query',
+ auth_ctx_workspace: 'workspace',
+ auth_ctx_limit: 'limit',
+ auth_ctx_format: 'format',
+ auth_ctx_download: 'download',
+ auth_ctx_orphanCount: 'orphan count',
+ auth_ctx_orphanIds: 'orphan ids',
+ auth_ctx_cmd: 'command',
+ auth_ctx_sessionId: 'session id',
+ auth_ctx_mcodeSessionId: 'mcode session',
+ auth_ctx_source: 'source',
+ alerts_title: 'System alerts',
+ alerts_empty: 'No alerts',
+ alerts_clear: 'Clear',
+ alerts_session: 'session',
+ alerts_level_info: 'Info',
+ alerts_level_warn: 'Warning',
+ alerts_level_error: 'Error',
+ workspace_sync_tui: "Also update mcode TUI's cwd (write cwd.json)",
+ workspace_recents_title: 'Recent',
+ workspace_browse: 'Browse directories…',
+ workspace_loading: 'Loading…',
+ mode_read: 'Read-only',
+ fs_picker_dialog_title: 'Select directory',
+ fs_picker_home: 'Home directory',
+ fs_picker_root: 'Root directory',
+ fs_picker_mkdir: 'New folder',
+ fs_picker_cancel: 'Cancel',
+ fs_picker_confirm: 'OK',
+ fs_picker_pick_current: 'Choose this directory',
+ fs_picker_filter_placeholder: 'Filter… (globs like *.txt)',
+ fs_picker_clear_filter: 'Clear filter',
+ fs_picker_col_name: 'Name',
+ fs_picker_col_size: 'Size',
+ fs_picker_col_mtime: 'Modified',
+ fs_picker_col_mode: 'Permissions',
+ fs_picker_loading: 'Loading…',
+ fs_picker_empty: 'This directory is empty',
+ fs_picker_no_match: 'No matching results',
+ fs_picker_load_failed: 'Failed to load directory',
+ fs_picker_mkdir_prompt: 'New folder name:',
+ fs_picker_mkdir_failed: 'Failed to create folder',
+ fs_picker_create_failed: 'Create failed',
+ fs_picker_select: 'Select',
+ fs_picker_selected: '{n} selected',
+ fs_picker_not_loaded: 'fs-picker component not loaded (check index.html includes app/fs-picker.js)',
+};
diff --git a/packages/webui-react/src/i18n/index.ts b/packages/webui-react/src/i18n/index.ts
new file mode 100644
index 00000000..e44330a7
--- /dev/null
+++ b/packages/webui-react/src/i18n/index.ts
@@ -0,0 +1,63 @@
+/**
+ * src/i18n/index.ts —— 双语字典入口(纯函数,零 React / 零 DOM 扫描)
+ * 【职责】t(key, lang?) 取文案(缺键回落 key 本身,与 vanilla 行为一致);
+ * setLang / getLang 管当前语言并经 KeyValueStorePort 持久化(webui-lang,
+ * 首次默认 en,与 public/app/i18n.js 一致);LANGS 枚举可用语言。
+ * 【接缝】词典数据在 zh.ts / en.ts;持久化走 KeyValueStorePort(默认
+ * core/store/kv-port.ts 的 localStorage 实现,可用 setLangStore 注入替换)。
+ * 不做 DOM 的 data-i18n 扫描(那是 ui 层的职责),保持可测纯逻辑。
+ */
+import type { KeyValueStorePort } from '../contracts/ports';
+import { createKvPort } from '../core/store/kv-port';
+import type { I18nKey } from './zh';
+import { zh } from './zh';
+import { en } from './en';
+
+export const LANGS = ['zh', 'en'] as const;
+export type Lang = (typeof LANGS)[number];
+
+export const LANG_STORE_KEY = 'webui-lang';
+export const DEFAULT_LANG: Lang = 'en';
+
+const DICTS: Record> = { zh, en };
+
+function isLang(v: unknown): v is Lang {
+ return typeof v === 'string' && (LANGS as readonly string[]).includes(v);
+}
+
+let kv: KeyValueStorePort = createKvPort();
+
+function readInitial(): Lang {
+ const saved = kv.get(LANG_STORE_KEY);
+ return isLang(saved) ? saved : DEFAULT_LANG;
+}
+
+let currentLang: Lang = readInitial();
+
+/** 替换持久化实现(测试 / 组装根注入)。会用新 kv 重新读一次当前语言。 */
+export function setLangStore(store: KeyValueStorePort): void {
+ kv = store;
+ currentLang = readInitial();
+}
+
+/** 取文案;lang 省略时用当前语言。缺键回落到 key 本身(与 vanilla t() 一致)。 */
+export function t(key: string, lang?: Lang): string {
+ const l: Lang = lang && isLang(lang) ? lang : currentLang;
+ const dict = DICTS[l];
+ const v = dict ? dict[key] : undefined;
+ return v || key;
+}
+
+/** 切换语言并持久化;非法值忽略。 */
+export function setLang(lang: Lang): void {
+ if (!isLang(lang)) return;
+ currentLang = lang;
+ kv.set(LANG_STORE_KEY, lang);
+}
+
+export function getLang(): Lang {
+ return currentLang;
+}
+
+/** 全部文案键的类型(供 ui 层做键名约束)。 */
+export type { I18nKey };
diff --git a/packages/webui-react/src/i18n/zh.ts b/packages/webui-react/src/i18n/zh.ts
new file mode 100644
index 00000000..3ff3ab85
--- /dev/null
+++ b/packages/webui-react/src/i18n/zh.ts
@@ -0,0 +1,268 @@
+/**
+ * src/i18n/zh.ts —— 简体中文文案词典(键名 1:1 对齐 public/app/i18n.js)
+ * 【职责】只放文案数据,零逻辑、零 IO;是全部 UI 文案的唯一中文来源。
+ * 【接缝】key 类型 I18nKey 由本文件派生;en.ts 用 Record
+ * 强制中英键集合完全一致(漏键即类型错误)。
+ */
+export const zh = {
+ title: 'Mcode Web UI',
+ new_chat: '新建会话',
+ search_placeholder: '搜索会话...',
+ workspace: '工作区',
+ recent: '最近会话',
+ no_sessions: '暂无会话记录',
+ model_picker_title: '切换模型',
+ model_picker_loading: '加载中...',
+ model_picker_empty: '没有可用模型',
+ model_picker_hint: '回车发送 /model 命令给 mcode',
+ cancel: '取消',
+ usage: '套餐用量',
+ usage_loading: '点击套餐用量加载...',
+ quota_card_title: '套餐用量 (Token Plan)',
+ quota_card_enabled: '显示套餐用量',
+ quota_card_enabled_help: '关闭后,套餐用量按钮在主界面消失',
+ quota_card_key: 'Subscription Key',
+ quota_card_key_help: '从 platform.minimaxi.com/user-center/token-plan 获取,明文存到 settings.json',
+ save: '保存',
+ clear: '清空',
+ quota_no_data_hint: '套餐用量暂未启用。点击下方按钮到「外观」面板配置。',
+ quota_go_settings: '去外观面板设置',
+ quota_disabled: '未启用',
+ quota_saved: '已保存',
+ quota_need_key: '请填 Subscription Key',
+ quota_clear_confirm: '确定清空 Subscription Key?清空后套餐用量数据不显示。',
+ quota_cleared: '已清空',
+ quota_status_configured: '已配置',
+ quota_status_not_configured: '未配置',
+ quota_source_env: 'env',
+ quota_source_file: 'file',
+ quota_delete_disabled_external: '当前 key 由外部源管理(env / file),无法在界面删除',
+ quota_input_placeholder_env: 'env 优先,此处的值在 env 取消前不会被使用',
+ quota_input_placeholder_file: 'file 优先,此处的值在文件移除前不会被使用',
+ quota_modal_title: '配置 Subscription Key',
+ delete_key: '删除',
+ upgrade: '升级',
+ appearance: '外观',
+ language: '语言',
+ settings: '设置',
+ appearance_light: '明亮',
+ appearance_dark: '深色',
+ appearance_theme: '主题',
+ appearance_theme_help: '切换亮色/深色主题',
+ appearance_quota_help: '关掉后,套餐用量按钮在主界面消失',
+ language_zh: '简体中文',
+ language_en: 'English',
+ empty_hint_1: '还没有消息 — 在下方输入开始对话',
+ empty_hint_2: '按 / 触发命令检索,Ctrl+V 粘贴图片',
+ welcome_tagline: '还没有消息 — 在下方输入开始对话',
+ hint_help: '查看命令',
+ hint_status: '查看状态',
+ hint_sessions: '会话列表',
+ hint_usage: '套餐用量',
+ input_placeholder: '输入消息... (/ 命令 · @ 文件 · Ctrl+V 粘贴图片)',
+ hint_footer: '/ 命令 · @ 文件 · Ctrl+V 粘贴图片 · Enter 发送',
+ section_session: '会话',
+ section_model: '模型',
+ section_workspace: '工作区',
+ section_context: '上下文',
+ section_goal: '目标',
+ section_todo: '待办',
+ section_plan: '计划',
+ section_thinking: '思考',
+ show_thinking: '查看思考',
+ lines: '行',
+ ask_title: '提问',
+ plan_review: '计划预览',
+ plan_view_btn: '查看完整计划',
+ r_title: '标题', r_dir: '目录', r_branch: '分支', r_tree: '状态',
+ r_used: '已用', r_percent: '占比', r_tps: '速度', r_cache: '缓存',
+ status_idle: '空闲',
+ status_offline: '离线',
+ status_thinking: '思考中',
+ status_running: '运行中',
+ status_completed: '完成',
+ tps_label: 'tok/s',
+ goal_active: '进行中',
+ goal_complete: '完成',
+ goal_paused: '暂停',
+ todo_done: '已完成',
+ todo_pending: '待办',
+ user_loading: 'Mcode Web',
+ user_default: 'Mcode',
+ mode_default: '默认',
+ mode_ask: '询问', mode_ask_desc: '敏感操作确认',
+ mode_auto: '自动', mode_auto_desc: '仅高风险询问',
+ mode_full: '完全 访问', mode_full_desc: '无需确认',
+ mode_plan: 'Plan 模式', mode_plan_desc: '强制 mcode 按 Plan: 格式输出',
+ drop_hint: '松开上传文件',
+ toggle_theme: '切换主题',
+ toggle_lang: '中 / English',
+ network: '局域网访问',
+ slash_no_results: '没有匹配的命令',
+ slash_section_cmd: '命令',
+ slash_section_skill: '技能',
+ perm_full: '完全 访问',
+ perm_ask: '询问',
+ perm_read: '只读',
+ plan_mode_continue: '继续 plan',
+ plan_mode_deny: '拒绝',
+ lan_access: '局域网访问',
+ lan_on: '开',
+ lan_off: '关',
+ lan_link_title: '点击复制局域网访问 URL',
+ copy_success: '已复制',
+ copy_failed: '复制失败',
+ copy_failed_manual: '复制失败,请手动复制',
+ model_switched: '已切到',
+ sessions_list: '会话列表',
+ refresh: '刷新',
+ quota_remaining: '剩余',
+ quota_5h_limit: '5 小时限额',
+ quota_weekly_limit: '每周限额',
+ quota_next_reset: '下次重置',
+ quota_loading_fail: '加载失败',
+ quota_loading_idle: '点击套餐用量加载…',
+ usage_refreshed: '用量已刷新',
+ lan_title_on: '局域网已开启 — 局域网内其他设备可访问',
+ lan_title_off: '局域网已关闭 — 只有本电脑能访问',
+ plan_mode_on: '已开 Plan 模式(下次发消息时 mcode 会按 Plan: 格式输出)',
+ plan_mode_off: '已关 Plan 模式',
+ perm_mode_note: '权限 mode 仅更新 webui UI,mcode 实际 mode 由启动 --permission 标志决定(0.1.5 不支持中途改)',
+ ask_reopened: '✓ ask_user 弹窗已重新开启(清空 presentedKeys)',
+ ask_no_reset: 'ask_user 弹窗未开启过(无需重置)',
+ workspace_unset: '(未设置)',
+ workspace_unset_short: '未设置',
+ workspace_current: '当前',
+ workspace_switch: '点击切换工作区',
+ sidebar_loading: '加载会话中…',
+ workspace_picker_title: '切换工作区',
+ workspace_picker_select: '选择工作区',
+ workspace_picker_current: '当前:',
+ session_delete: '删除此会话',
+ session_delete_confirm: '删除?',
+ session_delete_yes: '删',
+ session_delete_cancel: '×',
+ session_rename: '重命名此会话',
+ session_rename_ok: '✓ 已重命名',
+ session_rename_fail: '重命名失败',
+ btn_send_title: '发送 (Enter)',
+ btn_stop_title: '停止 (/stop)',
+ workspace_unset_text: '未选择(点击选择)',
+ chip_online_title: '当前连到 webui server 的 tab 数',
+ chip_online_single: '1 台',
+ chip_online_plural: '{n} 台',
+ topbar_beta_title: 'Beta 测试版',
+ force_reload: '强制刷新',
+ force_reload_title: '强制刷新 (绕过浏览器缓存)',
+ chip_offline: '离线',
+ workspace_use_tui: '切换到 mcode TUI 当前的工作区',
+ workspace_reset: '恢复 webui 启动时检测到的默认工作区',
+ workspace_locked_in_chat: '对话已开始,工作区已锁定。点击左侧「新建会话」可重新选择工作区。',
+ workspace_picker_hint: '点击选择工作区',
+ native_candidates_title: '找到多个匹配目录:',
+ native_candidates_none: '未找到匹配目录,已打开目录树',
+ native_picked_fill: '目录选择失败,请尝试手动输入路径',
+ ws_nows_btn: '无需工作空间',
+ ws_nows_unavailable: '临时目录不可用',
+ ws_add: '添加工作区…',
+ ask_user_other_placeholder: '其他...',
+ ask_user_clear: '清空',
+ ask_user_skip: '跳过',
+ ask_user_send: '发送',
+ ask_user_resend: '已答完 (点重发)',
+ ask_user_step_count: '共 {n} 步',
+ ask_user_answered: '已答: {answer}',
+ ask_user_skipped: '已跳过',
+ ask_user_no_options_hint: '无预设选项 — 用下方"其他"输入回答',
+ ask_user_send_count: '发送 ({n} 题)',
+ ask_user_resend_count: '已答完 ({n} 题, 点重发)',
+ topbar_readonly_zh: '只读',
+ topbar_readonly_en: 'READ ONLY',
+ topbar_readonly_title: 'webui 当前处于只读模式 (远程客户端不能发送/删除)',
+ lan_card_title: '局域网安全设置',
+ lan_card_readonly: '只读模式',
+ lan_card_readonly_help: '远程客户端只能读取,不能发送消息/删除会话',
+ lan_card_token_auth: 'Token 鉴权',
+ lan_card_token_auth_help: '需要 ?token= 或 Authorization header;本机不受限',
+ lan_card_reset_token: '重置 token',
+ lan_card_reset_token_confirm: '确定要重置 token? 旧 token 会立即失效',
+ lan_card_token_value: '当前 token',
+ lan_card_token_show: '显示',
+ lan_card_token_hide: '隐藏',
+ lan_card_token_saved: '已保存 — 查看请点"重置"',
+ lan_card_token_disabled: 'Token 鉴权已关闭',
+ lan_card_token_copy: '复制',
+ lan_card_token_copied: '已复制到剪贴板',
+ lan_card_token_new_warning: '新的 token — 请在另一台设备用上面的 URL 打开',
+ lan_card_token_ack: '我已保存',
+ lan_card_token_ack_help: '保存后 token 不会再次显示;下次需要查看可点"重置"',
+ lan_card_token_rotated_toast: 'token 已重置,新值已自动同步',
+ auth_title: '需要授权确认',
+ auth_queue_pos: '待确认 {i}/{n}',
+ auth_expires_in: '确认时限',
+ auth_approve: '允许',
+ auth_deny: '拒绝',
+ auth_decision_failed: '决定提交失败',
+ auth_action_session_delete: '删除会话',
+ auth_action_sessions_cleanup_orphans: '清理孤儿会话',
+ auth_action_session_cleanup_all: '清空全部会话',
+ auth_action_session_export: '导出会话',
+ auth_action_session_search: '跨工作区搜索会话',
+ auth_action_token_reset: '重置访问令牌',
+ auth_action_slash_clear: '清空 / 新建对话',
+ auth_action_startup_cleanup: '启动时清理',
+ auth_ctx_targetSessionId: '目标会话',
+ auth_ctx_matchKind: '匹配方式',
+ auth_ctx_isMcodeSid: 'mcode 会话 ID',
+ auth_ctx_isOrphan: '孤儿会话',
+ auth_ctx_chatLen: '对话行数',
+ auth_ctx_q: '搜索词',
+ auth_ctx_workspace: '工作区',
+ auth_ctx_limit: '数量上限',
+ auth_ctx_format: '格式',
+ auth_ctx_download: '下载',
+ auth_ctx_orphanCount: '孤儿数量',
+ auth_ctx_orphanIds: '孤儿会话列表',
+ auth_ctx_cmd: '命令',
+ auth_ctx_sessionId: '会话 ID',
+ auth_ctx_mcodeSessionId: 'mcode 会话',
+ auth_ctx_source: '来源',
+ alerts_title: '系统通知',
+ alerts_empty: '暂无通知',
+ alerts_clear: '清空',
+ alerts_session: '会话',
+ alerts_level_info: '信息',
+ alerts_level_warn: '警告',
+ alerts_level_error: '错误',
+ workspace_sync_tui: '同时更新 mcode TUI 的工作目录(写入 cwd.json)',
+ workspace_recents_title: '最近使用',
+ workspace_browse: '浏览目录…',
+ workspace_loading: '加载中…',
+ mode_read: '只读',
+ fs_picker_dialog_title: '选择目录',
+ fs_picker_home: '用户目录',
+ fs_picker_root: '根目录',
+ fs_picker_mkdir: '新建文件夹',
+ fs_picker_cancel: '取消',
+ fs_picker_confirm: '确定',
+ fs_picker_pick_current: '选择当前目录',
+ fs_picker_filter_placeholder: '过滤…(支持 glob,如 *.txt)',
+ fs_picker_clear_filter: '清除过滤',
+ fs_picker_col_name: '名称',
+ fs_picker_col_size: '大小',
+ fs_picker_col_mtime: '修改时间',
+ fs_picker_col_mode: '权限',
+ fs_picker_loading: '加载中…',
+ fs_picker_empty: '该目录为空',
+ fs_picker_no_match: '没有匹配的结果',
+ fs_picker_load_failed: '加载目录失败',
+ fs_picker_mkdir_prompt: '输入文件夹名称:',
+ fs_picker_mkdir_failed: '创建文件夹失败',
+ fs_picker_create_failed: '创建失败',
+ fs_picker_select: '选择',
+ fs_picker_selected: '已选择 {n} 项',
+ fs_picker_not_loaded: 'fs-picker 组件未加载(检查 index.html 是否引入 app/fs-picker.js)',
+} as const;
+
+/** 全部文案键(en.ts 必须一一对应)。 */
+export type I18nKey = keyof typeof zh;
diff --git a/packages/webui-react/src/main.tsx b/packages/webui-react/src/main.tsx
new file mode 100644
index 00000000..08cee488
--- /dev/null
+++ b/packages/webui-react/src/main.tsx
@@ -0,0 +1,24 @@
+/**
+ * main.tsx —— 浏览器入口(组装根的最外层)
+ * ============================================================================
+ * 只做三件事:注入设计令牌样式、装配 Registry、挂载 。
+ * 业务编排全部在 features/app-controller.ts,视觉全部在 ui/。
+ * ============================================================================
+ */
+
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import './styles/tokens.css';
+import { App } from './App';
+import { RegistryProvider } from './features/registry-context';
+
+const el = document.getElementById('root');
+if (!el) throw new Error('找不到 #root 挂载点');
+
+createRoot(el).render(
+
+
+
+
+ ,
+);
diff --git a/packages/webui-react/src/styles/tokens.css b/packages/webui-react/src/styles/tokens.css
new file mode 100644
index 00000000..84157be3
--- /dev/null
+++ b/packages/webui-react/src/styles/tokens.css
@@ -0,0 +1,76 @@
+/* styles/tokens.css —— 设计令牌(1:1 对齐 packages/webui/public/styles/main.css) */
+/* 所有 UI 组件只消费这里的 CSS 变量;换主题/换视觉只改本文件。 */
+
+:root,
+:root[data-theme='light'] {
+ --bg: #fafafa;
+ --bg-elevated: #ffffff;
+ --bg-sidebar: #f4f4f5;
+ --bg-hover: #ededee;
+ --bg-active: #e2e2e4;
+ --bg-input: #ffffff;
+ --text: #1a1a1c;
+ --text-secondary: #5f5f66;
+ --text-tertiary: #98989e;
+ --border: #e2e2e4;
+ --border-light: #ededee;
+ --accent: #17171a;
+ --accent-hover: #000000;
+ --accent-bg: #ededee;
+ --accent-text: #2a2a2e;
+ --on-accent: #ffffff;
+ --success: #75757c;
+ --warning: #4f4f56;
+ --danger: #bf5645;
+ --status-on: #1e9e5a;
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
+ --shadow-md: 0 4px 14px rgba(0, 0, 0, 0.09);
+ --shadow-lg: 0 14px 38px rgba(0, 0, 0, 0.14);
+ --user-accent: #17171a;
+ --accent-glow: rgba(23, 23, 26, 0.1);
+ --hairline: rgba(26, 26, 28, 0.08);
+ --font-mono: ui-monospace, 'Cascadia Code', Consolas, 'SFMono-Regular', Menlo, monospace;
+ --radius-sm: 6px;
+ --radius-md: 10px;
+ --radius-lg: 14px;
+}
+
+:root[data-theme='dark'] {
+ --bg: #0b0b0c;
+ --bg-elevated: #141416;
+ --bg-sidebar: #101012;
+ --bg-hover: #1c1c1f;
+ --bg-active: #26262a;
+ --bg-input: #131315;
+ --text: #ececee;
+ --text-secondary: #a2a2a8;
+ --text-tertiary: #6d6d74;
+ --border: #26262a;
+ --border-light: #1d1d20;
+ --accent: #f4f4f5;
+ --accent-hover: #ffffff;
+ --accent-bg: rgba(244, 244, 245, 0.1);
+ --accent-text: #e4e4e7;
+ --on-accent: #101012;
+ --success: #9d9da3;
+ --warning: #c8c8cd;
+ --danger: #cc6b5c;
+ --status-on: #3fbf7f;
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
+ --shadow-md: 0 4px 10px rgba(0, 0, 0, 0.42);
+ --shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.55);
+ --user-accent: #f4f4f5;
+ --accent-glow: rgba(244, 244, 245, 0.12);
+ --hairline: rgba(236, 236, 238, 0.08);
+}
+
+html,
+body,
+#root {
+ height: 100%;
+ margin: 0;
+ background: var(--bg);
+ color: var(--text);
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
+ 'Microsoft YaHei', Roboto, Helvetica, Arial, sans-serif;
+}
diff --git a/packages/webui-react/src/ui/chat/EmptyState.tsx b/packages/webui-react/src/ui/chat/EmptyState.tsx
new file mode 100644
index 00000000..85aed39d
--- /dev/null
+++ b/packages/webui-react/src/ui/chat/EmptyState.tsx
@@ -0,0 +1,30 @@
+/**
+ * EmptyState.tsx —— 欢迎空态(品牌 logo + 还没有消息,在下方输入开始对话)
+ * 哑组件,纯展示,文案可由 props 覆盖(i18n 由上层注入)。
+ */
+
+import { memo } from 'react';
+import './empty.css';
+
+export interface EmptyStateProps {
+ /** 品牌 logo 地址(默认同 vanilla /brand-logo.png)。 */
+ logoSrc?: string;
+ /** 可选主标题(不传则只显示提示语,对齐 vanilla 欢迎页)。 */
+ title?: string;
+ /** 副标题/提示语。 */
+ subtitle?: string;
+}
+
+export const EmptyState = memo(function EmptyState({
+ logoSrc = '/brand-logo.png',
+ title,
+ subtitle = '还没有消息 — 在下方输入开始对话',
+}: EmptyStateProps) {
+ return (
+
+

+ {title !== undefined && title !== '' ?
{title}
: null}
+
{subtitle}
+
+ );
+});
diff --git a/packages/webui-react/src/ui/chat/MessageItem.tsx b/packages/webui-react/src/ui/chat/MessageItem.tsx
new file mode 100644
index 00000000..e2a346c8
--- /dev/null
+++ b/packages/webui-react/src/ui/chat/MessageItem.tsx
@@ -0,0 +1,84 @@
+/**
+ * MessageItem.tsx —— 单条消息
+ * ============================================================================
+ * 哑组件:只吃 ChatMessage。用户消息右侧气泡;助手消息左侧品牌 logo 头像;
+ * 系统消息居中弱化。消息体由 blocks/BlockView 按 kind 分发。
+ * React.memo 包裹 + key 稳定(MessageList 用 message.id)→ 长列表块级复用。
+ * ============================================================================
+ */
+
+import { memo } from 'react';
+import type { ChatMessage } from '../../contracts/domain';
+import { BlockView } from './blocks';
+import './item.css';
+
+export interface MessageItemProps {
+ message: ChatMessage;
+ /** 品牌 logo 地址(默认同 vanilla /brand-logo.png)。 */
+ logoSrc?: string;
+ /** ask-user 块的受控选中态透传(由上层按会话保存)。 */
+ askSelectedIds?: readonly string[];
+ onAskToggleOption?: (optionId: string) => void;
+ onAskConfirm?: (optionIds: string[]) => void;
+}
+
+const ROLE_LABEL: Record = {
+ user: '你',
+ assistant: 'MiniMax Code',
+ system: '系统',
+};
+
+const DEFAULT_LOGO = '/brand-logo.png';
+
+export const MessageItem = memo(function MessageItem({
+ message,
+ logoSrc = DEFAULT_LOGO,
+ askSelectedIds,
+ onAskToggleOption,
+ onAskConfirm,
+}: MessageItemProps) {
+ const isUser = message.role === 'user';
+ const isSystem = message.role === 'system';
+
+ const avatar = isUser ? (
+
+ {ROLE_LABEL.user}
+
+ ) : isSystem ? (
+
+ 系
+
+ ) : (
+
+

+
+ );
+
+ const body = (
+
+ {message.blocks.map((block) => (
+
+ ))}
+ {message.streaming === true ? ▍ : null}
+
+ );
+
+ const roleClass =
+ message.role === 'user' ? 'msg msg--user' : isSystem ? 'msg msg--system' : 'msg msg--assistant';
+
+ return (
+
+ {isSystem ? null : avatar}
+
+ {isSystem ? null :
{ROLE_LABEL[message.role]}
}
+ {isUser ?
{body}
: body}
+
+
+ );
+});
diff --git a/packages/webui-react/src/ui/chat/MessageList.tsx b/packages/webui-react/src/ui/chat/MessageList.tsx
new file mode 100644
index 00000000..cd59e6f4
--- /dev/null
+++ b/packages/webui-react/src/ui/chat/MessageList.tsx
@@ -0,0 +1,128 @@
+/**
+ * MessageList.tsx —— 消息流滚动列表(哑组件)
+ * ============================================================================
+ * 职责单一:滚动消息列表 + 自动滚底 + 会话隔离的滚动位置。
+ * - 自动滚到底部;用户上滚离开底部后暂停自动滚动,回到底部后恢复
+ * (判定逻辑参照 packages/webui/public/app/chat-virtual-list.js 的
+ * isNearBottom / decideScrollBehavior 思路,纯阈值计算)。
+ * - onScrollBottomChange:跨越"贴底"阈值时回调一次,供上层决定是否
+ * 在流式期间继续跟随。
+ * - 会话隔离:滚动位置用 useRef