- beautifului.dev 组件直连:Sidebar Nav、Tool Chips、Approval Card(抽屉化)、Streaming/Thinking、Code Block、Selection Actions、Prompt Bar、Search
264 lines
10 KiB
JavaScript
264 lines
10 KiB
JavaScript
// 真实 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)
|
||
})
|