diff --git a/docs/eng-160/HANDOFF.md b/docs/eng-160/HANDOFF.md new file mode 100644 index 0000000..55b8275 --- /dev/null +++ b/docs/eng-160/HANDOFF.md @@ -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 换成受控传输;在此之前「已送出」绝不代表「已完成」。 diff --git a/docs/eng-160/evidence/browser-checks.json b/docs/eng-160/evidence/browser-checks.json new file mode 100644 index 0000000..336c918 --- /dev/null +++ b/docs/eng-160/evidence/browser-checks.json @@ -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" + ] +} diff --git a/docs/eng-160/evidence/default.png b/docs/eng-160/evidence/default.png new file mode 100644 index 0000000..adb5de4 Binary files /dev/null and b/docs/eng-160/evidence/default.png differ diff --git a/docs/eng-160/evidence/offline.png b/docs/eng-160/evidence/offline.png new file mode 100644 index 0000000..55ad72f Binary files /dev/null and b/docs/eng-160/evidence/offline.png differ diff --git a/docs/eng-160/evidence/report.png b/docs/eng-160/evidence/report.png new file mode 100644 index 0000000..5831230 Binary files /dev/null and b/docs/eng-160/evidence/report.png differ diff --git a/docs/eng-160/evidence/simulator-output.txt b/docs/eng-160/evidence/simulator-output.txt new file mode 100644 index 0000000..26abe0d --- /dev/null +++ b/docs/eng-160/evidence/simulator-output.txt @@ -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;未连接任何真实系统。 diff --git a/docs/eng-160/evidence/waiting.png b/docs/eng-160/evidence/waiting.png new file mode 100644 index 0000000..d8d3b5b Binary files /dev/null and b/docs/eng-160/evidence/waiting.png differ diff --git a/public/sim-reports/SIM-001-report.md b/public/sim-reports/SIM-001-report.md new file mode 100644 index 0000000..f15f07f --- /dev/null +++ b/public/sim-reports/SIM-001-report.md @@ -0,0 +1,7 @@ +# 模拟报告|SIM-001 + +这是 ENG-160 dry-run 的本地模拟文件,不对应真实工单。 + +模拟摘要:双角色桌面、领取确认、阻塞举手、报告文件与验收按钮均已进入前端演示。 + +未连接 Notion、真实 API、bridge 或任何凭证。 diff --git a/scripts/studio-browser.mjs b/scripts/studio-browser.mjs new file mode 100644 index 0000000..bd0cef8 --- /dev/null +++ b/scripts/studio-browser.mjs @@ -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 }); +} diff --git a/sim/dry-run.d.ts b/sim/dry-run.d.ts new file mode 100644 index 0000000..3fc5d93 --- /dev/null +++ b/sim/dry-run.d.ts @@ -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; diff --git a/sim/dry-run.js b/sim/dry-run.js new file mode 100644 index 0000000..32c1dd1 --- /dev/null +++ b/sim/dry-run.js @@ -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;未连接任何真实系统。'); +} diff --git a/src/main.ts b/src/main.ts index 21f335f..47daeb5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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"')]), diff --git a/src/studio/StudioHud.ts b/src/studio/StudioHud.ts new file mode 100644 index 0000000..7e67554 --- /dev/null +++ b/src/studio/StudioHud.ts @@ -0,0 +1,188 @@ +import type { Anchor } from '../world/World'; +import { bridgeView, latestReport, StudioStore, type StudioReport } from './state'; + +function esc(value: string) { + return value.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!); +} + +interface StudioHudActions { + claim(): void; + review(taskId: string, result: 'approved' | 'returned', note: string): void; +} + +type ModalKind = 'coo' | 'blocked' | 'report' | null; + +export class StudioHud { + private root = document.createElement('div'); + private banner = document.createElement('div'); + private notice = document.createElement('div'); + private tags = new Map(); + private file = document.createElement('button'); + private menu = document.createElement('div'); + private modal = document.createElement('div'); + private modalKind: ModalKind = null; + + constructor(container: HTMLElement, private studio: StudioStore, private actions: StudioHudActions) { + this.root.className = 'studio-hud'; + this.banner.className = 'studio-banner'; + this.banner.innerHTML = '
MSOS 工作室Agent Floor · dry-run
'; + this.notice.className = 'studio-notice'; + this.notice.setAttribute('role', 'status'); + this.notice.setAttribute('aria-live', 'polite'); + + const tagLayer = document.createElement('div'); + tagLayer.className = 'studio-tags'; + for (const [id, name, role] of [['coo', 'COO', '任务与验收'], ['engineer', 'Engineer', '执行与报告']]) { + const tag = document.createElement('button'); + tag.type = 'button'; + tag.className = 'studio-tag'; + tag.dataset.agent = id; + tag.innerHTML = `${name}${role}`; + tag.addEventListener('click', () => this.openAgent(id)); + tagLayer.append(tag); + this.tags.set(id, tag); + } + + this.file.type = 'button'; + this.file.className = 'studio-file'; + this.file.addEventListener('click', () => this.openReport()); + this.file.innerHTML = ''; + + this.menu.className = 'studio-menu-wrap'; + this.menu.innerHTML = ''; + this.menu.querySelector('.studio-claim')!.addEventListener('click', () => { this.actions.claim(); this.closeMenu(); }); + this.menu.querySelector('.studio-menu-close')!.addEventListener('click', () => this.closeMenu()); + this.menu.querySelector('.studio-scrim')!.addEventListener('click', () => this.closeMenu()); + + this.modal.className = 'studio-modal-wrap'; + this.root.append(this.banner, tagLayer, this.file, this.notice, this.menu, this.modal); + container.append(this.root); + this.studio.subscribe(() => this.render()); + addEventListener('keydown', (event) => { + if (event.key !== 'Escape') return; + this.closeMenu(); + this.closeModal(); + }); + this.render(); + } + + tick() { + const view = bridgeView(this.studio.state); + const bridge = this.banner.querySelector('.studio-bridge')!; + bridge.textContent = view.text; + bridge.classList.toggle('stale', view.stale); + this.banner.title = view.note; + } + + private render() { + const state = this.studio.state; + this.tick(); + this.notice.textContent = state.notice; + const engineer = this.tags.get('engineer')!; + engineer.querySelector('.studio-task')!.textContent = state.engineer.blocked + ? '举手 · 点击查看阻塞原因' + : state.engineer.claimPending + ? '已送出领取请求,等待确认' + : state.engineer.taskTitle; + engineer.classList.toggle('blocked', Boolean(state.engineer.blocked)); + engineer.classList.toggle('pending', state.engineer.claimPending); + this.tags.get('coo')!.querySelector('.studio-task')!.textContent = state.engineer.taskId ? `模拟工单 ${state.engineer.taskId}` : '模拟派单与验收'; + + const claim = this.menu.querySelector('.studio-claim')!; + claim.disabled = state.engineer.claimPending; + claim.textContent = state.engineer.claimPending ? '已送出领取请求,等待确认' : '向 COO 领任务'; + this.menu.querySelector('.studio-menu-reason')!.textContent = state.engineer.lastRejection || ''; + this.renderFile(); + this.renderModal(); + } + + private renderFile() { + const report = latestReport(this.studio.state); + this.file.classList.toggle('show', Boolean(report)); + this.file.classList.toggle('rejected', report?.status === 'rejected'); + if (!report) return; + this.file.querySelector('.studio-file-copy')!.textContent = report.status === 'rejected' + ? `${report.taskId} · 退回修改` + : `${report.taskId} · 模拟报告 · 待验收`; + this.file.setAttribute('aria-label', `打开 ${report.taskId} 模拟报告`); + } + + frame(anchors: Anchor[], reportAnchor: Anchor | null) { + const stageTop = 78; + const safeX = (x: number, element: HTMLElement) => { + const half = element.offsetWidth / 2; + return Math.max(half + 8, Math.min(this.root.clientWidth - half - 8, x)); + }; + for (const anchor of anchors) { + const tag = this.tags.get(anchor.id); + if (!tag) continue; + tag.style.transform = `translate3d(${safeX(anchor.x, tag)}px,${anchor.y + stageTop}px,0) translate(-50%,-100%)`; + tag.style.visibility = anchor.visible ? 'visible' : 'hidden'; + tag.style.zIndex = String(1000 - Math.round(anchor.depth * 4)); + } + if (reportAnchor) { + this.file.style.transform = `translate3d(${safeX(reportAnchor.x, this.file)}px,${reportAnchor.y + stageTop}px,0) translate(-50%,-50%)`; + this.file.style.visibility = reportAnchor.visible ? 'visible' : 'hidden'; + } + } + + openAgent(id: string) { + if (id === 'coo') this.modalKind = 'coo'; + else if (id === 'engineer' && this.studio.state.engineer.blocked) this.modalKind = 'blocked'; + else if (id === 'engineer') { + this.menu.classList.add('show'); + this.menu.querySelector('.studio-claim')!.focus(); + return; + } + this.renderModal(); + this.modal.querySelector('button')?.focus(); + } + + openReport() { + if (!latestReport(this.studio.state)) return; + this.modalKind = 'report'; + this.renderModal(); + this.modal.querySelector('.studio-close')?.focus(); + } + + private closeMenu() { this.menu.classList.remove('show'); } + private closeModal() { this.modalKind = null; this.renderModal(); } + + private renderModal() { + if (this.modalKind === 'report' && !latestReport(this.studio.state)) this.modalKind = null; + this.modal.classList.toggle('show', Boolean(this.modalKind)); + if (!this.modalKind) { this.modal.innerHTML = ''; return; } + const close = ''; + let body = ''; + if (this.modalKind === 'coo') { + body = `${close}COO · 模拟角色

任务与验收

模拟|COO 负责确认领取与验收结果。此页面不会连接真实 COO 系统。

`; + } else if (this.modalKind === 'blocked') { + const blocked = this.studio.state.engineer.blocked!; + body = `${close}${esc(blocked.taskId)} · 模拟阻塞

Engineer 举手了

${esc(blocked.reason)}

关闭窗口不会清除未解决标记;需由后续 dry-run 事件处理。

`; + } else { + const report = latestReport(this.studio.state)!; + body = this.reportBody(report); + } + this.modal.innerHTML = ``; + this.modal.querySelectorAll('.studio-scrim,.studio-close').forEach((button) => button.addEventListener('click', () => this.closeModal())); + if (this.modalKind === 'report') this.bindReview(); + } + + private reportBody(report: StudioReport) { + const pending = this.studio.state.reviewPending[report.taskId]; + const link = report.reportLocal + ? `打开本地模拟报告` + : report.reportUrl + ? `打开模拟报告链接` + : '模拟|未提供报告链接'; + return `${esc(report.taskId)} · ${report.status === 'rejected' ? '模拟退回' : '待验收'}

桌上报告

${esc(report.summary)}

${link}${report.note ? `

${esc(report.note)}

` : ''}

${pending ? '已送出,等待桥接确认' : '只有收到 review_result 才会改变文件状态'}

`; + } + + private bindReview() { + const report = latestReport(this.studio.state); + if (!report) return; + const note = this.modal.querySelector('#review-note')!; + this.modal.querySelector('.studio-approve')?.addEventListener('click', () => this.actions.review(report.taskId, 'approved', note.value || '模拟|验收通过')); + this.modal.querySelector('.studio-return')?.addEventListener('click', () => this.actions.review(report.taskId, 'returned', note.value || '模拟|退回修改')); + } +} diff --git a/src/studio/boot.ts b/src/studio/boot.ts new file mode 100644 index 0000000..788ee40 --- /dev/null +++ b/src/studio/boot.ts @@ -0,0 +1,88 @@ +import './studio.css'; +import { createState } from '../../shared/protocol.js'; +import { runDryRun } from '../../sim/dry-run.js'; +import { store } from '../core/store'; +import { World } from '../world/World'; +import { StudioHud } from './StudioHud'; +import { latestReport, StudioStore, type StudioEvent } from './state'; +import { postStudioAction, type StudioActionPayload } from './transport'; + +export async function bootStudio(app: HTMLElement, stage: HTMLElement) { + document.title = 'MSOS 工作室 · 模拟模式'; + document.documentElement.dataset.theme = 'light'; + app.classList.add('studio-mode'); + stage.classList.add('studio-stage'); + + const studio = new StudioStore(); + let hud: StudioHud; + const world = new World(stage, { + onPick(agentId) { if (agentId) hud.openAgent(agentId); }, + onHover() { /* Studio tags provide their own hover state. */ }, + onMessage() { /* No real messages are sent in dry-run. */ }, + }); + + const sendAction = async (payload: Omit) => { + const action = studio.beginAction({ ...payload, ts: Date.now() }); + try { + await postStudioAction(action); + } catch { + studio.state.notice = '模拟|本地动作处理器未接受请求,请重试'; + studio.state.engineer.claimPending = false; + if (action.task_id) delete studio.state.reviewPending[action.task_id]; + studio.emit(); + } + }; + + hud = new StudioHud(app, studio, { + claim: () => { void sendAction({ action: 'claim_request', agent: 'engineer' }); }, + review: (taskId, result, note) => { void sendAction({ action: 'review', task_id: taskId, result, note }); }, + }); + + store.replace(createState()); + store.subscribe(() => world.sync(store.state)); + store.applyLocal({ type: 'join', agent: 'coo', name: 'COO', role: 'Lead', source: 'dry-run', team: 'MSOS 工作室', activity: '模拟|确认任务与验收' }); + store.applyLocal({ type: 'join', agent: 'engineer', name: 'Engineer', role: 'Engineer', source: 'dry-run', team: 'MSOS 工作室', activity: '模拟|等待领取任务' }); + store.setMode('demo'); + world.setTheme('light'); + // Frame only the two-person MSOS room, not the surrounding demo amenities. + world.focusStudio(); + + studio.onEvent((ev) => { + if (ev.type === 'claim_confirmed' && ev.agent === 'engineer') world.walkEngineerToCoo(); + }); + studio.subscribe(() => { + const current = store.state.agents.engineer; + const engineer = studio.state.engineer; + const status = engineer.blocked ? 'waiting' : engineer.taskId ? 'working' : 'idle'; + const activity = engineer.blocked ? `模拟|举手:${engineer.blocked.reason}` : engineer.taskTitle; + if (current && (current.status !== status || current.activity !== activity)) { + store.applyLocal({ type: 'status', agent: 'engineer', status, activity }); + } + const report = latestReport(studio.state); + world.setStudioReport(report ? report.status : null); + }); + + world.onFrame = () => hud.frame(world.anchors(), world.deskAnchor('engineer')); + setInterval(() => hud.tick(), 1000); + + const dispatch = (event: StudioEvent) => studio.apply(event); + const params = new URLSearchParams(location.search); + const now = Date.now(); + dispatch({ type: 'bridge_status', mode: 'dry-run', connected: true, last_update_ts: now, note: '模拟|本地 dry-run 已就绪' }); + + const capture = params.get('capture'); + if (capture === 'waiting') { + void sendAction({ action: 'claim_request', agent: 'engineer' }); + } else if (capture === 'report') { + dispatch({ type: 'claim_confirmed', agent: 'engineer', task_id: 'SIM-001', task_title: '模拟|制作工作室领任务与验收界面', source: '模拟 COO' }); + dispatch({ type: 'report_ready', agent: 'engineer', task_id: 'SIM-001', report_local: '/sim-reports/SIM-001-report.md', summary: '模拟|已完成双角色桌面、领取确认、阻塞举手与验收交互;本文件只是本地演示。' }); + hud.openReport(); + } else if (capture === 'offline') { + dispatch({ type: 'bridge_status', mode: 'dry-run', connected: false, last_update_ts: now - 65_000, note: '模拟|桥接断线证据' }); + } else if (params.has('simulate')) { + void runDryRun(dispatch, { delayMs: 1400 }); + } + + // Read-only inspection handles for the local dry-run checks and captures. + Object.assign(window, { __studio: studio, __studioHud: hud, __floor: world }); +} diff --git a/src/studio/state.d.ts b/src/studio/state.d.ts new file mode 100644 index 0000000..5fac872 --- /dev/null +++ b/src/studio/state.d.ts @@ -0,0 +1,23 @@ +export type StudioEventType = 'claim_confirmed' | 'claim_rejected' | 'blocked' | 'report_ready' | 'review_result' | 'bridge_status'; +export interface StudioEvent { type: StudioEventType; agent?: string; task_id?: string; task_title?: string; source?: string; reason?: string; report_url?: string; report_local?: string; summary?: string; result?: string; note?: string; mode?: string; connected?: boolean; last_update_ts?: number; ts?: number } +export interface StudioAction { action: 'claim_request' | 'review'; agent?: string; task_id?: string; result?: string; note?: string; ts: number } +export interface StudioReport { taskId: string; status: 'ready' | 'rejected'; summary: string; reportUrl: string | null; reportLocal: string | null; note: string } +export interface StudioState { + bridge: { received: boolean; mode: string; connected: boolean | null; lastUpdateTs: number | null; note: string }; + engineer: { taskId: string | null; taskTitle: string; blocked: { taskId: string; reason: string } | null; claimPending: boolean; lastRejection: string | null }; + reports: Record; reportOrder: string[]; reviewPending: Record; notice: string; actions: StudioAction[]; +} +export function createStudioState(): StudioState; +export function applyStudioEvent(state: StudioState, ev: StudioEvent): StudioState; +export function beginStudioAction(state: StudioState, payload: Omit & { ts?: number }): StudioAction; +export function latestReport(state: StudioState): StudioReport | null; +export function bridgeView(state: StudioState, now?: number): { text: string; stale: boolean; note: string }; +export class StudioStore { + state: StudioState; + constructor(); + subscribe(fn: () => void): () => boolean; + onEvent(fn: (ev: StudioEvent) => void): () => boolean; + apply(ev: StudioEvent): void; + emit(): void; + beginAction(payload: Omit & { ts?: number }): StudioAction; +} diff --git a/src/studio/state.js b/src/studio/state.js new file mode 100644 index 0000000..ca9e4dc --- /dev/null +++ b/src/studio/state.js @@ -0,0 +1,141 @@ +// ENG-160 dry-run studio state. Deliberately separate from shared/protocol.js: +// no new event is accepted by, or changes the behaviour of, the existing server. + +export function createStudioState() { + return { + bridge: { received: false, mode: 'dry-run', connected: null, lastUpdateTs: null, note: '模拟|尚未收到桥接状态' }, + engineer: { taskId: null, taskTitle: '模拟|等待领取任务', blocked: null, claimPending: false, lastRejection: null }, + reports: {}, + reportOrder: [], + reviewPending: {}, + notice: '模拟|点击 Engineer,选择「向 COO 领任务」', + actions: [], + }; +} + +export function applyStudioEvent(state, ev) { + if (!ev || typeof ev !== 'object') return state; + switch (ev.type) { + case 'bridge_status': + state.bridge = { + received: true, + mode: String(ev.mode || 'dry-run'), + connected: ev.connected === true, + lastUpdateTs: Number.isFinite(ev.last_update_ts) ? ev.last_update_ts : null, + note: String(ev.note || '模拟|桥接状态已更新'), + }; + break; + case 'claim_confirmed': + if (ev.agent !== 'engineer') break; + state.engineer.taskId = String(ev.task_id || 'SIM-001'); + state.engineer.taskTitle = String(ev.task_title || '模拟|未命名工单'); + state.engineer.claimPending = false; + state.engineer.lastRejection = null; + state.engineer.blocked = null; + state.notice = `模拟|已领取 ${state.engineer.taskId} · ${state.engineer.taskTitle} · 来源 ${String(ev.source || '模拟 COO')}`; + break; + case 'claim_rejected': + if (ev.agent !== 'engineer') break; + state.engineer.claimPending = false; + state.engineer.lastRejection = String(ev.reason || '模拟|未提供原因'); + state.notice = `模拟|领取被退回:${state.engineer.lastRejection}`; + break; + case 'blocked': + if (ev.agent !== 'engineer') break; + state.engineer.blocked = { + taskId: String(ev.task_id || state.engineer.taskId || 'SIM-001'), + reason: String(ev.reason || '模拟|未提供阻塞原因'), + }; + state.notice = `模拟|Engineer 举手:${state.engineer.blocked.reason}`; + break; + case 'report_ready': { + if (ev.agent !== 'engineer') break; + const taskId = String(ev.task_id || state.engineer.taskId || 'SIM-001'); + if (!state.reports[taskId]) state.reportOrder.push(taskId); + state.reports[taskId] = { + taskId, + status: 'ready', + summary: String(ev.summary || '模拟|没有摘要'), + reportUrl: ev.report_url ? String(ev.report_url) : null, + reportLocal: ev.report_local ? String(ev.report_local) : null, + note: '', + }; + delete state.reviewPending[taskId]; + state.notice = `模拟|${taskId} 报告已放到 Engineer 桌上,待验收`; + break; + } + case 'review_result': { + const taskId = String(ev.task_id || ''); + const report = state.reports[taskId]; + if (!report) break; + const approved = ['approved', 'accepted', 'pass', 'passed'].includes(String(ev.result).toLowerCase()); + delete state.reviewPending[taskId]; + report.note = String(ev.note || '模拟|没有验收备注'); + if (approved) { + delete state.reports[taskId]; + state.reportOrder = state.reportOrder.filter((id) => id !== taskId); + state.notice = `模拟|${taskId} 验收通过,文件已收回`; + } else { + report.status = 'rejected'; + state.notice = `模拟|${taskId} 退回修改:${report.note}`; + } + break; + } + } + return state; +} + +/** Record the intent only. Confirmation events, never clicks, complete the work. */ +export function beginStudioAction(state, payload) { + const action = { ...payload, ts: Number.isFinite(payload.ts) ? payload.ts : Date.now() }; + state.actions.push(action); + if (action.action === 'claim_request' && action.agent === 'engineer') { + state.engineer.claimPending = true; + state.notice = '模拟|已送出领取请求,等待确认'; + } + if (action.action === 'review' && action.task_id) { + const taskId = String(action.task_id); + state.reviewPending[taskId] = { result: String(action.result || ''), note: String(action.note || '') }; + state.notice = `模拟|${taskId} 已送出,等待桥接确认`; + } + return action; +} + +export function latestReport(state) { + const id = state.reportOrder.at(-1); + return id ? state.reports[id] : null; +} + +export function bridgeView(state, now = Date.now()) { + const b = state.bridge; + if (!b.received) return { text: '模拟模式|状态未知', stale: true, note: b.note }; + const stale = !b.connected || !b.lastUpdateTs || now - b.lastUpdateTs > 60_000; + if (stale) { + const time = b.lastUpdateTs + ? new Date(b.lastUpdateTs).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false }) + : '未知'; + return { text: `模拟模式|资料过时/断线,最后更新 ${time}`, stale: true, note: b.note }; + } + return { text: '模拟模式|桥接已连接(本地模拟)', stale: false, note: b.note }; +} + +export class StudioStore { + constructor() { + this.state = createStudioState(); + this.listeners = new Set(); + this.eventListeners = new Set(); + } + subscribe(fn) { this.listeners.add(fn); return () => this.listeners.delete(fn); } + onEvent(fn) { this.eventListeners.add(fn); return () => this.eventListeners.delete(fn); } + emit() { for (const fn of this.listeners) fn(); } + apply(ev) { + applyStudioEvent(this.state, ev); + for (const fn of this.eventListeners) fn(ev); + this.emit(); + } + beginAction(payload) { + const action = beginStudioAction(this.state, payload); + this.emit(); + return action; + } +} diff --git a/src/studio/studio.css b/src/studio/studio.css new file mode 100644 index 0000000..a04fdda --- /dev/null +++ b/src/studio/studio.css @@ -0,0 +1,3 @@ +.studio-mode{--studio-ink:#172947;--studio-wine:#7f3f4d;--studio-paper:#f8f8f5;--studio-sage:#6f8478}.studio-mode .stage{top:78px;background:#dfe7e5}.studio-hud{position:absolute;inset:0;pointer-events:none;color:var(--studio-ink);font-family:'Geist Variable',system-ui,-apple-system,'PingFang SC',sans-serif}.studio-hud>*{pointer-events:auto}.studio-banner{position:absolute;top:max(10px,env(safe-area-inset-top));left:12px;right:12px;min-height:58px;padding:9px 14px;background:rgba(248,248,245,.96);border:1px solid rgba(23,41,71,.18);border-radius:14px;display:flex;align-items:center;justify-content:space-between;gap:12px;box-shadow:0 5px 18px rgba(23,41,71,.12);z-index:3000}.studio-brand{display:flex;flex-direction:column;white-space:nowrap}.studio-brand strong{font-size:16px}.studio-brand span{font-size:10px;color:#68747d}.studio-bridge{text-align:right;font-size:12px;font-weight:650;line-height:1.35;color:#34624c}.studio-bridge.stale{color:#8a433b}.studio-tags{position:absolute;inset:0;pointer-events:none;overflow:hidden}.studio-tag{pointer-events:auto;position:absolute;left:0;top:0;min-width:128px;min-height:52px;max-width:180px;padding:6px 10px;border:1px solid rgba(23,41,71,.25);border-radius:10px;background:rgba(248,248,245,.95);box-shadow:0 4px 12px rgba(23,41,71,.18);text-align:left;cursor:pointer}.studio-tag-top{display:flex;align-items:baseline;justify-content:space-between;gap:8px}.studio-tag b{font-size:14px}.studio-tag i{font-size:9px;font-style:normal;color:#68747d}.studio-task{display:block;margin-top:3px;font-size:10px;line-height:1.25;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.studio-tag.blocked{border:2px solid #b06a32;background:#fff3df}.studio-tag.pending{border-color:#7f3f4d}.studio-file{position:absolute;left:0;top:0;display:none;min-width:176px;min-height:48px;padding:7px 10px;border:1px solid #8c744b;border-radius:7px;background:#fff9e9;color:#4a3b23;box-shadow:0 4px 12px rgba(23,41,71,.2);font-size:11px;font-weight:700;z-index:2500;cursor:pointer}.studio-file.show{display:flex;align-items:center;gap:8px}.studio-file.rejected{background:#f9dfdb;border:2px solid #a33d35;color:#7b2924}.studio-notice{position:absolute;left:50%;bottom:max(14px,env(safe-area-inset-bottom));transform:translateX(-50%);width:max-content;max-width:calc(100vw - 24px);min-height:38px;padding:9px 14px;border-radius:10px;background:rgba(23,41,71,.92);color:#fff;font-size:11px;line-height:1.45;text-align:center;z-index:3100;pointer-events:none}.studio-menu-wrap,.studio-modal-wrap{position:absolute;inset:0;display:grid;place-items:center;padding:16px;visibility:hidden;pointer-events:none;z-index:4000}.studio-menu-wrap.show,.studio-modal-wrap.show{visibility:visible;pointer-events:auto}.studio-scrim{position:absolute;inset:0;width:100%;height:100%;border:0;background:rgba(13,24,37,.45);cursor:default}.studio-menu,.studio-modal{position:relative;width:min(360px,100%);max-height:calc(100dvh - 32px);overflow:auto;padding:22px;border-radius:18px;background:var(--studio-paper);box-shadow:0 20px 60px rgba(13,24,37,.3)}.studio-menu h2,.studio-modal h2{margin:4px 0 16px;font-size:23px}.studio-kicker{font-size:11px;font-weight:700;color:#6e7580}.studio-primary,.studio-secondary,.studio-approve,.studio-return{width:100%;min-height:48px;border-radius:10px;font-weight:700;cursor:pointer}.studio-primary{border:0;background:var(--studio-wine);color:#fff}.studio-primary:disabled{background:#d7d1cc;color:#5e5a56}.studio-secondary{margin-top:8px;border:1px solid rgba(23,41,71,.25);background:transparent}.studio-menu-reason{color:#94443b;font-size:12px;line-height:1.5}.studio-close{position:absolute;right:10px;top:10px;width:44px;height:44px;border:0;background:transparent;font-size:26px;cursor:pointer}.studio-modal p{font-size:14px;line-height:1.65}.studio-reason{padding:12px;border-left:4px solid #a33d35;background:#f9e5e1;color:#742e29}.studio-help,.studio-pending{font-size:11px!important;color:#66727d}.studio-report-link{display:flex;align-items:center;justify-content:center;min-height:44px;margin:10px 0;border:1px solid #536878;border-radius:9px;color:#294d68;font-weight:700;text-decoration:none}.studio-note-label{display:block;margin:14px 0 6px;font-size:12px;font-weight:700}.studio-modal textarea{width:100%;min-height:76px;padding:10px;border:1px solid #aab3b6;border-radius:9px;background:#fff;color:#172947;font:inherit;resize:vertical}.studio-review-actions{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:12px}.studio-approve{border:0;background:#315f4a;color:#fff}.studio-return{border:0;background:#8d4038;color:#fff}.studio-approve:disabled,.studio-return:disabled{opacity:.5;cursor:wait}.studio-tag:focus-visible,.studio-file:focus-visible,.studio-primary:focus-visible,.studio-secondary:focus-visible,.studio-close:focus-visible,.studio-approve:focus-visible,.studio-return:focus-visible,.studio-report-link:focus-visible{outline:3px solid #236b8e;outline-offset:3px} +@media(max-width:600px){.studio-banner{left:8px;right:8px;padding:8px 10px;gap:8px}.studio-brand strong{font-size:14px}.studio-brand span{font-size:9px}.studio-bridge{font-size:11px;max-width:205px}.studio-tag{min-width:120px;max-width:160px}.studio-notice{bottom:max(10px,env(safe-area-inset-bottom));font-size:10px}.studio-menu,.studio-modal{padding:20px 18px}.studio-review-actions{gap:8px}} +@media(prefers-reduced-motion:reduce){.studio-hud *{animation:none!important;transition:none!important}} diff --git a/src/studio/transport.ts b/src/studio/transport.ts new file mode 100644 index 0000000..de125f9 --- /dev/null +++ b/src/studio/transport.ts @@ -0,0 +1,30 @@ +// Local dry-run /actions handler. The Request is a real POST-shaped request, +// but it is resolved here and never passed to the network or the Agent Floor server. +export type StudioActionPayload = { + action: 'claim_request' | 'review'; + agent?: string; + task_id?: string; + result?: string; + note?: string; + ts: number; +}; + +export const dryRunActionRequests: StudioActionPayload[] = []; + +export async function postStudioAction(payload: StudioActionPayload): Promise { + const request = new Request('/actions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + return localActionHandler(request); +} + +async function localActionHandler(request: Request): Promise { + if (request.method !== 'POST' || new URL(request.url).pathname !== '/actions') { + return Response.json({ error: '模拟|未知动作入口' }, { status: 404 }); + } + const body = await request.json() as StudioActionPayload; + dryRunActionRequests.push(body); + return Response.json({ accepted: true, mode: '模拟模式', pending: true }, { status: 202 }); +} diff --git a/src/world/Character.ts b/src/world/Character.ts index b31efb8..06bece7 100644 --- a/src/world/Character.ts +++ b/src/world/Character.ts @@ -262,6 +262,15 @@ export class Character { this.speed = this.paceFor(this.path); } + /** Dry-run studio visit: walk to another desk and then return to this seat. */ + visit(waypoints: [number, number][]) { + if (!this.seat || this.mode === 'leaving' || this.mode === 'gone') return; + this.mode = 'walking'; + this.sitAmount = 0; + this.path = [...waypoints, [this.seat.x, this.seat.z]].map(([x, z]) => new Vector3(x, 0, z)); + this.speed = this.paceFor(this.path); + } + /** Long trips are walked briskly so arrivals never take more than ~9 s. */ private paceFor(path: Vector3[]) { let len = 0; diff --git a/src/world/Workstation.ts b/src/world/Workstation.ts index b07570b..ce842b8 100644 --- a/src/world/Workstation.ts +++ b/src/world/Workstation.ts @@ -1,4 +1,4 @@ -import { AdditiveBlending, Group, Mesh, MeshBasicMaterial, PlaneGeometry } from 'three'; +import { AdditiveBlending, Group, Mesh, MeshBasicMaterial, MeshStandardMaterial, PlaneGeometry } from 'three'; import type { Agent } from '../../shared/protocol'; import { clay, G } from './materials'; import { Kit } from './kit'; @@ -59,6 +59,8 @@ export class Workstation { private chair = new Group(); private screenMat: MeshBasicMaterial; private target: Seat | null = null; + private report = new Group(); + private reportMat = new MeshStandardMaterial({ color: '#f4e9c9', roughness: 0.85 }); constructor(agent: Agent) { const h = hash(agent.id); @@ -165,6 +167,15 @@ export class Workstation { const desk = k.build(); this.root.add(desk); + // ENG-160: optional dry-run report on the desk. Hidden in every normal floor. + const paper = new Mesh(new PlaneGeometry(0.28, 0.38), this.reportMat); + paper.rotation.x = -Math.PI / 2; + paper.rotation.z = 0.12; + this.report.add(paper); + this.report.position.set(0.34, DESK_H + 0.058, -0.64); + this.report.visible = false; + this.root.add(this.report); + // Live screen this.screenMat = new MeshBasicMaterial({ map: this.screen.texture, toneMapped: false }); this.screenMat.color.setScalar(screenBoost); @@ -217,10 +228,16 @@ export class Workstation { if (near) this.screen.update(agent, t, dark); } + setReport(status: 'ready' | 'rejected' | null) { + this.report.visible = Boolean(status); + this.reportMat.color.set(status === 'rejected' ? '#b94b43' : '#f4e9c9'); + } + dispose() { this.screen.dispose(); screenMats.delete(this.screenMat); this.screenMat.dispose(); + this.reportMat.dispose(); this.root.removeFromParent(); } } diff --git a/src/world/World.ts b/src/world/World.ts index ed67c51..3c16264 100644 --- a/src/world/World.ts +++ b/src/world/World.ts @@ -101,6 +101,7 @@ export class World { } private book = new SeatBook(); private state: WorldState | null = null; + private studioReport: 'ready' | 'rejected' | null = null; private theme: ThemeName = 'light'; private reduced = prefersReducedMotion(); private raycaster = new Raycaster(); @@ -311,6 +312,7 @@ export class World { s.character.setSeat(seat); } s.character.setStatus(agent.status); + if (agent.id === 'engineer') s.desk.setReport(this.studioReport); if (agent.status === 'offline' && !s.leaving) this.depart(agent.id); } @@ -400,6 +402,62 @@ export class World { }); } + // ---------------------------------------------------------------- ENG-160 dry-run studio + + setStudioReport(status: 'ready' | 'rejected' | null) { + this.studioReport = status; + this.agents.get('engineer')?.desk.setReport(status); + } + + walkEngineerToCoo() { + const engineer = this.agents.get('engineer'); + const room = engineer ? this.layout.get(engineer.team) : null; + const from = room?.seats.get('engineer'); + const to = room?.seats.get('coo'); + if (!room || !from || !to) return; + const aisle = room.d / 2 - 1.15; + engineer!.character.visit([ + [from.x, aisle], + [to.x, aisle], + [to.x, to.z + 0.9], + [to.x, aisle], + [from.x, aisle], + ]); + } + + focusStudio() { + const room = this.rooms.get('msos'); + const coo = this.agents.get('coo'); + const engineer = this.agents.get('engineer'); + if (!room || !coo || !engineer) return; + this.autoFrame = false; + const { w, d } = room.size; + const target = coo.character.root.getWorldPosition(new Vector3()) + .add(engineer.character.root.getWorldPosition(new Vector3())).multiplyScalar(0.5); + target.y = 0.6; + const dir = new Vector3(0.3, 1.1, 1).normalize(); + // Centre the seats rather than the asymmetric room so both HTML name tags + // stay inside a phone viewport. Ignore fitDistance's own target adjustment. + const dist = this.fitDistance([{ x: room.center.x, z: room.center.z, w, d }], target.clone(), dir, 0.82); + this.flyTo(target, target.clone().add(dir.multiplyScalar(dist)), null); + } + + deskAnchor(id: string): Anchor | null { + const seated = this.agents.get(id); + if (!seated) return null; + const v = seated.desk.root.getWorldPosition(new Vector3()); + v.y += 1.25; + const depth = v.distanceTo(this.camera.position); + v.project(this.camera); + return { + id, + x: (v.x * 0.5 + 0.5) * this.container.clientWidth, + y: (-v.y * 0.5 + 0.5) * this.container.clientHeight, + depth, + visible: v.z < 1 && seated.character.root.visible && !seated.leaving, + }; + } + // ---------------------------------------------------------------- selection & camera setSelected(id: string | null) { diff --git a/test/studio.test.mjs b/test/studio.test.mjs new file mode 100644 index 0000000..b038bc5 --- /dev/null +++ b/test/studio.test.mjs @@ -0,0 +1,67 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { normalizeEvent } from '../shared/protocol.js'; +import { applyStudioEvent, beginStudioAction, bridgeView, createStudioState, latestReport } from '../src/studio/state.js'; +import { createDryRunEvents } from '../sim/dry-run.js'; + +test('studio events stay out of the existing server protocol', () => { + assert.equal(normalizeEvent({ type: 'claim_confirmed', agent: 'engineer' }), null); + assert.equal(normalizeEvent({ type: 'review_result', task_id: 'SIM-001' }), null); +}); + +test('claim click waits; only claim_confirmed assigns the task', () => { + const s = createStudioState(); + beginStudioAction(s, { action: 'claim_request', agent: 'engineer', ts: 10 }); + assert.equal(s.engineer.claimPending, true); + assert.equal(s.engineer.taskId, null); + assert.match(s.notice, /等待确认/); + applyStudioEvent(s, { type: 'claim_confirmed', agent: 'engineer', task_id: 'SIM-001', task_title: '模拟|测试任务', source: '模拟 COO' }); + assert.equal(s.engineer.taskId, 'SIM-001'); + assert.equal(s.engineer.claimPending, false); +}); + +test('claim rejection keeps the reason visible', () => { + const s = createStudioState(); + beginStudioAction(s, { action: 'claim_request', agent: 'engineer' }); + applyStudioEvent(s, { type: 'claim_rejected', agent: 'engineer', reason: '模拟|稍后再试' }); + assert.equal(s.engineer.lastRejection, '模拟|稍后再试'); + assert.equal(s.engineer.taskId, null); +}); + +test('blocked remains unresolved until a later confirmed claim', () => { + const s = createStudioState(); + applyStudioEvent(s, { type: 'blocked', agent: 'engineer', task_id: 'SIM-001', reason: '模拟|缺少字段' }); + assert.equal(s.engineer.blocked.reason, '模拟|缺少字段'); + beginStudioAction(s, { action: 'claim_request', agent: 'engineer' }); + assert.ok(s.engineer.blocked, 'click must not clear the marker'); +}); + +test('review click cannot clear or recolour a report before review_result', () => { + const s = createStudioState(); + applyStudioEvent(s, { type: 'report_ready', agent: 'engineer', task_id: 'SIM-001', summary: '模拟|摘要', report_local: '/sim-reports/SIM-001-report.md' }); + beginStudioAction(s, { action: 'review', task_id: 'SIM-001', result: 'approved', note: '模拟|通过' }); + assert.equal(latestReport(s).status, 'ready'); + assert.ok(s.reviewPending['SIM-001']); + applyStudioEvent(s, { type: 'review_result', task_id: 'SIM-001', result: 'returned', note: '模拟|退回' }); + assert.equal(latestReport(s).status, 'rejected'); + applyStudioEvent(s, { type: 'review_result', task_id: 'SIM-001', result: 'approved', note: '模拟|通过' }); + assert.equal(latestReport(s), null); +}); + +test('bridge banner covers unknown, fresh, stale and disconnected states', () => { + const s = createStudioState(); + assert.match(bridgeView(s, 100_000).text, /状态未知/); + applyStudioEvent(s, { type: 'bridge_status', mode: 'dry-run', connected: true, last_update_ts: 100_000, note: '模拟' }); + assert.match(bridgeView(s, 120_000).text, /已连接/); + assert.match(bridgeView(s, 161_001).text, /资料过时/断线/); + applyStudioEvent(s, { type: 'bridge_status', connected: false, last_update_ts: 161_001, note: '模拟' }); + assert.match(bridgeView(s, 161_001).text, /断线/); +}); + +test('simulator includes every dry-run event in a deterministic order', () => { + const events = createDryRunEvents(1000); + assert.deepEqual(events.map((e) => e.type), ['bridge_status', 'claim_rejected', 'claim_confirmed', 'blocked', 'report_ready', 'review_result']); + assert.equal(events.filter((e) => e.type === 'claim_rejected').length, 1); + assert.equal(events.filter((e) => e.type === 'blocked').length, 1); + assert.ok(events.every((e) => !e.task_id || e.task_id.startsWith('SIM-'))); +});