chore: scripts 与 preview 调试工具移出版本跟踪(.gitignore 忽略)
This commit is contained in:
@@ -1,12 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>beautifului primitives preview</title>
|
||||
</head>
|
||||
<body class="bg-zinc-50 text-zinc-900">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,210 +0,0 @@
|
||||
// beautifului.dev 组件视觉验收页(静态构建,模拟聊天流内的真实用法)
|
||||
import { useRef, useState } from 'react'
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './preview.css'
|
||||
import { Thinking } from '../src/renderer/src/components/Thinking'
|
||||
import { StreamingText } from '../src/renderer/src/components/StreamingText'
|
||||
import { ApprovalStack } from '../src/renderer/src/components/ApprovalCard'
|
||||
import { ToolChips } from '../src/renderer/src/components/ToolChips'
|
||||
import { SidebarNav } from '../src/renderer/src/components/SidebarNav'
|
||||
import { Search } from '../src/renderer/src/components/Search'
|
||||
import { CodeBlock } from '../src/renderer/src/components/CodeBlock'
|
||||
import { SelectionActions } from '../src/renderer/src/components/SelectionActions'
|
||||
import { Loading } from '../src/renderer/src/components/Loading'
|
||||
import { Markdown } from '../src/renderer/src/components/Markdown'
|
||||
import type { ApprovalRequest, SessionMeta } from '../src/shared/types'
|
||||
|
||||
const thinkingRows = [
|
||||
{ primary: '用户想了解今天的品类热点,我先查一下最近的趋势数据。' },
|
||||
{ primary: '需要调用 query_trend 工具获取近 7 天的热点词,再按类目分组。' },
|
||||
{ primary: '结果里先给 TOP3 品类,附上建议的标题关键词。' }
|
||||
]
|
||||
|
||||
const toolCalls = [
|
||||
{
|
||||
id: 'call-1',
|
||||
name: 'query_trend',
|
||||
tool: {
|
||||
toolName: 'query_trend',
|
||||
args: { category: 'home', days: 7 },
|
||||
status: 'done' as const,
|
||||
result: { content: [{ type: 'text', text: '{ "top": ["收纳", "香薰", "桌布"], "lift": [0.32, 0.21, 0.18] }' }] }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'call-2',
|
||||
name: 'draft_title',
|
||||
tool: {
|
||||
toolName: 'draft_title',
|
||||
args: { keywords: ['收纳', '灵感'] },
|
||||
status: 'error' as const,
|
||||
result: { content: [{ type: 'text', text: 'Error: rate limited, retry after 30s' }] }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'call-3',
|
||||
name: 'update_price',
|
||||
pendingApproval: { args: { sku: 'SKU-1024', from: 39.9, to: 35.9 } }
|
||||
}
|
||||
]
|
||||
|
||||
const approvalRequest: ApprovalRequest = {
|
||||
id: 'approval-1',
|
||||
toolName: 'update_price',
|
||||
args: { sku: 'SKU-1024', from: 39.9, to: 35.9 }
|
||||
}
|
||||
|
||||
const sessions: SessionMeta[] = [
|
||||
{ id: 's1', path: 'a.json', name: '供应商记录整理', firstMessage: '帮我整理供应商', created: '', modified: new Date().toISOString(), messageCount: 12 },
|
||||
{ id: 's2', path: 'b.json', name: '', firstMessage: '根据热点词起草一个商品标题方案', created: '', modified: new Date(Date.now() - 86400000).toISOString(), messageCount: 5 },
|
||||
{ id: 's3', path: 'c.json', name: '', firstMessage: '查询最近一次上架任务的执行结果', created: '', modified: new Date(Date.now() - 3 * 86400000).toISOString(), messageCount: 8 }
|
||||
]
|
||||
|
||||
const CODE_SNIPPET = `export async function churnBatch() {
|
||||
const flavor = await getFlavor("pistachio");
|
||||
const base = await dairy.fetch({ flavor });
|
||||
await freezer.store(base, { temp: "-16C" });
|
||||
if (!base.approved) return null;
|
||||
return base.gallons;
|
||||
}`
|
||||
|
||||
const DIFF_SNIPPET = `@@ -1,4 +1,5 @@
|
||||
export const hero = "mint-chip";
|
||||
-const windows = slots;
|
||||
+const windows = slots.filter(
|
||||
+ (s) => s.temp <= -12,
|
||||
+);
|
||||
schedule(windows);`
|
||||
|
||||
const PARA =
|
||||
'百香果风味在整个周末保持热销榜首。周六一早就要开始搅拌,让批次在下午高峰前有足够时间固化。同时需要预留两个晚间冷冻窗口,避免与常规生产冲突。'
|
||||
|
||||
function PreviewCard({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="mb-8 rounded-2xl border border-zinc-200 bg-white p-5">
|
||||
<h2 className="mb-3 text-[12px] font-medium tracking-wide text-zinc-400 uppercase">{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const [applied, setApplied] = useState('')
|
||||
const selectionBoxRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 调试分区:URL hash 指定只渲染某一节(#thinking / #streaming / #tools /
|
||||
// #approval / #code / #selection / #loading / #sidebar / #none),空 hash 全渲染
|
||||
const only = window.location.hash.slice(1)
|
||||
const show = (id: string): boolean => only === '' || only === id
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-6 py-8">
|
||||
<h1 className="mb-6 text-[14px] font-semibold text-zinc-900">beautifului.dev · 8 组件直连验收(light)</h1>
|
||||
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_240px] items-start gap-6">
|
||||
<div className="min-w-0">
|
||||
{show('thinking') && (
|
||||
<PreviewCard title="Thinking · 流式中 / 已完成">
|
||||
<Thinking rows={thinkingRows} streaming />
|
||||
<div className="mt-4">
|
||||
<Thinking rows={thinkingRows} streaming={false} />
|
||||
</div>
|
||||
</PreviewCard>
|
||||
)}
|
||||
|
||||
{show('streaming') && (
|
||||
<PreviewCard title="Streaming Text · 流式正文 + Follow-ups">
|
||||
<StreamingText streaming={false} text="已完成的回答文本。" followUps={['冬季哪些口味卖得最好', '对比凝胶和软冰的毛利']} onFollowUp={(q) => setApplied(q)}>
|
||||
<Markdown text={'夏季核果风味需求激增——**桃子和杏子**领先。建议先盘点蛋筒库存,再推广华夫碗套餐。'} />
|
||||
</StreamingText>
|
||||
</PreviewCard>
|
||||
)}
|
||||
|
||||
{show('tools') && (
|
||||
<PreviewCard title="Tool Chips · 真实工具调用(含待审批行)">
|
||||
<div className="w-fit max-w-full rounded-card bg-inset p-3">
|
||||
<ToolChips calls={toolCalls} thinking={['先查近 7 天热点词,再按类目分组给出 TOP3。']} />
|
||||
</div>
|
||||
</PreviewCard>
|
||||
)}
|
||||
|
||||
{show('approval') && (
|
||||
<PreviewCard title="Approval Card · 单题向导(允许/拒绝 + 倒计时)">
|
||||
<ApprovalStack requests={[approvalRequest]} onResolve={(_id, approved) => setApplied(approved ? '已允许' : '已拒绝')} />
|
||||
<div className="h-2" />
|
||||
</PreviewCard>
|
||||
)}
|
||||
|
||||
{show('code') && (
|
||||
<PreviewCard title="Code Block · 行号代码 / unified diff">
|
||||
<CodeBlock code={CODE_SNIPPET} lang="ts" filename="churn.ts" />
|
||||
<div className="mt-4">
|
||||
<CodeBlock code={DIFF_SNIPPET} lang="diff" />
|
||||
</div>
|
||||
</PreviewCard>
|
||||
)}
|
||||
|
||||
{show('selection') && (
|
||||
<PreviewCard title="Selection Actions · 划选文本试试">
|
||||
<div ref={selectionBoxRef} className="relative pb-10">
|
||||
<p className="text-[13px] leading-relaxed text-ink">{PARA}</p>
|
||||
<SelectionActions containerRef={selectionBoxRef} onApply={(instruction, selection) => setApplied(`${instruction}:${selection.slice(0, 18)}…`)} />
|
||||
</div>
|
||||
{applied && <p className="mt-2 font-mono text-[11px] text-zinc-500">onApply → {applied}</p>}
|
||||
</PreviewCard>
|
||||
)}
|
||||
|
||||
{show('loading') && (
|
||||
<PreviewCard title="Loading(附带)">
|
||||
<Loading label="思考中" variant="Dots" />
|
||||
</PreviewCard>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{show('sidebar') && (
|
||||
<div className="sticky top-8">
|
||||
<PreviewCard title="Sidebar Nav · 可折叠">
|
||||
<div className="h-[560px] overflow-hidden rounded-xl border border-zinc-200">
|
||||
<SidebarNav
|
||||
sessionState={{ sessionId: 's1', streaming: false, activeTools: [], messageCount: 12 }}
|
||||
sessions={sessions}
|
||||
workspaces={[{ name: 'Default', path: 'C:/workspaces/Default' }]}
|
||||
activeWorkspace={{ name: 'Default', path: 'C:/workspaces/Default' }}
|
||||
onCreateWorkspace={() => undefined}
|
||||
onOpenWorkspace={() => undefined}
|
||||
onNewSession={() => setApplied('新会话')}
|
||||
onOpenSession={() => setApplied('打开会话')}
|
||||
onRenameSession={() => setApplied('重命名')}
|
||||
onDeleteSession={() => setApplied('删除')}
|
||||
onRefreshSessions={() => setApplied('刷新')}
|
||||
onOpenSearch={() => setSearchOpen(true)}
|
||||
onOpenSettings={() => setApplied('设置')}
|
||||
/>
|
||||
</div>
|
||||
</PreviewCard>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{show('sidebar') && (
|
||||
<Search
|
||||
open={searchOpen}
|
||||
onClose={() => setSearchOpen(false)}
|
||||
sessions={sessions}
|
||||
onNewSession={() => setApplied('新会话')}
|
||||
onRefreshSessions={() => setApplied('刷新')}
|
||||
onOpenSettings={() => setApplied('设置')}
|
||||
onOpenSession={() => setApplied('打开会话')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
)
|
||||
@@ -1,6 +0,0 @@
|
||||
/* 预览页专用入口:复用应用全套样式(tailwind + beautifului foundation + 字体),
|
||||
* 并显式声明组件源码目录参与工具类扫描(vite root 在 preview/ 下,
|
||||
* 自动检测覆盖不到 ../src)。 */
|
||||
@import "../src/renderer/src/styles/globals.css";
|
||||
|
||||
@source "../src/renderer/src";
|
||||
@@ -1,263 +0,0 @@
|
||||
// 真实 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)
|
||||
})
|
||||
@@ -1,95 +0,0 @@
|
||||
// 预览页视觉验收截图(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)
|
||||
})
|
||||
@@ -1,21 +0,0 @@
|
||||
// 验证侧栏折叠交互
|
||||
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)
|
||||
})
|
||||
@@ -1,41 +0,0 @@
|
||||
// 预览页控制台诊断: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)
|
||||
})
|
||||
@@ -1,17 +0,0 @@
|
||||
// 对比 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))
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
// 生成应用图标(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)
|
||||
})
|
||||
@@ -1,15 +0,0 @@
|
||||
// 打印 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)
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
// 打印 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)
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
// 验证 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())
|
||||
@@ -1,103 +0,0 @@
|
||||
// 复现渲染层"输出一闪而过":用与 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)
|
||||
@@ -1,41 +0,0 @@
|
||||
// 验证 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))
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
// 验证渲染层对 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`)
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* v0.3.0 冒烟:模型目录 / API key login-logout / setModel / 系统提示词
|
||||
* 不打真实 LLM 请求;key 用假值(仅验证凭据写入与模型切换逻辑)。
|
||||
*/
|
||||
import { createAgentSessionServices, createAgentSessionRuntime, createAgentSessionFromServices, getAgentDir, SessionManager } from '@earendil-works/pi-coding-agent'
|
||||
import { homedir } from 'node:os'
|
||||
import { readFileSync, writeFileSync, mkdtempSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
|
||||
const cwd = homedir()
|
||||
const agentDir = getAgentDir()
|
||||
|
||||
const services = await createAgentSessionServices({ cwd, agentDir })
|
||||
const factory = async ({ cwd: nc, agentDir: na, sessionManager }) => {
|
||||
const svc = nc === services.cwd && na === services.agentDir ? services : await createAgentSessionServices({ cwd: nc, agentDir: na })
|
||||
const result = await createAgentSessionFromServices({ services: svc, sessionManager })
|
||||
return { ...result, services: svc, diagnostics: svc.diagnostics }
|
||||
}
|
||||
const runtime = await createAgentSessionRuntime(factory, { cwd, agentDir, sessionManager: SessionManager.continueRecent(cwd) })
|
||||
const mr = runtime.session.modelRuntime
|
||||
|
||||
// 1) 模型目录
|
||||
const providers = mr.getProviders()
|
||||
console.log('[smoke3] providers:', providers.map((p) => p.id).join(', '))
|
||||
const anthropic = providers.find((p) => p.id === 'anthropic')
|
||||
if (anthropic) {
|
||||
const models = mr.getModels('anthropic')
|
||||
console.log('[smoke3] anthropic models: %d (first: %s)', models.length, models[0]?.id)
|
||||
|
||||
// 2) login(假 key)→ listCredentials
|
||||
const before = await mr.listCredentials()
|
||||
await mr.login('anthropic', 'api_key', { prompt: async () => 'sk-smoke-fake-key', notify: async () => undefined })
|
||||
const after = await mr.listCredentials()
|
||||
console.log('[smoke3] credentials before=%d after=%d (anthropic=%s)', before.length, after.length, after.some((c) => c.providerId === 'anthropic' && c.type === 'api_key'))
|
||||
|
||||
// auth.json 应包含 anthropic 条目
|
||||
const auth = JSON.parse(readFileSync(join(agentDir, 'auth.json'), 'utf8'))
|
||||
console.log('[smoke3] auth.json has anthropic:', Boolean(auth.anthropic))
|
||||
|
||||
// 3) setModel(有凭据时允许)
|
||||
const target = models[0]
|
||||
await runtime.session.setModel(target, { persist: false })
|
||||
console.log('[smoke3] setModel ok -> %s/%s', runtime.session.model?.provider, runtime.session.model?.id)
|
||||
|
||||
// 4) logout
|
||||
await mr.logout('anthropic')
|
||||
const finalCreds = await mr.listCredentials()
|
||||
console.log('[smoke3] after logout, anthropic credential present:', finalCreds.some((c) => c.providerId === 'anthropic'))
|
||||
} else {
|
||||
console.log('[smoke3] anthropic not found in providers, skip key tests')
|
||||
}
|
||||
|
||||
// 5) 系统提示词 override(独立 services,用 resourceLoaderOptions.systemPrompt)
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'aso-smoke-'))
|
||||
const promptText = '你是冒烟测试助手。'
|
||||
const svc2 = await createAgentSessionServices({ cwd, agentDir, resourceLoaderOptions: { systemPrompt: promptText } })
|
||||
console.log('[smoke3] systemPrompt applied:', svc2.resourceLoader ? 'services-created' : 'fail')
|
||||
const rt2 = await createAgentSessionFromServices({ services: svc2, sessionManager: SessionManager.inMemory(cwd) })
|
||||
console.log('[smoke3] session systemPrompt startsWith expected:', rt2.session.systemPrompt.startsWith(promptText.slice(0, 8)))
|
||||
|
||||
await runtime.dispose()
|
||||
writeFileSync(join(tmp, 'done'), '')
|
||||
console.log('[smoke3] done')
|
||||
process.exit(0)
|
||||
@@ -1,76 +0,0 @@
|
||||
/**
|
||||
* pi SDK 会话管理冒烟测试(v0.2.0)
|
||||
* 验证:runtime 创建、list、newSession、switchSession(历史恢复)、rename、删除(回收站)。
|
||||
*/
|
||||
import { createAgentSessionRuntime, createAgentSessionServices, createAgentSessionFromServices, getAgentDir, SessionManager } from '@earendil-works/pi-coding-agent'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const cwd = homedir()
|
||||
const agentDir = getAgentDir()
|
||||
|
||||
console.log('[smoke2] listing sessions (cwd=%s)...', cwd)
|
||||
const before = await SessionManager.list(cwd)
|
||||
console.log('[smoke2] found %d sessions', before.length)
|
||||
|
||||
const initialServices = await createAgentSessionServices({ cwd, agentDir })
|
||||
const factory = async ({ cwd: nextCwd, agentDir: nextAgentDir, sessionManager, sessionStartEvent }) => {
|
||||
const services =
|
||||
nextCwd === initialServices.cwd && nextAgentDir === initialServices.agentDir
|
||||
? initialServices
|
||||
: await createAgentSessionServices({ cwd: nextCwd, agentDir: nextAgentDir })
|
||||
const result = await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent })
|
||||
return { ...result, services, diagnostics: services.diagnostics }
|
||||
}
|
||||
|
||||
const sessionManager = SessionManager.create(cwd)
|
||||
const runtime = await createAgentSessionRuntime(factory, { cwd, agentDir, sessionManager })
|
||||
let rebindCount = 0
|
||||
runtime.setRebindSession(async () => {
|
||||
rebindCount += 1
|
||||
})
|
||||
console.log('[smoke2] runtime ready, session=%s', runtime.session.sessionId)
|
||||
|
||||
// 重命名
|
||||
runtime.session.setSessionName('smoke-测试会话')
|
||||
console.log('[smoke2] renamed to: %s', runtime.session.sessionName)
|
||||
|
||||
// 会话里追加用户+助手消息(直接写会话文件,不触发 LLM)
|
||||
// pi 约定:只有出现 assistant 消息后才会真正落盘(避免空会话残留文件)
|
||||
runtime.session.sessionManager.appendMessage({ role: 'user', content: '这是一条冒烟测试消息', timestamp: Date.now() })
|
||||
runtime.session.sessionManager.appendMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: '冒烟回复(不经过 LLM)' }],
|
||||
api: 'openai-completions',
|
||||
provider: 'smoke',
|
||||
model: 'smoke-model',
|
||||
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, cost: 0 },
|
||||
stopReason: 'stop',
|
||||
timestamp: Date.now()
|
||||
})
|
||||
console.log('[smoke2] messages in session:', runtime.session.messages.length)
|
||||
|
||||
const listAfter = await SessionManager.list(cwd)
|
||||
const mine = listAfter.find((s) => s.name === 'smoke-测试会话' || s.firstMessage.includes('冒烟测试'))
|
||||
console.log('[smoke2] session visible in list: %s (total %d)', Boolean(mine), listAfter.length)
|
||||
if (mine) {
|
||||
console.log('[smoke2] meta: name=%s firstMessage=%s count=%d', mine.name, JSON.stringify(mine.firstMessage.slice(0, 30)), mine.messageCount)
|
||||
|
||||
// 新会话(切换)
|
||||
await runtime.newSession()
|
||||
console.log('[smoke2] newSession ok, rebind=%d, new id=%s, messages=%d', rebindCount, runtime.session.sessionId, runtime.session.messages.length)
|
||||
|
||||
// 切回
|
||||
await runtime.switchSession(mine.path)
|
||||
console.log(
|
||||
'[smoke2] switch back ok, rebind=%d, id=%s, messages restored=%d, name=%s',
|
||||
rebindCount,
|
||||
runtime.session.sessionId,
|
||||
runtime.session.messages.length,
|
||||
runtime.session.sessionName ?? '(none)'
|
||||
)
|
||||
}
|
||||
|
||||
await runtime.dispose()
|
||||
console.log('[smoke2] done')
|
||||
process.exit(0)
|
||||
@@ -1,66 +0,0 @@
|
||||
/**
|
||||
* v0.4.0 冒烟:业务工具注册 / 手动直调(D2 双模式路径)/ tool_call 拦截契约
|
||||
* 说明:审批拦截逻辑与 src/main/pi/approval.ts 保持一致(此处内联同构实现,
|
||||
* 因冒烟脚本无法直接加载 TS 源文件;拦截契约由 pi 的 ExtensionHandler 类型保证)。
|
||||
*/
|
||||
import { createAgentSessionFromServices, createAgentSessionServices, defineTool, getAgentDir, SessionManager } from '@earendil-works/pi-coding-agent'
|
||||
import { Type } from '@earendil-works/pi-ai'
|
||||
import { homedir } from 'node:os'
|
||||
|
||||
const cwd = homedir()
|
||||
const agentDir = getAgentDir()
|
||||
|
||||
// 与 src/main/tools/demo-tools.ts 同构的工具定义
|
||||
const hotwordLookup = defineTool({
|
||||
name: 'hotword_lookup',
|
||||
label: '热点词查询(演示)',
|
||||
description: 'demo',
|
||||
parameters: Type.Object({ query: Type.String(), limit: Type.Optional(Type.Number()) }),
|
||||
async execute(_id, params) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `热点词查询结果(演示数据源,query="${params.query}"):ok` }],
|
||||
details: { source: 'demo' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ---- 1) 工具注册检查(customTools 路径) ----
|
||||
const services = await createAgentSessionServices({ cwd, agentDir })
|
||||
const { session } = await createAgentSessionFromServices({
|
||||
services,
|
||||
sessionManager: SessionManager.inMemory(cwd),
|
||||
customTools: [hotwordLookup]
|
||||
})
|
||||
const all = session.getAllTools()
|
||||
console.log('[smoke4] registered tools:', all.map((t) => t.name).join(', '))
|
||||
console.log('[smoke4] hotword_lookup registered:', all.some((t) => t.name === 'hotword_lookup'))
|
||||
|
||||
// ---- 2) 手动直调(双模式共用能力的证明,不经 LLM) ----
|
||||
const result = await hotwordLookup.execute('smoke-call', { query: '宠物' }, undefined, undefined, {})
|
||||
console.log('[smoke4] manual execute text:', JSON.stringify(result.content[0]?.text?.slice(0, 60)))
|
||||
|
||||
// ---- 3) tool_call 拦截契约(模拟 ExtensionAPI 捕获 handler) ----
|
||||
const handlers = {}
|
||||
const fakePi = { on: (event, handler) => { handlers[event] = handler } }
|
||||
const needsApproval = new Set(['price_check', 'bash'])
|
||||
let decision = false
|
||||
fakePi.on('tool_call', async (event) => {
|
||||
if (!needsApproval.has(event.toolName)) return
|
||||
const approved = decision
|
||||
if (!approved) return { block: true, reason: '用户拒绝了本次工具调用审批' }
|
||||
return
|
||||
})
|
||||
|
||||
const passthrough = await handlers['tool_call']({ type: 'tool_call', toolName: 'read', input: { path: 'x' } })
|
||||
console.log('[smoke4] read(不在名单)放行:', passthrough === undefined)
|
||||
|
||||
decision = false
|
||||
const denied = await handlers['tool_call']({ type: 'tool_call', toolName: 'price_check', input: { product_id: 'P1', target_price: 9.9 } })
|
||||
console.log('[smoke4] price_check 拒绝 -> block:', JSON.stringify(denied))
|
||||
|
||||
decision = true
|
||||
const approvedRes = await handlers['tool_call']({ type: 'tool_call', toolName: 'bash', input: { command: 'ls' } })
|
||||
console.log('[smoke4] bash 批准 -> 放行:', approvedRes === undefined)
|
||||
|
||||
console.log('[smoke4] done')
|
||||
process.exit(0)
|
||||
@@ -1,39 +0,0 @@
|
||||
/**
|
||||
* pi SDK 集成冒烟测试(与 src/main/pi/agent-service.ts 相同的调用路径)
|
||||
* 用法:node scripts/smoke.mjs
|
||||
* 验证点:会话创建、事件订阅、prompt 在无凭据时的报错传播。
|
||||
*/
|
||||
import { createAgentSession } from '@earendil-works/pi-coding-agent'
|
||||
import { homedir } from 'node:os'
|
||||
|
||||
const events = []
|
||||
let settled = false
|
||||
|
||||
console.log('[smoke] creating session (cwd=%s)...', homedir())
|
||||
const { session } = await createAgentSession({ cwd: homedir() })
|
||||
console.log('[smoke] session created: id=%s model=%s/%s', session.sessionId, session.model?.provider ?? '-', session.model?.id ?? '-')
|
||||
|
||||
session.subscribe((event) => {
|
||||
events.push(event.type)
|
||||
if (event.type === 'message_update' && event.assistantMessageEvent?.type?.endsWith('_delta')) {
|
||||
const d = event.assistantMessageEvent.delta ?? ''
|
||||
process.stdout.write(d)
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
await session.prompt('回复"链路正常"两个字即可,不要调用任何工具。')
|
||||
console.log('\n[smoke] prompt resolved; event sequence:', [...new Set(events)].join(' -> '))
|
||||
const last = session.getLastAssistantText()
|
||||
console.log('[smoke] last assistant text:', last ? JSON.stringify(last.slice(0, 120)) : '(none)')
|
||||
settled = true
|
||||
} catch (err) {
|
||||
console.log('\n[smoke] prompt rejected as expected (no credentials):', err instanceof Error ? err.message : err)
|
||||
}
|
||||
|
||||
const stats = session.getSessionStats()
|
||||
console.log('[smoke] stats:', JSON.stringify({ messages: session.messages.length, cost: stats.cost }))
|
||||
console.log('[smoke] active tools:', session.getActiveToolNames().join(', '))
|
||||
session.dispose()
|
||||
console.log('[smoke] done, settled=%s', String(settled))
|
||||
process.exit(0)
|
||||
Reference in New Issue
Block a user