Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
74 changes: 74 additions & 0 deletions docs/eng-160/HANDOFF.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# ENG-160-UI — MSOS 工作室 dry-run 前端

## STATUS

DONE(前端 dry-run)。基线 `main@c5422bb`,分支 `kimi/studio-ui`。未连接任何真实系统,未修改 Agent Floor server、`shared/protocol.js`、bridge 或 arthur-os。

## 现有事件类型与状态机

- 原协议只接受 `join / status / tool / message / task / leave / reset`;未知事件由 `normalizeEvent()` 丢弃。
- `join` 建立团队、角色与座位;`status` 更新 `idle / thinking / working / waiting / done / error / offline`;`tool` 默认令角色进入 `working`;`message` 产生角色间消息效果;`task` 更新团队任务;`leave` 转为 `offline`;`reset` 清空世界。
- `POST /events` 规范化并应用事件;`GET /stream` 先发送 `snapshot`,随后持续发送 `event + effects`,清理角色时发送 `removed`。本轮没有改这些行为。

## 新增 dry-run 事件(给 Engineer 对接)

这些事件目前只由 `src/studio/state.js` 的独立前端状态机消费,不是原 server 协议:

| 事件 | 字段 | 前端结果 |
| --- | --- | --- |
| `claim_confirmed` | `agent, task_id, task_title, source` | Engineer 走到 COO 桌前再回座位,名牌显示工单名 |
| `claim_rejected` | `agent, reason` | 保留并显示领取被拒原因 |
| `blocked` | `agent, task_id, reason` | Engineer 举手;点击角色只看原因,不清除标记 |
| `report_ready` | `agent, task_id, report_url \| report_local, summary` | 桌上出现文件,状态为待验收 |
| `review_result` | `task_id, result, note` | 通过后文件消失;退回后文件变红 |
| `bridge_status` | `mode, connected, last_update_ts, note` | 更新持续横幅;断线或超过 60 秒显示过时,未收到则显示状态未知 |

本地动作意图为 `POST /actions` 形状:`claim_request {action, agent, ts}` 与 `review {action, task_id, result, note, ts}`。`src/studio/transport.ts` 用本地 handler 返回 202,请求不会发出网络;点击只进入等待状态,必须由上述确认事件完成状态变更。

## CHANGED

- `src/main.ts`:仅在 `?studio` 动态加载独立工作室,不改变原入口。
- `src/studio/boot.ts`:建立 COO/Engineer 两席、连接 dry-run 状态机与场景。
- `src/studio/state.js`、`state.d.ts`:实现领取、阻塞、报告、验收与桥接横幅状态。
- `src/studio/transport.ts`:本地 mock `/actions` handler,记录请求意图,不发送网络。
- `src/studio/StudioHud.ts`、`studio.css`:双角色名牌、菜单、桌上文件、报告 modal、按钮与持续状态横幅。
- `src/world/Character.ts`:增加 `visit()` 往返走位。
- `src/world/Workstation.ts`:增加默认隐藏的报告文件及退回红色状态。
- `src/world/World.ts`:增加工作室取景、Engineer 往返 COO、报告与桌面锚点;原 reducer 和 server 不变。
- `sim/dry-run.js`、`dry-run.d.ts`:按固定顺序发送全部模拟事件,含一次拒绝与一次阻塞。
- `public/sim-reports/SIM-001-report.md`:本地模拟报告。
- `test/studio.test.mjs`:新增 7 项状态机与协议隔离测试。
- `scripts/studio-browser.mjs`:Chrome CDP 390×844 手机模式真实坐标验收与截图。
- `docs/eng-160/evidence/`:四张截图、浏览器验收 JSON、模拟器终端输出。

## TESTED

- `npm test`:17/17 PASS(原测试 10 项+新增 7 项)。
- `npm run build`:TypeScript 检查与 Vite production build PASS。
- `node sim/dry-run.js`:输出 `bridge_status → claim_rejected → claim_confirmed → blocked → report_ready → review_result`,全部标为模拟。
- `node scripts/studio-browser.mjs`:PASS;390×844、mobile emulation、真实坐标点击。覆盖 Engineer 菜单、领取等待确认、文件打开、验收通过、退回修改、阻塞原因、断线横幅;检查角色名牌和按钮可见、点击区至少 44px、无横向溢出、无外部资源来源。

## 证据

- `default.png`:默认只有 COO 与 Engineer 两席。
- `waiting.png`:领取请求已送出但仍等待确认。
- `report.png`:桌上报告打开,摘要、链接与验收按钮可见。
- `offline.png`:资料过时/断线横幅与退回文件。
- `browser-checks.json`:手机坐标验收结果。
- `simulator-output.txt`:模拟器终端输出。

## 本地运行

```bash
npm exec vite -- --host 127.0.0.1 --port 5175
# 打开 http://127.0.0.1:5175/?studio
# 自动播放: http://127.0.0.1:5175/?studio&simulate
node sim/dry-run.js
```

## 未接真实系统/边界

- 只完成画面、独立状态机与本地 dry-run 事件流;没有接 Notion、真实 COO、真实工单、真实 `/actions`、SSE 或 bridge。
- 没有 token、真实工单编号或真实报告链接;唯一编号为 `SIM-001`。
- 没有启动或修改 Agent Floor server;没有要求 Electron;没有改 `~/msos-studio/bridge/` 或 arthur-os。
- Engineer 对接时需在桥接层把真实确认结果映射为新增事件,并把本地 action handler 换成受控传输;在此之前「已送出」绝不代表「已完成」。
22 changes: 22 additions & 0 deletions docs/eng-160/evidence/browser-checks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"passed": true,
"viewport": "390x844",
"mobileEmulation": true,
"coordinateClicks": [
"Engineer menu",
"claim_request",
"report file",
"approve",
"return",
"blocked reason"
],
"externalOrigins": [
"http://127.0.0.1:5175"
],
"screenshots": [
"default.png",
"waiting.png",
"report.png",
"offline.png"
]
}
Binary file added docs/eng-160/evidence/default.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/eng-160/evidence/offline.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/eng-160/evidence/report.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions docs/eng-160/evidence/simulator-output.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[模拟] bridge_status {"type":"bridge_status","mode":"dry-run","connected":true,"last_update_ts":1793900410617,"note":"模拟|本地事件序列已连接","ts":1793900410617}
[模拟] claim_rejected {"type":"claim_rejected","agent":"engineer","reason":"模拟|COO 正在整理上一张工单,请稍后再领取","ts":1793900410618}
[模拟] claim_confirmed {"type":"claim_confirmed","agent":"engineer","task_id":"SIM-001","task_title":"模拟|制作工作室领任务与验收界面","source":"模拟 COO","ts":1793900410619}
[模拟] blocked {"type":"blocked","agent":"engineer","task_id":"SIM-001","reason":"模拟|等待确认报告摘要字段,未连接真实系统","ts":1793900410620}
[模拟] report_ready {"type":"report_ready","agent":"engineer","task_id":"SIM-001","report_local":"/sim-reports/SIM-001-report.md","summary":"模拟|已完成双角色桌面、领取确认、阻塞举手与验收交互;本文件只是本地演示。","ts":1793900410621}
[模拟] review_result {"type":"review_result","task_id":"SIM-001","result":"returned","note":"模拟|请补充手机端空状态说明后再验收","ts":1793900410622}
[模拟] 顺序完成:claim_rejected → claim_confirmed → blocked → report_ready → review_result;未连接任何真实系统。
Binary file added docs/eng-160/evidence/waiting.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions public/sim-reports/SIM-001-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# 模拟报告|SIM-001

这是 ENG-160 dry-run 的本地模拟文件,不对应真实工单。

模拟摘要:双角色桌面、领取确认、阻塞举手、报告文件与验收按钮均已进入前端演示。

未连接 Notion、真实 API、bridge 或任何凭证。
117 changes: 117 additions & 0 deletions scripts/studio-browser.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// ENG-160 Chrome mobile-mode interaction checks + four evidence screenshots.
// Requires only the Vite server: npm exec vite -- --host 127.0.0.1 --port 5175
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

const base = process.env.STUDIO_URL || 'http://127.0.0.1:5175';
const out = path.resolve('docs/eng-160/evidence');
fs.mkdirSync(out, { recursive: true });
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'eng160-chrome-'));
const chrome = spawn('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', [
'--headless=new', '--use-angle=swiftshader', '--no-first-run', '--no-default-browser-check',
'--remote-debugging-port=9223', `--user-data-dir=${profile}`, 'about:blank',
], { stdio: 'ignore' });
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
let ws;
try {
let version;
for (let i = 0; i < 40; i++) {
try { version = await fetch('http://127.0.0.1:9223/json/version').then((r) => r.json()); break; } catch { await wait(150); }
}
if (!version) throw new Error('Chrome DevTools did not start');
const target = await fetch('http://127.0.0.1:9223/json/new?about:blank', { method: 'PUT' }).then((r) => r.json());
ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((resolve, reject) => { ws.addEventListener('open', resolve, { once: true }); ws.addEventListener('error', reject, { once: true }); });
let id = 0;
const pending = new Map();
ws.addEventListener('message', (event) => {
const data = JSON.parse(event.data);
if (!data.id || !pending.has(data.id)) return;
const { resolve, reject } = pending.get(data.id);
pending.delete(data.id);
if (data.error) reject(new Error(JSON.stringify(data.error))); else resolve(data.result);
});
const send = (method, params = {}) => new Promise((resolve, reject) => {
const callId = ++id;
pending.set(callId, { resolve, reject });
ws.send(JSON.stringify({ id: callId, method, params }));
});
const evaluate = async (expression) => {
const result = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
if (result.exceptionDetails) throw new Error(JSON.stringify(result.exceptionDetails));
return result.result.value;
};
const navigate = async (capture) => {
await send('Page.navigate', { url: `${base}/?studio&capture=${capture}` });
await wait(5500);
};
const click = async (selector) => {
const point = await evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});if(!e)throw new Error('missing ${selector}');const r=e.getBoundingClientRect();return{x:r.left+r.width/2,y:r.top+r.height/2}})()`);
await send('Input.dispatchMouseEvent', { type: 'mousePressed', ...point, button: 'left', clickCount: 1 });
await send('Input.dispatchMouseEvent', { type: 'mouseReleased', ...point, button: 'left', clickCount: 1 });
await wait(150);
};
const shot = async (name) => {
const result = await send('Page.captureScreenshot', { format: 'png', fromSurface: true });
fs.writeFileSync(path.join(out, `${name}.png`), Buffer.from(result.data, 'base64'));
console.log(`[模拟截图] ${name}.png`);
};
const assert = (value, message) => { if (!value) throw new Error(message); };

await send('Page.enable');
await send('Emulation.setDeviceMetricsOverride', { width: 390, height: 844, deviceScaleFactor: 1, mobile: true });
await send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 5 });

await navigate('default');
assert(await evaluate(`Object.keys(__studio.state.reports).length===0 && document.querySelectorAll('.studio-tag').length===2`), 'default must have two roles and no report');
const defaultLayout = await evaluate(`({width:document.documentElement.scrollWidth,banner:document.querySelector('.studio-banner').getBoundingClientRect().toJSON(),tags:[...document.querySelectorAll('.studio-tag')].map(e=>({id:e.dataset.agent,visibility:getComputedStyle(e).visibility,...e.getBoundingClientRect().toJSON()}))})`);
assert(defaultLayout.width === 390 && defaultLayout.banner.right <= 390, 'default horizontal overflow');
assert(defaultLayout.tags.every((tag) => tag.visibility === 'visible' && tag.left >= 0 && tag.right <= 390 && tag.height >= 44), 'role tag clipped or below 44px');
await shot('default');

await click('[data-agent="engineer"]');
assert(await evaluate(`document.querySelector('.studio-menu-wrap').classList.contains('show')`), 'Engineer menu did not open');
assert(await evaluate(`document.querySelector('.studio-claim').getBoundingClientRect().height >= 44`), 'claim button below 44px');
await click('.studio-claim');
assert(await evaluate(`__studio.state.engineer.claimPending && __studio.state.engineer.taskId===null`), 'claim click must wait for confirmation');
await shot('waiting');

await evaluate(`__studio.apply({type:'claim_confirmed',agent:'engineer',task_id:'SIM-001',task_title:'模拟|手机交互验收',source:'模拟 COO'});__studio.apply({type:'report_ready',agent:'engineer',task_id:'SIM-001',report_local:'/sim-reports/SIM-001-report.md',summary:'模拟|手机模式坐标点击与验收状态检查。'})`);
await wait(300);
assert(await evaluate(`(()=>{const r=document.querySelector('.studio-file').getBoundingClientRect();return getComputedStyle(document.querySelector('.studio-file')).visibility==='visible'&&r.left>=0&&r.right<=390&&r.height>=44})()`), 'report file clipped or below 44px');
await click('.studio-file');
assert(await evaluate(`document.querySelector('.studio-modal-wrap').classList.contains('show')`), 'report did not open');
await shot('report');
const reportLayout = await evaluate(`({width:document.documentElement.scrollWidth,modal:document.querySelector('.studio-modal').getBoundingClientRect().toJSON(),buttons:[...document.querySelectorAll('.studio-modal button')].filter(e=>!e.classList.contains('studio-scrim')).map(e=>({c:e.className,w:e.getBoundingClientRect().width,h:e.getBoundingClientRect().height}))})`);
assert(reportLayout.width === 390 && reportLayout.modal.right <= 390, 'report modal overflow');
assert(reportLayout.buttons.every((b) => b.w >= 44 && b.h >= 44), 'report button below 44px');
await click('.studio-approve');
assert(await evaluate(`__studio.state.reports['SIM-001'].status==='ready' && !!__studio.state.reviewPending['SIM-001']`), 'approval click changed report before confirmation');
await evaluate(`__studio.apply({type:'review_result',task_id:'SIM-001',result:'approved',note:'模拟|通过'})`);
assert(await evaluate(`!__studio.state.reports['SIM-001']`), 'approved review_result should remove file');

await evaluate(`__studio.apply({type:'report_ready',agent:'engineer',task_id:'SIM-001',summary:'模拟|退回路径',report_local:'/sim-reports/SIM-001-report.md'});__studioHud.openReport()`);
await click('.studio-return');
assert(await evaluate(`__studio.state.reports['SIM-001'].status==='ready'`), 'return click changed report before confirmation');
await evaluate(`__studio.apply({type:'review_result',task_id:'SIM-001',result:'returned',note:'模拟|退回'});__studioHud.closeModal();__studio.apply({type:'blocked',agent:'engineer',task_id:'SIM-001',reason:'模拟|阻塞原因检查'})`);
assert(await evaluate(`__studio.state.reports['SIM-001'].status==='rejected'`), 'returned result should make file red');
await click('[data-agent="engineer"]');
assert(await evaluate(`document.querySelector('.studio-reason')?.textContent.includes('阻塞原因检查')`), 'blocked reason did not open');

await evaluate(`__studioHud.closeModal();__studio.apply({type:'bridge_status',mode:'dry-run',connected:false,last_update_ts:Date.now()-65000,note:'模拟|断线检查'})`);
await wait(200);
assert(await evaluate(`document.querySelector('.studio-bridge').textContent.includes('资料过时/断线')`), 'offline banner missing');
await shot('offline');

const origins = await evaluate(`[...new Set(performance.getEntriesByType('resource').map(e=>new URL(e.name).origin))]`);
assert(origins.every((origin) => origin === new URL(base).origin), 'unexpected external resource');
const report = { passed: true, viewport: '390x844', mobileEmulation: true, coordinateClicks: ['Engineer menu', 'claim_request', 'report file', 'approve', 'return', 'blocked reason'], externalOrigins: origins, screenshots: ['default.png', 'waiting.png', 'report.png', 'offline.png'] };
fs.writeFileSync(path.join(out, 'browser-checks.json'), JSON.stringify(report, null, 2) + '\n');
console.log(JSON.stringify(report, null, 2));
} finally {
if (ws) ws.close();
chrome.kill('SIGTERM');
fs.rmSync(profile, { recursive: true, force: true });
}
3 changes: 3 additions & 0 deletions sim/dry-run.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import type { StudioEvent } from '../src/studio/state';
export function createDryRunEvents(now?: number): StudioEvent[];
export function runDryRun(emit: (event: StudioEvent) => void, options?: { delayMs?: number; now?: number }): Promise<void>;
24 changes: 24 additions & 0 deletions sim/dry-run.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// ENG-160 deterministic dry-run event source. No sockets, tokens or real systems.
export function createDryRunEvents(now = Date.now()) {
return [
{ type: 'bridge_status', mode: 'dry-run', connected: true, last_update_ts: now, note: '模拟|本地事件序列已连接', ts: now },
{ type: 'claim_rejected', agent: 'engineer', reason: '模拟|COO 正在整理上一张工单,请稍后再领取', ts: now + 1 },
{ type: 'claim_confirmed', agent: 'engineer', task_id: 'SIM-001', task_title: '模拟|制作工作室领任务与验收界面', source: '模拟 COO', ts: now + 2 },
{ type: 'blocked', agent: 'engineer', task_id: 'SIM-001', reason: '模拟|等待确认报告摘要字段,未连接真实系统', ts: now + 3 },
{ type: 'report_ready', agent: 'engineer', task_id: 'SIM-001', report_local: '/sim-reports/SIM-001-report.md', summary: '模拟|已完成双角色桌面、领取确认、阻塞举手与验收交互;本文件只是本地演示。', ts: now + 4 },
{ type: 'review_result', task_id: 'SIM-001', result: 'returned', note: '模拟|请补充手机端空状态说明后再验收', ts: now + 5 },
];
}

export async function runDryRun(emit, { delayMs = 1200, now = Date.now() } = {}) {
for (const event of createDryRunEvents(now)) {
emit(event);
if (delayMs > 0) await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}

const isCli = typeof process !== 'undefined' && process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href;
if (isCli) {
for (const event of createDryRunEvents()) console.log(`[模拟] ${event.type} ${JSON.stringify(event)}`);
console.log('[模拟] 顺序完成:claim_rejected → claim_confirmed → blocked → report_ready → review_result;未连接任何真实系统。');
}
5 changes: 5 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ const systemDark = matchMedia('(prefers-color-scheme: dark)');
let theme: ThemeName = savedTheme() || (systemDark.matches ? 'dark' : 'light');

async function boot() {
if (new URLSearchParams(location.search).has('studio')) {
const { bootStudio } = await import('./studio/boot');
await bootStudio(app, stage);
return;
}
// Canvas textures draw text, so the fonts must be ready first.
await Promise.race([
Promise.all([document.fonts.load('600 24px "Geist Variable"'), document.fonts.load('500 20px "Geist Mono Variable"')]),
Expand Down
Loading