;
+
+/** 进程/浏览器身份(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..2f99769a
--- /dev/null
+++ b/packages/webui-react/src/contracts/protocol.ts
@@ -0,0 +1,277 @@
+/**
+ * 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;
+}
+
+export interface WirePlanOption {
+ label?: string;
+ description?: string;
+ desc?: string;
+}
+
+/** wire state.plan —— plan_update / plan_removed 事件维护。 */
+export interface WirePlan {
+ active?: boolean;
+ planId?: string | null;
+ title?: string | null;
+ summary?: string;
+ options?: WirePlanOption[];
+}
+
+/** wire state.goal —— goal_update 事件维护。 */
+export interface WireGoal {
+ active?: boolean;
+ text?: string | null;
+ description?: string | null;
+ status?: string | null;
+ duration?: number | string | null;
+}
+
+/** wire state.enterPlanMode —— mode_update(mode=plan) 事件维护。 */
+export interface WireEnterPlanMode {
+ active?: boolean;
+ prompt?: string | null;
+}
+
+/** 每个浏览器 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;
+ plan?: WirePlan;
+ goal?: WireGoal;
+ enterPlanMode?: WireEnterPlanMode;
+ [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..f98fd918
--- /dev/null
+++ b/packages/webui-react/src/core/defaults.ts
@@ -0,0 +1,70 @@
+/**
+ * core/defaults.ts —— 默认端口实现的装配点
+ * ============================================================================
+ * 【热插拔】createRegistry() 的默认后端。每个端口的具体实现分散在
+ * core/transport/* 与 core/services/* —— 本文件只负责组装,
+ * 因此换供应商/换传输/换状态库只改对应子目录 + 这里的一行。
+ *
+ * 【热插拔时序】全部 service 只接收**同一个可变 ports holder**,且每次用到
+ * 端口时才从 holder 现读字段(不在构造期解构捕获实例)。因此 replacePort /
+ * createRegistry 的覆盖只要**原地改写本 holder**(见 core/registry.ts 的
+ * Object.assign / 原地赋值),已创建的 service 立即用上新端口 —— 这就是
+ * "replacePort('notifier', …) 之后立即生效"的结构性保证。
+ * ============================================================================
+ */
+
+import type { Registry } from '../contracts/ports';
+
+import { createHttpPort } from './transport/http-port';
+import type { AuthedHttpPort } 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 type { SessionService } from './services/session-service';
+import { createChatService } from './services/chat-service';
+import { createModelService } from './services/model-service';
+import type { ModelService } from './services/model-service';
+import { createWorkspaceService } from './services/workspace-service';
+import type { WorkspaceService } 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';
+import { createInteractService } from './services/interact-service';
+
+/**
+ * 可变端口持有器 = 装配出来的 Registry 本体。
+ * 字段比 Registry 更宽:http 是带身份操作的 AuthedHttpPort,sessions/models/workspace
+ * 是带扩展方法的实现类型 —— service 之间互引(chat -> sessions 等)也走这个持有器。
+ */
+export interface DefaultPorts extends Registry {
+ http: AuthedHttpPort;
+ sessions: SessionService;
+ models: ModelService;
+ workspace: WorkspaceService;
+}
+
+export function createDefaultRegistry(): DefaultPorts {
+ // 先落 kv,其余字段按依赖顺序回填(service 之间经持有器互引,如 chat -> sessions)。
+ // 用一次收窄赋值起步,随后逐字段回填到**同一个对象**上。
+ const ports = { kv: createKvPort() } as DefaultPorts;
+ ports.clock = { now: () => Date.now() };
+ ports.http = createHttpPort({ ports });
+ ports.stream = createStreamPort({ ports });
+ // 默认 notifier 是 console 实现;ui 层经 replacePort('notifier', …) 原地换成 antd 实现。
+ ports.notifier = createNotifierPort();
+ ports.sessions = createSessionService({ ports });
+ ports.chat = createChatService({ ports });
+ // 注入 kv,使"自定义供应商"跨 reload 持久化(否则只活在当前页)。
+ ports.models = createModelService({ ports });
+ ports.workspace = createWorkspaceService({ ports });
+ ports.settings = createSettingsService({ ports });
+ ports.usage = createUsageService({ ports });
+ ports.alerts = createAlertsService({ ports });
+ ports.auth = createAuthService({ ports });
+ ports.upload = createUploadService({ ports });
+ ports.interact = createInteractService({ ports });
+ return ports;
+}
diff --git a/packages/webui-react/src/core/registry.ts b/packages/webui-react/src/core/registry.ts
new file mode 100644
index 00000000..0422589e
--- /dev/null
+++ b/packages/webui-react/src/core/registry.ts
@@ -0,0 +1,53 @@
+/**
+ * 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 {
+ // 必须用 Object.assign 回填到同一个对象,而不是 spread 出新对象:
+ // core 各 service 持有的是这个 registry 对象本身(ports holder),
+ // 换成新对象会让 replacePort/overrides 作用在拷贝上,service 内部看不到新端口。
+ return Object.assign(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;
+}
+
+/**
+ * 热插拔单个端口:换供应商 / 换传输 / 换状态库,其余模块无感。
+ *
+ * 【关键】必须**原地改**而不是换新对象 —— 调用方(例如 app-controller)可能在
+ * 模块级就持有了 registry 引用;若这里返回新对象,那些持有者会继续用旧端口,
+ * "热插拔"就变成假的。
+ */
+export function replacePort(key: K, impl: Registry[K]): void {
+ const live = getRegistry() as unknown as Record;
+ live[key as string] = 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..e324f342
--- /dev/null
+++ b/packages/webui-react/src/core/services/alerts-service.ts
@@ -0,0 +1,137 @@
+/**
+ * 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';
+
+/** alerts-service 用到的端口窄视图(持有器视图)。 */
+export interface AlertsPorts {
+ http: HttpPort;
+ stream: StreamPort;
+}
+
+export interface AlertsServiceDeps {
+ /** 端口持有器:字段每次用时现读 —— 热替换后立即生效,不在构造期捕获实例。 */
+ ports: AlertsPorts;
+}
+
+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 ports = deps.ports;
+ /** 到达顺序即环形缓冲顺序(最旧在前);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();
+ }
+
+ ports.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 ports.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..e99fa03a
--- /dev/null
+++ b/packages/webui-react/src/core/services/auth-service.ts
@@ -0,0 +1,136 @@
+/**
+ * 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';
+
+/** auth-service 用到的端口窄视图(持有器视图)。 */
+export interface AuthPorts {
+ http: HttpPort;
+ stream: StreamPort;
+}
+
+export interface AuthServiceDeps {
+ /** 端口持有器:字段每次用时现读 —— 热替换后立即生效,不在构造期捕获实例。 */
+ ports: AuthPorts;
+}
+
+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 = 该请求已在别处被决定(或已过期被服务端丢弃)。
+ * 鸭子类型兜底:HttpPort 实现也可以抛普通 Error —— 只要带 status=404(数字或
+ * 字符串)、code 写明 404 / not_found,或消息里写明 404 / not found,都识别为已决定。
+ */
+function isNotFound(e: unknown): boolean {
+ if (e instanceof HttpError) return e.status === 404;
+ const o = asRecord(e);
+ if (o) {
+ if (o['status'] === 404 || o['status'] === '404') return true;
+ const code = o['code'];
+ if (typeof code === 'string' && /\b404\b|not[_ -]?found/i.test(code)) return true;
+ }
+ return e instanceof Error && /\b404\b|not found/i.test(e.message);
+}
+
+export function createAuthService(deps: AuthServiceDeps): AuthServicePort {
+ const ports = deps.ports;
+ /** 按 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();
+ }
+
+ ports.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 ports.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..4604d22e
--- /dev/null
+++ b/packages/webui-react/src/core/services/chat-service.ts
@@ -0,0 +1,55 @@
+/**
+ * core/services/chat-service.ts —— ChatServicePort 实现(发送 / 停止 / 斜杠命令)
+ * 【职责】POST /api/send(content + attachments)、POST /api/stop、POST /api/cmd;
+ * 发送成功后只做乐观 running 标记 —— 用户消息本身由服务端 handleSend /
+ * handleCmdCommand 追加到 cs.chat 并经 pushStateFor 广播(本地再 echo 会重复)。
+ * 【接缝】实现 contracts/ports.ts 的 ChatServicePort;只写目标会话的切片
+ * (经 SessionService.update),绝不影响其它会话。端口经持有器每次用时现读,
+ * 热替换后立即生效。
+ */
+import type { HttpPort, ChatServicePort } from '../../contracts/ports';
+import type { SessionId } from '../../contracts/domain';
+import type { SessionService } from './session-service';
+
+/** chat-service 用到的端口窄视图(持有器视图)。 */
+export interface ChatPorts {
+ http: HttpPort;
+ sessions: SessionService;
+}
+
+export interface ChatServiceDeps {
+ /** 端口持有器:字段每次用时现读 —— 热替换后立即生效,不在构造期捕获实例。 */
+ ports: ChatPorts;
+}
+
+export function createChatService(deps: ChatServiceDeps): ChatServicePort {
+ const ports = deps.ports;
+
+ /**
+ * 置 running 态(乐观):服务端 handleSend/handleCmdCommand 会把用户消息追加到
+ * cs.chat 并经 pushStateFor 广播 —— 本地再 echo 一条用户消息会与广播重复,
+ * 造成「1 条消息显示 2 次」。所以这里只标记 running,不写 messages。
+ */
+ function markRunning(sessionId: SessionId): void {
+ ports.sessions.update(sessionId, (prev) => ({ ...prev, running: true }));
+ }
+
+ return {
+ async send(sessionId: SessionId, content: string, attachments?: string[]): Promise {
+ await ports.http.post('/api/send', { content, attachments: attachments ?? [] });
+ markRunning(sessionId);
+ },
+
+ async stop(sessionId: SessionId): Promise {
+ await ports.http.post('/api/stop', {});
+ // 本地视图立即回到空闲;服务端 running 态随后由流帧校正
+ ports.sessions.update(sessionId, (prev) => ({ ...prev, inflightId: null, running: false }));
+ },
+
+ async command(sessionId: SessionId, cmd: string): Promise {
+ // POST /api/cmd 保持 { cmd }(服务端按当前 cid 路由);命令文本由服务端广播回显。
+ await ports.http.post('/api/cmd', { cmd });
+ markRunning(sessionId);
+ },
+ };
+}
diff --git a/packages/webui-react/src/core/services/interact-service.ts b/packages/webui-react/src/core/services/interact-service.ts
new file mode 100644
index 00000000..fc2dc3cd
--- /dev/null
+++ b/packages/webui-react/src/core/services/interact-service.ts
@@ -0,0 +1,66 @@
+/**
+ * core/services/interact-service.ts —— InteractServicePort 实现(模式互动)
+ * 【职责】plan / planmode 应答走 POST /api/answer(服务端清对应状态并广播
+ * state);权限模式走 GET /api/permissions-modes(目录)与 POST
+ * /api/permissions(切换 —— mcode 固定于启动时,服务端仅同步 UI 标签)。
+ * 【接缝】实现 contracts/ports.ts 的 InteractServicePort。
+ * 【边界】agree/add 的后续话术**不在这里** —— 文案本地化属于 UI 层,
+ * 由容器经 chat.send 下发;传输层只搬状态。
+ */
+import type {
+ HttpPort,
+ InteractServicePort,
+ PermissionModeOption,
+} from '../../contracts/ports';
+
+/** interact-service 用到的端口窄视图(持有器视图)。 */
+export interface InteractPorts {
+ http: HttpPort;
+}
+
+export interface InteractServiceDeps {
+ /** 端口持有器:字段每次用时现读 —— 热替换后立即生效,不在构造期捕获实例。 */
+ ports: InteractPorts;
+}
+
+function asRecord(v: unknown): Record | null {
+ return typeof v === 'object' && v !== null ? (v as Record) : null;
+}
+
+function mapOption(raw: unknown): PermissionModeOption | null {
+ const o = asRecord(raw);
+ if (!o || typeof o['value'] !== 'string' || o['value'] === '') return null;
+ return {
+ value: o['value'],
+ label: typeof o['label'] === 'string' ? o['label'] : o['value'],
+ mcodeValue: typeof o['mcodeValue'] === 'string' ? o['mcodeValue'] : undefined,
+ };
+}
+
+export function createInteractService(deps: InteractServiceDeps): InteractServicePort {
+ const ports = deps.ports;
+ return {
+ async answerPlan(option, context) {
+ await ports.http.post('/api/answer', { type: 'plan', option, context });
+ },
+ async answerPlanMode(choice) {
+ await ports.http.post('/api/answer', { type: 'planmode', option: choice });
+ },
+ async permissionModes() {
+ const raw = await ports.http.get('/api/permissions-modes');
+ const o = asRecord(raw);
+ const pick = (key: string): PermissionModeOption[] => {
+ const list = o ? o[key] : null;
+ if (!Array.isArray(list)) return [];
+ return list.flatMap((entry) => {
+ const mapped = mapOption(entry);
+ return mapped ? [mapped] : [];
+ });
+ };
+ return { webui: pick('webui'), mcode: pick('mcode') };
+ },
+ async setPermissionMode(mode) {
+ await ports.http.post('/api/permissions', { mode });
+ },
+ };
+}
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..c1e3f1eb
--- /dev/null
+++ b/packages/webui-react/src/core/services/model-service.ts
@@ -0,0 +1,258 @@
+/**
+ * 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 {
+ ModelGroup,
+ 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';
+
+/** model-service 用到的端口窄视图(持有器视图)。 */
+export interface ModelPorts {
+ http: HttpPort;
+ sessions: SessionService;
+ /** 自定义供应商的持久化(持有器未提供则退化为内存,重启即失)。 */
+ kv?: KeyValueStorePort;
+}
+
+export interface ModelServiceDeps {
+ /** 端口持有器:字段每次用时现读 —— 热替换后立即生效,不在构造期捕获实例。 */
+ ports: ModelPorts;
+}
+
+/** 比端口更宽:自定义供应商的管理留给设置面板;分组目录供两段式选择器。 */
+export interface ModelService extends ModelServicePort {
+ addCustomProvider(option: ProviderOption): void;
+ removeCustomProvider(id: ProviderId): void;
+ customProviders(): ProviderOption[];
+ /** 按供应商分组的模型目录(服务端未分组时由扁平目录派生)。 */
+ groups(): Promise;
+}
+
+interface Catalog {
+ models: ModelOption[];
+ groups: ModelGroup[];
+ 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 ports = deps.ports;
+ // 无 kv 注入时降级为进程内存储(自定义供应商不跨 reload)
+ const memoryKv = new Map();
+ const memoryKvPort: KeyValueStorePort = {
+ get: (k) => memoryKv.get(k) ?? null,
+ set: (k, v) => void memoryKv.set(k, v),
+ remove: (k) => void memoryKv.delete(k),
+ };
+ /** kv 用时现读 —— 持有器上被热替换后立即生效。 */
+ function kv(): KeyValueStorePort {
+ return ports.kv ?? memoryKvPort;
+ }
+
+ 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 {
+ // 持久化失败不影响内存态
+ }
+ }
+
+ function mapModelEntry(o: Record): ModelOption | null {
+ if (typeof o['id'] !== 'string' || !o['id']) return null;
+ const id = o['id'];
+ const provider = typeof o['provider'] === 'string' && o['provider'] ? o['provider'] : splitModelId(id);
+ return {
+ id,
+ label: typeof o['label'] === 'string' && o['label'] ? o['label'] : id,
+ provider,
+ contextLimit: typeof o['contextLimit'] === 'number' ? o['contextLimit'] : undefined,
+ };
+ }
+
+ /** 服务端 groups 优先;缺省(旧后端/单测 fake)按 provider 从扁平目录派生。 */
+ function deriveGroups(models: ModelOption[], rawGroups: unknown): ModelGroup[] {
+ if (Array.isArray(rawGroups)) {
+ const out: ModelGroup[] = [];
+ for (const raw of rawGroups) {
+ const g = asRecord(raw);
+ if (!g || typeof g['id'] !== 'string' || !g['id']) continue;
+ const list: ModelOption[] = [];
+ for (const entry of Array.isArray(g['models']) ? g['models'] : []) {
+ const o = asRecord(entry);
+ if (!o) continue;
+ const mapped = mapModelEntry(o);
+ if (mapped) list.push(mapped);
+ }
+ out.push({
+ id: g['id'],
+ label: typeof g['label'] === 'string' && g['label'] ? g['label'] : g['id'],
+ models: list,
+ });
+ }
+ if (out.length > 0) return out;
+ }
+ const map = new Map();
+ for (const m of models) {
+ let group = map.get(m.provider);
+ if (!group) {
+ group = { id: m.provider, label: m.provider, models: [] };
+ map.set(m.provider, group);
+ }
+ group.models.push(m);
+ }
+ return [...map.values()];
+ }
+
+ async function fetchCatalog(): Promise {
+ const res = await ports.http.get<{ models?: unknown; groups?: 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) continue;
+ const mapped = mapModelEntry(o);
+ if (mapped) models.push(mapped);
+ }
+ }
+ const groups = deriveGroups(models, res.groups);
+ const current = typeof res.current === 'string' ? res.current : '';
+ const hint = typeof res.hint === 'string' ? res.hint : undefined;
+
+ // 目录带回的 current 补齐「还没选过模型」的会话切片(不覆盖已有选择)
+ if (current) {
+ for (const id of ports.sessions.ids()) {
+ const s = ports.sessions.slice(id);
+ if (!s.selection.model) {
+ ports.sessions.update(id, {
+ selection: { ...s.selection, model: current, provider: splitModelId(current, s.selection.provider) },
+ });
+ }
+ }
+ }
+ return { models, groups, current, hint };
+ }
+
+ async function groupsList(): Promise {
+ return (await fetchCatalog()).groups;
+ }
+
+ 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 ports.sessions.slice(sessionId).selection;
+ },
+
+ async setProvider(sessionId: SessionId, provider: string): Promise {
+ const sel = ports.sessions.slice(sessionId).selection;
+ const list = await this.models(provider);
+ // 回落到该供应商第一个模型;该供应商暂无模型时保留原模型(仍发 set-model 保契约)
+ const modelId = list[0]?.id ?? sel.model;
+ await ports.http.post('/api/set-model', { model: modelId });
+ const next: ModelSelection = { ...sel, provider, model: modelId };
+ ports.sessions.update(sessionId, { selection: next });
+ return next;
+ },
+
+ async setModel(sessionId: SessionId, modelId: string): Promise {
+ const sel = ports.sessions.slice(sessionId).selection;
+ await ports.http.post('/api/set-model', { model: modelId });
+ const next: ModelSelection = {
+ ...sel,
+ model: modelId,
+ provider: splitModelId(modelId, sel.provider),
+ };
+ ports.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 = ports.sessions.slice(sessionId).selection;
+ // thinking 是本地契约字段:先落切片,服务端未实现也保留 set-model 调用
+ const next: ModelSelection = { ...sel, thinking: safe };
+ ports.sessions.update(sessionId, { selection: next });
+ try {
+ await ports.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();
+ },
+
+ groups: groupsList,
+ };
+}
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..f0896edc
--- /dev/null
+++ b/packages/webui-react/src/core/services/session-service.ts
@@ -0,0 +1,543 @@
+/**
+ * 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 {
+ ChatMessage,
+ ContextUsage,
+ GoalPhase,
+ GoalState,
+ MessageBlock,
+ ModelSelection,
+ PlanState,
+ Role,
+ SessionId,
+ SessionSlice,
+ SessionSummary,
+ ThinkingEffort,
+ TodoItem,
+ WorkspaceInfo,
+} from '../../contracts/domain';
+import { THINKING_EFFORTS, emptySessionSlice } from '../../contracts/domain';
+
+/** session-service 用到的端口窄视图(持有器视图)。 */
+export interface SessionPorts {
+ http: HttpPort;
+ /** 预留接缝:流式帧 -> 切片的翻译层将来挂在 features,这里不解析帧。 */
+ stream: StreamPort;
+ kv: KeyValueStorePort;
+}
+
+export interface SessionServiceDeps {
+ /** 端口持有器:字段每次用时现读 —— 热替换后立即生效,不在构造期捕获实例。 */
+ ports: SessionPorts;
+ /** 每会话切片的默认模型选择(默认 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[];
+ /**
+ * GET /api/state / state.snapshot 帧 → 切片水合。chat 只属于 state.sessionId
+ * 那个会话:写入按 state.sessionId 落位,其它会话切片一概不动(会话隔离)。
+ * 返回 state.sessionId(无则 null)。
+ */
+ hydrateFromWireState(raw: unknown): SessionId | null;
+}
+
+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,
+ // state.sessions 影子行只有 createdAt —— 回落它,避免相对时间显示 "—"。
+ updatedAt: Number(o['updatedAt']) || Number(o['createdAt']) || 0,
+ };
+}
+
+function isEffort(v: unknown): v is ThinkingEffort {
+ return typeof v === 'string' && (THINKING_EFFORTS as readonly string[]).includes(v);
+}
+
+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;
+}
+
+export interface ChatLinesResult {
+ messages: ChatMessage[];
+ todos: TodoItem[];
+}
+
+/**
+ * 服务端 chat 行语法 → 消息 / 待办(对齐 vanilla public/app/render.js#parseChatLines):
+ * › 或 > = 用户;● / • = 助手;▲ = 思考(连续行聚合成一块);! / ○ / [xxx] = 系统;
+ * ✓✔○◌◯✗✘× 前缀 = 待办行(像 [error]/Questionnaire 的除外);Plan: / Ask: / ◎ / →
+ * 成块文本平铺为助手文本 —— 历史水合只做展示,绝不触发 plan/ask 弹窗副作用。
+ * 续行并入当前块;每个 › / ● 行是独立一条消息(与 vanilla 一致)。
+ */
+export function chatLinesToMessages(raw: unknown[]): ChatLinesResult {
+ const messages: ChatMessage[] = [];
+ const todos: TodoItem[] = [];
+ let seq = 0;
+ let cur: { role: Role; kind: 'text' | 'thinking'; text: string } | null = null;
+
+ // 工具块(→ 前缀 + 缩进续行,服务端 mcode-acp 写入的文法):
+ // → toolName {input} ← 块头
+ // [completed] ← 状态行(completed/failed/in_progress)—— 必须吃进块里,
+ // output ← 否则原样漏出就是用户报告的「大量 [completed] 回显」
+ // @ /path ← 涉及的本地文件
+ // ! error ← 错误
+ let tool: { name: string; status: 'running' | 'done' | 'error'; out: string[]; locs: number; err: string | null } | null = null;
+ const flushTool = (): void => {
+ if (!tool) return;
+ const out = tool.out.slice(0, 8);
+ if (tool.out.length > 8) out.push('…');
+ const parts: string[] = [];
+ if (out.length > 0) parts.push(out.join('\n'));
+ if (tool.err) parts.push('! ' + tool.err);
+ if (tool.locs > 0) parts.push(`@ ${tool.locs} 个本地文件`);
+ const block: MessageBlock = {
+ id: 'wb-' + seq,
+ kind: 'tool-call',
+ toolName: tool.name,
+ status: tool.status,
+ summary: parts.length > 0 ? parts.join('\n') : undefined,
+ };
+ messages.push({ id: 'wm-' + seq, role: 'system', blocks: [block], ts: seq, streaming: false });
+ seq += 1;
+ tool = null;
+ };
+
+ const flush = (): void => {
+ flushTool();
+ if (!cur) return;
+ const block: MessageBlock =
+ cur.kind === 'thinking'
+ ? { id: 'wb-' + seq, kind: 'thinking', text: cur.text, done: true }
+ : { id: 'wb-' + seq, kind: 'text', text: cur.text, markdown: cur.role !== 'user' };
+ messages.push({ id: 'wm-' + seq, role: cur.role, blocks: [block], ts: seq, streaming: false });
+ seq += 1;
+ cur = null;
+ };
+ const feed = (role: Role, kind: 'text' | 'thinking', text: string): void => {
+ // 只有连续 ▲ 思考行聚合成一块;其余前缀行各自成条(vanilla 语义)。
+ if (kind === 'thinking' && cur && cur.kind === 'thinking') {
+ cur.text += '\n' + text;
+ return;
+ }
+ flush();
+ cur = { role, kind, text };
+ };
+ // 续行并入当前块;无当前块时视为助手续写。(放在闭包里读 cur —— 外层循环直接
+ // 读会被 TS 的闭包捕获收窄判成 never。)
+ const appendContinuation = (text: string): void => {
+ if (cur) cur.text += '\n' + text;
+ else feed('assistant', 'text', text);
+ };
+
+ for (const rawLine of raw) {
+ const line = typeof rawLine === 'string' ? rawLine : rawLine == null ? '' : String(rawLine);
+ if (line.trim() === '') continue;
+ const systemish =
+ /^\[(error|warning|info|system)\]/i.test(line) ||
+ /Questionnaire|requires.*user input|requires.*interactive/i.test(line);
+ const todo = line.match(/^([✓✔○◌◯✗✘×])\s+(.+)$/);
+ if (todo && !systemish) {
+ const mark = todo[1];
+ todos.push({
+ id: 'wt-' + todos.length,
+ content: todo[2],
+ status: mark === '✓' || mark === '✔' ? 'completed' : 'pending',
+ });
+ feed('system', 'text', line);
+ continue;
+ }
+ if (/^[›>]\s+/.test(line)) {
+ feed('user', 'text', line.replace(/^[›>]\s+/, ''));
+ continue;
+ }
+ if (/^[●•]\s+/.test(line)) {
+ feed('assistant', 'text', line.replace(/^[●•]\s+/, ''));
+ continue;
+ }
+ if (/^▲\s+/.test(line)) {
+ feed('assistant', 'thinking', line.replace(/^▲\s+/, ''));
+ continue;
+ }
+ if (systemish || /^[○◯!]\s+/.test(line)) {
+ feed('system', 'text', line.replace(/^[○◯!]\s+/, ''));
+ continue;
+ }
+ if (/^→\s+/.test(line)) {
+ flush();
+ const tm = line.match(/^→\s+(\S+?)(?:\s{2,}(.+))?$/);
+ tool = { name: tm ? tm[1] : 'tool', status: 'running', out: [], locs: 0, err: null };
+ continue;
+ }
+ // 工具块的缩进续行(必须先于其它分类 —— vanilla 同款文法)。
+ if (tool && /^\s{2,}\S/.test(line)) {
+ const stripped = line.replace(/^\s{2,}/, '');
+ const st = stripped.match(/^\[([^\]]+)\]$/);
+ if (st) {
+ const s = st[1];
+ tool.status = s === 'completed' ? 'done' : s === 'failed' ? 'error' : 'running';
+ } else if (/^!\s+/.test(stripped)) {
+ tool.err = stripped.replace(/^!\s+/, '');
+ tool.status = 'error';
+ } else if (/^@\s+/.test(stripped)) {
+ tool.locs += 1;
+ } else if (tool.out.length < 12) {
+ tool.out.push(stripped);
+ }
+ continue;
+ }
+ if (/^(Plan\s*[::]|Ask\b|[◎])/i.test(line.trim())) {
+ feed('assistant', 'text', line);
+ continue;
+ }
+ if (tool) flushTool();
+ appendContinuation(line);
+ }
+ flush();
+ return { messages, todos };
+}
+
+function mapContextUsage(o: Record): ContextUsage {
+ const used = num(o['used']) ?? num(o['tokens']) ?? 0;
+ const limit = num(o['limit']) ?? 0;
+ return {
+ used,
+ limit,
+ percent: num(o['percent']) ?? (limit > 0 ? Math.round((used * 100) / limit) : 0),
+ tps: num(o['tps']) ?? 0,
+ source: 'api-state',
+ };
+}
+
+/** wire state.plan → PlanState;inactive / 非对象 → null(应答后服务端已清空)。 */
+function mapPlanState(o: Record): PlanState | null {
+ if (o['active'] !== true) return null;
+ const rawOptions = Array.isArray(o['options']) ? o['options'] : [];
+ const options = rawOptions.flatMap((raw) => {
+ const p = typeof raw === 'object' && raw !== null ? (raw as Record) : null;
+ if (!p) return [];
+ return [{
+ label: typeof p['label'] === 'string' ? p['label'] : '',
+ desc: typeof p['desc'] === 'string' ? p['desc']
+ : typeof p['description'] === 'string' ? p['description'] : '',
+ }];
+ });
+ return {
+ active: true,
+ planId: typeof o['planId'] === 'string' ? o['planId'] : null,
+ title: typeof o['title'] === 'string' ? o['title'] : '',
+ summary: typeof o['summary'] === 'string' ? o['summary'] : '',
+ options,
+ };
+}
+
+const GOAL_PHASES: readonly GoalPhase[] = ['active', 'paused', 'blocked', 'complete'];
+
+/** wire state.goal → GoalState;inactive → null。status 未知值保守落 'active'。 */
+function mapGoalState(o: Record): GoalState | null {
+ if (o['active'] !== true) return null;
+ const text = typeof o['text'] === 'string' ? o['text']
+ : typeof o['description'] === 'string' ? o['description'] : '';
+ if (text === '') return null;
+ const rawStatus = typeof o['status'] === 'string' ? o['status'] : 'active';
+ const phase = (GOAL_PHASES as readonly string[]).includes(rawStatus) ? (rawStatus as GoalPhase) : 'active';
+ const duration = typeof o['duration'] === 'number' && Number.isFinite(o['duration']) ? o['duration'] : 0;
+ return { objective: text, phase, rounds: duration, startedAt: undefined };
+}
+
+function mapWorkspaceInfo(o: Record): WorkspaceInfo {
+ return {
+ dir: typeof o['dir'] === 'string' ? o['dir'] : null,
+ branch: typeof o['branch'] === 'string' ? o['branch'] : null,
+ tree: typeof o['tree'] === 'string' ? o['tree'] : null,
+ };
+}
+
+export function createSessionService(deps: SessionServiceDeps): SessionService {
+ const ports = deps.ports;
+ const fallback: ModelSelection = deps.defaultSelection ?? DEFAULT_MODEL_SELECTION;
+ /** 会话隔离的核心结构:每个 sessionId 一份独立 store,互不共享。 */
+ const slices = new Map>();
+ /** 上次水合指纹(sid:chatLen:tail80)—— 相同则跳过全量重解析。 */
+ let lastHydrateKey = '';
+ /** 已水合过的会话 —— 首次水合的消息视为「历史」(无真实时间戳,UI 显示 --)。 */
+ const hydratedSessions = new Set();
+
+ function loadSelection(id: SessionId): ModelSelection {
+ try {
+ const raw = ports.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 {
+ ports.kv.set(selKey(id), JSON.stringify(sel));
+ } catch {
+ // 持久化失败不影响内存态
+ }
+ }
+
+ /**
+ * 新建会话切片 —— 逐字段新建,任何字段都不与别的切片(或模块常量)共享引用。
+ * 即便 emptySessionSlice 的默认值将来改成常量复用,这里也逐个复制:
+ * messages / todos / attachments 数组与 selection / context / goal 对象
+ * 都不得跨会话共享(#2 会话隔离的结构保证)。
+ */
+ function freshSlice(id: SessionId): SessionSlice {
+ const base = emptySessionSlice(id, loadSelection(id));
+ return {
+ ...base,
+ selection: { ...base.selection },
+ messages: [...base.messages],
+ todos: [...base.todos],
+ attachments: [...base.attachments],
+ context: base.context ? { ...base.context } : null,
+ goal: base.goal ? { ...base.goal } : null,
+ inflightId: null,
+ };
+ }
+
+ function storeFor(id: SessionId): Store {
+ let s = slices.get(id);
+ if (!s) {
+ s = createStore(freshSlice(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 ports.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 ports.http.post<{ id?: unknown; session?: { id?: unknown } }>('/api/sessions', body);
+ // 服务端实际回 { ok, session: { id, ... } };兼容旧的 { id } 直出形状。
+ const nested = res.session && typeof res.session === 'object' ? res.session.id : undefined;
+ const id = typeof nested === 'string' ? nested : 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 {
+ const res = await ports.http.post<{ session?: unknown }>('/api/sessions/switch', { id });
+ // 会话隔离:切换只通知服务端,本地任何切片都不清空、不重建。
+ // 响应回带该会话的 chat —— 立即水合它**自己的**切片(按 res.session.id 落位),
+ // 切换会话马上能看到对话内容,且绝不写到别的会话上。
+ const s = asRecord(res.session);
+ const sid = s && typeof s['id'] === 'string' && s['id'] !== '' ? s['id'] : id;
+ const chat = s && Array.isArray(s['chat']) ? s['chat'] : null;
+ if (chat) {
+ // 切会话载入的是历史 chat —— 标记已水合,避免后续 hydrate 把首条新消息误判为历史。
+ hydratedSessions.add(sid);
+ const parsed = chatLinesToMessages(chat);
+ update(sid, (prev) => ({
+ ...prev,
+ messages: parsed.messages,
+ todos: parsed.todos.length > 0 ? parsed.todos : prev.todos,
+ summary:
+ s && typeof s['title'] === 'string'
+ ? prev.summary
+ ? { ...prev.summary, title: s['title'] }
+ : {
+ id: sid,
+ title: s['title'],
+ workspace: null,
+ mcodeSessionId: typeof s['mcodeSessionId'] === 'string' ? s['mcodeSessionId'] : null,
+ titleCustom: false,
+ updatedAt: Date.now(),
+ }
+ : prev.summary,
+ }));
+ }
+ },
+
+ async rename(id: SessionId, title: string): Promise {
+ await ports.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 ports.http.del('/api/sessions/' + encodeURIComponent(id));
+ slices.delete(id); // 只丢这一份切片,其它会话原样保留
+ try {
+ ports.kv.remove(selKey(id));
+ } catch {
+ // 忽略
+ }
+ },
+
+ slice(id: SessionId): SessionSlice {
+ return storeFor(id).get();
+ },
+
+ subscribe(id: SessionId, listener: () => void): () => void {
+ return storeFor(id).subscribe(listener);
+ },
+
+ hydrateFromWireState(raw: unknown): SessionId | null {
+ const s = asRecord(raw);
+ if (!s) return null;
+ const sid = typeof s['sessionId'] === 'string' && s['sessionId'] !== '' ? s['sessionId'] : null;
+ if (!sid) return null;
+ const chat = Array.isArray(s['chat']) ? s['chat'] : null;
+ // 指纹跳过:state.snapshot 高频推送,chat 未变时跳过 27955 行的全量 parse
+ // 与切片替换(大会话 1.8MB state 下这是主线程杀手)。
+ const tail = chat && chat.length > 0 ? chat[chat.length - 1] : '';
+ const fp = sid + ':' + (chat ? chat.length : 0) + ':' + (typeof tail === 'string' ? tail.slice(-80) : '');
+ if (fp === lastHydrateKey) return sid;
+ lastHydrateKey = fp;
+ const runningO = asRecord(s['running']);
+ const runningNow = runningO ? runningO['active'] === true : false;
+ const ctxO = asRecord(s['context']);
+ const wsO = asRecord(s['workspace']);
+ const modelO = asRecord(s['model']);
+ const planO = asRecord(s['plan']);
+ const goalO = asRecord(s['goal']);
+ const title = typeof s['sessionTitle'] === 'string' ? s['sessionTitle'] : null;
+ const mcodeSid = typeof s['mcodeSessionId'] === 'string' ? s['mcodeSessionId'] : null;
+ update(sid, (prev) => {
+ const parsed = chat ? chatLinesToMessages(chat) : null;
+ let messages = parsed ? parsed.messages : prev.messages;
+ if (parsed) {
+ const isFirstHydrate = !hydratedSessions.has(sid);
+ hydratedSessions.add(sid);
+ const now = Date.now();
+ messages = parsed.messages.map((m, i) => {
+ // 首次水合=历史消息:保留序号占位(ts 小值 → UI 显示 --)。
+ if (isFirstHydrate) return m;
+ // 后续水合:已有位置保留原时间戳;新追加的消息给当前时间。
+ const prevMsg = prev.messages[i];
+ return prevMsg ? { ...m, ts: prevMsg.ts } : { ...m, ts: now };
+ });
+ }
+ if (runningNow && messages.length > 0) {
+ const last = messages[messages.length - 1];
+ messages[messages.length - 1] = { ...last, streaming: true };
+ }
+ return {
+ ...prev,
+ summary:
+ title != null || mcodeSid != null
+ ? {
+ id: sid,
+ title: title ?? prev.summary?.title ?? '',
+ workspace: prev.summary?.workspace ?? null,
+ mcodeSessionId: mcodeSid ?? prev.summary?.mcodeSessionId ?? null,
+ titleCustom: prev.summary?.titleCustom ?? false,
+ updatedAt: prev.summary?.updatedAt ?? Date.now(),
+ }
+ : prev.summary,
+ messages,
+ todos: parsed && parsed.todos.length > 0 ? parsed.todos : prev.todos,
+ running: runningNow,
+ plan: planO ? mapPlanState(planO) : prev.plan,
+ goal: goalO ? mapGoalState(goalO) : prev.goal,
+ context: ctxO ? mapContextUsage(ctxO) : prev.context,
+ workspace: wsO ? mapWorkspaceInfo(wsO) : prev.workspace,
+ selection:
+ modelO && typeof modelO['name'] === 'string' && modelO['name'] !== ''
+ ? { ...prev.selection, model: modelO['name'] }
+ : prev.selection,
+ };
+ });
+ return sid;
+ },
+
+ 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..45d4c558
--- /dev/null
+++ b/packages/webui-react/src/core/services/settings-service.ts
@@ -0,0 +1,115 @@
+/**
+ * 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';
+
+/** settings-service 用到的端口窄视图(持有器视图)。 */
+export interface SettingsPorts {
+ http: AuthedHttpPort;
+ stream: StreamPort;
+}
+
+export interface SettingsServiceDeps {
+ /** 端口持有器:字段每次用时现读 —— 热替换后立即生效,不在构造期捕获实例。 */
+ ports: SettingsPorts;
+}
+
+/** 可写字段白名单:其余字段只读透传。 */
+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 ports = deps.ports;
+ 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,本地身份必须立刻跟上
+ ports.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) ports.http.setToken(next);
+ });
+
+ return {
+ async get(): Promise {
+ const res = await ports.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 ports.http.post>('/api/settings', body);
+ return mergeIntoCache({ ...body, ...res });
+ },
+
+ async resetToken(): Promise<{ token: string }> {
+ const res = await ports.http.post>('/api/settings', { resetToken: true });
+ const token = typeof res['currentToken'] === 'string' ? res['currentToken'] : '';
+ if (token) ports.http.setToken(token); // 新 token 立即生效(含 WS 重连地址)
+ mergeIntoCache({ ...res });
+ return { token };
+ },
+
+ async acknowledgeToken(): Promise {
+ const res = await ports.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..17c0ff02
--- /dev/null
+++ b/packages/webui-react/src/core/services/upload-service.ts
@@ -0,0 +1,64 @@
+/**
+ * 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';
+
+/** upload-service 用到的端口窄视图(持有器视图)。 */
+export interface UploadPorts {
+ http: HttpPort;
+}
+
+export interface UploadServiceDeps {
+ /** 端口持有器:字段每次用时现读 —— 热替换后立即生效,不在构造期捕获实例。 */
+ ports: UploadPorts;
+}
+
+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 ports = deps.ports;
+
+ return {
+ async upload(file: File | Blob, name: string): Promise {
+ const id = makeId();
+ try {
+ const res = (await ports.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..ea82769e
--- /dev/null
+++ b/packages/webui-react/src/core/services/usage-service.ts
@@ -0,0 +1,82 @@
+/**
+ * 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';
+
+/** usage-service 用到的端口窄视图(持有器视图)。 */
+export interface UsagePorts {
+ http: HttpPort;
+}
+
+export interface UsageServiceDeps {
+ /** 端口持有器:字段每次用时现读 —— 热替换后立即生效,不在构造期捕获实例。 */
+ ports: UsagePorts;
+}
+
+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 ports = deps.ports;
+
+ return {
+ async quota(): Promise {
+ // 用量数值的权威来源是 /api/state 的 usage 域:POST /api/usage 是 fire-and-forget
+ // 触发器(先回 {ok:true},查询异步跑完后经 state 推送到达),响应体里没有数值。
+ const state = asRecord(await ports.http.get('/api/state')) ?? {};
+ const res = asRecord(state['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'] : 'api-state',
+ hidden: res['hidden'] === true ? true : undefined,
+ };
+ },
+
+ async refresh(): Promise {
+ await ports.http.post('/api/refresh', {});
+ },
+
+ async context(_sessionId: SessionId): Promise {
+ // usage-real 按 cid(运行时)统计,暂不区分 sessionId;签名保留会话语义
+ const res = asRecord(await ports.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..5712f582
--- /dev/null
+++ b/packages/webui-react/src/core/services/workspace-service.ts
@@ -0,0 +1,301 @@
+/**
+ * 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 {
+ FsEntry,
+ FsFileResult,
+ FsListResult,
+ GitBranches,
+ GitStatus,
+ WorkspaceBrowseResult,
+ WorkspaceEntry,
+ WorkspaceInfo,
+ WorkspaceRecentEntry,
+} from '../../contracts/domain';
+
+/** workspace-service 用到的端口窄视图(持有器视图)。 */
+export interface WorkspacePorts {
+ http: HttpPort;
+ kv: KeyValueStorePort;
+}
+
+export interface WorkspaceServiceDeps {
+ /** 端口持有器:字段每次用时现读 —— 热替换后立即生效,不在构造期捕获实例。 */
+ ports: WorkspacePorts;
+}
+
+/** 比端口更宽:补齐 /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 ports = deps.ports;
+ let current: WorkspaceInfo | null = null;
+
+ function recentsRead(): WorkspaceEntry[] {
+ try {
+ const raw = ports.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 {
+ ports.kv.set(RECENTS_KEY, JSON.stringify(list.slice(0, RECENTS_MAX)));
+ } catch {
+ // 持久化失败不影响内存态
+ }
+ }
+
+ async function postWorkspace(body: Record): Promise {
+ const res = await ports.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 ports.http.get<{ dir?: unknown; parent?: unknown; children?: unknown }>(
+ '/api/workspace/browse' + query,
+ );
+ const entries: 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;
+ entries.push({
+ name: typeof o['name'] === 'string' ? o['name'] : baseName(o['path']),
+ path: o['path'],
+ // browse 只枚举目录;旧服务端不带 isDir 键 —— 缺失即目录,逐级下钻才可用。
+ isDir: o['isDir'] !== false,
+ });
+ }
+ }
+ return {
+ // 服务端回传当前目录(无 path 时落到第一个允许根);缺失时回落 path 或 null。
+ dir: typeof res.dir === 'string' && res.dir ? res.dir : (path ?? null),
+ parent: typeof res.parent === 'string' && res.parent ? res.parent : null,
+ entries,
+ };
+ },
+
+ async listRecent(): Promise {
+ const res = await ports.http.get<{ items?: unknown }>('/api/workspace/recent?limit=20');
+ const out: WorkspaceRecentEntry[] = [];
+ if (Array.isArray(res.items)) {
+ for (const raw of res.items) {
+ const o = asRecord(raw);
+ if (!o || typeof o['dir'] !== 'string' || !o['dir']) continue;
+ out.push({
+ name: typeof o['name'] === 'string' && o['name'] ? o['name'] : baseName(o['dir']),
+ path: o['dir'],
+ isDir: true,
+ sessionCount: typeof o['sessionCount'] === 'number' ? o['sessionCount'] : undefined,
+ lastActiveAt: typeof o['lastActiveAt'] === 'number' ? o['lastActiveAt'] : undefined,
+ });
+ }
+ }
+ return out;
+ },
+
+ async listDir(path: string): Promise {
+ const query = '?path=' + encodeURIComponent(path);
+ const res = await ports.http.get>('/api/fs/read' + query);
+ if (res['ok'] !== true) {
+ return { ok: false, dir: path, parent: null, entries: [], error: typeof res['error'] === 'string' ? res['error'] : 'read failed' };
+ }
+ const entries: FsEntry[] = [];
+ if (Array.isArray(res['entries'])) {
+ for (const raw of res['entries']) {
+ const o = asRecord(raw);
+ if (!o || typeof o['path'] !== 'string' || !o['path'] || typeof o['name'] !== 'string') continue;
+ entries.push({
+ name: o['name'],
+ path: o['path'],
+ isDir: o['type'] !== 'file',
+ size: typeof o['size'] === 'number' ? o['size'] : undefined,
+ mtime: typeof o['mtime'] === 'number' ? o['mtime'] : undefined,
+ mode: typeof o['mode'] === 'string' ? o['mode'] : undefined,
+ });
+ }
+ }
+ return {
+ ok: true,
+ dir: typeof res['path'] === 'string' ? res['path'] : path,
+ parent: typeof res['parent'] === 'string' && res['parent'] ? res['parent'] : null,
+ entries,
+ home: typeof res['home'] === 'string' && res['home'] ? res['home'] : null,
+ };
+ },
+
+ async readFile(path: string): Promise {
+ const query = '?path=' + encodeURIComponent(path);
+ const res = await ports.http.get>('/api/fs/file' + query);
+ if (res['ok'] !== true || typeof res['content'] !== 'string') {
+ return {
+ ok: false,
+ path,
+ content: null,
+ error: typeof res['error'] === 'string' ? res['error'] : 'read failed',
+ };
+ }
+ return {
+ ok: true,
+ path: typeof res['path'] === 'string' ? res['path'] : path,
+ content: res['content'],
+ size: typeof res['size'] === 'number' ? res['size'] : undefined,
+ };
+ },
+
+ async createDir(path: string): Promise<{ ok: boolean; error?: string }> {
+ const res = await ports.http.post>('/api/fs/mkdir', { path });
+ return {
+ ok: res['ok'] === true,
+ error: typeof res['error'] === 'string' ? res['error'] : undefined,
+ };
+ },
+
+ async openInSystem(path: string, mode: 'file' | 'folder'): Promise<{ ok: boolean; error?: string }> {
+ const res = await ports.http.post>('/api/fs/open', { path, mode });
+ return {
+ ok: res['ok'] === true,
+ error: typeof res['error'] === 'string' ? res['error'] : undefined,
+ };
+ },
+
+ rawFileUrl(path: string): string {
+ return '/api/fs/raw?path=' + encodeURIComponent(path);
+ },
+
+ async gitStatus(dir: string): Promise {
+ const res = await ports.http.get>('/api/git/status?dir=' + encodeURIComponent(dir));
+ return {
+ ok: res['ok'] === true,
+ isRepo: res['isRepo'] === true,
+ branch: typeof res['branch'] === 'string' ? res['branch'] : null,
+ upstream: typeof res['upstream'] === 'string' ? res['upstream'] : null,
+ ahead: typeof res['ahead'] === 'number' ? res['ahead'] : 0,
+ behind: typeof res['behind'] === 'number' ? res['behind'] : 0,
+ files: Array.isArray(res['files'])
+ ? (res['files'] as Array>).map((f) => ({
+ x: typeof f['x'] === 'string' ? f['x'] : ' ',
+ y: typeof f['y'] === 'string' ? f['y'] : ' ',
+ path: typeof f['path'] === 'string' ? f['path'] : '',
+ origPath: typeof f['origPath'] === 'string' ? f['origPath'] : null,
+ staged: f['staged'] === true,
+ }))
+ : [],
+ error: typeof res['error'] === 'string' ? res['error'] : undefined,
+ };
+ },
+
+ async gitBranches(dir: string): Promise {
+ const res = await ports.http.get>('/api/git/branches?dir=' + encodeURIComponent(dir));
+ return {
+ ok: res['ok'] === true,
+ error: typeof res['error'] === 'string' ? res['error'] : undefined,
+ branches: Array.isArray(res['branches'])
+ ? (res['branches'] as Array>).map((b) => ({
+ name: typeof b['name'] === 'string' ? b['name'] : '',
+ current: b['current'] === true,
+ }))
+ : [],
+ };
+ },
+
+ async gitCheckout(dir: string, branch: string): Promise<{ ok: boolean; error?: string }> {
+ const res = await ports.http.post>('/api/git/checkout', { dir, branch });
+ return {
+ ok: res['ok'] === true,
+ error: typeof res['error'] === 'string' ? res['error'] : undefined,
+ };
+ },
+
+ async gitDiff(dir: string, file: string): Promise<{ ok: boolean; diff: string; error?: string }> {
+ const res = await ports.http.get>(
+ '/api/git/diff?dir=' + encodeURIComponent(dir) + '&file=' + encodeURIComponent(file),
+ );
+ return {
+ ok: res['ok'] === true,
+ diff: typeof res['diff'] === 'string' ? res['diff'] : '',
+ error: typeof res['error'] === 'string' ? res['error'] : undefined,
+ };
+ },
+
+ 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..fb523999
--- /dev/null
+++ b/packages/webui-react/src/core/transport/http-port.ts
@@ -0,0 +1,196 @@
+/**
+ * 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;
+
+/** http 用到的端口窄视图(持有器视图)。 */
+export interface HttpPorts {
+ kv: KeyValueStorePort;
+}
+
+export interface HttpPortDeps {
+ /** 端口持有器:kv 每次用时现读 —— 热替换后立即生效,不在构造期捕获实例。 */
+ ports: HttpPorts;
+}
+
+/** 携带 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 ports = deps.ports;
+
+ // ── token 引导:URL ?token= 优先 → kv 兜底;随即抹掉地址栏 ────────────────
+ let token = '';
+ const urlToken = readUrlToken();
+ if (urlToken) {
+ token = urlToken;
+ ports.kv.set(WEBUI_TOKEN_KEY, urlToken);
+ } else {
+ token = ports.kv.get(WEBUI_TOKEN_KEY) ?? '';
+ }
+ stripTokenFromUrl();
+
+ // ── cid 引导:每浏览器一个稳定 client id,随所有请求上行 ───────────────────
+ let cid = ports.kv.get(WEBUI_CID_KEY) ?? '';
+ if (!cid) {
+ cid = createCid();
+ ports.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) ports.kv.set(WEBUI_TOKEN_KEY, token);
+ else ports.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..33a99f61
--- /dev/null
+++ b/packages/webui-react/src/core/transport/stream-port.ts
@@ -0,0 +1,193 @@
+/**
+ * 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';
+
+/** stream 用到的端口窄视图(持有器视图)。 */
+export interface StreamPorts {
+ /** 取实时 token / cid(token 可能被 auth.token_rotated 轮换过,必须现读)。 */
+ http: AuthedHttpPort;
+ /** 兜底身份源(http 未注入身份时从 kv 读)。 */
+ kv: KeyValueStorePort;
+}
+
+export interface StreamPortDeps {
+ /** 端口持有器:字段每次用时现读 —— 热替换后立即生效,不在构造期捕获实例。 */
+ ports: StreamPorts;
+}
+
+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.ports.http.getToken() || deps.ports.kv.get(WEBUI_TOKEN_KEY) || '';
+ const cid = deps.ports.http.cid() || deps.ports.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..85724139
--- /dev/null
+++ b/packages/webui-react/src/features/app-controller.ts
@@ -0,0 +1,773 @@
+/**
+ * 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,
+ ContextUsage,
+ EnterPlanModeState,
+ ModelGroup,
+ ModelSelection,
+ PendingAuth,
+ ProviderOption,
+ ModelOption,
+ SessionGroup,
+ SessionId,
+ SessionSlice,
+ SessionSummary,
+ ThinkingEffort,
+ UsageInfo,
+ WorkspaceBrowseResult,
+ WorkspaceInfo,
+ WorkspaceRecentEntry,
+} from '../contracts/domain';
+import { groupSessionsByWorkspace } from '../contracts/domain';
+import type { WireSettings } from '../contracts/protocol';
+import type { PermissionModeCatalog, Registry } from '../contracts/ports';
+import type { SessionService } from '../core/services/session-service';
+import type { ModelService } from '../core/services/model-service';
+import type { SlashEntry } from '../ui/composer/SlashOverlay';
+
+export type ThemeMode = 'light' | 'dark';
+export type Lang = 'zh' | 'en';
+
+/** 右侧栏 Tab 页标识。 */
+export type RightTab = "files" | "preview" | "browser" | "git" | "details";
+const RIGHT_TABS: readonly RightTab[] = [
+ "files", "preview", "browser", "git", "details",
+];
+
+export interface AppSnapshot {
+ ready: boolean;
+ activeSessionId: SessionId | null;
+ sessions: SessionSummary[];
+ groups: SessionGroup[];
+ /** 按 searchQuery 过滤后的分组(列表渲染用这个,不要用 groups)。 */
+ filteredGroups: SessionGroup[];
+ /** 当前会话的输入草稿 —— 按 SessionId 隔离,切会话各自保留。 */
+ draft: string;
+ /** 当前会话的 ask-user 勾选态(blockId -> 选中的 optionId 列表),按会话隔离。 */
+ askSelections: Record;
+ /** 当前会话的隔离切片;无会话时为 null。 */
+ slice: SessionSlice | null;
+ providers: ProviderOption[];
+ models: ModelOption[];
+ /** 按供应商分组的模型目录(模型选择器两段式的第一段)。 */
+ modelGroups: ModelGroup[];
+ selection: ModelSelection | null;
+ settings: WireSettings | null;
+ usage: UsageInfo | null;
+ context: ContextUsage | null;
+ alerts: AlertItem[];
+ alertsUnread: number;
+ authQueue: PendingAuth[];
+ workspace: WorkspaceInfo | null;
+ recents: WorkspaceRecentEntry[];
+ theme: ThemeMode;
+ lang: Lang;
+ leftOpen: boolean;
+ rightOpen: boolean;
+/** 右侧栏当前 Tab;null = 右栏整体收起。 */
+ rightTab: RightTab | null;
+ /** 面板宽度(px,kv 持久化)。 */
+ leftWidth: number;
+ rightWidth: number;
+ searchQuery: string;
+ collapsedGroups: string[];
+ /** 服务端可用斜杠命令目录(state.availableCommands 派生);空则容器用内置兜底表。 */
+ slashEntries: SlashEntry[];
+ /** mcode 请求进入 plan 模式(state.enterPlanMode 直读);应答后服务端清空。 */
+ enterPlanMode: EnterPlanModeState | null;
+ /** 当前权限模式标签(state.permissions,如 "Full access")。 */
+ permissionLabel: 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;
+ // 对话
+ /** 发送文本;无参则发当前会话草稿。以 '/' 开头会自动路由到 sendCommand。 */
+ send(content?: string): Promise;
+ stop(): Promise;
+ sendCommand(cmd: string): Promise;
+ /** 重试:把最后一条用户消息原样重发(重新生成最近一次回答)。 */
+ resendLast(): Promise;
+ /** 写当前会话草稿(按 SessionId 隔离,互不覆盖)。 */
+ setDraft(text: string): void;
+ // ask-user 块的受控交互
+ sendAskOptionToggle(blockId: string, optionId: string): void;
+ sendAskConfirm(blockId: string, optionIds: string[]): void;
+ // 模型三段式(本次新增能力)
+ 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;
+ // 模式互动:plan / planmode 应答 + 权限模式(新增接缝)
+ /** 应答方案弹窗;agree/add 的后续话术由 UI 层经 send 下发(本地化不属于控制器)。 */
+ answerPlan(option: 'agree' | 'skip' | 'add', context?: string): Promise;
+ /** 应答「进入 plan 模式?」;服务端置 planMode 并清 enterPlanMode。 */
+ answerPlanMode(choice: 'continue' | 'deny'): Promise;
+ /** 权限模式目录(下拉选项)。 */
+ permissionModes(): Promise;
+ /** 切换权限模式(服务端同步 UI 标签)。 */
+ setPermissionMode(mode: string): Promise;
+ // 纯 UI 状态
+ setTheme(theme: ThemeMode): void;
+ setLang(lang: Lang): void;
+ setLeftOpen(v: boolean): void;
+ setRightOpen(v: boolean): void;
+ /** 右侧面板可见性批量设置(标题栏右侧的工具钮)。 */
+ setRightTab(tab: RightTab | null): void;
+ /** 面板宽度设置(拖拽把手;kv 持久化)。 */
+ setPanelWidth(key: 'left' | 'right', width: number): void;
+ setSearchQuery(q: string): void;
+ toggleGroup(key: string): void;
+}
+
+export interface AppController {
+ snapshot(): AppSnapshot;
+ subscribe(listener: () => void): () => void;
+ actions: AppActions;
+}
+
+/** GET /api/settings 与 state 快照里扁平摆放的设置字段(同名对齐 WireSettings)。 */
+const SETTINGS_WIRE_KEYS = [
+ 'lanBroadcast', 'lanBind', 'readOnly', 'tokenEnabled', 'currentToken',
+ 'tokenAcknowledged', 'tokenRotatedAt', 'lanIp', 'lanUrl', 'lanUrlWithToken',
+ 'localUrl', 'lanExposed', 'bindRestartPending', 'lanExposureNotice',
+ 'trustedOrigins', 'defaultModel', 'defaultWorkspace', 'mcodeCmd', 'mcodeVersion',
+ 'port', 'host', 'bindHost', 'quotaEnabled', 'hasTokenPlanKey',
+ 'tokenPlanApiKeyMasked', 'tokenPlanApiKeySource', 'tokenPlanApiKeyFilePath',
+] as const;
+
+function asRecord(v: unknown): Record | null {
+ return typeof v === 'object' && v !== null ? (v as Record) : null;
+}
+
+function numOrNull(v: unknown): number | null {
+ return typeof v === 'number' && Number.isFinite(v) ? v : null;
+}
+
+/** weekly 可能是 91 / "91%" / "unlimited" —— 一律收窄为百分比或 null。 */
+function weeklyPercentOf(v: unknown): number | null {
+ if (typeof v === 'number' && Number.isFinite(v)) return v;
+ if (typeof v === 'string') {
+ const t = v.trim().replace(/%$/, '');
+ if (t === '' || t.toLowerCase() === 'unlimited') return null;
+ const n = Number(t);
+ return Number.isFinite(n) ? n : null;
+ }
+ return null;
+}
+
+export function createAppController(reg: Registry): AppController {
+ const listeners = new Set<() => void>();
+ // 快照记忆化 —— 这是 useSyncExternalStore 的硬性要求:状态未变时必须返回
+ // **同一个引用**。若每次 snapshot() 都新建对象,React 会认为状态一直在变,
+ // 触发「Maximum update depth exceeded」无限重渲染(React #185)。
+ let cachedSnapshot: AppSnapshot | null = null;
+ const notify = () => {
+ cachedSnapshot = null; // 先作废缓存,再通知订阅者重取
+ 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 modelGroups: ModelGroup[] = [];
+ let settings: WireSettings | null = null;
+ let usage: UsageInfo | null = null;
+ let context: ContextUsage | null = null;
+ let workspace: WorkspaceInfo | null = null;
+ let recents: WorkspaceRecentEntry[] = [];
+ let theme: ThemeMode = 'light';
+ let lang: Lang = 'zh';
+ let leftOpen = true;
+ let rightOpen = false;
+ // 右侧栏 Tab(v3):单栏 + Tab 页;kv 持久化。
+ let rightTab: RightTab | null = "files";
+ try {
+ const rawTab = reg.kv.get("webui_right_tab");
+ if (rawTab && (RIGHT_TABS as readonly string[]).includes(rawTab)) rightTab = rawTab as RightTab;
+ } catch { /* 坏数据当没有 */ }
+ // 面板宽度(kv 持久化;webui_panel_widths)。
+ let panelWidths: { left: number; right: number } = { left: 240, right: 420 };
+ try {
+ const raw = reg.kv.get('webui_panel_widths');
+ if (raw) {
+ const o = asRecord(JSON.parse(raw) as unknown);
+ if (o) {
+ panelWidths = {
+ left: typeof o['left'] === 'number' ? o['left'] : panelWidths.left,
+ right: typeof o['right'] === 'number' ? o['right'] : panelWidths.right,
+ };
+ }
+ }
+ } catch { /* 坏数据当没有 */ }
+ let searchQuery = '';
+ let collapsedGroups: string[] = [];
+ let slashEntries: SlashEntry[] = [];
+ // 模式互动(per-cid,不按会话隔离):mcode 请求进入 plan 模式 + 权限标签。
+ let enterPlanMode: EnterPlanModeState | null = null;
+ let permissionLabel = '';
+ // 会话隔离的输入草稿:切会话各自保留,绝不共享同一份缓冲。
+ const drafts = new Map();
+ // ask-user 选项的勾选态,同样按会话隔离。
+ const askSelections = new Map>();
+
+ const activeSlice = (): SessionSlice | null =>
+ activeSessionId ? reg.sessions.slice(activeSessionId) : null;
+
+ /** 按搜索词过滤:命中标题或工作区路径即保留;空词不过滤。纯派生,无 IO。 */
+ function filtered(list: SessionSummary[], query: string): SessionSummary[] {
+ const q = query.trim().toLowerCase();
+ if (q === '') return list;
+ return list.filter(
+ (s) =>
+ s.title.toLowerCase().includes(q) ||
+ (s.workspace ?? '').toLowerCase().includes(q),
+ );
+ }
+
+ function snapshot(): AppSnapshot {
+ if (cachedSnapshot) return cachedSnapshot;
+ const slice = activeSlice();
+ const groups = groupSessionsByWorkspace(sessions);
+ const built: AppSnapshot = {
+ ready,
+ activeSessionId,
+ sessions,
+ groups,
+ filteredGroups: groupSessionsByWorkspace(filtered(sessions, searchQuery)),
+ draft: activeSessionId ? (drafts.get(activeSessionId) ?? '') : '',
+ askSelections: activeSessionId ? (askSelections.get(activeSessionId) ?? {}) : {},
+ slice,
+ providers,
+ models,
+ modelGroups,
+ 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,
+ rightTab,
+ leftWidth: panelWidths.left,
+ rightWidth: panelWidths.right,
+ searchQuery,
+ collapsedGroups,
+ enterPlanMode,
+ permissionLabel,
+ slashEntries,
+ };
+ cachedSnapshot = built;
+ return built;
+ }
+
+ // ── 订阅所有会话切片 + 各服务,统一通知 ────────────────────────────────
+ 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 = [];
+ }
+ // 分组目录:服务端 /api/models groups(配置文件 + 内置);旧实现缺 groups() 时回落派生。
+ try {
+ const svc = reg.models as Partial;
+ modelGroups = typeof svc.groups === 'function' ? await svc.groups() : [];
+ } catch {
+ modelGroups = [];
+ }
+ }
+
+ /** 已有工作区列表 = 服务端 sessions 库聚合(含会话数角标)+ 本地 kv recents 去重合并。 */
+ async function loadWorkspaces() {
+ try {
+ workspace = reg.workspace.current();
+ } catch {
+ workspace = null;
+ }
+ try {
+ const serverList = await reg.workspace.listRecent();
+ const local = reg.workspace.recents();
+ const seen = new Set();
+ const merged: WorkspaceRecentEntry[] = [];
+ for (const w of serverList) {
+ if (seen.has(w.path)) continue;
+ seen.add(w.path);
+ merged.push(w);
+ }
+ for (const w of local) {
+ if (seen.has(w.path)) continue;
+ seen.add(w.path);
+ merged.push({ name: w.name, path: w.path, isDir: true });
+ }
+ recents = merged;
+ } catch {
+ // 服务端聚合失败(旧后端无 /api/workspace/recent)时回落本地 kv。
+ recents = reg.workspace.recents();
+ }
+ }
+
+ 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; }
+ await loadWorkspaces();
+ 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;
+ const prev = activeSessionId;
+ activeSessionId = id; // 旧切片原样保留 —— 会话隔离
+ resubscribeSession();
+ context = null;
+ try {
+ await reg.sessions.switchTo(id);
+ } catch (e) {
+ // 服务端拒绝切换(如运行中 409「请先停止再切换」):回滚本地选择,
+ // 并把错误上抛给 UI toast —— 半切换状态比切不动更糟。
+ activeSessionId = prev;
+ resubscribeSession();
+ notify();
+ throw e instanceof Error ? e : new Error(String(e));
+ }
+ 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();
+ },
+
+ setDraft(text) {
+ if (!activeSessionId) return;
+ // 每个会话一份草稿 —— 切走再切回,输入内容原样还在。
+ drafts.set(activeSessionId, text);
+ notify();
+ },
+
+ sendAskOptionToggle(blockId, optionId) {
+ const id = activeSessionId;
+ if (!id) return;
+ const perBlock = askSelections.get(id) ?? {};
+ const cur = perBlock[blockId] ?? [];
+ perBlock[blockId] = cur.includes(optionId)
+ ? cur.filter((x) => x !== optionId)
+ : [...cur, optionId];
+ askSelections.set(id, perBlock);
+ notify();
+ },
+
+ sendAskConfirm(blockId, optionIds) {
+ const id = activeSessionId;
+ if (!id) return;
+ const perBlock = askSelections.get(id) ?? {};
+ perBlock[blockId] = optionIds;
+ askSelections.set(id, perBlock);
+ notify();
+ },
+
+ async send(content) {
+ const id = activeSessionId;
+ if (!id) return;
+ // 斜杠命令与普通消息分流:'/' 开头走 /api/cmd,否则走 /api/send。
+ const text = (content ?? drafts.get(id) ?? '').trim();
+ if (text === '') return;
+ drafts.delete(id);
+ if (text.startsWith('/')) {
+ await reg.chat.command(id, text);
+ notify();
+ return;
+ }
+ const slice = reg.sessions.slice(id);
+ const refs = slice.attachments.filter((a) => a.status === 'done').map((a) => '@' + a.path);
+ // 用户消息的回显统一归 chat-service(它会 append 一条并置 inflightId)。
+ // 控制器这里再 push 一条会导致用户消息渲染两遍。
+ slice.attachments = [];
+ notify();
+ await reg.chat.send(id, text, refs);
+ },
+ async stop() {
+ if (activeSessionId) await reg.chat.stop(activeSessionId);
+ },
+ async sendCommand(cmd) {
+ if (activeSessionId) await reg.chat.command(activeSessionId, cmd);
+ },
+ async resendLast() {
+ const id = activeSessionId;
+ if (!id) return;
+ const msgs = reg.sessions.slice(id).messages;
+ let text = '';
+ for (let i = msgs.length - 1; i >= 0; i -= 1) {
+ const m = msgs[i];
+ if (m.role !== 'user') continue;
+ const parts: string[] = [];
+ for (const b of m.blocks) {
+ if (b.kind === 'text') parts.push(b.text);
+ }
+ text = parts.join('\n').trim();
+ break;
+ }
+ if (text === '') return;
+ // 复用 send 的分流('/' 命令 vs 普通消息);重试不带附件引用。
+ await actions.send(text);
+ },
+
+ 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);
+ await loadWorkspaces();
+ notify();
+ },
+ async resetWorkspace() {
+ workspace = await reg.workspace.reset();
+ await loadWorkspaces();
+ 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(); },
+
+ async answerPlan(option, context) {
+ await reg.interact.answerPlan(option, context);
+ // 应答已生效(服务端清 plan 并广播 state)——本地无需再改切片。
+ },
+ async answerPlanMode(choice) {
+ await reg.interact.answerPlanMode(choice);
+ },
+ permissionModes() {
+ return reg.interact.permissionModes();
+ },
+ async setPermissionMode(mode) {
+ await reg.interact.setPermissionMode(mode);
+ // 标签经下一次 state 推送回流(POST /api/permissions 会 pushStateFor)。
+ },
+
+ setTheme(t) { theme = t; notify(); },
+ setLang(l) { lang = l; notify(); },
+ setLeftOpen(v) { leftOpen = v; notify(); },
+ setRightOpen(v) { rightOpen = v; notify(); },
+ setRightTab(tab) {
+ rightTab = tab;
+ try { reg.kv.set("webui_right_tab", tab || ""); } catch { /* 不致命 */ }
+ notify();
+ },
+ setPanelWidth(key, width) {
+ const clamped =
+ key === 'left' ? Math.max(200, Math.min(420, width)) :
+ Math.max(280, Math.min(800, width));
+ panelWidths = { ...panelWidths, [key]: clamped };
+ try { reg.kv.set('webui_panel_widths', JSON.stringify(panelWidths)); } catch { /* 持久化失败不致命 */ }
+ notify();
+ },
+ setSearchQuery(q) { searchQuery = q; notify(); },
+ toggleGroup(key) {
+ collapsedGroups = collapsedGroups.includes(key)
+ ? collapsedGroups.filter((k) => k !== key)
+ : [...collapsedGroups, key];
+ notify();
+ },
+ };
+
+ // ── 服务端状态同步(消息展示 / 切会话内容)──────────────────────────────
+ /**
+ * GET /api/state 基线与 state.snapshot 帧同构。chat 只属于 state.sessionId
+ * 那个会话 —— 切片写入由 SessionService.hydrateFromWireState 按 state.sessionId
+ * 落位(绝不串会话),这里只补控制器级的会话列表 / 用量 / 上下文变量。
+ */
+ function applyWireState(raw: unknown): void {
+ const s = asRecord(raw);
+ if (!s) return;
+ let changed = false;
+
+ const rows = Array.isArray(s['sessions']) ? s['sessions'] : null;
+ if (rows) {
+ const list: SessionSummary[] = [];
+ for (const r of rows) {
+ const o = asRecord(r);
+ if (!o || typeof o['id'] !== 'string' || o['id'] === '') continue;
+ list.push({
+ id: o['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']) || Number(o['createdAt']) || 0,
+ });
+ }
+ sessions = list;
+ changed = true;
+ }
+
+ const impl = reg.sessions as Partial;
+ const sid = typeof s['sessionId'] === 'string' && s['sessionId'] !== '' ? s['sessionId'] : null;
+ if (sid && typeof impl.hydrateFromWireState === 'function') {
+ impl.hydrateFromWireState(raw);
+ // 初始采纳服务端当前会话;之后本地点选优先(selectSession 自己走 switch)。
+ if (activeSessionId === null) {
+ activeSessionId = sid;
+ resubscribeSession();
+ }
+ const slice = reg.sessions.slice(sid);
+ if (sid === activeSessionId) context = slice.context;
+ if (slice.workspace) workspace = slice.workspace;
+ changed = true;
+ }
+
+ const usageO = asRecord(s['usage']);
+ if (usageO) {
+ usage = {
+ fiveHourPercent: numOrNull(usageO['fiveHourPercent'] ?? usageO['remaining']),
+ weeklyPercent: weeklyPercentOf(usageO['weeklyPercent'] ?? usageO['weekly']),
+ fetchedAt: numOrNull(usageO['fetchedAt']),
+ };
+ changed = true;
+ }
+
+ // 斜杠命令目录:服务端 availableCommands(local / mcode 两组)→ 面板条目。
+ const cmdsO = asRecord(s['availableCommands']);
+ if (cmdsO) {
+ const entries: SlashEntry[] = [];
+ const add = (list: unknown): void => {
+ if (!Array.isArray(list)) return;
+ for (const c of list) {
+ const o = asRecord(c);
+ if (!o || typeof o['name'] !== 'string' || o['name'] === '') continue;
+ const name = o['name'].startsWith('/') ? o['name'] : '/' + o['name'];
+ entries.push({
+ id: 'cmd:' + name,
+ cmd: name,
+ desc: typeof o['description'] === 'string' ? o['description'] : '',
+ kind: 'cmd',
+ });
+ }
+ };
+ add(cmdsO['local']);
+ add(cmdsO['mcode']);
+ if (entries.length > 0) {
+ slashEntries = entries;
+ changed = true;
+ }
+ }
+
+ // 模式互动(per-cid):enterPlanMode 直读(应答后服务端清空 → null,
+ // PlanModeModal 随之关闭);permissions 是标签字符串(如 "Full access")。
+ const epmO = asRecord(s['enterPlanMode']);
+ if (epmO) {
+ enterPlanMode = {
+ active: epmO['active'] === true,
+ prompt: typeof epmO['prompt'] === 'string' ? epmO['prompt'] : null,
+ };
+ changed = true;
+ }
+ if (typeof s['permissions'] === 'string') {
+ permissionLabel = s['permissions'];
+ changed = true;
+ }
+
+ // 设置字段在 state 里扁平摆放(与 /api/settings 同名)——合入 settings。
+ // POST /api/settings 会广播 state,多标签页 / 外部改动借此同步到界面。
+ const flat: Record = {};
+ let hasFlat = false;
+ for (const key of SETTINGS_WIRE_KEYS) {
+ if (key in s) {
+ flat[key] = s[key];
+ hasFlat = true;
+ }
+ }
+ if (hasFlat) {
+ settings = { ...(settings ?? {}), ...flat };
+ changed = true;
+ }
+
+ if (changed) notify();
+ }
+
+ // 基线 GET /api/state + 增量 state.snapshot 帧。这里也是 /api/stream 的唯一连接点:
+ // 授权弹窗(needs_authorization —— 删除会话 / token 重置走 authorize 门)、告警、
+ // 状态推送全依赖这条流;不连接则删除等操作会一直挂到超时被拒。
+ subDeps.push(
+ reg.stream.onFrame((raw) => {
+ const f = asRecord(raw);
+ if (f && f['type'] === 'state.snapshot') applyWireState(f['payload']);
+ }),
+ );
+ reg.stream.connect();
+ void reg.http
+ .get('/api/state')
+ .then((state) => applyWireState(state))
+ .catch(() => { /* 基线拉取失败不阻塞 —— 流帧会补齐 */ });
+
+ 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/chat-feature.tsx b/packages/webui-react/src/features/chat-feature.tsx
new file mode 100644
index 00000000..ccb40c0f
--- /dev/null
+++ b/packages/webui-react/src/features/chat-feature.tsx
@@ -0,0 +1,286 @@
+/** chat-feature —— 中栏容器:标题栏(会话切换下拉)+ 消息流 + 执行状态条 + 操作行接线(接缝:SessionSlice → ChatArea/MessageList props)。 */
+import { useEffect, useMemo, useState } from 'react';
+
+import type { AskUserBlock } from '../contracts/domain';
+import { MessageList } from '../ui/chat/MessageList';
+import { ChatArea } from '../ui/layout/ChatArea';
+import { SessionTitleBar } from '../ui/chat/SessionTitleBar';
+import type { ExecStats } from '../ui/chat/ExecStatusRow';
+import type { Feedback } from '../ui/chat/MessageActions';
+import { IconButton } from '../ui/primitives/IconButton';
+import { Icon } from '../ui/primitives/Icon';
+import type { AppController } from './app-controller';
+import { ComposerFeature } from './composer-feature';
+import { useRegistry } from './registry-context';
+import { useAppActions, useAppSnapshot } from './use-app';
+
+export interface ChatFeatureProps {
+ controller: AppController;
+}
+
+/**
+ * 渲染窗口:长会话(数万行 hydrate 出数千条消息)全量渲染会压垮渲染进程 ——
+ * 实测 41 万字会话点击即黑屏。只渲染最近 WINDOW 条,「加载更早」每次放行一批。
+ */
+const RENDER_WINDOW = 300;
+const RENDER_WINDOW_STEP = 500;
+
+export function ChatFeature({ controller }: ChatFeatureProps) {
+ const s = useAppSnapshot(controller);
+ const a = useAppActions(controller);
+ const { notifier } = useRegistry();
+
+ const allMessages = s.slice?.messages ?? [];
+ const running = s.slice?.running ?? false;
+ const total = allMessages.length;
+ // 额外放行的更早消息数(「加载更早」累加;切会话归零)。
+ const [extraCount, setExtraCount] = useState(0);
+ const sessionId = s.activeSessionId;
+ useEffect(() => {
+ setExtraCount(0);
+ }, [sessionId]);
+ const from = Math.max(0, total - RENDER_WINDOW - extraCount);
+ const messages = useMemo(
+ () => (from === 0 ? allMessages : allMessages.slice(from)),
+ [allMessages, from],
+ );
+ const loadEarlier = (): void => setExtraCount((w) => w + RENDER_WINDOW_STEP);
+ const hiddenEarlier = from;
+
+ // ── 会话切换下拉(标题栏 ∨)──────────────────────────────────────────────
+ const [switcherOpen, setSwitcherOpen] = useState(false);
+
+ // ── 执行状态条数据:客户端计时(running 起止)+ 服务端 context.tps ──────
+ // 服务端只有 running 布尔与 tps,无逐回合耗时 —— 客户端记录 running 起点,
+ // 运行中每秒跳一次;结束时定格,作为「共执行 N 秒」保留到下一回合。
+ const tps = s.slice?.context?.tps ?? 0;
+ const [runStart, setRunStart] = useState(null);
+ const [lastDuration, setLastDuration] = useState(null);
+ const [, setTick] = useState(0);
+ useEffect(() => {
+ if (running) {
+ setRunStart((prev) => (prev === null ? Date.now() : prev));
+ } else if (runStart !== null) {
+ setLastDuration(Math.max(1, Math.round((Date.now() - runStart) / 1000)));
+ setRunStart(null);
+ }
+ }, [running, runStart]);
+ useEffect(() => {
+ if (!running) return;
+ const t = setInterval(() => setTick((v) => v + 1), 1000);
+ return () => clearInterval(t);
+ }, [running]);
+ // 切会话清零(执行统计只属于当前会话的最近回合)。
+ useEffect(() => {
+ setRunStart(null);
+ setLastDuration(null);
+ }, [sessionId]);
+ const execStats: ExecStats | null = useMemo(() => {
+ if (running && runStart !== null) {
+ return { durationSec: (Date.now() - runStart) / 1000, tps: tps > 0 ? tps : null, running: true };
+ }
+ if (!running && lastDuration !== null) {
+ return { durationSec: lastDuration, tps: tps > 0 ? tps : null, running: false };
+ }
+ return null;
+ // tick 参与 deps:运行中每秒触发重算(lint 不查未用 dep)。
+ }, [running, runStart, lastDuration, tps]);
+
+ // ── 赞 / 踩(本地 UI 态,按消息 id,会话切换即弃)────────────────────────
+ const [feedbacks, setFeedbacks] = useState>({});
+ useEffect(() => {
+ setFeedbacks({});
+ }, [sessionId]);
+
+ // 行内 ask-user 块的应答:MessageList 的回调只带 optionId,块 id 由容器定位到
+ // 「最后一个未答 ask 块」(同一时刻通常只有一个待答提问);勾选态在本容器镜像
+ // 一份用于受控渲染,同时同步给 actions.sendAskOptionToggle 按会话隔离保存。
+ const activeAsk = useMemo(() => {
+ let found: AskUserBlock | null = null;
+ for (const m of messages) {
+ for (const b of m.blocks) {
+ if (b.kind === 'ask-user' && b.answered !== true) found = b;
+ }
+ }
+ return found;
+ }, [messages]);
+ const [askSelections, setAskSelections] = useState>({});
+
+ const toggleAskOption = (optionId: string): void => {
+ const block = activeAsk;
+ if (!block) return;
+ a.sendAskOptionToggle(block.id, optionId);
+ setAskSelections((prev) => {
+ const cur = prev[block.id] ?? [];
+ const next = block.multiSelect
+ ? cur.includes(optionId)
+ ? cur.filter((x) => x !== optionId)
+ : [...cur, optionId]
+ : [optionId];
+ return { ...prev, [block.id]: next };
+ });
+ };
+
+ const confirmAsk = (optionIds: string[]): void => {
+ const block = activeAsk;
+ if (!block) return;
+ a.sendAskConfirm(block.id, optionIds);
+ setAskSelections((prev) => ({ ...prev, [block.id]: [] }));
+ };
+
+ const copyResult = (ok: boolean): void => {
+ notifier.toast(ok ? '已复制' : '当前环境不支持复制', ok ? 'success' : 'warn');
+ };
+
+ const title = s.slice?.summary?.title ?? '';
+
+ return (
+ setSwitcherOpen((v) => !v)}
+ left={
+ a.setLeftOpen(!s.leftOpen)}
+ />
+ }
+ right={
+ <>
+ a.setRightTab(s.rightTab === 'files' ? null : 'files')}
+ />
+ a.setRightTab(s.rightTab === 'preview' ? null : 'preview')}
+ />
+ a.setRightTab(s.rightTab === 'browser' ? null : 'browser')}
+ />
+ a.setRightTab(s.rightTab === 'git' ? null : 'git')}
+ />
+ a.setRightTab(s.rightTab === 'details' ? null : 'details')}
+ />
+ >
+ }
+ dropdown={
+ switcherOpen ? (
+ <>
+ {/* 点外部收起 */}
+ setSwitcherOpen(false)}
+ aria-hidden="true"
+ />
+
+ {s.sessions.length === 0 ? (
+
+ 暂无会话
+
+ ) : (
+ s.sessions.map((sess) => (
+
+ ))
+ )}
+
+ >
+ ) : null
+ }
+ />
+ }
+ // 缺口 #4:empty / thinking 插槽置空 —— 空态与 ThinkingBar 统一由 MessageList
+ // 内部渲染(它已按 messages/streaming 二选一),不再出现双份。
+ messages={
+ <>
+ {hiddenEarlier > 0 ? (
+
+
+
+ ) : null}
+
setFeedbacks((prev) => ({ ...prev, [mid]: next }))}
+ onCopyResult={copyResult}
+ onRetry={() => {
+ void a.resendLast().catch((e: unknown) => {
+ notifier.toast(e instanceof Error ? e.message : String(e), 'error');
+ });
+ }}
+ />
+ >
+ }
+ composer={}
+ onDropFiles={(files) => void a.uploadFiles(files)}
+ />
+ );
+}
diff --git a/packages/webui-react/src/features/composer-feature.tsx b/packages/webui-react/src/features/composer-feature.tsx
new file mode 100644
index 00000000..d359cf09
--- /dev/null
+++ b/packages/webui-react/src/features/composer-feature.tsx
@@ -0,0 +1,277 @@
+/** composer-feature —— 输入区容器:草稿/发送/附件/模型与工作区浮层/斜杠命令/权限弹窗的接线(接缝:AppSnapshot.draft+actions → Composer props)。 */
+import { useEffect, useRef, useState } from 'react';
+
+import type { WorkspaceEntry } from '../contracts/domain';
+import { Composer, type PermissionMode } from '../ui/composer/Composer';
+import { ModelPicker } from '../ui/composer/ModelPicker';
+import { SlashOverlay, type SlashEntry } from '../ui/composer/SlashOverlay';
+import { WorkspaceChip, type WorkspaceQuickItem } from '../ui/composer/WorkspaceChip';
+import { WorkspacePicker } from '../ui/composer/WorkspacePicker';
+import { PermissionModal } from '../ui/modals';
+import type { AppController } from './app-controller';
+import { useRegistry } from './registry-context';
+import { useAppActions, useAppSnapshot } from './use-app';
+
+export interface ComposerFeatureProps {
+ controller: AppController;
+}
+
+/**
+ * 斜杠命令表 —— 对齐 webui/server/lib/interaction/commands.js 的本地命令
+ * (/new /clear /status /sessions /help /usage /stop /goal*)+ 常用 mcode 命令。
+ */
+const SLASH_ENTRIES: SlashEntry[] = [
+ { id: 'clear', cmd: '/clear', desc: '清空当前对话', kind: 'cmd' },
+ { id: 'compact', cmd: '/compact', desc: '压缩上下文以释放窗口', kind: 'cmd' },
+ { id: 'new', cmd: '/new', desc: '新建会话', kind: 'cmd' },
+ { id: 'plan', cmd: '/plan', desc: '进入 Plan 模式,先出方案', kind: 'cmd' },
+ { id: 'status', cmd: '/status', desc: '查看当前状态', kind: 'cmd' },
+ { id: 'sessions', cmd: '/sessions', desc: '查看最近会话', kind: 'cmd' },
+ { id: 'usage', cmd: '/usage', desc: '查询用量', kind: 'cmd' },
+ { id: 'goal', cmd: '/goal', desc: '设定目标(配 /goal-done、/goal-blocked 收尾)', kind: 'cmd' },
+ { id: 'goal-done', cmd: '/goal-done', desc: '标记目标完成', kind: 'cmd' },
+ { id: 'goal-blocked', cmd: '/goal-blocked', desc: '标记目标受阻', kind: 'cmd' },
+ { id: 'stop', cmd: '/stop', desc: '停止当前任务', kind: 'cmd' },
+ { id: 'help', cmd: '/help', desc: '可用命令', kind: 'cmd' },
+];
+
+/** 全限定模型 id → 短名(只留 '/' 后最后一段)。 */
+function shortModelName(full: string | undefined): string {
+ if (!full) return '—';
+ return full.includes('/') ? (full.split('/').pop() ?? full) : full;
+}
+
+/** 目录路径 → 短名(最后一段;根/空路径回落整串)。 */
+function dirShortName(path: string | null): string {
+ if (!path) return '无工作区';
+ return path.split('/').filter(Boolean).pop() ?? path;
+}
+
+export function ComposerFeature({ controller }: ComposerFeatureProps) {
+ const s = useAppSnapshot(controller);
+ const a = useAppActions(controller);
+ const { notifier, workspace: workspaceSvc } = useRegistry();
+
+ const [modelPickerOpen, setModelPickerOpen] = useState(false);
+ const [wsChipOpen, setWsChipOpen] = useState(false);
+ const [wsPickerOpen, setWsPickerOpen] = useState(false);
+ const [slashOpen, setSlashOpen] = useState(false);
+ // 权限模式:以服务端标签(state.permissions,如 "Full access")回读为准;
+ // 切换经 POST /api/permissions 同步(mcode 固定于启动时,服务端仅同步 UI 标签)。
+ const [permMode, setPermMode] = useState('ask');
+ const [permOpen, setPermOpen] = useState(false);
+ // 「选择目录」的浏览模式:null = 显示 recents;否则经 /api/fs/read 逐级浏览
+ // (含文件条目:size/mtime/mode 直接来自服务端 readDirectory)。
+ const [browse, setBrowse] = useState<{
+ cwd: string;
+ parent: string | null;
+ home: string | null;
+ entries: WorkspaceEntry[];
+ } | null>(null);
+ const [browseLoading, setBrowseLoading] = useState(false);
+
+ const fileRef = useRef(null);
+
+ // 斜杠命令面板:输入以 '/' 开头且尚无空格时自动打开;Esc 关闭后再次输入重开。
+ useEffect(() => {
+ setSlashOpen(s.draft.startsWith('/') && !s.draft.includes(' '));
+ }, [s.draft]);
+
+ // 权限标签 → 模式值(服务端标签:Ask / Auto / Read / Full access);
+ // 未知标签保守回落 'ask',避免把 UI 徽标显示成用户没选过的档位。
+ useEffect(() => {
+ const label = s.permissionLabel;
+ if (label === 'Ask') setPermMode('ask');
+ else if (label === 'Auto') setPermMode('auto');
+ else if (label === 'Read') setPermMode('read');
+ else if (label === 'Full access') setPermMode('full');
+ }, [s.permissionLabel]);
+
+ const selection = s.slice?.selection;
+ const wsPath = s.slice?.workspace?.dir ?? s.workspace?.dir ?? null;
+
+ const closeWs = (): void => {
+ setWsChipOpen(false);
+ setWsPickerOpen(false);
+ setBrowse(null);
+ };
+
+ const pickWorkspace = (dir: string): void => {
+ void a.useWorkspace(dir).then(closeWs);
+ };
+
+ /** 进入目录浏览模式(/api/fs/read:支持 ~ 与 documents 等关键字,含文件条目)。 */
+ const enterBrowse = (path: string | undefined): void => {
+ const target = path !== undefined && path.trim() !== '' ? path.trim() : '~';
+ setBrowseLoading(true);
+ workspaceSvc
+ .listDir(target)
+ .then((res) => {
+ if (!res.ok) {
+ notifier.toast(res.error ?? '目录不存在', 'error');
+ return;
+ }
+ setBrowse({
+ cwd: res.dir ?? target,
+ parent: res.parent,
+ home: res.home ?? null,
+ entries: res.entries,
+ });
+ })
+ .catch(() => notifier.toast('浏览目录失败', 'error'))
+ .finally(() => setBrowseLoading(false));
+ };
+
+ /** 浏览模式「新建文件夹」:重名自动编号,成功后刷新当前目录。 */
+ const handleCreateFolder = (): void => {
+ if (!browse) return;
+ const existing = new Set(browse.entries.map((e) => e.name));
+ let name = '新建文件夹';
+ for (let i = 2; existing.has(name); i += 1) name = '新建文件夹(' + String(i) + ')';
+ const target = browse.cwd.endsWith('/') ? browse.cwd + name : browse.cwd + '/' + name;
+ void workspaceSvc
+ .createDir(target)
+ .then((res) => {
+ if (res.ok) {
+ notifier.toast('已创建 ' + name, 'success');
+ enterBrowse(browse.cwd);
+ } else {
+ notifier.toast(res.error ?? '创建失败', 'error');
+ }
+ })
+ .catch(() => notifier.toast('创建失败', 'error'));
+ };
+
+ const handleSelectWsPath = (path: string): void => {
+ pickWorkspace(path);
+ };
+
+ /** 确定/选择目录:path = 高亮目录;缺省 = 浏览模式选当前目录,recents 模式进入浏览。 */
+ const handlePickDirectory = (path?: string): void => {
+ if (browse) {
+ pickWorkspace(path ?? browse.cwd);
+ return;
+ }
+ enterBrowse(path ?? wsPath ?? '~');
+ };
+
+ const handleNoWorkspace = (): void => {
+ void a.resetWorkspace().then(closeWs);
+ };
+
+ const chipItems: WorkspaceQuickItem[] = s.recents.map((e) => ({ path: e.path, name: e.name, sessionCount: e.sessionCount }));
+ // 命令面板条目:优先服务端真实目录(state.availableCommands 派生),为空用内置兜底。
+ const slashEntries = s.slashEntries.length > 0 ? s.slashEntries : SLASH_ENTRIES;
+
+ return (
+ <>
+ {/* 缺口 #1:附件按钮驱动隐藏的文件选择框;选完即上传并清空 value(可重复选同名文件)。 */}
+ {
+ const files = Array.from(e.target.files ?? []);
+ if (files.length > 0) void a.uploadFiles(files);
+ e.target.value = '';
+ }}
+ />
+
+ void a.send()}
+ running={s.slice?.running ?? false}
+ onStop={() => void a.stop()}
+ disabled={s.settings?.readOnly === true}
+ attachments={s.slice?.attachments ?? []}
+ onRemoveAttachment={a.removeAttachment}
+ onAttachClick={() => fileRef.current?.click()}
+ mode={permMode}
+ onModeClick={() => setPermOpen(true)}
+ modelLabel={shortModelName(selection?.model)}
+ modelTitle={selection ? `${selection.provider} / ${selection.model} · ${selection.thinking}` : undefined}
+ onModelClick={() => {
+ setWsPickerOpen(false);
+ setModelPickerOpen(true);
+ }}
+ popoverSlot={
+ <>
+ void a.setModel(m)}
+ onSelectThinking={(e) => void a.setThinking(e)}
+ onSubmitCustom={(v) => {
+ void a.submitCustomModel(v);
+ setModelPickerOpen(false);
+ }}
+ onClose={() => setModelPickerOpen(false)}
+ />
+
+ >
+ }
+ workspaceSlot={
+ {
+ setWsChipOpen((v) => !v);
+ setWsPickerOpen(false);
+ }}
+ items={chipItems}
+ onSelect={pickWorkspace}
+ onAddWorkspace={() => {
+ setWsChipOpen(false);
+ setWsPickerOpen(true);
+ }}
+ />
+ }
+ />
+
+ {
+ a.setDraft('');
+ setSlashOpen(false);
+ void a.sendCommand(entry.cmd);
+ }}
+ onClose={() => setSlashOpen(false)}
+ />
+
+ {
+ setPermMode(mode);
+ setPermOpen(false);
+ void a.setPermissionMode(mode).catch((e: unknown) => {
+ notifier.toast(`权限模式同步失败:${e instanceof Error ? e.message : String(e)}`, 'error');
+ });
+ }}
+ onClose={() => setPermOpen(false)}
+ />
+ >
+ );
+}
diff --git a/packages/webui-react/src/features/modals-feature.tsx b/packages/webui-react/src/features/modals-feature.tsx
new file mode 100644
index 00000000..50f7fd86
--- /dev/null
+++ b/packages/webui-react/src/features/modals-feature.tsx
@@ -0,0 +1,286 @@
+/** modals-feature —— 弹窗容器:授权队列/ask 问卷/Plan/PlanMode/ApiKey 的挂载与应答接线(接缝:AppSnapshot → 哑组件弹窗 props)。 */
+import { useEffect, useMemo, useRef, useState } from 'react';
+
+import type { PlanBlock } from '../contracts/domain';
+import {
+ ApiKeyModal,
+ AskModal,
+ AuthModal,
+ PlanModal,
+ PlanModeModal,
+ type ApiKeySource,
+ type AskModalAnswer,
+ type AskModalQuestion,
+ type PlanChoice,
+ type PlanModeChoice,
+} from '../ui/modals';
+import type { AppController, Lang } from './app-controller';
+import { useRegistry } from './registry-context';
+import { useAppActions, useAppSnapshot } from './use-app';
+
+export interface ModalsFeatureProps {
+ controller: AppController;
+}
+
+/**
+ * plan 应答的后续话术:agree/add 的决定要到达模型,唯一可用通道是把话术
+ * 作为消息发出(vanilla 的 /api/answer 曾是 no-op,决定根本没送出去)。
+ * 文案在 UI 层本地化;skip 不发。
+ */
+function planFollowUpText(choice: PlanChoice, contextText: string, lang: Lang): string | null {
+ const ctx = contextText.trim();
+ if (choice === 'agree') {
+ return lang === 'en' ? 'Approved. Proceed with the plan.' : '同意该计划,开始执行。';
+ }
+ if (choice === 'add') {
+ if (ctx === '') return null;
+ return lang === 'en'
+ ? `Addendum: ${ctx}\n\nProceed with the updated plan.`
+ : `补充:${ctx}\n\n请按补充后的计划执行。`;
+ }
+ return null;
+}
+
+/** 授权请求 ctx 字段 → 友好名('cid' 由 AuthModal 自动跳过)。 */
+const AUTH_CTX_LABELS: Record = {
+ cmd: '命令',
+ chatLen: '当前消息数',
+ sessionId: '会话',
+ mcodeSessionId: 'mcode 会话',
+ source: '触发来源',
+};
+
+export function ModalsFeature({ controller }: ModalsFeatureProps) {
+ const s = useAppSnapshot(controller);
+ const a = useAppActions(controller);
+ const { notifier } = useRegistry();
+
+ // ── ① AuthModal:授权队列(最紧急 —— 不接线会让请求静默挂到超时被拒) ──
+ const head = s.authQueue[0] ?? null;
+ const [msLeft, setMsLeft] = useState(0);
+ const [deciding, setDeciding] = useState(false);
+ const [authError, setAuthError] = useState(null);
+
+ // 倒计时:msLeft = expiresAt - Date.now(),每秒刷新。
+ useEffect(() => {
+ const req = s.authQueue[0];
+ if (!req) return;
+ const tick = (): void => setMsLeft(Math.max(0, req.expiresAt - Date.now()));
+ tick();
+ const timer = window.setInterval(tick, 1000);
+ return () => window.clearInterval(timer);
+ }, [s.authQueue]);
+
+ const decide = (approve: boolean): void => {
+ const req = s.authQueue[0];
+ if (!req || deciding) return;
+ setDeciding(true);
+ setAuthError(null);
+ void a
+ .decideAuth(req.requestId, approve)
+ .catch((e: unknown) => setAuthError(e instanceof Error ? e.message : String(e)))
+ .finally(() => setDeciding(false));
+ };
+
+ // ── ② AskModal:当前会话里未答的 ask-user 块构成问卷 ──
+ const questions = useMemo(() => {
+ const out: AskModalQuestion[] = [];
+ for (const m of s.slice?.messages ?? []) {
+ for (const b of m.blocks) {
+ if (b.kind === 'ask-user' && b.answered !== true) {
+ out.push({ id: b.id, question: b.question, options: b.options, multiSelect: b.multiSelect });
+ }
+ }
+ }
+ return out;
+ }, [s.slice]);
+
+ // 提交/跳过后本地收起(块的 answered 由服务端回流才会置真);题目集合变化时重新弹出。
+ const questionsKey = questions.map((q) => q.id).join('|');
+ const [askDismissed, setAskDismissed] = useState(false);
+ useEffect(() => {
+ setAskDismissed(false);
+ }, [questionsKey]);
+
+ const submitAsk = (answers: AskModalAnswer[]): void => {
+ for (const ans of answers) {
+ a.sendAskConfirm(ans.questionId, ans.optionIds);
+ // 「其他」自由文本按 vanilla 语义作为一条消息回答(/api/send isAskAnswer 的等价物)。
+ if (ans.text) void a.send(ans.text);
+ }
+ setAskDismissed(true);
+ };
+
+ // ── ③ PlanModal:wire state.plan 直读(plan_update 事件维护)优先,
+ // 历史会话无 wire plan 时回落到聊天文本里的 pending plan 块 ──
+ const pendingPlan = useMemo(() => {
+ let found: PlanBlock | null = null;
+ for (const m of s.slice?.messages ?? []) {
+ for (const b of m.blocks) {
+ if (b.kind === 'plan' && b.status === 'pending') found = b;
+ }
+ }
+ return found;
+ }, [s.slice]);
+
+ // live plan:应答(POST /api/answer type=plan)后服务端清 cs.plan 并广播
+ // state → slice.plan 变 null → 弹窗必然关闭,无需本地手动维持开合。
+ const livePlan = s.slice?.plan ?? null;
+ const livePlanKey = livePlan ? livePlan.planId ?? livePlan.title : null;
+ const [livePlanDismissed, setLivePlanDismissed] = useState(false);
+ useEffect(() => {
+ setLivePlanDismissed(false);
+ }, [livePlanKey]);
+
+ const [planOpen, setPlanOpen] = useState(false);
+ const [planContext, setPlanContext] = useState('');
+ const planShownRef = useRef(null);
+ useEffect(() => {
+ const plan = pendingPlan;
+ if (plan && plan.id !== planShownRef.current) {
+ planShownRef.current = plan.id;
+ setPlanOpen(true);
+ }
+ }, [pendingPlan]);
+
+ // ── ④ PlanModeModal:state.enterPlanMode.active 驱动(mode_update 事件);
+ // 应答后服务端清 enterPlanMode → active 变 false 自动关闭。 ──
+ const planModeActive = s.enterPlanMode?.active === true;
+ const planModePrompt = s.enterPlanMode?.prompt ?? null;
+ const [planModeDismissed, setPlanModeDismissed] = useState(false);
+ const planModePromptRef = useRef(null);
+ useEffect(() => {
+ if (planModePrompt !== planModePromptRef.current) {
+ planModePromptRef.current = planModePrompt;
+ setPlanModeDismissed(false);
+ }
+ }, [planModePrompt]);
+
+ // ── ⑤ ApiKeyModal:受控 open 用本地 useState ──
+ const [apiKeyOpen, setApiKeyOpen] = useState(false);
+ const [apiKeyValue, setApiKeyValue] = useState('');
+
+ const choosePlan = (choice: PlanChoice, contextText: string): void => {
+ setPlanOpen(false);
+ setLivePlanDismissed(true);
+ void a
+ .answerPlan(choice, contextText)
+ .then(() => {
+ // agree/add 的决定要到达模型 —— 作为消息发出;skip 不发。
+ const text = planFollowUpText(choice, contextText, s.lang);
+ if (text !== null) return a.send(text);
+ })
+ .catch((e: unknown) => {
+ notifier.toast(`应答失败:${e instanceof Error ? e.message : String(e)}`, 'error');
+ });
+ };
+
+ const choosePlanMode = (choice: PlanModeChoice): void => {
+ setPlanModeDismissed(true);
+ void a.answerPlanMode(choice).catch((e: unknown) => {
+ notifier.toast(`应答失败:${e instanceof Error ? e.message : String(e)}`, 'error');
+ });
+ };
+
+ // ApiKeyModal:保存/清空 Subscription Key —— 对齐 vanilla events.js 的
+ // saveApiKey/deleteApiKey(POST /api/settings { tokenPlanApiKey })。
+ const saveApiKey = (): void => {
+ const k = apiKeyValue.trim();
+ if (!k) {
+ notifier.toast('请先填入 Subscription Key', 'warn');
+ return;
+ }
+ void a.updateSettings({ tokenPlanApiKey: k, quotaEnabled: true }).then(() => {
+ setApiKeyValue('');
+ setApiKeyOpen(false);
+ notifier.toast('已保存', 'success');
+ void a.refreshUsage();
+ });
+ };
+
+ const deleteApiKey = (): void => {
+ void (async () => {
+ const ok = await notifier.confirm('清空 Subscription Key', '确定清空 Subscription Key?清空后套餐用量数据不显示。');
+ if (!ok) return;
+ await a.updateSettings({ tokenPlanApiKey: '' });
+ setApiKeyOpen(false);
+ notifier.toast('已清空', 'success');
+ void a.refreshUsage();
+ })();
+ };
+
+ const settings = s.settings;
+ const apiKeySourceValue = settings?.tokenPlanApiKeySource;
+ const apiKeySource: ApiKeySource =
+ apiKeySourceValue === 'env' || apiKeySourceValue === 'file' || apiKeySourceValue === 'settings'
+ ? apiKeySourceValue
+ : '';
+
+ return (
+ <>
+ decide(true)}
+ onDeny={() => decide(false)}
+ />
+
+ 0}
+ questions={questions}
+ onSubmit={submitAsk}
+ onSkip={() => {
+ void a.send('esc');
+ setAskDismissed(true);
+ }}
+ onClose={() => setAskDismissed(true)}
+ />
+
+ {
+ setPlanOpen(false);
+ setLivePlanDismissed(true);
+ }}
+ />
+
+ setPlanModeDismissed(true)}
+ />
+
+ setApiKeyOpen(false)}
+ />
+ >
+ );
+}
diff --git a/packages/webui-react/src/features/panels-feature.tsx b/packages/webui-react/src/features/panels-feature.tsx
new file mode 100644
index 00000000..d5d5779e
--- /dev/null
+++ b/packages/webui-react/src/features/panels-feature.tsx
@@ -0,0 +1,178 @@
+/** panels-feature —— 右栏容器(v3 Tab 化):单栏 + Tab 页(文件/预览/浏览器/Git/详情),全部挂载按显示隐藏(保留各自状态),左缘拖拽调宽(接缝:SessionSlice/registry.workspace → FileTree/DocPreview/Browser/Git/RightPanel props)。 */
+import { useCallback, useEffect, useState } from 'react';
+import type { CSSProperties } from 'react';
+
+import { BrowserPanel } from '../ui/files/BrowserPanel';
+import { DocPreviewPanel } from '../ui/files/DocPreviewPanel';
+import { FileTreePanel } from '../ui/files/FileTreePanel';
+import { GitPanel } from '../ui/files/GitPanel';
+import { ResizeHandle } from '../ui/primitives/ResizeHandle';
+import { RightPanel } from '../ui/layout/RightPanel';
+import type { AppController } from './app-controller';
+import type { GoalState } from '../contracts/domain';
+import { useRegistry } from './registry-context';
+import { useAppActions, useAppSnapshot } from './use-app';
+
+export interface PanelsFeatureProps {
+ controller: AppController;
+}
+
+/** 已运行时长 → "3m12s" / "1h5m"(纯展示格式化)。 */
+function formatElapsed(ms: number): string {
+ if (!Number.isFinite(ms) || ms < 0) return '';
+ const total = Math.floor(ms / 1000);
+ const mm = Math.floor(total / 60);
+ const ss = total % 60;
+ if (mm >= 60) return `${Math.floor(mm / 60)}h${mm % 60}m`;
+ return mm > 0 ? `${mm}m${ss}s` : `${ss}s`;
+}
+
+/** 工作区路径末段(面包屑项目名)。 */
+function projectLabel(dir: string | null | undefined): string | undefined {
+ if (!dir) return undefined;
+ const parts = dir.split(/[\\/]/).filter(Boolean);
+ return parts.length ? parts[parts.length - 1] : dir;
+}
+
+export function PanelsFeature({ controller }: PanelsFeatureProps) {
+ const s = useAppSnapshot(controller);
+ const a = useAppActions(controller);
+ const reg = useRegistry();
+
+ // 文档预览当前文件;内置浏览器当前 URL(切工作区清空,文件树重新自动选中 README)。
+ const [docPath, setDocPath] = useState(null);
+ const [browserUrl, setBrowserUrl] = useState(null);
+ const [reloadKey, setReloadKey] = useState(0);
+ const wsDir = s.slice?.workspace?.dir ?? s.workspace?.dir ?? '';
+ useEffect(() => {
+ setDocPath(null);
+ setBrowserUrl(null);
+ }, [wsDir]);
+
+ const selection = s.slice?.selection;
+ const contextLimit = selection
+ ? (s.models.find((m) => m.id === selection.model)?.contextLimit ?? null)
+ : null;
+ const goal: GoalState | null = s.slice?.goal ?? null;
+
+ const readFile = useCallback((p: string) => reg.workspace.readFile(p), [reg]);
+ const listDir = useCallback((p: string) => reg.workspace.listDir(p), [reg]);
+ const gitStatus = useCallback((d: string) => reg.workspace.gitStatus(d), [reg]);
+ const gitBranches = useCallback((d: string) => reg.workspace.gitBranches(d), [reg]);
+ const gitDiff = useCallback((d: string, f2: string) => reg.workspace.gitDiff(d, f2), [reg]);
+ const gitCheckout = useCallback(
+ (d: string, branch: string) => reg.workspace.gitCheckout(d, branch),
+ [reg],
+ );
+
+ /** 文件树选中分发:html → 内置浏览器 Tab;其余 → 文档预览 Tab。 */
+ const openFile = (path: string): void => {
+ if (/\.\.html?$/i.test(path)) {
+ setBrowserUrl(reg.workspace.rawFileUrl(path));
+ a.setRightTab('browser');
+ return;
+ }
+ setDocPath(path);
+ a.setRightTab('preview');
+ };
+
+ return (
+