diff --git a/.gitignore b/.gitignore index b7c78eab..70cd71f7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +packages/webui/public/react/ .turbo/ .cache/ .pnpm-store/ diff --git a/package.json b/package.json index 082c68f1..ab10fec9 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "test:policy": "node scripts/run-vitest-suite.mjs policy", "test:byok": "node --test test/byok.test.mjs", "test:webui": "pnpm --filter @mavis/webui test", + "test:webui-react": "pnpm --filter @mavis/webui-react test", "check:standalone": "node scripts/check-standalone-boundary.mjs", "check:tsconfig": "node scripts/gen-tsconfig-paths.mjs", "gen:tsconfig": "node scripts/gen-tsconfig-paths.mjs --write", diff --git a/packages/webui-react/README.md b/packages/webui-react/README.md new file mode 100644 index 00000000..c30abc94 --- /dev/null +++ b/packages/webui-react/README.md @@ -0,0 +1,97 @@ +# @mavis/webui-react + +MiniMax Code Web UI 的 **React + Ant Design** 重写版:1:1 复刻 `packages/webui/public` 的 vanilla 单页 UI, +并按 **高内聚低耦合 / 热插拔易迭代** 的分层重构,解决原实现的两大痛点:模块化不足、会话隔离显示不可靠。 + +## 为什么重写 + +| 痛点 | 原实现 | 本实现 | +| --- | --- | --- | +| 模块化 | 单页 + 全局可变 `state`,`render.js` / `events.js` / `state.js` 三方循环 import | 四层单向依赖,模块之间只通过 `contracts/` 的接口咬合 | +| 会话隔离显示 | 所有会话共享一份 `chat` / `model` / `context`,切会话互相串扰 | 每个 `SessionId` 一份 `SessionSlice`,消息/流式/上下文/模型选择互不串扰 | +| 供应商 / 模型 / 思考强度 | 模型按钮被 `hidden`(`session/set_config_option` 未实现) | 三段式选择器,按会话独立保存,经 `ModelServicePort` 可热插拔换供应商 | + +## 分层 + +依赖方向严格单向,**只有 `features/` 允许同时看见 core 与 ui**: + +``` + ui/ 哑组件(props 进 / 事件出,零业务、零 IO、零 antd 之外的依赖) + │ + ▼ + features/ 咬合枢纽:把端口翻译成快照 + 动作(app-controller.ts) + │ + ▼ + core/ 端口实现(transport / services / store),零 UI 依赖 + │ + ▼ + contracts/ 唯一咬合面:protocol.ts(线上格式)+ domain.ts(领域对象)+ ports.ts(接口) +``` + +| 目录 | 职责 | 换掉它需要动什么 | +| --- | --- | --- | +| `src/contracts/` | 线上格式、领域对象、端口接口 | 改契约 = 改 API,其他层随之编译报错提示 | +| `src/core/transport/` | HTTP / WebSocket / token / cid | 只换这两个文件(例如换成 fetch mock 或 SSE) | +| `src/core/services/` | 每个领域服务一个文件,实现对应 Port | 换供应商 = 换 `model-service.ts`,其余无感 | +| `src/core/store/` | 极简可观察 store(零依赖) | 可整体换成 zustand / Redux,签名已对齐 | +| `src/ui/` | 哑组件 + 同目录同名 `.css` | 换 antd 大版本或换视觉,只动这层 | +| `src/features/` | 编排:`app-controller.ts` + React 绑定 | 业务变更只动这层 | + +## 热插拔 + +所有端口经组装根注入,不许自己 `new`: + +```ts +import { createRegistry, replacePort } from './core/registry'; + +const registry = createRegistry(); // 生产:默认实现 +const testRegistry = createRegistry({ http: fakeHttp }); // 测试:注入 fake +replacePort('models', myProviderService); // 运行期:换供应商 +``` + +## 会话隔离 + +`contracts/domain.ts` 的 `SessionSlice` 是隔离的结构保证: + +```ts +interface SessionSlice { + id: SessionId; + messages: ChatMessage[]; // 每会话独立消息流 + inflightId: string | null; // 每会话独立的流式缓冲 + running: boolean; + selection: ModelSelection; // 每会话独立的 供应商/模型/思考强度 + context: ContextUsage | null; + workspace: WorkspaceInfo | null; + todos: TodoItem[]; + goal: GoalState | null; + attachments: Attachment[]; +} +``` + +`SessionServicePort.slice(id)` 按 id 取(必要时创建)切片;`subscribe(id, fn)` 只订阅该会话。 +控制器切换会话时**不清理**旧切片,回来时内容原样还在。 + +## 供应商 / 模型 / 思考强度 + +`ui/composer/ModelPicker.tsx` 是三段式选择器(供应商 → 模型 → 思考强度五档 off/low/medium/high/max), +数据全部经 `ModelServicePort`,因此换供应商实现只需 `replacePort('models', ...)`。 +每个 `SessionSlice.selection` 独立保存,切会话后选择随之切换。 + +## 开发 + +```bash +pnpm install +pnpm --filter @mavis/webui-react dev # http://127.0.0.1:5180/react/,代理到 127.0.0.1:18090 +pnpm --filter @mavis/webui-react test # vitest(jsdom) +pnpm --filter @mavis/webui-react build # 产物到 ../webui/public/react/,由现有 webui server 直接托管 +``` + +dev 代理目标可用 `WEBUI_TARGET` 覆盖。构建产物落在 `packages/webui/public/react/`, +现有 webui server 的静态托管无需任何改动即可提供新 UI。 + +## 测试 + +| 测试 | 验证什么 | +| --- | --- | +| `test/domain.test.ts` | 分组排序、模型 id 拆分、切片构造、不可信 wire 归一化 | +| `test/app-controller.test.ts` | **会话隔离**(切换不串扰、按会话独立保存选择)、**热插拔**(换供应商实现不动其他端口) | diff --git a/packages/webui-react/index.html b/packages/webui-react/index.html new file mode 100644 index 00000000..48700c03 --- /dev/null +++ b/packages/webui-react/index.html @@ -0,0 +1,13 @@ + + + + + + +Mcode Web UI + + +
+ + + diff --git a/packages/webui-react/package.json b/packages/webui-react/package.json new file mode 100644 index 00000000..055d4253 --- /dev/null +++ b/packages/webui-react/package.json @@ -0,0 +1,35 @@ +{ + "name": "@mavis/webui-react", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Modular React + Ant Design rewrite of the MiniMax Code web UI. Layered as contracts/core/ui/features with port-based hot-swappable seams.", + "scripts": { + "dev": "vite", + "build": "tsc -p tsconfig.build.json --noEmit && vite build", + "preview": "vite preview", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@ant-design/icons": "^5.6.1", + "antd": "^5.24.2", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.1.0", + "@testing-library/user-event": "^14.5.2", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^5.0.0", + "jsdom": "^25.0.1", + "typescript": "5.9.3", + "vite": "7.3.6", + "vitest": "4.1.11" + }, + "license": "MIT" +} diff --git a/packages/webui-react/scripts/check-layering.mjs b/packages/webui-react/scripts/check-layering.mjs new file mode 100644 index 00000000..57b5321a --- /dev/null +++ b/packages/webui-react/scripts/check-layering.mjs @@ -0,0 +1,113 @@ +#!/usr/bin/env node +/** + * check-layering.mjs —— 分层铁律的机器验证(咬合力的守门员) + * ============================================================================ + * 把高内聚低耦合从口头约定变成 CI 可执行的约束。任何越层 import 都会在这里 + * 报错,防止重构时不知不觉把接缝咬坏。 + * + * 允许的依赖方向(单向向下): + * ui/ -> contracts/domain, contracts/ports, core/store, ui/* + * features/ -> contracts/*, core/*, ui/* (唯一可同时看见两侧) + * core/ -> contracts/*, core/* + * contracts/ -> contracts/* (自包含,不依赖任何层) + * + * 用法:node scripts/check-layering.mjs (退出码 0 = 通过) + * ============================================================================ + */ + +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SRC = join(fileURLToPath(new URL('.', import.meta.url)), '..', 'src'); + +function walk(dir, out = []) { + for (const name of readdirSync(dir)) { + const p = join(dir, name); + if (statSync(p).isDirectory()) walk(p, out); + else if (/\.(ts|tsx)$/.test(name)) out.push(p); + } + return out; +} + +const IMPORT_RE = /(?:^|\n)\s*(?:import|export)[\s\S]*?from\s+['"]([^'"]+)['"]/g; + +function importsOf(file) { + const text = readFileSync(file, 'utf8'); + const specifiers = []; + let m; + while ((m = IMPORT_RE.exec(text)) !== null) specifiers.push(m[1]); + return specifiers; +} + +function layerOf(relPath) { + const head = relPath.split(/[\\/]/)[0]; + return head === 'contracts' || head === 'core' || head === 'features' || head === 'ui' ? head : null; +} + +// 每一层允许依赖的目标。null 表示同层。 +const ALLOWED = { + contracts: new Set(['contracts']), + core: new Set(['contracts', 'core']), + features: new Set(['contracts', 'core', 'features', 'ui']), + ui: new Set(['contracts/domain', 'contracts/ports', 'core/store', 'ui']), +}; + +// 额外的硬禁令:ui 与 core 都不得引入 React 之外的 UI 框架到 core;core 不得用 react。 +const FORBIDDEN = [ + { layer: 'core', pattern: /^react(-dom)?$|^antd$|^@ant-design\//, reason: 'core 必须零 UI 依赖' }, + { layer: 'ui', pattern: /^\.\.\/contracts\/protocol$/, reason: 'ui 只见 domain/ports,不得见线上格式' }, + { layer: 'ui', pattern: /^\.\.\/core\/(?!store\/)/, reason: 'ui 不得依赖 core 实现(只能用 core/store 的纯 store)' }, + { layer: 'ui', pattern: /^\.\.\/features\//, reason: 'ui 不得反向依赖 features' }, +]; + +const files = walk(SRC); +const violations = []; + +for (const file of files) { + const rel = relative(SRC, file).replace(/\\/g, '/'); + const layer = layerOf(rel); + if (!layer) continue; + for (const spec of importsOf(file)) { + // 相对 import 解析到哪一层 + let target = null; + if (spec.startsWith('.')) { + const abs = join(SRC, rel, '..', spec); + const relTarget = relative(SRC, abs).replace(/\\/g, '/'); + target = layerOf(relTarget); + if (relTarget.startsWith('contracts/')) target = 'contracts/' + relTarget.split('/')[1].replace(/\.ts$/, ''); + } else { + // 包 import:只对 core 层做 UI 框架禁令 + for (const f of FORBIDDEN) { + if (f.layer === layer && f.pattern.test(spec)) violations.push({ rel, spec, reason: f.reason }); + } + continue; + } + if (!target) continue; + const allowed = ALLOWED[layer]; + const ok = [...allowed].some((a) => target === a || target.startsWith(a + '/') || (a.endsWith('.ts') && target === a.replace(/\.ts$/, ''))); + // ui 层的白名单是精确子路径,单独判 + if (layer === 'ui') { + const fine = target === 'contracts/domain' || target === 'contracts/ports' || target === 'core/store' || target.startsWith('ui'); + if (!fine) violations.push({ rel, spec, reason: 'ui 层只允许依赖 contracts/domain、contracts/ports、core/store、ui/*' }); + continue; + } + if (!ok) violations.push({ rel, spec, reason: layer + ' 不得依赖 ' + target }); + } + // 相对路径的硬禁令 + for (const f of FORBIDDEN) { + if (f.layer !== layer) continue; + for (const spec of importsOf(file)) { + if (spec.startsWith('.') && f.pattern.test(spec)) violations.push({ rel, spec, reason: f.reason }); + } + } +} + +if (violations.length === 0) { + console.log('分层检查通过:' + files.length + ' 个文件,0 处越层依赖。'); + process.exit(0); +} + +console.error('分层检查失败:发现 ' + violations.length + ' 处越层依赖\n'); +for (const v of violations) console.error(' ' + v.rel + '\n import ' + v.spec + '\n → ' + v.reason); +process.exit(1); diff --git a/packages/webui-react/src/App.tsx b/packages/webui-react/src/App.tsx new file mode 100644 index 00000000..bdbd6640 --- /dev/null +++ b/packages/webui-react/src/App.tsx @@ -0,0 +1,78 @@ +/** + * App.tsx —— 组装根的视图侧 + * ============================================================================ + * 【咬合】本文件只做装配:控制器构造(在 之外的进程内单例)、 + * ConfigProvider(主题/语言)、AppShell 四插槽拼装、主题/语言副作用。 + * 快照到 props 的翻译全部下沉到 features/*-feature.tsx 六个容器。 + * 【热插拔】Registry 在这里被注入 NotifierPort 的 antd 实现 —— 证明端口可换。 + * ============================================================================ + */ + +import { useEffect } from 'react'; +import { App as AntApp, ConfigProvider, message, theme as antdTheme, Modal } from 'antd'; +import zhCN from 'antd/locale/zh_CN'; +import enUS from 'antd/locale/en_US'; + +import { AppShell } from './ui/layout/AppShell'; +import { createAppController } from './features/app-controller'; +import { useAppActions, useAppSnapshot } from './features/use-app'; +import { SessionsFeature } from './features/sessions-feature'; +import { ChatFeature } from './features/chat-feature'; +import { PanelsFeature } from './features/panels-feature'; +import { ModalsFeature } from './features/modals-feature'; +import { replacePort, getRegistry } from './core/registry'; +import type { NotifierPort } from './contracts/ports'; +import { setLang } from './i18n'; + +// 控制器是进程内单例:StrictMode 的双重渲染不会重建它(重建会丢会话隔离状态)。 +const controller = createAppController(getRegistry()); + +/** antd 的 NotifierPort 实现 —— 运行期热插拔注入,替换 core 的 console 默认实现。 */ +const antdNotifier: NotifierPort = { + toast(msg, kind = 'info') { + if (kind === 'error') message.error(msg); + else if (kind === 'warn') message.warning(msg); + else if (kind === 'success') message.success(msg); + else message.info(msg); + }, + confirm(title, body) { + return new Promise((resolve) => { + Modal.confirm({ title, content: body, onOk: () => resolve(true), onCancel: () => resolve(false) }); + }); + }, +}; + +export function App() { + const s = useAppSnapshot(controller); + const a = useAppActions(controller); + + // 热插拔:用 antd 实现替换 NotifierPort(core 不感知 antd)。 + useEffect(() => { replacePort('notifier', antdNotifier); }, []); + + // 主题与语言跟随快照,落到 (tokens.css 据此切换)。 + useEffect(() => { document.documentElement.dataset.theme = s.theme; }, [s.theme]); + useEffect(() => { setLang(s.lang); }, [s.lang]); + + return ( + + + {/* 参考布局:无全局顶栏 —— 四栏直接满高(会话状态并入标题栏/左栏用户卡)。 */} + { a.setLeftOpen(false); a.setRightOpen(false); }} + left={} + chat={} + right={} + /> + + + + ); +} diff --git a/packages/webui-react/src/contracts/domain.ts b/packages/webui-react/src/contracts/domain.ts new file mode 100644 index 00000000..102ed7a5 --- /dev/null +++ b/packages/webui-react/src/contracts/domain.ts @@ -0,0 +1,402 @@ +/** + * contracts/domain.ts —— 领域契约(domain contract) + * ============================================================================ + * 【这是"咬合面"的第二半】定义 UI 与 core 之间共用的**领域对象**。 + * + * 规则(高内聚低耦合): + * 1. ui/ 只 import 本文件(+ ports.ts),**禁止** import protocol.ts / core 实现。 + * 2. core/ 把 protocol.ts 的不可信 wire 数据翻译成本文件的对象后再上抛。 + * 3. 一切按 sessionId 切片存放 —— 这是"会话隔离显示"的结构性保证: + * 不同会话的消息、流式缓冲、上下文、模型/思考强度互不串扰。 + * 4. 本文件只放类型 + 纯函数(归一化/派生),不放 IO、不放 React。 + * ============================================================================ + */ + +// ── 标识 ─────────────────────────────────────────────────────────────────── +export type SessionId = string; +export type Cid = string; + +// ── 供应商 / 模型 / 思考强度(新增能力:可换供应商、模型、思考强度)────────── +/** 供应商 id,例如 'minimax_api' | 'openai' | 'anthropic' | … */ +export type ProviderId = string; + +export interface ProviderOption { + id: ProviderId; + label: string; + /** 该供应商下模型为空时给用户的提示(例如指向 mcode TUI 配置)。 */ + hint?: string; +} + +export interface ModelOption { + /** 全限定 id:/,例如 'minimax_api/MiniMax-M3'。 */ + id: string; + label: string; + provider: ProviderId; + /** 上下文窗口(token);0 表示未知。 */ + contextLimit?: number; +} + +/** 模型目录的供应商分组(/api/models groups —— 目录按供应商分组返回)。 */ +export interface ModelGroup { + id: ProviderId; + label: string; + models: ModelOption[]; +} + +/** 思考强度 —— 映射到各家 reasoning effort。 */ +export type ThinkingEffort = 'off' | 'low' | 'medium' | 'high' | 'max'; + +export const THINKING_EFFORTS: readonly ThinkingEffort[] = ['off', 'low', 'medium', 'high', 'max']; + +export interface ModelSelection { + provider: ProviderId; + /** 全限定 id。 */ + model: string; + thinking: ThinkingEffort; +} + +// ── 工作区 ───────────────────────────────────────────────────────────────── +export interface WorkspaceInfo { + dir: string | null; + branch?: string | null; + tree?: string | null; +} + +export interface WorkspaceEntry { + name: string; + path: string; + isDir: boolean; +} + +/** 最近/已有工作区条目(在 WorkspaceEntry 上附加会话数角标与服务端聚合时间)。 */ +export interface WorkspaceRecentEntry extends WorkspaceEntry { + sessionCount?: number; + lastActiveAt?: number; +} + +/** 服务端目录浏览结果:当前目录 + 上级目录(供「上一级」导航)+ 子目录列表。 */ +export interface WorkspaceBrowseResult { + dir: string | null; + parent: string | null; + entries: WorkspaceEntry[]; +} + +// ── 文件系统(右栏文件树 / 文档预览)──────────────────────────────────────── + +/** /api/fs/read 条目(文件树懒加载节点)。 */ +export interface FsEntry { + name: string; + path: string; + isDir: boolean; + /** 字节大小(文件)。 */ + size?: number; + /** 修改时间(毫秒)。 */ + mtime?: number; + /** 权限字符串(如 rwxr-xr-x)。 */ + mode?: string; +} + +/** /api/fs/read 结果。 */ +export interface FsListResult { + ok: boolean; + dir: string | null; + parent: string | null; + entries: FsEntry[]; + /** 服务端主目录(readDirectory 回传)—— 面包屑/顶层判定用。 */ + home?: string | null; + error?: string; +} + +/** /api/fs/file 结果(文本文件内容;≤512KB,二进制拒读)。 */ +export interface FsFileResult { + ok: boolean; + path: string | null; + content: string | null; + size?: number; + error?: string; +} + +// ── git(右栏 Git 面板:变更 / 分支 / diff)───────────────────────────────── + +/** 单个变更文件(porcelain v1 的 XY + 路径)。 */ +export interface GitFileChange { + /** 暂存区状态(M/A/D/R/…;' ' = 无)。 */ + x: string; + /** 工作区状态。 */ + y: string; + path: string; + /** 重命名前的原路径。 */ + origPath?: string | null; + /** 是否已暂存。 */ + staged: boolean; +} + +/** /api/git/status 结果;isRepo=false 表示目录不是 git 仓库。 */ +export interface GitStatus { + ok: boolean; + isRepo: boolean; + branch?: string | null; + upstream?: string | null; + ahead?: number; + behind?: number; + files: GitFileChange[]; + error?: string; +} + +/** 本地分支。 */ +export interface GitBranch { + name: string; + current: boolean; +} + +/** /api/git/branches 结果。 */ +export interface GitBranches { + ok: boolean; + branches: GitBranch[]; + error?: string; +} + +// ── 会话 ─────────────────────────────────────────────────────────────────── +export interface SessionSummary { + id: SessionId; + title: string; + workspace: string | null; + mcodeSessionId: string | null; + titleCustom: boolean; + updatedAt: number; +} + +/** 按工作区分组(左侧栏的折叠分组)。 */ +export interface SessionGroup { + /** 分组键:工作区路径,空串表示"无工作区"。 */ + key: string; + label: string; + sessions: SessionSummary[]; +} + +// ── 消息与结构化块 ───────────────────────────────────────────────────────── +export type Role = 'user' | 'assistant' | 'system'; + +export type BlockKind = + | 'text' + | 'thinking' + | 'tool-call' + | 'tool-result' + | 'plan' + | 'ask-user' + | 'error'; + +export interface MessageBlockBase { + id: string; + kind: BlockKind; +} + +export interface TextBlock extends MessageBlockBase { kind: 'text'; text: string; markdown?: boolean } +export interface ThinkingBlock extends MessageBlockBase { kind: 'thinking'; text: string; done?: boolean } +export interface ToolCallBlock extends MessageBlockBase { + kind: 'tool-call'; + toolName: string; + args?: unknown; + status: 'running' | 'done' | 'error'; + summary?: string; +} +export interface ToolResultBlock extends MessageBlockBase { kind: 'tool-result'; ok: boolean; text: string } +export interface PlanBlock extends MessageBlockBase { + kind: 'plan'; + title: string; + steps: string[]; + status: 'pending' | 'agreed' | 'skipped'; +} +export interface AskUserBlock extends MessageBlockBase { + kind: 'ask-user'; + question: string; + options: AskUserOption[]; + multiSelect: boolean; + answered?: boolean; +} +export interface ErrorBlock extends MessageBlockBase { kind: 'error'; text: string } + +export type MessageBlock = + | TextBlock + | ThinkingBlock + | ToolCallBlock + | ToolResultBlock + | PlanBlock + | AskUserBlock + | ErrorBlock; + +export interface ChatMessage { + id: string; + role: Role; + blocks: MessageBlock[]; + ts: number; + /** 是否仍在流式生成中。 */ + streaming?: boolean; +} + +export interface AskUserOption { + id: string; + label: string; + desc?: string; +} + +// ── 右栏状态 ─────────────────────────────────────────────────────────────── +export type TodoStatus = 'pending' | 'in_progress' | 'completed'; +export interface TodoItem { id: string; content: string; status: TodoStatus } + +export type GoalPhase = 'active' | 'paused' | 'blocked' | 'complete'; +export interface GoalState { + objective: string; + phase: GoalPhase; + rounds: number; + startedAt?: number; +} + +// ── 模式互动(wire state.plan / state.enterPlanMode 直读)────────────────── +/** plan_update 推送的方案选项条目(wire state.plan.options)。 */ +export interface PlanOption { + label: string; + desc: string; +} + +/** + * 服务端 plan 状态(plan_update / plan_removed 维护)。 + * 应答(POST /api/answer type=plan)后服务端清空 → null,弹窗随之关闭。 + */ +export interface PlanState { + active: boolean; + planId: string | null; + title: string; + summary: string; + options: PlanOption[]; +} + +/** mcode 请求进入 plan 模式(mode_update → state.enterPlanMode)。 */ +export interface EnterPlanModeState { + active: boolean; + prompt: string | null; +} + +export interface ContextUsage { + used: number; + limit: number; + percent: number; + tps: number; + cacheRead?: number; + model?: string; + source?: string; +} + +// ── 会话隔离切片:每个 sessionId 一份,互不串扰 ───────────────────────────── +export interface SessionSlice { + id: SessionId; + summary: SessionSummary | null; + messages: ChatMessage[]; + /** + * 正在流式接收的 **assistant 占位消息** 的 id。 + * + * 语义约束(务必遵守): + * - 只能指向 assistant 侧的未定稿消息;**绝不**指向用户消息 —— + * 用户消息一发出去即已定稿。 + * - 由流式翻译层在创建 assistant 占位块时置入,在定稿/停止时清空。 + * - null 表示该会话无未定稿输出。 + * - 仅描述本会话,绝不跨会话共享。 + * + * 判断「会话是否在跑」请用 {@link SessionSlice.running},不要用 inflightId。 + */ + inflightId: string | null; + running: boolean; + /** 每会话独立的模型/供应商/思考强度选择。 */ + selection: ModelSelection; + context: ContextUsage | null; + workspace: WorkspaceInfo | null; + todos: TodoItem[]; + goal: GoalState | null; + /** wire state.plan 直读(plan_update 事件维护);应答后服务端清空 → null。 */ + plan: PlanState | null; + attachments: Attachment[]; +} + +export interface Attachment { + id: string; + name: string; + /** 服务端返回的绝对路径(已 @ 引用)。 */ + path: string; + size: number; + status: 'uploading' | 'done' | 'error'; + error?: string; +} + +// ── 套餐用量 ─────────────────────────────────────────────────────────────── +export interface UsageInfo { + fiveHourPercent: number | null; + weeklyPercent: number | null; + fetchedAt: number | null; + source?: string; + hidden?: boolean; +} + +// ── 授权弹窗队列项 ───────────────────────────────────────────────────────── +export interface PendingAuth { + requestId: string; + action: string; + ctx: Record; + expiresAt: number; + receivedAt: number; +} + +// ── 异常通道 ─────────────────────────────────────────────────────────────── +export interface AlertItem { + id: string; + ts: number; + level: 'info' | 'warn' | 'error'; + msg: string; + src: string; + sessionId: string | null; + count: number; +} + +// ── 纯派生函数(无 IO)───────────────────────────────────────────────────── +/** 按工作区把会话列表分组并按更新时间倒序 —— 左栏渲染的唯一排序来源。 */ +export function groupSessionsByWorkspace(sessions: SessionSummary[]): SessionGroup[] { + const map = new Map(); + for (const s of sessions) { + const key = s.workspace ?? ''; + const arr = map.get(key); + if (arr) arr.push(s); + else map.set(key, [s]); + } + const groups: SessionGroup[] = []; + for (const [key, list] of map) { + list.sort((a, b) => b.updatedAt - a.updatedAt); + groups.push({ key, label: key || '(无工作区)', sessions: list }); + } + groups.sort((a, b) => (a.sessions[0]?.updatedAt ?? 0) - (b.sessions[0]?.updatedAt ?? 0)); + groups.reverse(); + return groups; +} + +/** 从全限定模型 id 拆出供应商;无 '/' 时回落到 fallback。 */ +export function splitModelId(id: string, fallback: ProviderId = 'minimax_api'): ModelSelection['provider'] { + return id.includes('/') ? id.split('/')[0] : fallback; +} + +/** 构造一个空的会话隔离切片。 */ +export function emptySessionSlice(id: SessionId, selection: ModelSelection): SessionSlice { + return { + id, + summary: null, + messages: [], + inflightId: null, + running: false, + // 关键:必须复制一份 —— 若按引用存入,两个会话切片会共享同一个模型选择 + // 对象,改 A 会话的供应商/模型/思考强度会串到 B 会话(会话隔离被破坏)。 + selection: { ...selection }, + context: null, + workspace: null, + todos: [], + goal: null, + plan: null, + attachments: [], + }; +} diff --git a/packages/webui-react/src/contracts/index.ts b/packages/webui-react/src/contracts/index.ts new file mode 100644 index 00000000..88e1e662 --- /dev/null +++ b/packages/webui-react/src/contracts/index.ts @@ -0,0 +1,3 @@ +export * from './protocol'; +export * from './domain'; +export * from './ports'; diff --git a/packages/webui-react/src/contracts/ports.ts b/packages/webui-react/src/contracts/ports.ts new file mode 100644 index 00000000..c1ea1359 --- /dev/null +++ b/packages/webui-react/src/contracts/ports.ts @@ -0,0 +1,279 @@ +/** + * contracts/ports.ts —— 端口契约(port contract)= 咬合面的齿轮接口 + * ============================================================================ + * 【这是"咬合面"的第三半】模块之间**只**通过这里的 Port 接口互相依赖。 + * + * 高内聚低耦合: + * - 每个 Port 只描述**一个职责**(一问一答/一条流/一份状态),接口窄而稳。 + * - 调用方依赖接口,不依赖实现;实现方不 import 调用方。依赖方向单向指向本文件。 + * + * 热插拔易迭代: + * - 所有实现经 core/registry 的 `createRegistry({ ...overrides })` 注入。 + * - 换供应商 / 换传输 / 换状态库 / 换 UI 提示层,只需提供另一个实现并在组装根替换。 + * - 单测里注入 fake 实现即可完全离线跑 core 与 features。 + * ============================================================================ + */ + +import type { + AlertItem, + Attachment, + ContextUsage, + Cid, + FsFileResult, + FsListResult, + GitBranches, + GitStatus, + ModelOption, + ModelSelection, + PendingAuth, + ProviderOption, + SessionId, + SessionSummary, + SessionSlice, + ThinkingEffort, + UsageInfo, + WorkspaceBrowseResult, + WorkspaceEntry, + WorkspaceInfo, + WorkspaceRecentEntry, + ChatMessage, +} from './domain'; +import type { ClientFrame, WireClientState, WireSettings } from './protocol'; + +// ════════════════════════════════════════════════════════════════════════════ +// 1. 基础设施端口(Infra ports) +// ════════════════════════════════════════════════════════════════════════════ + +/** 时钟 —— 让倒计时/超时可测。 */ +export interface ClockPort { + now(): number; +} + +/** + * 带状态码的 HTTP 错误。HttpPort 的实现在非 2xx / ok:false 时抛出它, + * 调用方可按 status 分支(例如授权决定的 404 = 已在别处决定)。 + * 实现方也可以抛普通 Error;调用方应做鸭子类型兜底。 + */ +export interface HttpError extends Error { + status?: number; + code?: string; +} + +/** HTTP 传输。只负责"发请求拿 JSON",不含任何端点语义。 */ +export interface HttpPort { + get(path: string): Promise; + post(path: string, body?: unknown): Promise; + del(path: string): Promise; + upload(path: string, file: Blob, name: string): Promise; +} + +/** WebSocket 事件流传输。断线重连 + resume 由实现负责。 */ +export interface StreamPort { + /** 建立连接(幂等:重复调用不会产生第二条连接)。 */ + connect(): void; + /** 主动断开并停止重连。 */ + close(): void; + send(frame: ClientFrame): void; + /** 订阅底层帧;返回退订函数。 */ + onFrame(listener: (raw: unknown) => void): () => void; + /** 连接状态,用于顶栏指示灯。 */ + status(): StreamStatus; +} + +export type StreamStatus = 'idle' | 'connecting' | 'open' | 'reconnecting' | 'closed'; + +/** 本地持久化(localStorage 可替换为 IndexedDB / 服务端同步)。 */ +export interface KeyValueStorePort { + get(key: string): string | null; + set(key: string, value: string): void; + remove(key: string): void; +} + +/** 用户提示(antd message / modal 由 ui 层实现,core 不感知 antd)。 */ +export interface NotifierPort { + toast(message: string, kind?: 'info' | 'success' | 'warn' | 'error'): void; + /** 返回 Promise,true=用户确认。 */ + confirm(title: string, body: string): Promise; +} + +// ════════════════════════════════════════════════════════════════════════════ +// 2. 领域服务端口(Domain service ports)—— 按职责内聚 +// ════════════════════════════════════════════════════════════════════════════ + +/** 会话生命周期 + 会话隔离切片的读写。 */ +export interface SessionServicePort { + list(): Promise; + create(workspace?: string | null): Promise; + switchTo(id: SessionId): Promise; + rename(id: SessionId, title: string): Promise; + remove(id: SessionId): Promise; + /** 取(必要时创建)某个会话的隔离切片。 */ + slice(id: SessionId): SessionSlice; + subscribe(id: SessionId, listener: () => void): () => void; +} + +/** 对话:发送 / 停止 / 斜杠命令。 */ +export interface ChatServicePort { + send(sessionId: SessionId, content: string, attachments?: string[]): Promise; + stop(sessionId: SessionId): Promise; + command(sessionId: SessionId, cmd: string): Promise; +} + +/** 模型能力:供应商 / 模型 / 思考强度 三段式切换(本次新增能力)。 */ +export interface ModelServicePort { + /** 供应商列表(从模型目录派生 + 自定义)。 */ + providers(): Promise; + /** 某供应商下的模型列表。 */ + models(provider?: string): Promise; + /** 当前生效选择。 */ + current(sessionId: SessionId): ModelSelection; + /** 切换供应商(模型回落到该供应商第一个)。 */ + setProvider(sessionId: SessionId, provider: string): Promise; + /** 切换模型。 */ + setModel(sessionId: SessionId, modelId: string): Promise; + /** 切换思考强度。 */ + setThinking(sessionId: SessionId, effort: ThinkingEffort): Promise; +} + +/** 工作区。 */ +export interface WorkspaceServicePort { + current(): WorkspaceInfo | null; + use(dir: string, syncTui?: boolean): Promise; + reset(): Promise; + /** 逐级浏览服务端目录(返回当前目录 + 上级目录,供「上一级」导航)。 */ + browse(path?: string): Promise; + recents(): WorkspaceEntry[]; + addRecent(path: string): void; + /** 服务端聚合的已有工作区列表(含会话数角标,来源于 sessions 库)。 */ + listRecent(): Promise; + /** 列目录(/api/fs/read,目录 + 文件条目;供右栏文件树懒加载)。 */ + listDir(path: string): Promise; + /** 读文本文件内容(/api/fs/file,≤512KB、拒二进制;供右栏文档预览)。 */ + readFile(path: string): Promise; + /** 创建目录(/api/fs/mkdir,供选择目录弹窗「新建文件夹」)。 */ + createDir(path: string): Promise<{ ok: boolean; error?: string }>; + /** 在系统中打开文件/目录(xdg-open;folder 模式打开文件管理器)。 */ + openInSystem(path: string, mode: 'file' | 'folder'): Promise<{ ok: boolean; error?: string }>; + /** 原样文件的 URL(/api/fs/raw)—— 内置浏览器 iframe 直接打开 html 等。 */ + rawFileUrl(path: string): string; + /** 工作区 git 状态(分支 + 变更文件)。 */ + gitStatus(dir: string): Promise; + /** 本地分支列表。 */ + gitBranches(dir: string): Promise; + /** 切换本地分支。 */ + gitCheckout(dir: string, branch: string): Promise<{ ok: boolean; error?: string }>; + /** 单文件相对 HEAD 的 diff。 */ + gitDiff(dir: string, file: string): Promise<{ ok: boolean; diff: string; error?: string }>; +} + +/** 服务端设置(LAN / 只读 / token 等)。 */ +export interface SettingsServicePort { + get(): Promise; + update(patch: Partial): Promise; + resetToken(): Promise<{ token: string }>; + acknowledgeToken(): Promise; +} + +/** 套餐用量 + 上下文消耗。 */ +export interface UsageServicePort { + quota(): Promise; + refresh(): Promise; + /** + * 会话的上下文消耗。注意:服务端 /api/usage-real 当前按 cid(浏览器 tab) + * 统计而非按 sessionId,参数保留会话语义以便将来按会话切分。 + */ + context(sessionId: SessionId): Promise; +} + +/** 异常通道(bell)。 */ +export interface AlertsServicePort { + snapshot(): Promise; + list(): AlertItem[]; + unread(): number; + markRead(): void; + clear(): void; + subscribe(listener: () => void): () => void; +} + +/** 每请求授权(authorize 门)。 */ +export interface AuthServicePort { + pending(): PendingAuth[]; + decide(requestId: string, approve: boolean): Promise; + subscribe(listener: () => void): () => void; +} + +/** 附件上传。 */ +export interface UploadServicePort { + upload(file: File | Blob, name: string): Promise; +} + +// ── 模式互动(plan / planmode 应答 + 权限模式)────────────────────────────── + +/** 权限模式目录条目(GET /api/permissions-modes)。 */ +export interface PermissionModeOption { + value: string; + label: string; + mcodeValue?: string; +} + +/** 权限模式目录:webui 四档 + mcode 原值两组。 */ +export interface PermissionModeCatalog { + webui: PermissionModeOption[]; + mcode: PermissionModeOption[]; +} + +/** + * 模式互动应答:plan / planmode 走 POST /api/answer,权限模式走 + * /api/permissions(切换)与 /api/permissions-modes(目录)。 + * agree/add 的后续话术由 UI 层经 chat.send 下发 —— 文案本地化不属于传输层职责。 + */ +export interface InteractServicePort { + /** 应答方案弹窗;服务端清 plan 状态并广播 state。 */ + answerPlan(option: 'agree' | 'skip' | 'add', context?: string): Promise; + /** 应答「进入 plan 模式?」请求;服务端置 planMode 并清 enterPlanMode。 */ + answerPlanMode(choice: 'continue' | 'deny'): Promise; + /** 权限模式目录(下拉选项)。 */ + permissionModes(): Promise; + /** 切换权限模式(mcode 固定于启动时,服务端仅同步 UI 标签)。 */ + setPermissionMode(mode: string): Promise; +} + +// ════════════════════════════════════════════════════════════════════════════ +// 3. 组装根(composition root)—— 热插拔的替换点 +// ════════════════════════════════════════════════════════════════════════════ + +/** + * 全部端口的集合。**任何模块都不许自己 new 实现**,一律从这里取。 + * 热插拔 = 用 `createRegistry({ http: myHttp })` 覆盖任意一项。 + */ +export interface Registry { + // infra + clock: ClockPort; + http: HttpPort; + stream: StreamPort; + kv: KeyValueStorePort; + notifier: NotifierPort; + // domain services + sessions: SessionServicePort; + chat: ChatServicePort; + models: ModelServicePort; + workspace: WorkspaceServicePort; + settings: SettingsServicePort; + usage: UsageServicePort; + alerts: AlertsServicePort; + auth: AuthServicePort; + upload: UploadServicePort; + interact: InteractServicePort; +} + +/** 部分覆盖:未提供的端口由 core 的默认实现补齐。 */ +export type RegistryOverrides = Partial; + +/** 进程/浏览器身份(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 ( +
+ a.setPanelWidth('right', w)} + onReset={() => a.setPanelWidth('right', 420)} + /> + {/* 全部挂载、按 Tab 显示隐藏 —— 保留文件树展开/浏览器页面/Git 数据等状态 */} +
+ { + const clipboard = navigator.clipboard?.writeText(p); + if (clipboard) void clipboard.then(() => reg.notifier.toast('已复制路径', 'success'), () => reg.notifier.toast('复制失败', 'error')); + else reg.notifier.toast('当前环境不支持复制', 'warn'); + }, + onOpenSystem: (p, isDir) => { + void reg.workspace.openInSystem(p, isDir ? 'folder' : 'file').then( + (res) => { if (!res.ok) reg.notifier.toast(res.error ?? '打开失败', 'error'); }, + ); + }, + onOpenFolder: (p) => { + void reg.workspace.openInSystem(p, 'folder').then( + (res) => { if (!res.ok) reg.notifier.toast(res.error ?? '打开失败', 'error'); }, + ); + }, + onOpenBrowser: (p) => { + setBrowserUrl(reg.workspace.rawFileUrl(p)); + a.setRightTab('browser'); + }, + onPreview: (p) => { + setDocPath(p); + a.setRightTab('preview'); + }, + }} + /> +
+
+ { + setDocPath(null); + a.setRightTab('files'); + }} + /> +
+
+ { + setBrowserUrl(u); + setReloadKey((k) => k + 1); + }} + onClose={() => a.setRightTab('files')} + /> +
+
+ reg.notifier.toast(msg, kind)} + /> +
+
+
+ +
+
+
+ ); +} diff --git a/packages/webui-react/src/features/registry-context.tsx b/packages/webui-react/src/features/registry-context.tsx new file mode 100644 index 00000000..c6508bcf --- /dev/null +++ b/packages/webui-react/src/features/registry-context.tsx @@ -0,0 +1,31 @@ +/** + * features/registry-context.tsx —— 把端口注入 React 树的唯一入口 + * ============================================================================ + * 【咬合】features 层是全项目唯一允许同时看见 core(Registry)与 ui 的层。 + * ui 组件从不 import 本文件;它们只收 props。本文件提供的 useRegistry() + * 只被 features/ 内部与 App 组装根使用。 + * 【热插拔】测试里用 即可整套替换。 + * ============================================================================ + */ + +import { createContext, useContext, type ReactNode } from 'react'; +import type { Registry } from '../contracts/ports'; +import { getRegistry } from '../core/registry'; + +const RegistryContext = createContext(null); + +export interface RegistryProviderProps { + value?: Registry; + children: ReactNode; +} + +export function RegistryProvider({ value, children }: RegistryProviderProps) { + const reg = value ?? getRegistry(); + return {children}; +} + +export function useRegistry(): Registry { + const reg = useContext(RegistryContext); + if (!reg) throw new Error('useRegistry 必须在 内使用'); + return reg; +} diff --git a/packages/webui-react/src/features/sessions-feature.tsx b/packages/webui-react/src/features/sessions-feature.tsx new file mode 100644 index 00000000..d0fd9371 --- /dev/null +++ b/packages/webui-react/src/features/sessions-feature.tsx @@ -0,0 +1,151 @@ +/** sessions-feature —— 左栏容器:会话列表/搜索/外观/语言/局域网卡/用量弹层的接线,删除确认走 NotifierPort(接缝:filteredGroups+actions → LeftPanel/SessionList props)。 */ +import { useState } from 'react'; + +import type { SessionId } from '../contracts/domain'; +import { KeyValueRow } from '../ui/primitives/KeyValueRow'; +import { LeftPanel, type LanTokenState } from '../ui/layout/LeftPanel'; +import { SessionList } from '../ui/sessions/SessionList'; +import type { AppController } from './app-controller'; +import { useRegistry } from './registry-context'; +import { useAppActions, useAppSnapshot } from './use-app'; + +export interface SessionsFeatureProps { + controller: AppController; +} + +export function SessionsFeature({ controller }: SessionsFeatureProps) { + const s = useAppSnapshot(controller); + const a = useAppActions(controller); + const { notifier } = useRegistry(); + + // 弹层开合是纯界面瞬态,留在本容器。 + const [usageOpen, setUsageOpen] = useState(false); + const [appearanceOpen, setAppearanceOpen] = useState(false); + const [lanOpen, setLanOpen] = useState(false); + const [showUsage, setShowUsage] = useState(true); + const [tokenVisible, setTokenVisible] = useState(false); + // 通知铃铛(参考布局:挂在左栏底部用户卡旁,原顶栏迁移)。 + const [alertsOpen, setAlertsOpen] = useState(false); + + const settings = s.settings; + + // 删除确认已收敛到行内(× → 「删除?是/否」)—— 「是」即最终确认, + // 不再二次弹 Modal(原 NotifierPort.confirm 与行内确认重复,删个会话要点 4 次)。 + const handleDelete = (id: SessionId): void => { + void a.deleteSession(id).catch((e: unknown) => { + notifier.toast(e instanceof Error ? e.message : String(e), 'error'); + }); + }; + + const copyText = (text: string, okMsg: string): void => { + const p = navigator.clipboard?.writeText(text); + if (p) { + void p.then( + () => notifier.toast(okMsg, 'success'), + () => notifier.toast('复制失败', 'error'), + ); + } else { + notifier.toast('当前环境不支持复制', 'warn'); + } + }; + + const token: LanTokenState = { + enabled: settings?.tokenEnabled === true, + token: typeof settings?.currentToken === 'string' ? settings.currentToken : '', + visible: tokenVisible, + showWarning: settings?.tokenEnabled === true && settings?.tokenAcknowledged !== true, + }; + + return ( + void a.newChat()} + searchValue={s.searchQuery} + onSearchChange={a.setSearchQuery} + onRefreshSessions={() => void a.refreshSessions()} + // 缺口 #3:一律用已按 searchQuery 过滤的 filteredGroups,裸 groups 不进列表。 + renderList={() => ( + + void a.selectSession(id).catch((e: unknown) => { + notifier.toast(e instanceof Error ? e.message : String(e), 'error'); + }) + } + onRename={(id, title) => { + void a.renameSession(id, title).catch((e: unknown) => { + notifier.toast(e instanceof Error ? e.message : String(e), 'error'); + }); + }} + onDelete={handleDelete} + /> + )} + // 套餐用量弹层 + usageHidden={!showUsage} + usageOpen={usageOpen} + onToggleUsage={() => setUsageOpen((v) => !v)} + usageValue={s.usage?.fiveHourPercent != null ? `${s.usage.fiveHourPercent}%` : '—'} + onRefreshUsage={() => void a.refreshUsage()} + usage={{ + body: ( + <> + + + + ), + }} + // 外观卡片(主题 + 用量显示开关) + appearanceOpen={appearanceOpen} + onToggleAppearance={() => setAppearanceOpen((v) => !v)} + appearanceValue={s.theme === 'dark' ? '深色' : '明亮'} + dark={s.theme === 'dark'} + onToggleTheme={(dark) => a.setTheme(dark ? 'dark' : 'light')} + showUsage={showUsage} + onToggleShowUsage={setShowUsage} + // 语言 + languageValue={s.lang === 'zh' ? '中文' : 'EN'} + onToggleLanguage={() => a.setLang(s.lang === 'zh' ? 'en' : 'zh')} + // 局域网安全卡片 + lanValue={settings?.lanBroadcast === true ? '开' : '关'} + onToggleLan={() => setLanOpen((v) => !v)} + lanOpen={lanOpen} + lanBroadcast={settings?.lanBroadcast === true} + onToggleLanBroadcast={(on) => void a.updateSettings({ lanBroadcast: on })} + readOnly={settings?.readOnly === true} + onToggleReadOnly={(on) => void a.updateSettings({ readOnly: on })} + tokenAuth={settings?.tokenEnabled === true} + onToggleTokenAuth={(on) => void a.updateSettings({ tokenEnabled: on })} + token={token} + onToggleTokenVisible={() => setTokenVisible((v) => !v)} + onCopyToken={() => copyText(token.token, '已复制 token')} + onResetToken={() => { + // 授权门拒绝会抛 "authorize declined" —— 提示收口,不冒未处理拒绝。 + void a.resetToken().catch((e: unknown) => { + notifier.toast(e instanceof Error ? e.message : String(e), 'error'); + }); + }} + onAcknowledgeToken={() => void a.acknowledgeToken()} + // 重载页面(原顶栏「强制刷新」迁移至左栏底部)。 + onReload={() => window.location.reload()} + // 通知铃铛(用户卡旁,弹层数据与开合由本容器持有)。 + alerts={{ + unreadCount: s.alertsUnread, + open: alertsOpen, + onToggle: () => { + const next = !alertsOpen; + setAlertsOpen(next); + if (next) a.markAlertsRead(); + }, + alerts: s.alerts, + onClear: a.clearAlerts, + }} + // 可拖拽调宽(把手在面板右缘;双击复位 240)。 + width={s.leftWidth} + onResizeWidth={(d) => a.setPanelWidth('left', s.leftWidth + d)} + onResetWidth={() => a.setPanelWidth('left', 240)} + /> + ); +} diff --git a/packages/webui-react/src/features/topbar-feature.tsx b/packages/webui-react/src/features/topbar-feature.tsx new file mode 100644 index 00000000..438cfd8c --- /dev/null +++ b/packages/webui-react/src/features/topbar-feature.tsx @@ -0,0 +1,50 @@ +/** topbar-feature —— 顶栏容器:把快照的只读/局域网/告警状态与动作接到 TopBar 哑组件(接缝:AppSnapshot → TopBar props)。 */ +import { useState } from 'react'; + +import { TopBar } from '../ui/layout/TopBar'; +import type { AppController } from './app-controller'; +import { useRegistry } from './registry-context'; +import { useAppActions, useAppSnapshot } from './use-app'; + +export interface TopbarFeatureProps { + controller: AppController; +} + +export function TopbarFeature({ controller }: TopbarFeatureProps) { + const s = useAppSnapshot(controller); + const a = useAppActions(controller); + const { notifier } = useRegistry(); + const [alertsOpen, setAlertsOpen] = useState(false); + + const copyLanUrl = (url: string): void => { + const p = navigator.clipboard?.writeText(url); + if (p) { + void p.then( + () => notifier.toast('已复制局域网访问 URL', 'success'), + () => notifier.toast('复制失败', 'error'), + ); + } else { + notifier.toast('当前环境不支持复制', 'warn'); + } + }; + + return ( + a.setLeftOpen(!s.leftOpen)} + onToggleRight={() => a.setRightOpen(!s.rightOpen)} + readOnly={s.settings?.readOnly === true} + lanUrl={s.settings?.lanUrl ?? null} + onCopyLanUrl={copyLanUrl} + unreadCount={s.alertsUnread} + alertsOpen={alertsOpen} + onToggleAlerts={() => { + const next = !alertsOpen; + setAlertsOpen(next); + if (next) a.markAlertsRead(); + }} + alerts={s.alerts} + onClearAlerts={a.clearAlerts} + onForceReload={() => window.location.reload()} + /> + ); +} diff --git a/packages/webui-react/src/features/use-app.ts b/packages/webui-react/src/features/use-app.ts new file mode 100644 index 00000000..fcf12a27 --- /dev/null +++ b/packages/webui-react/src/features/use-app.ts @@ -0,0 +1,23 @@ +/** + * features/use-app.ts —— AppController 的 React 绑定 + * ============================================================================ + * 【低耦合】ui 组件不需要知道本文件的存在。只有 App 组装根用它把控制器的 + * 快照 + 动作拆成 props 递给各哑组件。 + * 【性能】按需取快照字段做选择器,避免任意状态变化引发整树重渲染。 + * ============================================================================ + */ + +import { useSyncExternalStore } from 'react'; +import type { AppActions, AppController, AppSnapshot } from './app-controller'; + +export function useAppSnapshot(controller: AppController): AppSnapshot { + return useSyncExternalStore( + controller.subscribe, + () => controller.snapshot(), + () => controller.snapshot(), + ); +} + +export function useAppActions(controller: AppController): AppActions { + return controller.actions; +} diff --git a/packages/webui-react/src/features/use-store.ts b/packages/webui-react/src/features/use-store.ts new file mode 100644 index 00000000..ea18e2bd --- /dev/null +++ b/packages/webui-react/src/features/use-store.ts @@ -0,0 +1,21 @@ +/** + * features/use-store.ts —— 极简 store 的 React 绑定 + * ============================================================================ + * 【为什么放这】core/store/create-store.ts 必须零 UI 依赖(headless 可复用), + * 而 useSyncExternalStore 是 React 专属。把这层桥接收进 features —— 正是它 + * 作为 core↔ui 咬合层的职责。 + * 【热插拔】换状态库时只改本文件与 core/store,订阅方无感。 + * ============================================================================ + */ + +import { useSyncExternalStore } from 'react'; +import type { Store } from '../core/store/create-store'; + +/** 组件里 useStore(store, s => s.x) 即可,选择器最小重渲染。 */ +export function useStore(store: Store, selector: (s: S) => T): T { + return useSyncExternalStore( + store.subscribe, + () => selector(store.snapshot()), + () => selector(store.snapshot()), + ); +} diff --git a/packages/webui-react/src/i18n/en.ts b/packages/webui-react/src/i18n/en.ts new file mode 100644 index 00000000..ff568fa9 --- /dev/null +++ b/packages/webui-react/src/i18n/en.ts @@ -0,0 +1,267 @@ +/** + * src/i18n/en.ts —— English 文案词典(键名 1:1 对齐 public/app/i18n.js) + * 【职责】只放文案数据,零逻辑、零 IO。 + * 【接缝】类型 Record 强制与 zh.ts 键集合完全一致 —— + * 任何漏键 / 多键都是编译错误,保证中英双语永远同步。 + */ +import type { I18nKey } from './zh'; + +export const en: Record = { + title: 'Mcode Web UI', + new_chat: 'New Chat', + search_placeholder: 'Search sessions...', + workspace: 'Workspace', + recent: 'Recent', + no_sessions: 'No sessions yet', + model_picker_title: 'Switch Model', + model_picker_loading: 'Loading...', + model_picker_empty: 'No models available', + model_picker_hint: 'Press Enter to send /model command to mcode', + cancel: 'Cancel', + usage: 'Usage', + usage_loading: 'Click to load usage...', + quota_card_title: 'Quota (Token Plan)', + quota_card_enabled: 'Show usage', + quota_card_enabled_help: 'When off, the usage button disappears from the main UI', + quota_card_key: 'Subscription Key', + quota_card_key_help: 'Get from platform.minimaxi.com/user-center/token-plan. Stored in plain text in settings.json', + save: 'Save', + clear: 'Clear', + quota_no_data_hint: 'Quota feature is not enabled. Click the button below to open Appearance settings.', + quota_go_settings: 'Open Appearance settings', + quota_disabled: 'Disabled', + quota_saved: 'Saved', + quota_need_key: 'Enter Subscription Key', + quota_clear_confirm: 'Clear Subscription Key? Quota data will stop showing.', + quota_cleared: 'Cleared', + quota_status_configured: 'Configured', + quota_status_not_configured: 'Not configured', + quota_source_env: 'env', + quota_source_file: 'file', + quota_delete_disabled_external: 'Key is managed externally (env / file); cannot be deleted from the UI', + quota_input_placeholder_env: 'env takes priority — this value is ignored while the env var is set', + quota_input_placeholder_file: 'file takes priority — this value is ignored while the file is present', + quota_modal_title: 'Configure Subscription Key', + delete_key: 'Delete', + upgrade: 'Upgrade', + appearance: 'Appearance', + language: 'Language', + settings: 'Settings', + appearance_light: 'Light', + appearance_dark: 'Dark', + appearance_theme: 'Theme', + appearance_theme_help: 'Toggle light/dark theme', + appearance_quota_help: 'When off, the usage button disappears from the main UI', + language_zh: '中文', + language_en: 'English', + empty_hint_1: 'No messages yet — start typing below', + empty_hint_2: 'Press / for commands, Ctrl+V to paste image', + welcome_tagline: 'No messages yet — start typing below', + hint_help: 'Show commands', + hint_status: 'Show status', + hint_sessions: 'Sessions', + hint_usage: 'Usage', + input_placeholder: 'Type a message... (/ commands · @ files · Ctrl+V images)', + hint_footer: '/ commands · @ files · Ctrl+V images · Enter to send', + section_session: 'SESSION', + section_model: 'MODEL', + section_workspace: 'WORKSPACE', + section_context: 'CONTEXT', + section_goal: 'GOAL', + section_todo: 'TODO', + section_plan: 'Plan', + section_thinking: 'Thinking', + show_thinking: 'Show thinking', + lines: 'lines', + ask_title: 'Ask', + plan_review: 'Plan Review', + plan_view_btn: 'View full plan', + r_title: 'Title', r_dir: 'Directory', r_branch: 'Branch', r_tree: 'Status', + r_used: 'Used', r_percent: 'Percent', r_tps: 'Speed', r_cache: 'cache', + status_idle: 'Idle', + status_offline: 'Offline', + status_thinking: 'Thinking', + status_running: 'Running', + status_completed: 'Done', + tps_label: 'tok/s', + goal_active: 'Active', + goal_complete: 'Complete', + goal_paused: 'Paused', + todo_done: 'Done', + todo_pending: 'Pending', + user_loading: 'Mcode Web', + user_default: 'Mcode', + mode_default: 'Default', + mode_ask: 'Ask', mode_ask_desc: 'Confirm sensitive', + mode_auto: 'Auto', mode_auto_desc: 'Only high-risk', + mode_full: 'Full Access', mode_full_desc: 'No confirm', + mode_plan: 'Plan Mode', mode_plan_desc: 'Force mcode to output Plan: format', + drop_hint: 'Drop file to upload', + toggle_theme: 'Toggle theme', + toggle_lang: '中 / English', + network: 'Network access', + slash_no_results: 'No matching commands', + slash_section_cmd: 'Commands', + slash_section_skill: 'Skills', + perm_full: 'Full access', + perm_ask: 'Ask', + perm_read: 'Read-only', + plan_mode_continue: 'Continue with plan', + plan_mode_deny: 'Deny', + lan_access: 'LAN Access', + lan_on: 'On', + lan_off: 'Off', + lan_link_title: 'Click to copy LAN access URL', + copy_success: 'Copied', + copy_failed: 'Copy failed', + copy_failed_manual: 'Copy failed, please copy manually', + model_switched: 'Switched to', + sessions_list: 'Sessions', + refresh: 'Refresh', + quota_remaining: 'left', + quota_5h_limit: '5-Hour Limit', + quota_weekly_limit: 'Weekly Limit', + quota_next_reset: 'Next reset', + quota_loading_fail: 'Load failed', + quota_loading_idle: 'Click to load usage…', + usage_refreshed: 'Usage refreshed', + lan_title_on: 'LAN access on — other devices on this network can access', + lan_title_off: 'LAN access off — only this computer can access', + plan_mode_on: 'Plan mode on (next message will use Plan: format for mcode)', + plan_mode_off: 'Plan mode off', + perm_mode_note: 'Permission mode only updates the webui UI; mcode\'s actual mode comes from the --permission launch flag (no mid-session change in 0.1.5)', + ask_reopened: '✓ ask_user modal re-enabled (presentedKeys cleared)', + ask_no_reset: 'ask_user modal was never dismissed (nothing to reset)', + workspace_unset: '(Unset)', + workspace_unset_short: 'Unset', + workspace_current: 'Current', + workspace_switch: 'Click to switch workspace', + sidebar_loading: 'Loading sessions…', + workspace_picker_title: 'Switch Workspace', + workspace_picker_select: 'Select Workspace', + workspace_picker_current: 'Current: ', + session_delete: 'Delete this session', + session_delete_confirm: 'Delete?', + session_delete_yes: 'Delete', + session_delete_cancel: '×', + session_rename: 'Rename this session', + session_rename_ok: '✓ Renamed', + session_rename_fail: 'Rename failed', + btn_send_title: 'Send (Enter)', + btn_stop_title: 'Stop (/stop)', + workspace_unset_text: 'Unset (click to select)', + chip_online_title: 'WebUI tabs currently connected to server', + chip_online_single: '1 dev', + chip_online_plural: '{n} devs', + topbar_beta_title: 'Beta version', + force_reload: 'Force reload', + force_reload_title: 'Force reload (bypass browser cache)', + chip_offline: 'offline', + workspace_use_tui: "Use mcode TUI's current workspace", + workspace_reset: 'Reset to default workspace detected at startup', + workspace_locked_in_chat: 'Chat already started — workspace locked. Click "New Chat" in the sidebar to pick a new workspace.', + workspace_picker_hint: 'Click to select workspace', + native_candidates_title: 'Multiple matching directories found:', + native_candidates_none: 'No matching directory found — opened built-in tree', + native_picked_fill: 'Directory selection failed, try entering the path manually', + ws_nows_btn: 'No workspace needed', + ws_nows_unavailable: 'Temporary directory unavailable', + ws_add: 'Add workspace…', + ask_user_other_placeholder: 'Other...', + ask_user_clear: 'Clear', + ask_user_skip: 'Skip', + ask_user_send: 'Send', + ask_user_resend: 'Answered (click to resend)', + ask_user_step_count: 'of {n} steps', + ask_user_answered: 'Answered: {answer}', + ask_user_skipped: 'Skipped', + ask_user_no_options_hint: 'No preset options — type in "Other" below', + ask_user_send_count: 'Send ({n} questions)', + ask_user_resend_count: 'Answered ({n} questions, click to resend)', + topbar_readonly_zh: '只读', + topbar_readonly_en: 'READ ONLY', + topbar_readonly_title: 'webui is in read-only mode (remote clients cannot send or delete)', + lan_card_title: 'LAN Security', + lan_card_readonly: 'Read-only mode', + lan_card_readonly_help: 'Remote clients can only read; cannot send or delete', + lan_card_token_auth: 'Token auth', + lan_card_token_auth_help: 'Requires ?token= or Authorization header; loopback exempt', + lan_card_reset_token: 'Reset token', + lan_card_reset_token_confirm: 'Reset the token? The old token becomes invalid immediately.', + lan_card_token_value: 'Current token', + lan_card_token_show: 'Show', + lan_card_token_hide: 'Hide', + lan_card_token_saved: 'Saved — click "Reset" to view again', + lan_card_token_disabled: 'Token auth disabled', + lan_card_token_copy: 'Copy', + lan_card_token_copied: 'Copied to clipboard', + lan_card_token_new_warning: 'New token — open the URL on another device to test', + lan_card_token_ack: 'I have saved it', + lan_card_token_ack_help: 'After saving the token will not be shown again; reset to view again', + lan_card_token_rotated_toast: 'Token rotated, new value auto-synced', + auth_title: 'Authorization required', + auth_queue_pos: 'pending {i}/{n}', + auth_expires_in: 'Time limit', + auth_approve: 'Approve', + auth_deny: 'Deny', + auth_decision_failed: 'Failed to submit decision', + auth_action_session_delete: 'Delete session', + auth_action_sessions_cleanup_orphans: 'Clean up orphan sessions', + auth_action_session_cleanup_all: 'Delete all sessions', + auth_action_session_export: 'Export session', + auth_action_session_search: 'Cross-workspace session search', + auth_action_token_reset: 'Reset access token', + auth_action_slash_clear: 'Clear / restart chat', + auth_action_startup_cleanup: 'Startup cleanup', + auth_ctx_targetSessionId: 'target session', + auth_ctx_matchKind: 'match kind', + auth_ctx_isMcodeSid: 'mcode session id', + auth_ctx_isOrphan: 'orphan', + auth_ctx_chatLen: 'chat lines', + auth_ctx_q: 'query', + auth_ctx_workspace: 'workspace', + auth_ctx_limit: 'limit', + auth_ctx_format: 'format', + auth_ctx_download: 'download', + auth_ctx_orphanCount: 'orphan count', + auth_ctx_orphanIds: 'orphan ids', + auth_ctx_cmd: 'command', + auth_ctx_sessionId: 'session id', + auth_ctx_mcodeSessionId: 'mcode session', + auth_ctx_source: 'source', + alerts_title: 'System alerts', + alerts_empty: 'No alerts', + alerts_clear: 'Clear', + alerts_session: 'session', + alerts_level_info: 'Info', + alerts_level_warn: 'Warning', + alerts_level_error: 'Error', + workspace_sync_tui: "Also update mcode TUI's cwd (write cwd.json)", + workspace_recents_title: 'Recent', + workspace_browse: 'Browse directories…', + workspace_loading: 'Loading…', + mode_read: 'Read-only', + fs_picker_dialog_title: 'Select directory', + fs_picker_home: 'Home directory', + fs_picker_root: 'Root directory', + fs_picker_mkdir: 'New folder', + fs_picker_cancel: 'Cancel', + fs_picker_confirm: 'OK', + fs_picker_pick_current: 'Choose this directory', + fs_picker_filter_placeholder: 'Filter… (globs like *.txt)', + fs_picker_clear_filter: 'Clear filter', + fs_picker_col_name: 'Name', + fs_picker_col_size: 'Size', + fs_picker_col_mtime: 'Modified', + fs_picker_col_mode: 'Permissions', + fs_picker_loading: 'Loading…', + fs_picker_empty: 'This directory is empty', + fs_picker_no_match: 'No matching results', + fs_picker_load_failed: 'Failed to load directory', + fs_picker_mkdir_prompt: 'New folder name:', + fs_picker_mkdir_failed: 'Failed to create folder', + fs_picker_create_failed: 'Create failed', + fs_picker_select: 'Select', + fs_picker_selected: '{n} selected', + fs_picker_not_loaded: 'fs-picker component not loaded (check index.html includes app/fs-picker.js)', +}; diff --git a/packages/webui-react/src/i18n/index.ts b/packages/webui-react/src/i18n/index.ts new file mode 100644 index 00000000..e44330a7 --- /dev/null +++ b/packages/webui-react/src/i18n/index.ts @@ -0,0 +1,63 @@ +/** + * src/i18n/index.ts —— 双语字典入口(纯函数,零 React / 零 DOM 扫描) + * 【职责】t(key, lang?) 取文案(缺键回落 key 本身,与 vanilla 行为一致); + * setLang / getLang 管当前语言并经 KeyValueStorePort 持久化(webui-lang, + * 首次默认 en,与 public/app/i18n.js 一致);LANGS 枚举可用语言。 + * 【接缝】词典数据在 zh.ts / en.ts;持久化走 KeyValueStorePort(默认 + * core/store/kv-port.ts 的 localStorage 实现,可用 setLangStore 注入替换)。 + * 不做 DOM 的 data-i18n 扫描(那是 ui 层的职责),保持可测纯逻辑。 + */ +import type { KeyValueStorePort } from '../contracts/ports'; +import { createKvPort } from '../core/store/kv-port'; +import type { I18nKey } from './zh'; +import { zh } from './zh'; +import { en } from './en'; + +export const LANGS = ['zh', 'en'] as const; +export type Lang = (typeof LANGS)[number]; + +export const LANG_STORE_KEY = 'webui-lang'; +export const DEFAULT_LANG: Lang = 'en'; + +const DICTS: Record> = { zh, en }; + +function isLang(v: unknown): v is Lang { + return typeof v === 'string' && (LANGS as readonly string[]).includes(v); +} + +let kv: KeyValueStorePort = createKvPort(); + +function readInitial(): Lang { + const saved = kv.get(LANG_STORE_KEY); + return isLang(saved) ? saved : DEFAULT_LANG; +} + +let currentLang: Lang = readInitial(); + +/** 替换持久化实现(测试 / 组装根注入)。会用新 kv 重新读一次当前语言。 */ +export function setLangStore(store: KeyValueStorePort): void { + kv = store; + currentLang = readInitial(); +} + +/** 取文案;lang 省略时用当前语言。缺键回落到 key 本身(与 vanilla t() 一致)。 */ +export function t(key: string, lang?: Lang): string { + const l: Lang = lang && isLang(lang) ? lang : currentLang; + const dict = DICTS[l]; + const v = dict ? dict[key] : undefined; + return v || key; +} + +/** 切换语言并持久化;非法值忽略。 */ +export function setLang(lang: Lang): void { + if (!isLang(lang)) return; + currentLang = lang; + kv.set(LANG_STORE_KEY, lang); +} + +export function getLang(): Lang { + return currentLang; +} + +/** 全部文案键的类型(供 ui 层做键名约束)。 */ +export type { I18nKey }; diff --git a/packages/webui-react/src/i18n/zh.ts b/packages/webui-react/src/i18n/zh.ts new file mode 100644 index 00000000..3ff3ab85 --- /dev/null +++ b/packages/webui-react/src/i18n/zh.ts @@ -0,0 +1,268 @@ +/** + * src/i18n/zh.ts —— 简体中文文案词典(键名 1:1 对齐 public/app/i18n.js) + * 【职责】只放文案数据,零逻辑、零 IO;是全部 UI 文案的唯一中文来源。 + * 【接缝】key 类型 I18nKey 由本文件派生;en.ts 用 Record + * 强制中英键集合完全一致(漏键即类型错误)。 + */ +export const zh = { + title: 'Mcode Web UI', + new_chat: '新建会话', + search_placeholder: '搜索会话...', + workspace: '工作区', + recent: '最近会话', + no_sessions: '暂无会话记录', + model_picker_title: '切换模型', + model_picker_loading: '加载中...', + model_picker_empty: '没有可用模型', + model_picker_hint: '回车发送 /model 命令给 mcode', + cancel: '取消', + usage: '套餐用量', + usage_loading: '点击套餐用量加载...', + quota_card_title: '套餐用量 (Token Plan)', + quota_card_enabled: '显示套餐用量', + quota_card_enabled_help: '关闭后,套餐用量按钮在主界面消失', + quota_card_key: 'Subscription Key', + quota_card_key_help: '从 platform.minimaxi.com/user-center/token-plan 获取,明文存到 settings.json', + save: '保存', + clear: '清空', + quota_no_data_hint: '套餐用量暂未启用。点击下方按钮到「外观」面板配置。', + quota_go_settings: '去外观面板设置', + quota_disabled: '未启用', + quota_saved: '已保存', + quota_need_key: '请填 Subscription Key', + quota_clear_confirm: '确定清空 Subscription Key?清空后套餐用量数据不显示。', + quota_cleared: '已清空', + quota_status_configured: '已配置', + quota_status_not_configured: '未配置', + quota_source_env: 'env', + quota_source_file: 'file', + quota_delete_disabled_external: '当前 key 由外部源管理(env / file),无法在界面删除', + quota_input_placeholder_env: 'env 优先,此处的值在 env 取消前不会被使用', + quota_input_placeholder_file: 'file 优先,此处的值在文件移除前不会被使用', + quota_modal_title: '配置 Subscription Key', + delete_key: '删除', + upgrade: '升级', + appearance: '外观', + language: '语言', + settings: '设置', + appearance_light: '明亮', + appearance_dark: '深色', + appearance_theme: '主题', + appearance_theme_help: '切换亮色/深色主题', + appearance_quota_help: '关掉后,套餐用量按钮在主界面消失', + language_zh: '简体中文', + language_en: 'English', + empty_hint_1: '还没有消息 — 在下方输入开始对话', + empty_hint_2: '按 / 触发命令检索,Ctrl+V 粘贴图片', + welcome_tagline: '还没有消息 — 在下方输入开始对话', + hint_help: '查看命令', + hint_status: '查看状态', + hint_sessions: '会话列表', + hint_usage: '套餐用量', + input_placeholder: '输入消息... (/ 命令 · @ 文件 · Ctrl+V 粘贴图片)', + hint_footer: '/ 命令 · @ 文件 · Ctrl+V 粘贴图片 · Enter 发送', + section_session: '会话', + section_model: '模型', + section_workspace: '工作区', + section_context: '上下文', + section_goal: '目标', + section_todo: '待办', + section_plan: '计划', + section_thinking: '思考', + show_thinking: '查看思考', + lines: '行', + ask_title: '提问', + plan_review: '计划预览', + plan_view_btn: '查看完整计划', + r_title: '标题', r_dir: '目录', r_branch: '分支', r_tree: '状态', + r_used: '已用', r_percent: '占比', r_tps: '速度', r_cache: '缓存', + status_idle: '空闲', + status_offline: '离线', + status_thinking: '思考中', + status_running: '运行中', + status_completed: '完成', + tps_label: 'tok/s', + goal_active: '进行中', + goal_complete: '完成', + goal_paused: '暂停', + todo_done: '已完成', + todo_pending: '待办', + user_loading: 'Mcode Web', + user_default: 'Mcode', + mode_default: '默认', + mode_ask: '询问', mode_ask_desc: '敏感操作确认', + mode_auto: '自动', mode_auto_desc: '仅高风险询问', + mode_full: '完全 访问', mode_full_desc: '无需确认', + mode_plan: 'Plan 模式', mode_plan_desc: '强制 mcode 按 Plan: 格式输出', + drop_hint: '松开上传文件', + toggle_theme: '切换主题', + toggle_lang: '中 / English', + network: '局域网访问', + slash_no_results: '没有匹配的命令', + slash_section_cmd: '命令', + slash_section_skill: '技能', + perm_full: '完全 访问', + perm_ask: '询问', + perm_read: '只读', + plan_mode_continue: '继续 plan', + plan_mode_deny: '拒绝', + lan_access: '局域网访问', + lan_on: '开', + lan_off: '关', + lan_link_title: '点击复制局域网访问 URL', + copy_success: '已复制', + copy_failed: '复制失败', + copy_failed_manual: '复制失败,请手动复制', + model_switched: '已切到', + sessions_list: '会话列表', + refresh: '刷新', + quota_remaining: '剩余', + quota_5h_limit: '5 小时限额', + quota_weekly_limit: '每周限额', + quota_next_reset: '下次重置', + quota_loading_fail: '加载失败', + quota_loading_idle: '点击套餐用量加载…', + usage_refreshed: '用量已刷新', + lan_title_on: '局域网已开启 — 局域网内其他设备可访问', + lan_title_off: '局域网已关闭 — 只有本电脑能访问', + plan_mode_on: '已开 Plan 模式(下次发消息时 mcode 会按 Plan: 格式输出)', + plan_mode_off: '已关 Plan 模式', + perm_mode_note: '权限 mode 仅更新 webui UI,mcode 实际 mode 由启动 --permission 标志决定(0.1.5 不支持中途改)', + ask_reopened: '✓ ask_user 弹窗已重新开启(清空 presentedKeys)', + ask_no_reset: 'ask_user 弹窗未开启过(无需重置)', + workspace_unset: '(未设置)', + workspace_unset_short: '未设置', + workspace_current: '当前', + workspace_switch: '点击切换工作区', + sidebar_loading: '加载会话中…', + workspace_picker_title: '切换工作区', + workspace_picker_select: '选择工作区', + workspace_picker_current: '当前:', + session_delete: '删除此会话', + session_delete_confirm: '删除?', + session_delete_yes: '删', + session_delete_cancel: '×', + session_rename: '重命名此会话', + session_rename_ok: '✓ 已重命名', + session_rename_fail: '重命名失败', + btn_send_title: '发送 (Enter)', + btn_stop_title: '停止 (/stop)', + workspace_unset_text: '未选择(点击选择)', + chip_online_title: '当前连到 webui server 的 tab 数', + chip_online_single: '1 台', + chip_online_plural: '{n} 台', + topbar_beta_title: 'Beta 测试版', + force_reload: '强制刷新', + force_reload_title: '强制刷新 (绕过浏览器缓存)', + chip_offline: '离线', + workspace_use_tui: '切换到 mcode TUI 当前的工作区', + workspace_reset: '恢复 webui 启动时检测到的默认工作区', + workspace_locked_in_chat: '对话已开始,工作区已锁定。点击左侧「新建会话」可重新选择工作区。', + workspace_picker_hint: '点击选择工作区', + native_candidates_title: '找到多个匹配目录:', + native_candidates_none: '未找到匹配目录,已打开目录树', + native_picked_fill: '目录选择失败,请尝试手动输入路径', + ws_nows_btn: '无需工作空间', + ws_nows_unavailable: '临时目录不可用', + ws_add: '添加工作区…', + ask_user_other_placeholder: '其他...', + ask_user_clear: '清空', + ask_user_skip: '跳过', + ask_user_send: '发送', + ask_user_resend: '已答完 (点重发)', + ask_user_step_count: '共 {n} 步', + ask_user_answered: '已答: {answer}', + ask_user_skipped: '已跳过', + ask_user_no_options_hint: '无预设选项 — 用下方"其他"输入回答', + ask_user_send_count: '发送 ({n} 题)', + ask_user_resend_count: '已答完 ({n} 题, 点重发)', + topbar_readonly_zh: '只读', + topbar_readonly_en: 'READ ONLY', + topbar_readonly_title: 'webui 当前处于只读模式 (远程客户端不能发送/删除)', + lan_card_title: '局域网安全设置', + lan_card_readonly: '只读模式', + lan_card_readonly_help: '远程客户端只能读取,不能发送消息/删除会话', + lan_card_token_auth: 'Token 鉴权', + lan_card_token_auth_help: '需要 ?token= 或 Authorization header;本机不受限', + lan_card_reset_token: '重置 token', + lan_card_reset_token_confirm: '确定要重置 token? 旧 token 会立即失效', + lan_card_token_value: '当前 token', + lan_card_token_show: '显示', + lan_card_token_hide: '隐藏', + lan_card_token_saved: '已保存 — 查看请点"重置"', + lan_card_token_disabled: 'Token 鉴权已关闭', + lan_card_token_copy: '复制', + lan_card_token_copied: '已复制到剪贴板', + lan_card_token_new_warning: '新的 token — 请在另一台设备用上面的 URL 打开', + lan_card_token_ack: '我已保存', + lan_card_token_ack_help: '保存后 token 不会再次显示;下次需要查看可点"重置"', + lan_card_token_rotated_toast: 'token 已重置,新值已自动同步', + auth_title: '需要授权确认', + auth_queue_pos: '待确认 {i}/{n}', + auth_expires_in: '确认时限', + auth_approve: '允许', + auth_deny: '拒绝', + auth_decision_failed: '决定提交失败', + auth_action_session_delete: '删除会话', + auth_action_sessions_cleanup_orphans: '清理孤儿会话', + auth_action_session_cleanup_all: '清空全部会话', + auth_action_session_export: '导出会话', + auth_action_session_search: '跨工作区搜索会话', + auth_action_token_reset: '重置访问令牌', + auth_action_slash_clear: '清空 / 新建对话', + auth_action_startup_cleanup: '启动时清理', + auth_ctx_targetSessionId: '目标会话', + auth_ctx_matchKind: '匹配方式', + auth_ctx_isMcodeSid: 'mcode 会话 ID', + auth_ctx_isOrphan: '孤儿会话', + auth_ctx_chatLen: '对话行数', + auth_ctx_q: '搜索词', + auth_ctx_workspace: '工作区', + auth_ctx_limit: '数量上限', + auth_ctx_format: '格式', + auth_ctx_download: '下载', + auth_ctx_orphanCount: '孤儿数量', + auth_ctx_orphanIds: '孤儿会话列表', + auth_ctx_cmd: '命令', + auth_ctx_sessionId: '会话 ID', + auth_ctx_mcodeSessionId: 'mcode 会话', + auth_ctx_source: '来源', + alerts_title: '系统通知', + alerts_empty: '暂无通知', + alerts_clear: '清空', + alerts_session: '会话', + alerts_level_info: '信息', + alerts_level_warn: '警告', + alerts_level_error: '错误', + workspace_sync_tui: '同时更新 mcode TUI 的工作目录(写入 cwd.json)', + workspace_recents_title: '最近使用', + workspace_browse: '浏览目录…', + workspace_loading: '加载中…', + mode_read: '只读', + fs_picker_dialog_title: '选择目录', + fs_picker_home: '用户目录', + fs_picker_root: '根目录', + fs_picker_mkdir: '新建文件夹', + fs_picker_cancel: '取消', + fs_picker_confirm: '确定', + fs_picker_pick_current: '选择当前目录', + fs_picker_filter_placeholder: '过滤…(支持 glob,如 *.txt)', + fs_picker_clear_filter: '清除过滤', + fs_picker_col_name: '名称', + fs_picker_col_size: '大小', + fs_picker_col_mtime: '修改时间', + fs_picker_col_mode: '权限', + fs_picker_loading: '加载中…', + fs_picker_empty: '该目录为空', + fs_picker_no_match: '没有匹配的结果', + fs_picker_load_failed: '加载目录失败', + fs_picker_mkdir_prompt: '输入文件夹名称:', + fs_picker_mkdir_failed: '创建文件夹失败', + fs_picker_create_failed: '创建失败', + fs_picker_select: '选择', + fs_picker_selected: '已选择 {n} 项', + fs_picker_not_loaded: 'fs-picker 组件未加载(检查 index.html 是否引入 app/fs-picker.js)', +} as const; + +/** 全部文案键(en.ts 必须一一对应)。 */ +export type I18nKey = keyof typeof zh; diff --git a/packages/webui-react/src/main.tsx b/packages/webui-react/src/main.tsx new file mode 100644 index 00000000..08cee488 --- /dev/null +++ b/packages/webui-react/src/main.tsx @@ -0,0 +1,24 @@ +/** + * main.tsx —— 浏览器入口(组装根的最外层) + * ============================================================================ + * 只做三件事:注入设计令牌样式、装配 Registry、挂载 。 + * 业务编排全部在 features/app-controller.ts,视觉全部在 ui/。 + * ============================================================================ + */ + +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import './styles/tokens.css'; +import { App } from './App'; +import { RegistryProvider } from './features/registry-context'; + +const el = document.getElementById('root'); +if (!el) throw new Error('找不到 #root 挂载点'); + +createRoot(el).render( + + + + + , +); diff --git a/packages/webui-react/src/styles/tokens.css b/packages/webui-react/src/styles/tokens.css new file mode 100644 index 00000000..84157be3 --- /dev/null +++ b/packages/webui-react/src/styles/tokens.css @@ -0,0 +1,76 @@ +/* styles/tokens.css —— 设计令牌(1:1 对齐 packages/webui/public/styles/main.css) */ +/* 所有 UI 组件只消费这里的 CSS 变量;换主题/换视觉只改本文件。 */ + +:root, +:root[data-theme='light'] { + --bg: #fafafa; + --bg-elevated: #ffffff; + --bg-sidebar: #f4f4f5; + --bg-hover: #ededee; + --bg-active: #e2e2e4; + --bg-input: #ffffff; + --text: #1a1a1c; + --text-secondary: #5f5f66; + --text-tertiary: #98989e; + --border: #e2e2e4; + --border-light: #ededee; + --accent: #17171a; + --accent-hover: #000000; + --accent-bg: #ededee; + --accent-text: #2a2a2e; + --on-accent: #ffffff; + --success: #75757c; + --warning: #4f4f56; + --danger: #bf5645; + --status-on: #1e9e5a; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 14px rgba(0, 0, 0, 0.09); + --shadow-lg: 0 14px 38px rgba(0, 0, 0, 0.14); + --user-accent: #17171a; + --accent-glow: rgba(23, 23, 26, 0.1); + --hairline: rgba(26, 26, 28, 0.08); + --font-mono: ui-monospace, 'Cascadia Code', Consolas, 'SFMono-Regular', Menlo, monospace; + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 14px; +} + +:root[data-theme='dark'] { + --bg: #0b0b0c; + --bg-elevated: #141416; + --bg-sidebar: #101012; + --bg-hover: #1c1c1f; + --bg-active: #26262a; + --bg-input: #131315; + --text: #ececee; + --text-secondary: #a2a2a8; + --text-tertiary: #6d6d74; + --border: #26262a; + --border-light: #1d1d20; + --accent: #f4f4f5; + --accent-hover: #ffffff; + --accent-bg: rgba(244, 244, 245, 0.1); + --accent-text: #e4e4e7; + --on-accent: #101012; + --success: #9d9da3; + --warning: #c8c8cd; + --danger: #cc6b5c; + --status-on: #3fbf7f; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35); + --shadow-md: 0 4px 10px rgba(0, 0, 0, 0.42); + --shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.55); + --user-accent: #f4f4f5; + --accent-glow: rgba(244, 244, 245, 0.12); + --hairline: rgba(236, 236, 238, 0.08); +} + +html, +body, +#root { + height: 100%; + margin: 0; + background: var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', + 'Microsoft YaHei', Roboto, Helvetica, Arial, sans-serif; +} diff --git a/packages/webui-react/src/ui/chat/EmptyState.tsx b/packages/webui-react/src/ui/chat/EmptyState.tsx new file mode 100644 index 00000000..85aed39d --- /dev/null +++ b/packages/webui-react/src/ui/chat/EmptyState.tsx @@ -0,0 +1,30 @@ +/** + * EmptyState.tsx —— 欢迎空态(品牌 logo + 还没有消息,在下方输入开始对话) + * 哑组件,纯展示,文案可由 props 覆盖(i18n 由上层注入)。 + */ + +import { memo } from 'react'; +import './empty.css'; + +export interface EmptyStateProps { + /** 品牌 logo 地址(默认同 vanilla /brand-logo.png)。 */ + logoSrc?: string; + /** 可选主标题(不传则只显示提示语,对齐 vanilla 欢迎页)。 */ + title?: string; + /** 副标题/提示语。 */ + subtitle?: string; +} + +export const EmptyState = memo(function EmptyState({ + logoSrc = '/brand-logo.png', + title, + subtitle = '还没有消息 — 在下方输入开始对话', +}: EmptyStateProps) { + return ( +
+ MiniMax Code + {title !== undefined && title !== '' ?
{title}
: null} +
{subtitle}
+
+ ); +}); diff --git a/packages/webui-react/src/ui/chat/ExecStatusRow.tsx b/packages/webui-react/src/ui/chat/ExecStatusRow.tsx new file mode 100644 index 00000000..d8ca7d20 --- /dev/null +++ b/packages/webui-react/src/ui/chat/ExecStatusRow.tsx @@ -0,0 +1,62 @@ +/** + * ExecStatusRow.tsx —— 执行状态条(参考布局:「共执行 5 秒 ›」 + 「24 token/s」) + * ============================================================================ + * 哑组件:挂在一次「用户提问 → 助手执行」的回合上(渲染在该回合助手消息上方)。 + * 左侧:耗时(运行中 = 实时秒数;完成 = 服务端 thinkingDuration)。 + * 右侧:生成速率 token/s(wire state.context.tps / running.tps)。 + * 数据由容器按会话级状态计算后传入;历史回合无逐回合统计,不渲染本组件。 + * ============================================================================ + */ + +import { memo } from 'react'; +import { Icon } from '../primitives/Icon'; +import './execstatus.css'; + +export interface ExecStats { + /** 已执行秒数(运行中实时;完成后定格)。 */ + durationSec: number | null; + /** 生成速率 token/s;0/无数据 → 不显示右侧。 */ + tps: number | null; + /** 是否仍在执行(控制耗时文案的"共执行/已执行"与呼吸点)。 */ + running: boolean; +} + +export interface ExecStatusRowProps { + stats: ExecStats; + /** 文案覆盖(i18n 接缝)。 */ + durationLabel?: (sec: number, running: boolean) => string; + tpsLabel?: (tps: number) => string; +} + +function defaultDurationLabel(sec: number, running: boolean): string { + return (running ? '已执行 ' : '共执行 ') + String(sec) + ' 秒'; +} + +function defaultTpsLabel(tps: number): string { + return String(Math.round(tps)) + ' token/s'; +} + +export const ExecStatusRow = memo(function ExecStatusRow({ + stats, + durationLabel = defaultDurationLabel, + tpsLabel = defaultTpsLabel, +}: ExecStatusRowProps) { + const { durationSec, tps, running } = stats; + if (durationSec === null && (tps === null || tps <= 0)) return null; + return ( +
+ + {running ? + {tps !== null && tps > 0 ? ( + + + {tpsLabel(tps)} + + ) : null} +
+ ); +}); diff --git a/packages/webui-react/src/ui/chat/MessageActions.tsx b/packages/webui-react/src/ui/chat/MessageActions.tsx new file mode 100644 index 00000000..cbfd2287 --- /dev/null +++ b/packages/webui-react/src/ui/chat/MessageActions.tsx @@ -0,0 +1,105 @@ +/** + * MessageActions.tsx —— 助手消息操作行(参考布局:复制 / 赞 / 踩 / 重试 + 时间戳) + * ============================================================================ + * 哑组件 + 少量本地瞬态(copied 勾的 2s 回闪由内部 useState 管理)。 + * - 复制:把消息纯文本写入剪贴板,成败经回调外抛(容器接 notifier)。 + * - 赞 / 踩:互斥三态(无 → 赞 / 无 → 踩),本地 UI 态,由容器持有(按消息 id)。 + * - 重试:仅当上层给 onRetry 时渲染(通常只挂在最后一条助手消息上)。 + * - 时间:YY/MM/dd HH:mm:ss;历史消息无真实时间戳 → "--"。 + * ============================================================================ + */ + +import { memo, useState } from 'react'; +import { IconButton } from '../primitives/IconButton'; +import { formatMessageTime, messageTimeISO } from './time'; +import './msgactions.css'; + +export type Feedback = 'like' | 'dislike' | null; + +export interface MessageActionsProps { + /** 消息 ts(毫秒);序号占位 → 时间显示 "--"。 */ + ts: number; + /** 取要复制的纯文本 —— 惰性:点击时才求值(渲染期不读 blocks/text getter)。 */ + getCopyText: () => string; + /** 当前反馈态(容器持有,按消息 id)。 */ + feedback?: Feedback; + onFeedback?: (next: Feedback) => void; + /** 复制结果外抛(容器接 toast)。 */ + onCopyResult?: (ok: boolean) => void; + /** 重试(重新生成)——仅最后一条助手消息传入。 */ + onRetry?: () => void; + labels?: Partial; +} + +export interface MessageActionsLabels { + copy: string; + copied: string; + like: string; + dislike: string; + retry: string; +} + +const DEFAULT_LABELS: MessageActionsLabels = { + copy: '复制', + copied: '已复制', + like: '有帮助', + dislike: '没帮助', + retry: '重试', +}; + +export const MessageActions = memo(function MessageActions({ + ts, + getCopyText, + feedback = null, + onFeedback, + onCopyResult, + onRetry, + labels, +}: MessageActionsProps) { + const l: MessageActionsLabels = { ...DEFAULT_LABELS, ...labels }; + const [copied, setCopied] = useState(false); + const time = formatMessageTime(ts); + + const handleCopy = (): void => { + const done = (ok: boolean): void => { + if (ok) { + setCopied(true); + setTimeout(() => setCopied(false), 1600); + } + onCopyResult?.(ok); + }; + const p = navigator.clipboard?.writeText(getCopyText()); + if (p) { + void p.then(() => done(true), () => done(false)); + } else { + done(false); + } + }; + + return ( +
+ + onFeedback?.(feedback === 'like' ? null : 'like')} + /> + onFeedback?.(feedback === 'dislike' ? null : 'dislike')} + /> + {onRetry ? : null} + +
+ ); +}); diff --git a/packages/webui-react/src/ui/chat/MessageItem.tsx b/packages/webui-react/src/ui/chat/MessageItem.tsx new file mode 100644 index 00000000..cc161ca5 --- /dev/null +++ b/packages/webui-react/src/ui/chat/MessageItem.tsx @@ -0,0 +1,117 @@ +/** + * MessageItem.tsx —— 单条消息(参考布局版) + * ============================================================================ + * 哑组件:只吃 ChatMessage + 少量交互回调。三种角色三种形态(对齐参考布局): + * - user:右侧灰色圆角气泡,无头像、无角色名;气泡下方右侧一小行时间。 + * - assistant:左对齐纯文本,无头像、无气泡;上方可挂执行状态条(ExecStatusRow, + * 由容器决定挂哪条),下方操作行(复制/赞/踩/重试 + 时间,MessageActions)。 + * - system:居中弱化细文本。 + * 消息体由 blocks/BlockView 按 kind 分发;React.memo + key 稳定(message.id)。 + * ============================================================================ + */ + +import { memo } from 'react'; +import type { ChatMessage } from '../../contracts/domain'; +import { BlockView } from './blocks'; +import { ExecStatusRow, type ExecStats } from './ExecStatusRow'; +import { MessageActions, type Feedback } from './MessageActions'; +import { formatMessageTime, messageTimeISO } from './time'; +import './item.css'; + +export interface MessageItemProps { + message: ChatMessage; + /** ask-user 块的受控选中态透传(由上层按会话保存)。 */ + askSelectedIds?: readonly string[]; + onAskToggleOption?: (optionId: string) => void; + onAskConfirm?: (optionIds: string[]) => void; + /** 执行状态条数据 —— 仅当回合(最后一条)助手消息由容器传入。 */ + execStats?: ExecStats | null; + /** 赞/踩反馈态(容器按消息 id 持有)。 */ + feedback?: Feedback; + onFeedback?: (messageId: string, next: Feedback) => void; + /** 复制结果外抛(容器接 toast)。 */ + onCopyResult?: (ok: boolean) => void; + /** 重试(重新生成)—— 仅最后一条助手消息由容器传入。 */ + onRetry?: () => void; +} + +/** + * 提取消息纯文本(复制用):拼接 text / thinking 块的内容。 + * 吃 blocks 数组而非 message —— 配合惰性求值,保证渲染期不重复读 blocks getter。 + */ +function messagePlainText(blocks: ChatMessage['blocks']): string { + const parts: string[] = []; + for (const b of blocks) { + if (b.kind === 'text' || b.kind === 'thinking') parts.push(b.text); + } + return parts.join('\n'); +} + +export const MessageItem = memo(function MessageItem({ + message, + askSelectedIds, + onAskToggleOption, + onAskConfirm, + execStats = null, + feedback = null, + onFeedback, + onCopyResult, + onRetry, +}: MessageItemProps) { + const isUser = message.role === 'user'; + const isSystem = message.role === 'system'; + const time = formatMessageTime(message.ts); + // 契约:blocks getter 每次渲染只读一次(块级 memo 性能测试靠它探测重渲染)。 + const blocks = message.blocks; + + const body = ( +
+ {blocks.map((block) => ( + + ))} + {message.streaming === true ? ▍ : null} +
+ ); + + if (isSystem) { + return ( +
+ {body} +
+ ); + } + + if (isUser) { + return ( +
+
{body}
+
+ +
+
+ ); + } + + return ( +
+ {execStats ? : null} + {body} + messagePlainText(blocks)} + feedback={feedback} + onFeedback={(next) => onFeedback?.(message.id, next)} + onCopyResult={onCopyResult} + onRetry={onRetry} + /> +
+ ); +}); diff --git a/packages/webui-react/src/ui/chat/MessageList.tsx b/packages/webui-react/src/ui/chat/MessageList.tsx new file mode 100644 index 00000000..a24f88f0 --- /dev/null +++ b/packages/webui-react/src/ui/chat/MessageList.tsx @@ -0,0 +1,165 @@ +/** + * MessageList.tsx —— 消息流滚动列表(哑组件) + * ============================================================================ + * 职责单一:滚动消息列表 + 自动滚底 + 会话隔离的滚动位置。 + * - 自动滚到底部;用户上滚离开底部后暂停自动滚动,回到底部后恢复 + * (判定逻辑参照 packages/webui/public/app/chat-virtual-list.js 的 + * isNearBottom / decideScrollBehavior 思路,纯阈值计算)。 + * - onScrollBottomChange:跨越"贴底"阈值时回调一次,供上层决定是否 + * 在流式期间继续跟随。 + * - 会话隔离:滚动位置用 useRef> 按 key 保存, + * 切会话恢复各自的滚动位置,绝不跨会话复用(也无任何草稿状态)。 + * - 性能:key = message.id 稳定;MessageItem / 各块视图全部 React.memo, + * 流式重渲染只影响变化的块(对应 chat-virtual-list.js 注释里"块级 memo" + * 的简单性能优化路线,暂不做窗口虚拟化)。 + * ============================================================================ + */ + +import { memo, useCallback, useEffect, useLayoutEffect, useRef } from 'react'; +import type { UIEvent } from 'react'; +import type { ChatMessage } from '../../contracts/domain'; +import { MessageItem } from './MessageItem'; +import type { ExecStats } from './ExecStatusRow'; +import type { Feedback } from './MessageActions'; +import { ThinkingBar } from './ThinkingBar'; +import { EmptyState } from './EmptyState'; +import './list.css'; + +/** "贴底"判定的像素阈值(对齐 chat-virtual-list.js 的 NEAR_BOTTOM_PX)。 */ +const NEAR_BOTTOM_PX = 50; + +function isNearBottom(scrollTop: number, clientHeight: number, scrollHeight: number): boolean { + if (scrollHeight <= 0) return true; + return scrollTop + clientHeight >= scrollHeight - NEAR_BOTTOM_PX; +} + +export interface MessageListProps { + messages: ChatMessage[]; + /** 是否有进行中的流式输出(显示"思考中"指示并维持自动滚底)。 */ + streaming: boolean; + /** 跨越"贴底"阈值时回调(true=在底部 / false=用户上滚离开底部)。 */ + onScrollBottomChange?: (atBottom: boolean) => void; + /** 会话隔离键(通常传 sessionId):滚动位置按 key 分别保存。 */ + sessionKey?: string; + /** 品牌 logo 地址(头像 / 欢迎空态)。 */ + logoSrc?: string; + /** ask-user 块交互回调透传(由上层按会话处理,组件自身不保存)。 */ + askSelectedIds?: readonly string[]; + onAskToggleOption?: (optionId: string) => void; + onAskConfirm?: (optionIds: string[]) => void; + /** 执行状态条数据 —— 只挂到最后一条助手消息(参考布局:回合耗时 + token/s)。 */ + execStats?: ExecStats | null; + /** 重试(重新生成)—— 只挂到最后一条助手消息。 */ + onRetry?: () => void; + /** 赞/踩反馈表(容器按消息 id 持有)。 */ + feedbacks?: Readonly>; + onFeedback?: (messageId: string, next: Feedback) => void; + /** 复制结果外抛(容器接 toast)。 */ + onCopyResult?: (ok: boolean) => void; +} + +export const MessageList = memo(function MessageList({ + messages, + streaming, + onScrollBottomChange, + sessionKey = '', + logoSrc, + askSelectedIds, + onAskToggleOption, + onAskConfirm, + execStats = null, + onRetry, + feedbacks, + onFeedback, + onCopyResult, +}: MessageListProps) { + const scrollRef = useRef(null); + /** 用户是否"贴底"(贴底才自动跟随)。 */ + const stickRef = useRef(true); + /** 上次通知上层的贴底状态(只为跨阈值时通知一次)。 */ + const notifiedRef = useRef(true); + /** 会话隔离:每个 sessionKey 一份滚动位置。 */ + const offsetsRef = useRef(new Map()); + /** 回调走 ref,避免因回调身份变化重绑滚动逻辑。 */ + const cbRef = useRef(onScrollBottomChange); + useEffect(() => { + cbRef.current = onScrollBottomChange; + }, [onScrollBottomChange]); + + // 自动滚到底部(用户上滚后暂停:stickRef=false)。 + // 布局前先滚一次(paint 前到位,避免闪跳),再在下一帧补滚一次 —— 流式分片 + // 渲染(markdown/代码高亮)会让 scrollHeight 在首帧后继续增长,单次设置会 + // 停在半途,用户看到的就是「不跟随」。 + useLayoutEffect(() => { + const el = scrollRef.current; + if (el === null || !stickRef.current) return; + el.scrollTop = el.scrollHeight; + const raf = requestAnimationFrame(() => { + if (stickRef.current) el.scrollTop = el.scrollHeight; + }); + return () => cancelAnimationFrame(raf); + }, [messages, streaming]); + + // 会话隔离:切换 sessionKey 恢复该会话自己的滚动位置(没有记录则贴底)。 + useLayoutEffect(() => { + const el = scrollRef.current; + if (el === null) return; + const saved = offsetsRef.current.get(sessionKey); + el.scrollTop = saved !== undefined ? saved : el.scrollHeight; + stickRef.current = isNearBottom(el.scrollTop, el.clientHeight, el.scrollHeight); + notifiedRef.current = stickRef.current; + }, [sessionKey]); + + const handleScroll = useCallback( + (e: UIEvent) => { + const el = e.currentTarget; + offsetsRef.current.set(sessionKey, el.scrollTop); + const near = isNearBottom(el.scrollTop, el.clientHeight, el.scrollHeight); + stickRef.current = near; + if (near !== notifiedRef.current) { + notifiedRef.current = near; + const cb = cbRef.current; + if (cb !== undefined) cb(near); + } + }, + [sessionKey], + ); + + const showEmpty = messages.length === 0 && !streaming; + + /** 最后一条助手消息 id:执行状态条 / 重试 / 反馈态只挂在它身上。 */ + let lastAssistantId: string | null = null; + for (let i = messages.length - 1; i >= 0; i -= 1) { + const m = messages[i]; + if (m !== undefined && m.role === 'assistant') { + lastAssistantId = m.id; + break; + } + } + + return ( +
+ {showEmpty ? ( + + ) : ( +
+ {messages.map((m) => ( + + ))} + {streaming ? : null} +
+ )} +
+ ); +}); diff --git a/packages/webui-react/src/ui/chat/SessionTitleBar.tsx b/packages/webui-react/src/ui/chat/SessionTitleBar.tsx new file mode 100644 index 00000000..05616151 --- /dev/null +++ b/packages/webui-react/src/ui/chat/SessionTitleBar.tsx @@ -0,0 +1,70 @@ +/** + * SessionTitleBar.tsx —— 会话标题栏(中栏顶部) + * ============================================================================ + * 参考布局:顶部居中的会话标题 + ∨(点开下拉快速切换会话), + * 右侧放面板开关等工具按钮。哑组件:下拉的开合与列表渲染由容器负责, + * 本组件只管「标题按钮 + 左右插槽」的结构与居中排版。 + * ============================================================================ + */ + +import { memo } from 'react'; +import type { ReactNode } from 'react'; +import { Icon } from '../primitives/Icon'; +import './titlebar.css'; + +export interface SessionTitleBarProps { + /** 会话标题(空 → 显示占位)。 */ + title: string; + /** 占位文案(无标题/无会话时)。 */ + placeholder?: string; + /** 标题按钮点击(容器用来开合会话切换下拉)。 */ + onTitleClick?: () => void; + /** 下拉展开态(控制 chevron 旋转与 aria)。 */ + expanded?: boolean; + /** 是否只读模式(显示徽标)。 */ + readOnly?: boolean; + readOnlyLabel?: string; + /** 运行中状态点。 */ + running?: boolean; + /** 左侧插槽(如侧栏折叠钮)。 */ + left?: ReactNode; + /** 右侧插槽(面板开关等)。 */ + right?: ReactNode; + /** 标题正下方的下拉内容(容器渲染,开合由容器控制)。 */ + dropdown?: ReactNode; +} + +export const SessionTitleBar = memo(function SessionTitleBar({ + title, + placeholder = '新会话', + onTitleClick, + expanded = false, + readOnly = false, + readOnlyLabel = '只读', + running = false, + left, + right, + dropdown, +}: SessionTitleBarProps) { + return ( +
+
{left}
+
+ + {readOnly ? {readOnlyLabel} : null} + {dropdown} +
+
{right}
+
+ ); +}); diff --git a/packages/webui-react/src/ui/chat/ThinkingBar.tsx b/packages/webui-react/src/ui/chat/ThinkingBar.tsx new file mode 100644 index 00000000..f7e3b949 --- /dev/null +++ b/packages/webui-react/src/ui/chat/ThinkingBar.tsx @@ -0,0 +1,31 @@ +/** + * ThinkingBar.tsx —— "思考中"指示(三个跳动圆点 + 思考中) + * 哑组件,纯展示;已被 ChatArea / MessageList 使用,仅导出即可。 + */ + +import { memo } from 'react'; +import './thinkingbar.css'; + +export interface ThinkingBarProps { + /** 文案(默认"思考中",对齐 vanilla data-i18n="status_thinking")。 */ + label?: string; + /** 品牌 logo 地址。 */ + logoSrc?: string; +} + +export const ThinkingBar = memo(function ThinkingBar({ + label = '思考中', + logoSrc = '/brand-logo.png', +}: ThinkingBarProps) { + return ( +
+ MiniMax Code +
+ ); +}); diff --git a/packages/webui-react/src/ui/chat/blocks/AskUserBlockView.tsx b/packages/webui-react/src/ui/chat/blocks/AskUserBlockView.tsx new file mode 100644 index 00000000..9bc6f4de --- /dev/null +++ b/packages/webui-react/src/ui/chat/blocks/AskUserBlockView.tsx @@ -0,0 +1,86 @@ +/** + * blocks/AskUserBlockView.tsx —— 提问块(问题 + 选项 + 多选框 + 已答标记) + * 哑组件:选中态与应答动作全部由 props 进出,组件不持久化状态。 + */ + +import { memo } from 'react'; +import type { AskUserBlock } from '../../../contracts/domain'; +import './blocks.css'; + +export interface AskUserBlockViewProps { + block: AskUserBlock; + /** 当前勾选的选项 id(受控;由上层按会话保存)。 */ + selectedIds?: readonly string[]; + /** 勾选/取消勾选一个选项(单选时上层做互斥)。 */ + onToggleOption?: (optionId: string) => void; + /** 提交答案(把全部勾选的 id 交回上层)。 */ + onConfirm?: (optionIds: string[]) => void; +} + +const OPTION_LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + +export const AskUserBlockView = memo(function AskUserBlockView({ + block, + selectedIds, + onToggleOption, + onConfirm, +}: AskUserBlockViewProps) { + const selected = selectedIds ?? []; + const answered = block.answered === true; + const interactive = !answered && onToggleOption !== undefined; + + return ( +
+
+ 需要你回答 + {block.multiSelect ? '可多选' : '单选'} +
+
{block.question}
+
+ {block.options.map((opt, idx) => { + const isSelected = selected.includes(opt.id); + return ( + + ); + })} +
+ {answered ? ( +
+ ✓ + 已回答 +
+ ) : onConfirm !== undefined ? ( +
+ +
+ ) : null} +
+ ); +}); diff --git a/packages/webui-react/src/ui/chat/blocks/ErrorBlockView.tsx b/packages/webui-react/src/ui/chat/blocks/ErrorBlockView.tsx new file mode 100644 index 00000000..c4f9d2f6 --- /dev/null +++ b/packages/webui-react/src/ui/chat/blocks/ErrorBlockView.tsx @@ -0,0 +1,20 @@ +/** + * blocks/ErrorBlockView.tsx —— 错误块(红框) + */ + +import { memo } from 'react'; +import type { ErrorBlock } from '../../../contracts/domain'; +import './blocks.css'; + +export interface ErrorBlockViewProps { + block: ErrorBlock; +} + +export const ErrorBlockView = memo(function ErrorBlockView({ block }: ErrorBlockViewProps) { + return ( +
+
错误
+
{block.text}
+
+ ); +}); diff --git a/packages/webui-react/src/ui/chat/blocks/PlanBlockView.tsx b/packages/webui-react/src/ui/chat/blocks/PlanBlockView.tsx new file mode 100644 index 00000000..bdcc12f5 --- /dev/null +++ b/packages/webui-react/src/ui/chat/blocks/PlanBlockView.tsx @@ -0,0 +1,37 @@ +/** + * blocks/PlanBlockView.tsx —— 计划块(标题 + 步骤列表 + 状态徽标) + */ + +import { memo } from 'react'; +import type { PlanBlock } from '../../../contracts/domain'; +import './blocks.css'; + +export interface PlanBlockViewProps { + block: PlanBlock; +} + +const STATUS_TEXT: Record = { + pending: '待确认', + agreed: '已确认', + skipped: '已跳过', +}; + +export const PlanBlockView = memo(function PlanBlockView({ block }: PlanBlockViewProps) { + return ( +
+
+ {block.title} + + {STATUS_TEXT[block.status]} + +
+
    + {block.steps.map((step, idx) => ( +
  1. + {step} +
  2. + ))} +
+
+ ); +}); diff --git a/packages/webui-react/src/ui/chat/blocks/TextBlockView.tsx b/packages/webui-react/src/ui/chat/blocks/TextBlockView.tsx new file mode 100644 index 00000000..2fa533a0 --- /dev/null +++ b/packages/webui-react/src/ui/chat/blocks/TextBlockView.tsx @@ -0,0 +1,25 @@ +/** + * blocks/TextBlockView.tsx —— Markdown 文本块 + * ============================================================================ + * 哑组件:只吃 TextBlock,渲染为 React 节点(不使用 dangerouslySetInnerHTML, + * 从根上杜绝 XSS)。渲染器来自 blocks/markdown.tsx —— 与右栏文档预览共享同一 + * 最小 Markdown 子集实现(复用保证两处视觉一致)。 + * markdown=false 时按纯文本(pre-wrap)渲染。 + * ============================================================================ + */ + +import { memo } from 'react'; +import type { TextBlock } from '../../../contracts/domain'; +import { renderMarkdown } from './markdown'; +import './blocks.css'; + +export interface TextBlockViewProps { + block: TextBlock; +} + +export const TextBlockView = memo(function TextBlockView({ block }: TextBlockViewProps) { + if (block.markdown === false) { + return
{block.text}
; + } + return
{renderMarkdown(block.text)}
; +}); diff --git a/packages/webui-react/src/ui/chat/blocks/ThinkingBlockView.tsx b/packages/webui-react/src/ui/chat/blocks/ThinkingBlockView.tsx new file mode 100644 index 00000000..68cb7d73 --- /dev/null +++ b/packages/webui-react/src/ui/chat/blocks/ThinkingBlockView.tsx @@ -0,0 +1,58 @@ +/** + * blocks/ThinkingBlockView.tsx —— 思考块(三态:折叠 / 5 行预览 / 全文) + * ============================================================================ + * 默认折叠;点标题进入「5 行预览」(固定高度 + 滚动),再点「展开全部」看全文, + * 「收起」回预览、再点标题回折叠。组件内 useState(不持久化 —— 与 vanilla + *
语义一致,切会话/重渲染不串状态)。 + * 【改动原因】用户反馈:展开后全文无上限,长思维链把会话撑爆 —— 固定 5 行预览。 + * ============================================================================ + */ + +import { memo, useState } from 'react'; +import type { ThinkingBlock } from '../../../contracts/domain'; +import './blocks.css'; + +export interface ThinkingBlockViewProps { + block: ThinkingBlock; +} + +type ThinkMode = 'fold' | 'preview' | 'full'; + +export const ThinkingBlockView = memo(function ThinkingBlockView({ block }: ThinkingBlockViewProps) { + const [mode, setMode] = useState('fold'); + const label = block.done === false ? '思考中' : '思考过程'; + + return ( +
+ + + {mode !== 'fold' ? ( + <> +
+            {block.text}
+          
+
+ {mode === 'preview' ? ( + + ) : ( + + )} +
+ + ) : null} +
+ ); +}); diff --git a/packages/webui-react/src/ui/chat/blocks/ToolCallBlockView.tsx b/packages/webui-react/src/ui/chat/blocks/ToolCallBlockView.tsx new file mode 100644 index 00000000..e5f9a0d0 --- /dev/null +++ b/packages/webui-react/src/ui/chat/blocks/ToolCallBlockView.tsx @@ -0,0 +1,58 @@ +/** + * blocks/ToolCallBlockView.tsx —— 工具调用块 + * toolName + 状态徽标(running/done/error)+ 可折叠 args(JSON)。 + */ + +import { memo } from 'react'; +import type { ToolCallBlock } from '../../../contracts/domain'; +import './blocks.css'; + +export interface ToolCallBlockViewProps { + block: ToolCallBlock; +} + +const STATUS_TEXT: Record = { + running: 'running', + done: 'done', + error: 'error', +}; + +/** unknown → 稳定可读的 JSON 文本(绝不抛错)。 */ +export function formatArgs(args: unknown): string { + if (typeof args === 'string') return args; + try { + return JSON.stringify(args, null, 2) ?? String(args); + } catch { + return String(args); + } +} + +export const ToolCallBlockView = memo(function ToolCallBlockView({ block }: ToolCallBlockViewProps) { + return ( +
+ + ▶ + {block.toolName} + {block.summary !== undefined && block.summary !== '' ? ( + {block.summary} + ) : null} + + {STATUS_TEXT[block.status]} + + +
+ {block.args !== undefined ? ( +
+
args
+
{formatArgs(block.args)}
+
+ ) : null} + {block.status === 'error' ? ( +
+
工具调用失败{block.summary ? ':' + block.summary : ''}
+
+ ) : null} +
+
+ ); +}); diff --git a/packages/webui-react/src/ui/chat/blocks/ToolResultBlockView.tsx b/packages/webui-react/src/ui/chat/blocks/ToolResultBlockView.tsx new file mode 100644 index 00000000..26b2de6e --- /dev/null +++ b/packages/webui-react/src/ui/chat/blocks/ToolResultBlockView.tsx @@ -0,0 +1,38 @@ +/** + * blocks/ToolResultBlockView.tsx —— 工具结果块 + * ok=false 时输出套红框(错误样式);输出可折叠。 + */ + +import { memo } from 'react'; +import type { ToolResultBlock } from '../../../contracts/domain'; +import './blocks.css'; + +export interface ToolResultBlockViewProps { + block: ToolResultBlock; +} + +export const ToolResultBlockView = memo(function ToolResultBlockView({ + block, +}: ToolResultBlockViewProps) { + return ( +
+ + ▶ + result + + {block.ok ? 'done' : 'error'} + + +
+
+
output
+ {block.ok ? ( +
{block.text}
+ ) : ( +
{block.text}
+ )} +
+
+
+ ); +}); diff --git a/packages/webui-react/src/ui/chat/blocks/blocks.css b/packages/webui-react/src/ui/chat/blocks/blocks.css new file mode 100644 index 00000000..96d4dd29 --- /dev/null +++ b/packages/webui-react/src/ui/chat/blocks/blocks.css @@ -0,0 +1,406 @@ +/* blocks/blocks.css —— 消息结构化块共用样式(text/thinking/tool/plan/ask/error) */ +/* 视觉基准 packages/webui/public/styles/main.css 的 .msg-* / .plan-block / .ask-block 段。 */ +/* 铁律:只使用 styles/tokens.css 的 CSS 变量,禁止硬编码颜色。 */ + +/* ── 通用:折叠头 ─────────────────────────────────────────────── */ +.blk-collapsible { + margin: 4px 0 4px 12px; + font-size: 12.5px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg); + overflow: hidden; +} +.blk-collapsible > summary { + list-style: none; + cursor: pointer; + padding: 6px 10px; + display: flex; + align-items: center; + gap: 6px; + user-select: none; + transition: background 0.12s; +} +.blk-collapsible > summary:hover { background: var(--bg-hover); } +.blk-collapsible > summary::-webkit-details-marker { display: none; } +.blk-collapsible > summary::marker { content: ''; } +.blk-caret { + display: inline-block; + font-size: 9px; + width: 10px; + flex-shrink: 0; + color: var(--text-tertiary); + transition: transform 0.15s; +} +.blk-collapsible[open] > summary .blk-caret { transform: rotate(90deg); color: var(--accent); } + +/* ── text:Markdown 文本块 ────────────────────────────────────── */ +.blk-text { + font-size: 14px; + line-height: 1.65; + word-wrap: break-word; +} +.blk-text--plain { white-space: pre-wrap; overflow-wrap: anywhere; } +.blk-text p { margin: 6px 0; } +.blk-text p:first-child { margin-top: 0; } +.blk-text p:last-child { margin-bottom: 0; } +.blk-text h1, .blk-text h2, .blk-text h3 { margin: 12px 0 6px; font-weight: 600; } +.blk-text h1 { font-size: 18px; } +.blk-text h2 { font-size: 16px; } +.blk-text h3 { font-size: 14px; } +.blk-text ul, .blk-text ol { margin: 6px 0; padding-left: 22px; } +.blk-text li { margin: 2px 0; } +.blk-text code { + background: var(--bg-hover); + padding: 1px 5px; + border-radius: 3px; + font-family: var(--font-mono); + font-size: 12.5px; +} +.blk-code { + background: var(--bg-sidebar); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 10px 12px; + margin: 8px 0; + overflow-x: auto; + font-size: 12.5px; +} +.blk-code code { background: none; padding: 0; font-family: var(--font-mono); } +.blk-text blockquote { + border-left: 3px solid var(--border); + padding-left: 12px; + color: var(--text-secondary); + margin: 6px 0; +} +.blk-text a { color: var(--accent); } +.blk-text hr { border: none; border-top: 1px solid var(--border); margin: 12px 0; } + +/* ── thinking:思考块(等宽、灰底、可折叠)─────────────────────── */ +.blk-thinking { color: var(--text-secondary); font-style: italic; } +.blk-thinking-label { color: var(--accent); font-weight: 600; font-style: normal; } +.blk-thinking-count { + margin-left: auto; + font-size: 10px; + color: var(--text-tertiary); + font-style: normal; + opacity: 0.7; +} +.blk-thinking-body { + margin: 0; + padding: 8px 12px 10px; + border-top: 1px solid var(--border); + background: var(--bg-sidebar); + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.5; + color: var(--text-secondary); + white-space: pre-wrap; + overflow-wrap: anywhere; + max-height: 320px; + overflow-y: auto; +} + +/* ── tool-call / tool-result ──────────────────────────────────── */ +.blk-tool-name { + font-family: var(--font-mono); + font-weight: 600; + color: var(--text); + font-size: 12.5px; +} +.blk-tool-summary { + color: var(--text-tertiary); + font-size: 11.5px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 40%; +} +.blk-tool-status { + margin-left: auto; + font-size: 10.5px; + padding: 1px 6px; + border-radius: 10px; + font-family: var(--font-mono); + text-transform: lowercase; + letter-spacing: 0.3px; + flex-shrink: 0; +} +.blk-tool-status--running { background: color-mix(in srgb, var(--accent) 20%, transparent); color: var(--accent); } +.blk-tool-status--done { background: color-mix(in srgb, var(--success) 20%, transparent); color: var(--success); } +.blk-tool-status--error { background: color-mix(in srgb, var(--danger) 20%, transparent); color: var(--danger); } +.blk-tool-body { + padding: 6px 10px 10px 10px; + border-top: 1px solid var(--border); + background: var(--bg-hover); +} +.blk-tool-section { margin-bottom: 6px; } +.blk-tool-section:last-child { margin-bottom: 0; } +.blk-tool-label { + display: inline-block; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-tertiary); + margin-bottom: 4px; +} +.blk-tool-pre { + margin: 0; + font-family: var(--font-mono); + font-size: 11.5px; + line-height: 1.5; + color: var(--text); + background: var(--bg); + padding: 6px 8px; + border-radius: 4px; + border: 1px solid var(--border); + overflow-x: auto; + white-space: pre-wrap; + word-break: break-all; + max-height: 240px; + overflow-y: auto; +} +.blk-tool-error { + font-size: 11.5px; + color: var(--danger); + background: color-mix(in srgb, var(--danger) 10%, transparent); + padding: 6px 8px; + border-radius: 4px; + border: 1px solid color-mix(in srgb, var(--danger) 30%, transparent); + font-family: var(--font-mono); + white-space: pre-wrap; + word-break: break-all; +} + +/* ── plan:计划块 ─────────────────────────────────────────────── */ +.blk-plan { + margin: 4px 0 10px 0; + padding: 14px 16px; + background: var(--bg-sidebar); + border: 1px solid var(--border); + border-left: 3px solid var(--accent); + border-radius: var(--radius-sm); + font-size: 13px; +} +.blk-plan-header { + display: flex; + align-items: center; + gap: 8px; + color: var(--accent-text); + font-weight: 600; + margin-bottom: 8px; +} +.blk-plan-title { font-size: 15px; } +.blk-plan-status { + margin-left: auto; + font-size: 10.5px; + padding: 1px 8px; + border-radius: 10px; + font-family: var(--font-mono); +} +.blk-plan-status--pending { background: var(--bg-hover); color: var(--text-tertiary); } +.blk-plan-status--agreed { background: color-mix(in srgb, var(--success) 20%, transparent); color: var(--success); } +.blk-plan-status--skipped { background: var(--bg-active); color: var(--text-secondary); } +.blk-plan-steps { + margin: 0; + padding-left: 22px; + color: var(--text); +} +.blk-plan-step { margin: 3px 0; line-height: 1.5; } + +/* ── ask-user:提问块 ─────────────────────────────────────────── */ +.blk-ask { + margin: 10px 0; + padding: 12px 14px; + background: var(--bg-sidebar); + border: 1px solid var(--border); + border-left: 3px solid var(--accent); + border-radius: var(--radius-sm); + font-size: 13px; +} +.blk-ask-header { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + color: var(--accent); + margin-bottom: 8px; +} +.blk-ask-multi { margin-left: auto; font-size: 11px; color: var(--text-tertiary); font-weight: normal; } +.blk-ask-question { color: var(--text); margin-bottom: 10px; font-size: 14px; font-weight: 500; } +.blk-ask-options { + display: flex; + flex-direction: column; + border-top: 1px solid var(--border-light); + border-bottom: 1px solid var(--border-light); +} +.blk-ask-opt { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + border: none; + border-bottom: 1px solid var(--border-light); + background: transparent; + color: var(--text); + font-size: 13px; + cursor: pointer; + text-align: left; + font-family: inherit; + transition: background 0.08s; + width: 100%; +} +.blk-ask-opt:last-child { border-bottom: none; } +.blk-ask-opt:hover:not(:disabled) { background: var(--bg-hover); } +.blk-ask-opt:disabled { opacity: 0.45; cursor: default; } +.blk-ask-opt--selected { + background: color-mix(in srgb, var(--accent) 14%, var(--bg-hover)); + color: var(--accent); + font-weight: 500; +} +.blk-ask-opt-box { + display: inline-grid; + place-items: center; + width: 22px; + height: 22px; + border-radius: 4px; + background: var(--bg-sidebar); + color: var(--text-tertiary); + font-size: 10.5px; + font-weight: 700; + font-family: var(--font-mono); + flex-shrink: 0; + border: 1px solid var(--border); +} +.blk-ask-opt--selected .blk-ask-opt-box { + background: var(--accent); + color: var(--on-accent); + border-color: var(--accent); +} +.blk-ask-opt-label { flex: 1; } +.blk-ask-opt-desc { + font-size: 11px; + color: var(--text-secondary); + margin-top: 2px; + line-height: 1.4; +} +.blk-ask-actions { display: flex; gap: 8px; margin-top: 10px; } +.blk-ask-confirm { + padding: 6px 16px; + background: var(--accent); + color: var(--on-accent); + border: 1px solid var(--accent); + border-radius: var(--radius-sm); + font-size: 12.5px; + cursor: pointer; + font-family: inherit; +} +.blk-ask-confirm:disabled { opacity: 0.45; cursor: default; } +.blk-ask-answered { + display: inline-flex; + align-items: center; + gap: 4px; + margin: 6px 0 2px; + padding: 4px 10px; + background: var(--bg-sidebar); + border: 1px solid var(--border); + border-radius: 4px; + font-size: 11.5px; + color: var(--text-secondary); + align-self: flex-start; +} +.blk-ask-answered-mark { color: var(--success); font-weight: 700; } + +/* ── error:错误块 ────────────────────────────────────────────── */ +.blk-error { + margin: 8px 0; + padding: 10px 12px; + border: 1px solid color-mix(in srgb, var(--danger) 35%, transparent); + border-left: 3px solid var(--danger); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--danger) 8%, transparent); +} +.blk-error-label { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--danger); + margin-bottom: 4px; +} +.blk-error-text { + font-family: var(--font-mono); + font-size: 12px; + color: var(--danger); + white-space: pre-wrap; + word-break: break-all; +} + +/* ── 未知 kind 兜底(JSON)────────────────────────────────────── */ +.blk-json { + margin: 6px 0; + padding: 8px 10px; + background: var(--bg-sidebar); + border: 1px dashed var(--border); + border-radius: var(--radius-sm); +} +.blk-json-label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-tertiary); + margin-bottom: 4px; +} +.blk-json-pre { + margin: 0; + font-family: var(--font-mono); + font-size: 11.5px; + color: var(--text-secondary); + white-space: pre-wrap; + word-break: break-all; + max-height: 240px; + overflow-y: auto; +} + +/* 思考块三态(折叠 / 5 行预览 / 全文):标题行 + 固定预览高 + 操作按钮 */ +.blk-thinking-toggle { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + padding: 2px 4px; + background: transparent; + border: none; + cursor: pointer; + font: inherit; + color: inherit; + text-align: left; +} + +.blk-thinking-toggle:hover .blk-thinking-label { text-decoration: underline; } + +/* 5 行预览:行高 1.5em × 5 = 7.5em;超出滚动 */ +.blk-thinking-body.blk-thinking-capped { + max-height: 7.5em; +} + +.blk-thinking-actions { + display: flex; + justify-content: flex-end; + gap: 6px; + margin-top: 2px; +} + +.blk-thinking-btn { + padding: 1px 8px; + background: transparent; + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-secondary); + font-size: 11px; + cursor: pointer; + font-family: inherit; +} + +.blk-thinking-btn:hover { background: var(--bg-hover); color: var(--text); } diff --git a/packages/webui-react/src/ui/chat/blocks/index.tsx b/packages/webui-react/src/ui/chat/blocks/index.tsx new file mode 100644 index 00000000..d8a98922 --- /dev/null +++ b/packages/webui-react/src/ui/chat/blocks/index.tsx @@ -0,0 +1,100 @@ +/** + * blocks/index.tsx —— 块分发器 BlockView + * ============================================================================ + * 按 block.kind 分发到具体块视图;未知 kind 兜底渲染 JSON(向前兼容: + * wire 侧新增块类型时旧 UI 不崩,只降级为 JSON 展示)。 + * + * 块分发表: + * 'text' → TextBlockView + * 'thinking' → ThinkingBlockView + * 'tool-call' → ToolCallBlockView + * 'tool-result'→ ToolResultBlockView + * 'plan' → PlanBlockView + * 'ask-user' → AskUserBlockView + * 'error' → ErrorBlockView + * default → UnknownBlockView(JSON 兜底) + * ============================================================================ + */ + +import { memo } from 'react'; +import type { MessageBlock } from '../../../contracts/domain'; +import { TextBlockView } from './TextBlockView'; +import { ThinkingBlockView } from './ThinkingBlockView'; +import { ToolCallBlockView } from './ToolCallBlockView'; +import { ToolResultBlockView } from './ToolResultBlockView'; +import { PlanBlockView } from './PlanBlockView'; +import { AskUserBlockView } from './AskUserBlockView'; +import { ErrorBlockView } from './ErrorBlockView'; +import './blocks.css'; + +export interface BlockViewProps { + block: MessageBlock; + /** 透传给 ask-user 块的受控选中态(由上层按会话保存)。 */ + askSelectedIds?: readonly string[]; + onAskToggleOption?: (optionId: string) => void; + onAskConfirm?: (optionIds: string[]) => void; +} + +/** 未知块兜底:整块 JSON 展示。 */ +const UnknownBlockView = memo(function UnknownBlockView({ block }: { block: MessageBlock }) { + let text: string; + try { + text = JSON.stringify(block, null, 2) ?? String(block); + } catch { + text = String(block); + } + return ( +
+
未知消息块
+
{text}
+
+ ); +}); + +export const BlockView = memo(function BlockView({ + block, + askSelectedIds, + onAskToggleOption, + onAskConfirm, +}: BlockViewProps) { + switch (block.kind) { + case 'text': + return ; + case 'thinking': + return ; + case 'tool-call': + return ; + case 'tool-result': + return ; + case 'plan': + return ; + case 'ask-user': + return ( + + ); + case 'error': + return ; + default: + return ; + } +}); + +export { TextBlockView } from './TextBlockView'; +export { ThinkingBlockView } from './ThinkingBlockView'; +export { ToolCallBlockView, formatArgs } from './ToolCallBlockView'; +export { ToolResultBlockView } from './ToolResultBlockView'; +export { PlanBlockView } from './PlanBlockView'; +export { AskUserBlockView } from './AskUserBlockView'; +export { ErrorBlockView } from './ErrorBlockView'; +export type { TextBlockViewProps } from './TextBlockView'; +export type { ThinkingBlockViewProps } from './ThinkingBlockView'; +export type { ToolCallBlockViewProps } from './ToolCallBlockView'; +export type { ToolResultBlockViewProps } from './ToolResultBlockView'; +export type { PlanBlockViewProps } from './PlanBlockView'; +export type { AskUserBlockViewProps } from './AskUserBlockView'; +export type { ErrorBlockViewProps } from './ErrorBlockView'; diff --git a/packages/webui-react/src/ui/chat/blocks/markdown.tsx b/packages/webui-react/src/ui/chat/blocks/markdown.tsx new file mode 100644 index 00000000..56c5040e --- /dev/null +++ b/packages/webui-react/src/ui/chat/blocks/markdown.tsx @@ -0,0 +1,169 @@ +/** + * blocks/markdown.tsx —— 最小 Markdown 子集渲染器(共享纯函数) + * ============================================================================ + * 从 TextBlockView 抽出:助手消息文本块与右栏文档预览(README 等)共用同一套 + * 渲染逻辑 —— 复用保证两处视觉一致,且都不使用 dangerouslySetInnerHTML(无 XSS)。 + * 支持:围栏代码块 / 标题(# ~ ####) / 列表(-、*、1.) / 引用(>) / + * 分隔线(---) / 行内 code、**粗体**、*斜体*、[文字](链接)。 + * 无外部运行时依赖。 + * ============================================================================ + */ + +import type { ReactNode } from 'react'; + +/** 行内小语法:`code` / **bold** / *em* / [text](href)。返回 React 节点,无 innerHTML。 */ +export function renderInline(text: string, keyPrefix: string): ReactNode[] { + const nodes: ReactNode[] = []; + const re = /(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*\n]+\*)|(\[[^\]]+\]\([^)\s]+\))/g; + let last = 0; + let k = 0; + let m: RegExpExecArray | null = re.exec(text); + while (m !== null) { + if (m.index > last) nodes.push(text.slice(last, m.index)); + const token = m[0]; + const key = keyPrefix + '-i' + String(k); + k += 1; + if (token.startsWith('`')) { + nodes.push({token.slice(1, -1)}); + } else if (token.startsWith('**')) { + nodes.push({token.slice(2, -2)}); + } else if (token.startsWith('[')) { + const mid = token.indexOf(']('); + const label = token.slice(1, mid); + const href = token.slice(mid + 2, -1); + nodes.push( + + {label} + , + ); + } else { + nodes.push({token.slice(1, -1)}); + } + last = m.index + token.length; + m = re.exec(text); + } + if (last < text.length) nodes.push(text.slice(last)); + return nodes; +} + +function startsSpecial(line: string): boolean { + return ( + /^```/.test(line) || + /^#{1,4}\s+/.test(line) || + /^(-{3,}|\*{3,})\s*$/.test(line) || + /^>\s?/.test(line) || + /^\s*[-*]\s+/.test(line) || + /^\s*\d+[.)]\s+/.test(line) + ); +} + +/** 最小 Markdown 子集 → React 节点列表(纯函数,无 DOM/无 IO)。 */ +export function renderMarkdown(src: string): ReactNode[] { + const lines = src.split('\n'); + const out: ReactNode[] = []; + let i = 0; + let k = 0; + while (i < lines.length) { + const line = lines[i]; + if (line.trim() === '') { + i += 1; + continue; + } + // 围栏代码块 + const fence = /^```(\S*)\s*$/.exec(line); + if (fence !== null) { + const lang = fence[1] || ''; + const buf: string[] = []; + i += 1; + while (i < lines.length && !/^```\s*$/.test(lines[i])) { + buf.push(lines[i]); + i += 1; + } + i += 1; // 吃掉收尾围栏(或到 EOF) + out.push( +
+          {buf.join('\n')}
+        
, + ); + k += 1; + continue; + } + // 标题 + const heading = /^(#{1,4})\s+(.*)$/.exec(line); + if (heading !== null) { + const level = Math.min(heading[1].length, 3); + const inner = renderInline(heading[2], 'md-h' + String(k)); + if (level === 1) out.push(

{inner}

); + else if (level === 2) out.push(

{inner}

); + else out.push(

{inner}

); + k += 1; + i += 1; + continue; + } + // 分隔线 + if (/^(-{3,}|\*{3,})\s*$/.test(line)) { + out.push(
); + k += 1; + i += 1; + continue; + } + // 引用 + if (/^>\s?/.test(line)) { + const buf: string[] = []; + while (i < lines.length && /^>\s?/.test(lines[i])) { + buf.push(lines[i].replace(/^>\s?/, '')); + i += 1; + } + out.push( +
{renderInline(buf.join(' '), 'md-q' + String(k))}
, + ); + k += 1; + i += 1; + continue; + } + // 列表(无序 / 有序,连续行成组) + if (/^\s*[-*]\s+/.test(line) || /^\s*\d+[.)]\s+/.test(line)) { + const ordered = /^\s*\d+[.)]\s+/.test(line); + const items: string[] = []; + while ( + i < lines.length && + (/^\s*[-*]\s+/.test(lines[i]) || /^\s*\d+[.)]\s+/.test(lines[i])) + ) { + items.push(lines[i].replace(/^\s*(?:[-*]|\d+[.)])\s+/, '')); + i += 1; + } + const inner = items.map((t, idx) => ( +
  • + {renderInline(t, 'md-' + String(k) + '-l' + String(idx))} +
  • + )); + out.push( + ordered ? ( +
      {inner}
    + ) : ( +
      {inner}
    + ), + ); + k += 1; + continue; + } + // 段落:连续普通行合并为一个

    (行间用
    ,贴近聊天原文排版) + const buf: string[] = []; + while (i < lines.length && lines[i].trim() !== '' && !startsSpecial(lines[i])) { + buf.push(lines[i]); + i += 1; + } + out.push( +

    + {buf.map((t, idx) => ( + + {idx > 0 ?
    : null} + {renderInline(t, 'md-' + String(k) + '-p' + String(idx))} +
    + ))} +

    , + ); + k += 1; + } + return out; +} diff --git a/packages/webui-react/src/ui/chat/empty.css b/packages/webui-react/src/ui/chat/empty.css new file mode 100644 index 00000000..094c2613 --- /dev/null +++ b/packages/webui-react/src/ui/chat/empty.css @@ -0,0 +1,33 @@ +/* chat/empty.css —— 欢迎空态(品牌 logo + 提示语) */ +/* 视觉基准:public/styles/main.css 的 .chat-empty / .chat-empty-logo / .chat-empty-subtitle。 */ +/* 铁律:只用 styles/tokens.css 的 CSS 变量。 */ + +.chat-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 55%; + padding: 60px 24px 20px; + text-align: center; + max-width: 800px; + margin: 0 auto; +} +.chat-empty-logo { + width: 110px; + height: 110px; + border-radius: 22px; + margin-bottom: 28px; + object-fit: contain; +} +.chat-empty-title { + font-size: 16px; + font-weight: 600; + color: var(--text); + margin-bottom: 8px; +} +.chat-empty-subtitle { + font-size: 17px; + color: var(--text-secondary); + font-weight: 500; +} diff --git a/packages/webui-react/src/ui/chat/execstatus.css b/packages/webui-react/src/ui/chat/execstatus.css new file mode 100644 index 00000000..0c0013b3 --- /dev/null +++ b/packages/webui-react/src/ui/chat/execstatus.css @@ -0,0 +1,47 @@ +/* chat/execstatus.css —— 执行状态条(ExecStatusRow) */ +/* 参考布局:左「共执行 5 秒 ›」,右「⚡24 token/s」,下方一条细分隔线。 */ + +.execrow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 6px 0 8px; + margin-bottom: 10px; + border-bottom: 1px solid var(--border-light); + font-size: 12px; + color: var(--text-tertiary); +} + +.execrow-left { + display: inline-flex; + align-items: center; + gap: 6px; +} + +/* 参考布局里耗时行尾的 › 箭头(可展开语义的视觉残留,这里静态展示) */ +.execrow-duration::after { + content: '›'; + margin-left: 4px; + color: var(--text-tertiary); +} + +.execrow-right { + display: inline-flex; + align-items: center; + gap: 4px; + font-variant-numeric: tabular-nums; +} + +/* 运行中的呼吸点 */ +.execrow-pulse { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--status-on); + animation: execrow-pulse 1.2s ease-in-out infinite; +} +@keyframes execrow-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.35; } +} diff --git a/packages/webui-react/src/ui/chat/item.css b/packages/webui-react/src/ui/chat/item.css new file mode 100644 index 00000000..c483e0d3 --- /dev/null +++ b/packages/webui-react/src/ui/chat/item.css @@ -0,0 +1,67 @@ +/* chat/item.css —— 单条消息样式(MessageItem,参考布局版) */ +/* 视觉基准:MiniMax Code 桌面端会话区 —— 用户消息右侧灰色圆角气泡(无头像、 */ +/* 无角色名);助手消息左对齐纯文本(无头像、无气泡),下方操作行 + 时间; */ +/* 系统消息居中弱化。铁律:只用 styles/tokens.css 的 CSS 变量。 */ + +.msg { + padding: 8px 0; +} + +/* 用户消息:整体右对齐(气泡 + 时间都靠右) */ +.msg--user { + display: flex; + flex-direction: column; + align-items: flex-end; +} + +/* 助手消息:左对齐纯文本 */ +.msg--assistant { + display: flex; + flex-direction: column; + align-items: flex-start; +} + +/* 系统消息:居中弱化为一条细线文本 */ +.msg--system { justify-content: center; } +.msg--system .msg-body { + font-size: 12px; + color: var(--text-tertiary); + text-align: center; +} + +.msg-body { min-width: 0; max-width: 100%; } + +/* 用户消息右侧气泡(参考布局:浅灰圆角、无描边) */ +.msg--user .msg-bubble { + background: var(--bg-active); + color: var(--text); + border-radius: 18px; + padding: 9px 15px; + max-width: 78%; +} +.msg--user .msg-bubble .msg-body { min-width: 0; } +.msg-bubble .blk-text a { color: var(--accent); } + +/* 用户消息时间(气泡下方右侧,弱化小字) */ +.msg-meta { + margin-top: 4px; + padding-right: 2px; +} +.msg-meta-time { + font-size: 11px; + color: var(--text-tertiary); + font-variant-numeric: tabular-nums; +} + +/* 流式光标 ▍ */ +.msg-cursor { + display: inline-block; + color: var(--accent); + animation: msg-cursor-blink 0.9s steps(2, end) infinite; + font-weight: 400; + margin-left: 1px; +} +@keyframes msg-cursor-blink { + 0%, 50% { opacity: 1; } + 50.01%, 100% { opacity: 0; } +} diff --git a/packages/webui-react/src/ui/chat/list.css b/packages/webui-react/src/ui/chat/list.css new file mode 100644 index 00000000..5d5846b8 --- /dev/null +++ b/packages/webui-react/src/ui/chat/list.css @@ -0,0 +1,18 @@ +/* chat/list.css —— 消息流滚动容器(MessageList) */ +/* 视觉基准:public/styles/main.css 的 .chat-scroll / .chat-inner。 */ +/* 铁律:只用 styles/tokens.css 的 CSS 变量。 */ + +.msg-list { + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + padding: 24px 0; +} +.msg-list-inner { + max-width: 1100px; + margin: 0 auto; + padding: 0 24px; + display: flex; + flex-direction: column; +} diff --git a/packages/webui-react/src/ui/chat/msgactions.css b/packages/webui-react/src/ui/chat/msgactions.css new file mode 100644 index 00000000..618b8653 --- /dev/null +++ b/packages/webui-react/src/ui/chat/msgactions.css @@ -0,0 +1,16 @@ +/* chat/msgactions.css —— 助手消息操作行(MessageActions) */ +/* 参考布局:一排灰色小图标(复制/赞/踩/重试)+ 右侧时间戳。 */ + +.msgact { + display: flex; + align-items: center; + gap: 2px; + margin-top: 6px; +} + +.msgact-time { + margin-left: 6px; + font-size: 11px; + color: var(--text-tertiary); + font-variant-numeric: tabular-nums; +} diff --git a/packages/webui-react/src/ui/chat/thinkingbar.css b/packages/webui-react/src/ui/chat/thinkingbar.css new file mode 100644 index 00000000..708ffb09 --- /dev/null +++ b/packages/webui-react/src/ui/chat/thinkingbar.css @@ -0,0 +1,40 @@ +/* chat/thinkingbar.css —— "思考中"指示(三个跳动圆点) */ +/* 视觉基准:public/styles/main.css 的 .chat-thinking / .chat-thinking-dots。 */ +/* 铁律:只用 styles/tokens.css 的 CSS 变量。 */ + +.thinking-bar { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 0 4px; +} +.thinking-bar-avatar { + width: 28px; + height: 28px; + border-radius: var(--radius-sm); + object-fit: cover; + flex-shrink: 0; +} +.thinking-dots { + display: inline-flex; + align-items: center; + gap: 4px; +} +.thinking-dots span { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--text-tertiary); + display: inline-block; + animation: thinking-bounce 1.2s ease-in-out infinite; +} +.thinking-dots span:nth-child(2) { animation-delay: 0.15s; } +.thinking-dots span:nth-child(3) { animation-delay: 0.3s; } +@keyframes thinking-bounce { + 0%, 80%, 100% { transform: translateY(0); opacity: 0.5; } + 40% { transform: translateY(-4px); opacity: 1; } +} +.thinking-bar-text { + font-size: 12.5px; + color: var(--text-secondary); +} diff --git a/packages/webui-react/src/ui/chat/time.ts b/packages/webui-react/src/ui/chat/time.ts new file mode 100644 index 00000000..0debf823 --- /dev/null +++ b/packages/webui-react/src/ui/chat/time.ts @@ -0,0 +1,18 @@ +/** + * chat/time.ts —— 消息时间格式化(MessageItem / MessageActions / ExecStatusRow 共用) + * 毫秒时间戳 → "YY/MM/dd HH:mm:ss";历史消息经 chatLinesToMessages 水合时 + * 用序号占位(0,1,2…),不是真实时间戳,一律返回 null(UI 显示 "--")。 + */ +export function formatMessageTime(ts: number): string | null { + if (!Number.isFinite(ts) || ts < 1e11) return null; + const d = new Date(ts); + const p = (n: number): string => String(n).padStart(2, '0'); + const yy = String(d.getFullYear()).slice(-2); + return `${yy}/${p(d.getMonth() + 1)}/${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`; +} + +/** ts → ISO(