v0.6.0: beautifului 整合 + 会话/工作区/队列/权限/命令/附件全链路
- beautifului.dev 组件直连:Sidebar Nav、Tool Chips、Approval Card(抽屉化)、Streaming/Thinking、Code Block、Selection Actions、Prompt Bar、Search
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
// 真实 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)
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
// 预览页视觉验收截图(Electron 离屏捕获):
|
||||
// npx electron scripts/capture-preview.cjs
|
||||
// 产出 preview-shots/*.png:整页 / 侧栏折叠 / 搜索面板 / 选区工具条
|
||||
const { app, BrowserWindow } = require('electron')
|
||||
const { mkdirSync, writeFileSync } = require('node:fs')
|
||||
const { join } = require('node:path')
|
||||
|
||||
// 离屏捕获在 GPU 合成异常时会得到空白帧,改用软件合成
|
||||
app.commandLine.appendSwitch('disable-gpu')
|
||||
|
||||
const URL = 'http://localhost:4173/'
|
||||
const OUT = join(__dirname, '..', 'preview-shots')
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
async function capture(win, name) {
|
||||
try {
|
||||
const image = await win.webContents.capturePage()
|
||||
writeFileSync(join(OUT, name), image.toPNG())
|
||||
console.log('saved', name, `${image.getSize().width}x${image.getSize().height}`)
|
||||
} catch (e) {
|
||||
console.log('capture failed', name, e.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function js(win, code) {
|
||||
try {
|
||||
return await win.webContents.executeJavaScript(code)
|
||||
} catch (e) {
|
||||
console.log('js failed:', e.message)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
mkdirSync(OUT, { recursive: true })
|
||||
const win = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 960,
|
||||
show: false,
|
||||
webPreferences: { paintWhenInitiallyHidden: true }
|
||||
})
|
||||
await win.loadURL(URL)
|
||||
await sleep(1500)
|
||||
|
||||
// 1) 整页(上半)
|
||||
await capture(win, '01-full.png')
|
||||
|
||||
// 2) 折叠侧栏(等宽度过渡)
|
||||
await js(win, `document.querySelector('[aria-label="折叠侧栏"]')?.click()`)
|
||||
await sleep(700)
|
||||
await capture(win, '02-sidebar-collapsed.png')
|
||||
await js(win, `document.querySelector('[aria-label="展开侧栏"]')?.click()`)
|
||||
await sleep(700)
|
||||
|
||||
// 3) 搜索面板(侧栏「搜索」行)+ 输入过滤
|
||||
await js(win, `[...document.querySelectorAll('button')].find((b) => b.textContent.trim() === '搜索')?.click()`)
|
||||
await sleep(600)
|
||||
await js(win, `(() => { const i = document.querySelector('input[aria-label="搜索会话或命令"]'); if (!i) return 'no-input'; const set = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; set.call(i, '供应'); i.dispatchEvent(new Event('input', { bubbles: true })); return 'ok' })()`)
|
||||
await sleep(400)
|
||||
await capture(win, '03-search-filtered.png')
|
||||
await js(win, `document.querySelector('input[aria-label="搜索会话或命令"]')?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))`)
|
||||
await sleep(400)
|
||||
|
||||
// 4) 划选文本 → Selection Actions 工具条(展开「更多」)
|
||||
await js(win, `(() => {
|
||||
const p = [...document.querySelectorAll('p')].find((el) => el.textContent.includes('百香果'));
|
||||
if (!p) return 'no-p';
|
||||
const textNode = p.firstChild;
|
||||
const range = document.createRange();
|
||||
range.setStart(textNode, 0);
|
||||
range.setEnd(textNode, 30);
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
|
||||
return 'ok';
|
||||
})()`)
|
||||
await sleep(600)
|
||||
await js(win, `document.querySelector('[aria-label="更多操作"]')?.click()`)
|
||||
await sleep(500)
|
||||
await capture(win, '04-selection-actions.png')
|
||||
|
||||
// 5) 审批卡交互:选择「允许执行」(radio 自动前进 480ms 后提交)
|
||||
await js(win, `[...document.querySelectorAll('button')].find((b) => b.textContent.trim() === '允许执行')?.click()`)
|
||||
await sleep(900)
|
||||
await capture(win, '05-approval-approved.png')
|
||||
|
||||
app.exit(0)
|
||||
}
|
||||
|
||||
app.whenReady().then(run).catch((err) => {
|
||||
console.error(err)
|
||||
app.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
// 验证侧栏折叠交互
|
||||
const { app, BrowserWindow } = require('electron')
|
||||
app.commandLine.appendSwitch('disable-gpu')
|
||||
app.commandLine.appendSwitch('disable-software-rasterizer')
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
const win = new BrowserWindow({ width: 1280, height: 900, show: false, webPreferences: { offscreen: true } })
|
||||
await win.loadURL('http://127.0.0.1:4173/')
|
||||
await new Promise((r) => setTimeout(r, 1200))
|
||||
const found = await win.webContents.executeJavaScript(`document.querySelector('[aria-label="折叠侧栏"]') ? 'found' : 'NOT-FOUND'`)
|
||||
console.log('collapse btn:', found)
|
||||
await win.webContents.executeJavaScript(`document.querySelector('[aria-label="折叠侧栏"]').click()`)
|
||||
await new Promise((r) => setTimeout(r, 600))
|
||||
console.log(
|
||||
'after click: collapsed=',
|
||||
await win.webContents.executeJavaScript(`document.querySelector('aside')?.getAttribute('data-sidebar-collapsed')`),
|
||||
'width=',
|
||||
await win.webContents.executeJavaScript(`document.querySelector('aside')?.style.width`)
|
||||
)
|
||||
app.exit(0)
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
// 预览页控制台诊断:node 调试某一分区
|
||||
// npx electron scripts/debug-preview.cjs "#thinking"
|
||||
const { app, BrowserWindow } = require('electron')
|
||||
|
||||
const hash = process.argv[2] ?? ''
|
||||
const URL = `http://127.0.0.1:4173/${hash}`
|
||||
|
||||
// 兜底退出(渲染进程卡死时 executeJavaScript 永不返回)
|
||||
const guard = setTimeout(() => {
|
||||
console.log('[guard] hard exit (page busy or crashed)')
|
||||
app.exit(2)
|
||||
}, 12000)
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
const win = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 900,
|
||||
show: false,
|
||||
webPreferences: { offscreen: true }
|
||||
})
|
||||
win.webContents.on('console-message', (_e, level, message, line, source) => {
|
||||
console.log(`[console:${level}]`, message, `(${source}:${line})`)
|
||||
})
|
||||
win.webContents.on('did-fail-load', (_e, code, desc, url) => {
|
||||
console.log(`[did-fail-load] ${code} ${desc} ${url}`)
|
||||
})
|
||||
win.webContents.on('render-process-gone', (_e, details) => {
|
||||
console.log('[crashed]', details.reason)
|
||||
})
|
||||
await win.loadURL(URL)
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const text = await win.webContents.executeJavaScript('document.body.innerText.slice(0, 120)')
|
||||
console.log('[body]', JSON.stringify(text))
|
||||
} catch (e) {
|
||||
console.log('[executeJavaScript failed]', e.message)
|
||||
}
|
||||
clearTimeout(guard)
|
||||
app.exit(0)
|
||||
}, 2500)
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
// 对比 out/renderer 旧 bundle 与当前源码的 reducer 逻辑
|
||||
const fs = require('fs')
|
||||
const t = fs.readFileSync('apps/desktop/out/renderer/assets/index-DJXyfP9T.js', 'utf8')
|
||||
|
||||
console.log('has message_end:', t.includes('message_end'))
|
||||
console.log('has optimistic-user:', t.includes('optimistic-user'))
|
||||
console.log('has session_switched:', t.includes('session_switched'))
|
||||
console.log('has agent_settled:', t.includes('agent_settled'))
|
||||
console.log('has approval_request:', t.includes('approval_request'))
|
||||
|
||||
// reducer 里 message_end 附近的代码
|
||||
let i = t.indexOf('"message_end"')
|
||||
if (i < 0) i = t.indexOf('message_end')
|
||||
if (i >= 0) {
|
||||
console.log('\n=== around first message_end ===')
|
||||
console.log(t.slice(i - 500, i + 900))
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// 生成应用图标(build/icon.ico + build/icon.png):
|
||||
// npx electron scripts/make-icons.cjs
|
||||
// 用 Electron 离屏渲染 build/icon.svg(居中于方形画布、留 9% 边距),
|
||||
// 输出多尺寸 PNG 并打包为 PNG 内嵌式 ICO(Vista+ 支持)。
|
||||
const { app, BrowserWindow } = require('electron')
|
||||
const { readFileSync, writeFileSync } = require('node:fs')
|
||||
const { join } = require('node:path')
|
||||
|
||||
app.commandLine.appendSwitch('disable-gpu')
|
||||
|
||||
const SIZES = [16, 24, 32, 48, 64, 128, 256]
|
||||
const ROOT = join(__dirname, '..')
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
/** PNG 内嵌式 ICO 打包 */
|
||||
function buildIco(pngs) {
|
||||
const count = pngs.length
|
||||
const header = Buffer.alloc(6)
|
||||
header.writeUInt16LE(0, 0) // reserved
|
||||
header.writeUInt16LE(1, 2) // type: icon
|
||||
header.writeUInt16LE(count, 4)
|
||||
const entries = Buffer.alloc(16 * count)
|
||||
const blobs = []
|
||||
let offset = 6 + 16 * count
|
||||
pngs.forEach((png, i) => {
|
||||
const size = png.size
|
||||
entries.writeUInt8(size >= 256 ? 0 : size, i * 16 + 0) // width
|
||||
entries.writeUInt8(size >= 256 ? 0 : size, i * 16 + 1) // height
|
||||
entries.writeUInt8(0, i * 16 + 2) // palette
|
||||
entries.writeUInt8(0, i * 16 + 3) // reserved
|
||||
entries.writeUInt16LE(1, i * 16 + 4) // planes
|
||||
entries.writeUInt16LE(32, i * 16 + 6) // bpp
|
||||
entries.writeUInt32LE(png.data.length, i * 16 + 8)
|
||||
entries.writeUInt32LE(offset, i * 16 + 12)
|
||||
offset += png.data.length
|
||||
blobs.push(png.data)
|
||||
})
|
||||
return Buffer.concat([header, entries, ...blobs])
|
||||
}
|
||||
|
||||
async function renderSize(win, svgDataUrl, size) {
|
||||
await win.loadURL(
|
||||
'data:text/html,' +
|
||||
encodeURIComponent(
|
||||
`<!doctype html><html><body style="margin:0;background:transparent">` +
|
||||
`<div style="display:flex;align-items:center;justify-content:center;width:${size}px;height:${size}px">` +
|
||||
`<img src="${svgDataUrl}" style="width:${Math.round(size * 0.86)}px;height:auto;display:block"/>` +
|
||||
`</div></body></html>`
|
||||
)
|
||||
)
|
||||
await sleep(350)
|
||||
const image = await win.webContents.capturePage({ x: 0, y: 0, width: size, height: size })
|
||||
return image.toPNG()
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const svg = readFileSync(join(ROOT, 'build', 'icon.svg'), 'utf8')
|
||||
const svgDataUrl = 'data:image/svg+xml;base64,' + Buffer.from(svg, 'utf8').toString('base64')
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: 256,
|
||||
height: 256,
|
||||
show: false,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
webPreferences: { paintWhenInitiallyHidden: true, offscreen: true }
|
||||
})
|
||||
|
||||
const pngs = []
|
||||
for (const size of SIZES) {
|
||||
await win.setSize ? win.setSize(size, size) : null
|
||||
const png = await renderSize(win, svgDataUrl, size)
|
||||
pngs.push({ size, data: png })
|
||||
console.log('rendered', size, `${png.length}B`)
|
||||
}
|
||||
|
||||
writeFileSync(join(ROOT, 'build', 'icon.png'), pngs.find((p) => p.size === 256).data)
|
||||
writeFileSync(join(ROOT, 'build', 'icon.ico'), buildIco(pngs))
|
||||
console.log('written build/icon.ico & build/icon.png')
|
||||
app.exit(0)
|
||||
}
|
||||
|
||||
app.whenReady().then(run).catch((err) => {
|
||||
console.error(err)
|
||||
app.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
// 打印 pi SDK 关键类型定义(skills / slash-commands / prompt 参数)
|
||||
const fs = require('node:fs')
|
||||
const files = [
|
||||
'dist/core/skills.d.ts',
|
||||
'dist/core/slash-commands.d.ts',
|
||||
'dist/modes/interactive/components/skill-invocation-message.d.ts'
|
||||
]
|
||||
for (const f of files) {
|
||||
console.log('=====', f)
|
||||
try {
|
||||
console.log(fs.readFileSync(f, 'utf8'))
|
||||
} catch (e) {
|
||||
console.log('READ FAIL', e.message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// 打印 ImageContent / PromptTemplate / AgentSessionServices 类型
|
||||
const fs = require('node:fs')
|
||||
const root = 'C:/Users/21976/Desktop/Develop/agent-store-operations/apps/desktop/node_modules'
|
||||
const files = [
|
||||
[root + '/@earendil-works/pi-ai/dist/compat.d.ts', /export (interface|type) (ImageContent|TextContent)[\s\S]*?\n\}/],
|
||||
[root + '/@earendil-works/pi-coding-agent/dist/core/prompt-templates.d.ts', null],
|
||||
[root + '/@earendil-works/pi-coding-agent/dist/core/sdk.d.ts', /export interface AgentSessionServices[\s\S]*?\n\}/]
|
||||
]
|
||||
for (const [f, re] of files) {
|
||||
console.log('=====', f.split('/').slice(-2).join('/'))
|
||||
try {
|
||||
const s = fs.readFileSync(f, 'utf8')
|
||||
if (re) {
|
||||
const m = s.match(re)
|
||||
console.log(m ? m[0] : '(no match)')
|
||||
} else {
|
||||
console.log(s.slice(0, 2500))
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('READ FAIL', e.message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// 验证 pi SDK 对空目录(无会话的工作区)的行为:
|
||||
// node scripts/repro-empty-workspace.mjs "C:\path\to\empty\dir"
|
||||
import { SessionManager, getAgentDir } from '@earendil-works/pi-coding-agent'
|
||||
import { mkdirSync, mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const target = process.argv[2] ?? mkdtempSync(join(tmpdir(), 'ws-empty-'))
|
||||
mkdirSync(target, { recursive: true })
|
||||
console.log('target cwd:', target)
|
||||
|
||||
try {
|
||||
const sm = SessionManager.continueRecent(target)
|
||||
console.log('continueRecent OK, sessionFile:', sm.sessionFile ?? '(none)')
|
||||
const list = await SessionManager.list(target)
|
||||
console.log('list count:', list.length)
|
||||
} catch (err) {
|
||||
console.error('continueRecent FAILED:', err instanceof Error ? err.message : err)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('agentDir:', getAgentDir())
|
||||
@@ -0,0 +1,103 @@
|
||||
// 复现渲染层"输出一闪而过":用与 agent-service.ts 相同的 SDK 调用链,
|
||||
// 订阅全部事件并打印事件序列与消息对象结构。
|
||||
import os from 'node:os'
|
||||
import {
|
||||
createAgentSessionRuntime,
|
||||
createAgentSessionServices,
|
||||
createAgentSessionFromServices,
|
||||
getAgentDir,
|
||||
SessionManager
|
||||
} from '@earendil-works/pi-coding-agent'
|
||||
|
||||
const cwd = os.homedir()
|
||||
const agentDir = getAgentDir()
|
||||
|
||||
const services = await createAgentSessionServices({ cwd, agentDir })
|
||||
const factory = async (opts) => {
|
||||
const result = await createAgentSessionFromServices({
|
||||
services,
|
||||
sessionManager: opts.sessionManager,
|
||||
sessionStartEvent: opts.sessionStartEvent
|
||||
})
|
||||
return { ...result, services, diagnostics: services.diagnostics }
|
||||
}
|
||||
|
||||
const sessionManager = SessionManager.continueRecent(cwd)
|
||||
const runtime = await createAgentSessionRuntime(factory, { cwd, agentDir, sessionManager })
|
||||
const session = runtime.session
|
||||
|
||||
let rebindCount = 0
|
||||
runtime.setRebindSession(async (s) => {
|
||||
rebindCount++
|
||||
console.log(`\n>>> REBIND fired (#${rebindCount}) — 渲染层会收到 session_switched,messages.length=${s.messages.length}`)
|
||||
})
|
||||
|
||||
session.subscribe((event) => {
|
||||
switch (event.type) {
|
||||
case 'message_start':
|
||||
console.log('EVT message_start role=', JSON.stringify(event.message?.role))
|
||||
break
|
||||
case 'message_update': {
|
||||
const inner = event.assistantMessageEvent
|
||||
if (inner) {
|
||||
if (inner.type.endsWith('_delta')) {
|
||||
process.stdout.write(`.`)
|
||||
} else {
|
||||
console.log(`EVT message_update ${inner.type}`)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'message_end': {
|
||||
const m = event.message
|
||||
console.log('\nEVT message_end role=', m?.role)
|
||||
console.log(' message keys:', Object.keys(m ?? {}))
|
||||
try {
|
||||
JSON.stringify(m)
|
||||
console.log(' JSON.stringify: OK, length=', JSON.stringify(m).length)
|
||||
} catch (err) {
|
||||
console.log(' JSON.stringify FAILED ->', err.message, '(toCloneable 会变成 null → 渲染层消息变空!)')
|
||||
}
|
||||
if (m?.role === 'assistant') {
|
||||
console.log(' stopReason=', m.stopReason, ' errorMessage=', m.errorMessage)
|
||||
console.log(' content types=', (m.content ?? []).map((b) => b.type).join(','))
|
||||
for (const b of m.content ?? []) {
|
||||
if (b.type === 'text') console.log(' text block:', JSON.stringify(b.text).slice(0, 200))
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'agent_end':
|
||||
console.log(`\nEVT agent_end willRetry=${event.willRetry} messages=${event.messages?.length}`)
|
||||
break
|
||||
default:
|
||||
console.log('EVT', event.type)
|
||||
}
|
||||
})
|
||||
|
||||
console.log('--- session:', session.sessionId, 'messages:', session.messages.length)
|
||||
console.log('--- model:', session.model?.provider + '/' + session.model?.id)
|
||||
console.log('--- sending "hi" ...')
|
||||
|
||||
try {
|
||||
await session.prompt('hi')
|
||||
console.log('\n--- prompt resolved')
|
||||
} catch (err) {
|
||||
console.log('\n--- prompt THREW:', err?.message)
|
||||
}
|
||||
|
||||
// 等待 agent_settled 之后的异步收尾
|
||||
await new Promise((r) => setTimeout(r, 1500))
|
||||
|
||||
console.log('\n=== final session.messages ===')
|
||||
for (const [i, m] of session.messages.entries()) {
|
||||
const blocks = Array.isArray(m.content)
|
||||
? m.content.map((b) => b.type).join(',')
|
||||
: typeof m.content === 'string'
|
||||
? 'str:' + m.content.slice(0, 30)
|
||||
: '??'
|
||||
console.log(`#${i} ${m.role} [${blocks}]`)
|
||||
}
|
||||
|
||||
console.log('\nrebindCount=', rebindCount)
|
||||
process.exit(0)
|
||||
@@ -0,0 +1,41 @@
|
||||
// 验证 Markdown.tsx 的 parseBlocks 是否产出空内容
|
||||
const INLINE = /^(\s*)([-*+]\s+|\d+[.)]\s+|>\s?|(#{1,3}\s+))?(.*)$/
|
||||
|
||||
function parseBlocks(text) {
|
||||
const blocks = []
|
||||
const lines = text.split('\n')
|
||||
let current = null
|
||||
const flush = () => {
|
||||
if (current) blocks.push(current)
|
||||
current = null
|
||||
}
|
||||
for (const line of lines) {
|
||||
const m = line.match(INLINE)
|
||||
if (!m) continue
|
||||
const [, indent, marker, content] = m
|
||||
const isListItem = Boolean(marker && /^([-*+]\s+|\d+[.)]\s+)/.test(marker))
|
||||
const kind = marker
|
||||
? marker.startsWith('#')
|
||||
? 'h1'
|
||||
: isListItem
|
||||
? /^\d/.test(marker) ? 'ol' : 'ul'
|
||||
: marker.startsWith('>') ? 'quote' : null
|
||||
: 'p'
|
||||
if (kind === 'p') {
|
||||
if (!current || current.kind !== 'p') {
|
||||
flush()
|
||||
current = { kind: 'p', lines: [] }
|
||||
}
|
||||
current.lines.push(content)
|
||||
continue
|
||||
}
|
||||
flush()
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
for (const t of ['pong', 'Hello world', '# 标题', '- 列表项', '> 引用', '1. 有序']) {
|
||||
const blocks = parseBlocks(t)
|
||||
const rendered = blocks.map((b) => b.lines.join('\n'))
|
||||
console.log(JSON.stringify(t), '->', JSON.stringify(rendered))
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// 验证渲染层对 session_switched 历史消息的渲染逻辑:
|
||||
// 用与主进程一致的 session.messages 源,跑 MessageItem 的 readBlocks + AssistantBody 判断。
|
||||
import os from 'node:os'
|
||||
import { createAgentSessionServices, getAgentDir } from '@earendil-works/pi-coding-agent'
|
||||
|
||||
const cwd = os.homedir()
|
||||
const services = await createAgentSessionServices({ cwd, agentDir: getAgentDir() })
|
||||
|
||||
// SessionManager.list + 加载最近会话的等价做法:直接用 services 里加载当前会话
|
||||
const session = services.sessionManager
|
||||
? null
|
||||
: null
|
||||
|
||||
// 走 SessionManager 拿最近会话并加载
|
||||
const { SessionManager } = await import('@earendil-works/pi-coding-agent')
|
||||
const sm = SessionManager.continueRecent(cwd)
|
||||
const smAny = sm
|
||||
// SessionManager.continueRecent 返回的 manager 需要挂到 session 上才有 messages;
|
||||
// 更直接:读它指向的会话文件并解析 message 条目
|
||||
const entries = []
|
||||
if (typeof smAny.loadEntries === 'function') {
|
||||
entries.push(...(await smAny.loadEntries()))
|
||||
}
|
||||
console.log('loadEntries available:', typeof smAny.loadEntries === 'function', 'entries:', entries.length)
|
||||
|
||||
// 退路:直接解析磁盘上的最近 jsonl(与主进程 toCloneable 后的消息同构)
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
const dir = join(cwd, '.pi', 'agent', 'sessions', '--C--Users-21976--')
|
||||
const files = readdirSync(dir).filter((f) => f.endsWith('.jsonl'))
|
||||
files.sort((a, b) => statSync(join(dir, b)).mtimeMs - statSync(join(dir, a)).mtimeMs)
|
||||
const latest = files[0]
|
||||
console.log('latest session file:', latest)
|
||||
|
||||
const lines = readFileSync(join(dir, latest), 'utf8').trim().split('\n')
|
||||
const messages = []
|
||||
for (const line of lines) {
|
||||
let entry
|
||||
try {
|
||||
entry = JSON.parse(line)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (entry.type === 'message' && entry.message) messages.push(entry.message)
|
||||
}
|
||||
|
||||
// ==== 渲染层逻辑复刻(与 MessageItem.tsx 一致)====
|
||||
function readBlocks(raw) {
|
||||
if (!raw || typeof raw !== 'object') return []
|
||||
const content = raw.content
|
||||
if (typeof content === 'string') return [{ type: 'text', text: content }]
|
||||
if (Array.isArray(content)) {
|
||||
return content.filter((c) => typeof c === 'object' && c !== null)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
for (const [i, m] of messages.entries()) {
|
||||
console.log(`\n=== history msg #${i} role=${m.role} ===`)
|
||||
const blocks = readBlocks(m)
|
||||
let rendered = 0
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'thinking' && typeof block.text === 'string') {
|
||||
console.log(' -> ThinkingBlock (text field)')
|
||||
rendered++
|
||||
continue
|
||||
}
|
||||
if (block.type === 'text' && block.text) {
|
||||
console.log(` -> Markdown("${String(block.text).slice(0, 60)}…")`)
|
||||
rendered++
|
||||
continue
|
||||
}
|
||||
if (block.type === 'toolCall' && block.id) {
|
||||
console.log(' -> ToolCard', block.name)
|
||||
rendered++
|
||||
continue
|
||||
}
|
||||
console.log(' -> **被丢弃** block type =', block.type, 'keys =', Object.keys(block).join(','))
|
||||
}
|
||||
console.log(` rendered ${rendered}/${blocks.length} blocks`)
|
||||
}
|
||||
Reference in New Issue
Block a user