Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
fedf10b
fix(webui): stop the stale mcode acp singleton before replacing it
fengzhi09 Sep 23, 2026
b614b9e
feat(webui-react): add modular React + Ant Design web UI with port-ba…
fengzhi09 Sep 23, 2026
86be341
chore(webui-react): tidy the vitest-suites exemption comments
fengzhi09 Sep 23, 2026
8a28e4b
fix(webui-react): make the React UI reachable and stop the render loop
fengzhi09 Sep 23, 2026
871bd9f
feat(webui-react): make the React UI the default and archive the orig…
fengzhi09 Sep 23, 2026
6ccbbeb
fix(release): drop stray verification artifacts from the source inven…
fengzhi09 Sep 24, 2026
d81e649
test(webui): decouple static-routing tests from the vite build artifact
fengzhi09 Sep 24, 2026
242a7fe
fix(webui-react): hydrate per-session chat state and fix toggles/work…
fengzhi09 Sep 24, 2026
4100ac2
fix(webui-react): read usage quota from the /api/state usage field
fengzhi09 Sep 24, 2026
e927f0b
fix(webui-react): sync settings from state and use the server slash c…
fengzhi09 Sep 24, 2026
a1875a0
fix(webui-react): make the plan answer channel real and sync permissi…
fengzhi09 Sep 24, 2026
c249deb
feat(webui-react): group the model catalog by provider and pick model…
fengzhi09 Sep 24, 2026
84e5409
fix(webui-react): parse tool blocks, cap thinking preview, pin autosc…
fengzhi09 Sep 24, 2026
f6119ce
fix(webui): default workspace browse to the first allowed root
fengzhi09 Sep 24, 2026
2a07f70
feat(webui-react): four-column workspace UI, tabbed right sidebar, se…
fengzhi09 Sep 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
node_modules/
dist/
packages/webui/public/react/
.turbo/
.cache/
.pnpm-store/
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
97 changes: 97 additions & 0 deletions packages/webui-react/README.md
Original file line number Diff line number Diff line change
@@ -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` | **会话隔离**(切换不串扰、按会话独立保存选择)、**热插拔**(换供应商实现不动其他端口) |
13 changes: 13 additions & 0 deletions packages/webui-react/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh" data-theme="light">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<meta name="theme-color" content="#ffffff" />
<title>Mcode Web UI</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
35 changes: 35 additions & 0 deletions packages/webui-react/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
113 changes: 113 additions & 0 deletions packages/webui-react/scripts/check-layering.mjs
Original file line number Diff line number Diff line change
@@ -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);
78 changes: 78 additions & 0 deletions packages/webui-react/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* App.tsx —— 组装根的视图侧
* ============================================================================
* 【咬合】本文件只做装配:控制器构造(在 <RegistryProvider> 之外的进程内单例)、
* 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<boolean>((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); }, []);

// 主题与语言跟随快照,落到 <html data-theme>(tokens.css 据此切换)。
useEffect(() => { document.documentElement.dataset.theme = s.theme; }, [s.theme]);
useEffect(() => { setLang(s.lang); }, [s.lang]);

return (
<ConfigProvider
locale={s.lang === 'zh' ? zhCN : enUS}
theme={{
algorithm: s.theme === 'dark' ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
token: { borderRadius: 10, fontFamily: 'inherit' },
}}
>
<AntApp>
{/* 参考布局:无全局顶栏 —— 四栏直接满高(会话状态并入标题栏/左栏用户卡)。 */}
<AppShell
leftOpen={s.leftOpen}
rightOpen={s.rightOpen}
onBackdropClick={() => { a.setLeftOpen(false); a.setRightOpen(false); }}
left={<SessionsFeature controller={controller} />}
chat={<ChatFeature controller={controller} />}
right={<PanelsFeature controller={controller} />}
/>
<ModalsFeature controller={controller} />
</AntApp>
</ConfigProvider>
);
}
Loading