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:
Rick Ye
2026-09-06 09:57:14 +08:00
parent 3c2e9e96c5
commit a5951849c8
96 changed files with 11828 additions and 4649 deletions
+263
View File
@@ -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)
})
+95
View File
@@ -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)
})
+21
View File
@@ -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)
})
+41
View File
@@ -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)
})
+17
View File
@@ -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))
}
+87
View File
@@ -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)
})
+15
View File
@@ -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)
}
}
+22
View File
@@ -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())
+103
View File
@@ -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)
+41
View File
@@ -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`)
}