// 真实 App 渲染层截图(离屏,注入假 IPC 数据): // npx electron scripts/capture-app.cjs // 产出 preview-shots/app-*.png:展开态 / 收起态 / markdown 渲染 const { app, BrowserWindow, ipcMain } = require('electron') const { mkdirSync, writeFileSync, existsSync } = require('node:fs') const { join } = require('node:path') app.commandLine.appendSwitch('disable-gpu') const ROOT = join(__dirname, '..') const OUT = join(ROOT, 'preview-shots') const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) const MARKDOWN_SAMPLE = [ '## 1. "这是演示数据,仅用于验证链路"', '', '我配置的 `hotword_lookup` 工具目前连的是**演示实现(demo)**,[项目主页](https://example.com)。', '', '| 说明 | 结论 |', '| :--- | ---: |', '| **工具可用** | 工具这个"壳"是好的 —— 链路没有问题 |', '| **数据源是空的** | 它背后的"仓库"是空的,查询返回"无匹配项" |', '', '- 列表项一,包含 `inline code`', '- 列表项二,**加粗** 与 ~~删除线~~', '', '```python', "print('Hello llm-ui!')", 'for i in range(3):', ' print(i)', '```', '', '```rust', 'fn main() {', ' println!("Hello, World!");', '}', '```' ].join('\n') const FAKE_STATE = { sessionId: 's1', sessionName: 'hi', streaming: false, activeTools: [], messageCount: 2, workspace: 'C:\\fake\\workspace' } const FAKE_SESSIONS = [ { id: 's1', path: 'C:\\fake\\s1.jsonl', name: 'hi', firstMessage: '帮我写一个hello world', created: '2026-01-01', modified: '2026-01-02', messageCount: 2 }, { id: 's2', path: 'C:\\fake\\s2.jsonl', name: 'hello world', firstMessage: '帮我写一个hello world', created: '2026-01-01', modified: '2026-01-02', messageCount: 1 } ] function registerFakeIpc() { ipcMain.handle('agent:state', () => FAKE_STATE) ipcMain.handle('sessions:list', () => FAKE_SESSIONS) ipcMain.handle('workspaces:list', () => [{ name: 'Default', path: 'C:\\fake\\workspace' }]) ipcMain.handle('models:catalog', () => ({ providers: [] })) ipcMain.handle('settings:get', () => ({ systemPrompt: '' })) ipcMain.handle('app:versions', () => ({ app: 'dev', electron: 'x', node: 'x', chrome: 'x', user: 'x' })) ipcMain.handle('agent:send', () => undefined) ipcMain.handle('chat:resources', () => ({ commands: [{ name: 'daily-report', description: '生成每日经营日报', kind: 'prompt' }], skills: [{ name: 'hotword-analysis', description: '分析近 7 天类目热点词' }] })) ipcMain.handle('files:list', () => [ { path: 'data/products.csv', name: 'products.csv', isDir: false }, { path: 'docs/sop.md', name: 'sop.md', isDir: false }, { path: 'hello_world.py', name: 'hello_world.py', isDir: false }, { path: 'assets', name: 'assets', isDir: true } ]) } async function capture(win, name) { // 离屏窗口 capturePage 常给旧帧:连拍多张,取最后一张(大概率已提交新帧) let image = null for (let k = 0; k < 4; k += 1) { win.webContents.invalidate() await sleep(320) image = await win.webContents.capturePage() } writeFileSync(join(OUT, name), image.toPNG()) console.log('saved', name, `${image.getSize().width}x${image.getSize().height}`) } async function run() { mkdirSync(OUT, { recursive: true }) registerFakeIpc() const preload = join(ROOT, 'out', 'preload', 'index.cjs') const win = new BrowserWindow({ width: 1280, height: 900, show: false, webPreferences: { paintWhenInitiallyHidden: true, backgroundThrottling: false, preload: existsSync(preload) ? preload : undefined, contextIsolation: true } }) await win.loadFile(join(ROOT, 'out', 'renderer', 'index.html')) // 清掉历史 localStorage(收起态持久化),再重载让侧栏以展开态启动 await win.webContents.executeJavaScript('try { localStorage.clear() } catch {}') await win.reload() await sleep(1200) // 注入一条含 markdown 的会话(user + assistant) win.webContents.send('agent:event', { type: 'session_switched', state: FAKE_STATE, messages: [ { role: 'user', content: '解释一下那两句话的意思' }, { role: 'assistant', content: [{ type: 'text', text: MARKDOWN_SAMPLE }] } ], activeTools: {} }) await sleep(1200) console.log( 'hl-diag:', await win.webContents.executeJavaScript( `(() => { const codes = [...document.querySelectorAll('code')]; return JSON.stringify({ ready: window.__shikiReady, codes: codes.map((c) => c.querySelectorAll('span[style]').length) }) })()` ) ) await capture(win, 'app-markdown.png') // 注入排队消息场景(streaming + 队列两条) win.webContents.send('agent:event', { type: 'agent_start' }) win.webContents.send('agent:event', { type: 'queue_update', steering: ['设置里面做成这样,分成基础设置、Agent 能力'], followUp: ['随后帮我补上skills、命令和附件的支持,chatbox里面/调起命令或skill,@则是引用文件,加号是附件'] }) await sleep(900) win.webContents.invalidate() await sleep(300) FAKE_STATE.permissionMode = 'confirm' await capture(win, 'app-queue.png') // 权限模式菜单 await win.webContents.executeJavaScript(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))`) win.webContents.send('agent:event', { type: 'agent_end' }) win.webContents.send('agent:event', { type: 'queue_update', steering: [], followUp: [] }) await sleep(500) await win.webContents.executeJavaScript( `[...document.querySelectorAll('button')].find((b) => b.title.startsWith('权限模式'))?.click()` ) await sleep(500) win.webContents.invalidate() await sleep(300) console.log( 'perm-diag:', await win.webContents.executeJavaScript( `(() => { const panels = [...document.querySelectorAll('div.fixed.z-40')]; const m = panels.find((d) => d.textContent.includes('减少确认次数')); if (!m) return 'closed'; const r = m.getBoundingClientRect(); const cs = getComputedStyle(m); return JSON.stringify({ x: r.x, y: r.y, w: r.width, h: r.height, opacity: cs.opacity, display: cs.display, zIndex: cs.zIndex, buttons: m.querySelectorAll('button').length }) })()` ) ) await capture(win, 'app-permissions.png') // 结束流式,恢复常规状态 win.webContents.send('agent:event', { type: 'agent_end' }) win.webContents.send('agent:event', { type: 'queue_update', steering: [], followUp: [] }) await sleep(400) // "/" 命令/技能面板 const typeInPrompt = (text) => win.webContents.executeJavaScript( `(() => { const ta = document.querySelector('textarea[aria-label="Prompt"]'); if (!ta) return 'no-ta'; const set = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; set.call(ta, ${JSON.stringify(text)}); ta.dispatchEvent(new Event('input', { bubbles: true })); return 'ok' })()` ) await typeInPrompt('/') await sleep(600) win.webContents.invalidate() await sleep(300) await capture(win, 'app-slash-menu.png') // "@ " 文件引用 await typeInPrompt('帮我看看 @') await sleep(600) win.webContents.invalidate() await sleep(300) await capture(win, 'app-at-menu.png') await typeInPrompt('') // 注入审批卡 win.webContents.send('agent:event', { type: 'approval_request', request: { id: 'approval-1', toolName: 'write', args: { path: 'hello_world.py', content: 'def main():\n print("Hello world")\n\nmain()' } } }) await sleep(800) win.webContents.invalidate() await sleep(400) await capture(win, 'app-approval.png') // 行内操作浮层:悬停操作按钮点击后弹出(重命名 / 删除) await win.webContents.executeJavaScript( `[...document.querySelectorAll('button')].find((b) => b.title === '重命名会话')?.click()` ) await sleep(500) win.webContents.invalidate() await sleep(300) await capture(win, 'app-row-rename.png') await win.webContents.executeJavaScript( `[...document.querySelectorAll('button')].find((b) => b.title === '删除会话')?.click()` ) await sleep(500) win.webContents.invalidate() await sleep(300) await capture(win, 'app-row-delete.png') await win.webContents.executeJavaScript(`document.body.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))`) // 设置页:Ctrl+, 打开,截「模型设置」与「常规」 await win.webContents.executeJavaScript( `window.dispatchEvent(new KeyboardEvent('keydown', { key: ',', ctrlKey: true, bubbles: true }))` ) await sleep(600) win.webContents.invalidate() await sleep(300) await capture(win, 'app-settings-model.png') await win.webContents.executeJavaScript( `[...document.querySelectorAll('button')].find((b) => b.textContent.trim() === '常规')?.click()` ) await sleep(400) win.webContents.invalidate() await sleep(300) await capture(win, 'app-settings-general.png') await win.webContents.executeJavaScript(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))`) console.log( 'diag:', await win.webContents.executeJavaScript( `(() => { const btn = document.querySelector('button[aria-label="拒绝并关闭"]'); if (!btn) return 'missing'; const card = btn.closest('div[class*="rounded-card"]') ?? btn.parentElement; const r = card.getBoundingClientRect(); return JSON.stringify({ x: r.x, y: r.y, w: r.width, h: r.height, winH: innerHeight, opacity: getComputedStyle(card).opacity }) })()` ) ) // 侧栏收起回归检查 const collapsed = await win.webContents.executeJavaScript( `(() => { const b = document.querySelector('[aria-label="折叠侧栏"]'); if (!b) return 'no-button'; b.click(); return 'clicked' })()` ) console.log('collapse:', collapsed) await sleep(800) const geo = await win.webContents.executeJavaScript( `(() => { const aside = document.querySelector('aside[aria-label="工作区导航"]') const row = document.querySelector('.sidebar-row') const icon = row ? row.querySelector('span') : null if (!aside || !row || !icon) return 'missing' const a = aside.getBoundingClientRect() const i = icon.getBoundingClientRect() return JSON.stringify({ rail: Math.round(a.width), iconCenter: Math.round(i.left - a.left + i.width / 2) }) })()` ) console.log('collapsed-geo:', geo) await capture(win, 'app-collapsed.png') app.exit(0) } app.whenReady().then(run).catch((err) => { console.error(err) app.exit(1) })