fix: stabilize background operations and large embedding results
This commit is contained in:
@@ -40,3 +40,15 @@ backend/.venv/Scripts/python.exe frontend/tests/performance/run-stress.py --url
|
||||
`--scroll` 使用纸间时光主题及高度受限的编辑区,派发 120 次真实 CDP 滚轮事件(先向下再向上),记录 animation frame 间隔与长任务;随后从文末全部折叠,记录滚动位置和光标位置。可追加 `--profile --sizes 120000 --runs 1` 保存 CPU profile,用 Chrome DevTools Performance 面板导入。采样会增加开销,勿将 profile 结果与无采样结果直接比较。
|
||||
|
||||
帧间隔包含无头浏览器、CDP 调度和布局开销,不等同于用户设备的 FPS。滚轮模式不验证输入法、保存或图表渲染。当前测试容器改为有限高度的 flex 布局,早期普通事务报告使用的容器布局不同,跨版本比较应分别保留同一布局下的基线。
|
||||
|
||||
主题对比使用 URL 查询参数:`stress.html?theme=light`、`?theme=dark`,默认是 `paper-moments`。诊断参数 `?variant=no-outline` 可关闭编辑区轮廓线,用于隔离旧版纸间时光的长文开销;1.8.1 已不再使用这条 outline。`--screenshot` 会在派发滚轮前保存当前视口 PNG,截图时间可能计入记录区间。
|
||||
|
||||
## Agent 与任务
|
||||
|
||||
`agent-task.html?kind=tasks&theme=light` 测试任务组件,`kind=trace` 测试 Trace;支持 light、dark、paper-moments。继续使用 `run-stress.py --scroll`,任务规模可设 `--sizes 100 1000`,Trace 可设 `--sizes 200 2000 10000`。每个规模在新页面中生成独立数据,所有 fetch 被拦截,未知请求直接失败,不落到真实后端。
|
||||
|
||||
任务先记录实际分页加载数量,再注入全量夹具测渲染上限,结果包含 `fullListIsInjected`。Trace 测量时间线、树形搜索及切换;滚轮区间与过滤区间分别计时。`scrollContainers` 和 `maxScrollTop` 用来确认目标实际滚动。完整结果及限制见 [Agent 与任务压测报告](../../../docs/development/Agent与任务压测报告.md)。
|
||||
|
||||
修复后任务会读取所有 API 页,渲染每页 100 条;Trace 每页 200 条,筛选仍覆盖完整数据。`renderedTasks`、`totalFilteredCount` 区分 DOM 数量与实际记录总数,不能把分页后的 DOM 数量误报为数据丢失。
|
||||
|
||||
`logs.html?theme=paper-moments` 使用隔离的合成日志,支持 `light`、`dark` 主题,用于筛选栏、日志详情和主题视觉检查。可搭配驱动的 `--scroll --screenshot --sizes 1 --runs 1` 保存首屏;该夹具不连接真实日志库,不用于测后端日志吞吐。
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<!doctype html><html><head><meta charset="utf-8"><title>Agent 与任务压测</title></head>
|
||||
<body><div id="viewport"><div id="app"></div></div>
|
||||
<script type="module">
|
||||
import { createApp, h, nextTick, ref } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import TasksView from '/src/features/tasks/TasksView.vue'
|
||||
import TraceTimeline from '/src/features/agent/TraceTimeline.vue'
|
||||
import { useTaskStore } from '/src/stores/task.ts'
|
||||
import { getCommunityThemePreviewCss } from '/src/services/themePackageService.ts'
|
||||
import '/src/styles/tokens.css'
|
||||
import '/src/styles/features.css'
|
||||
const frame = () => new Promise(resolve => requestAnimationFrame(resolve))
|
||||
const settle = async () => { await nextTick(); await frame(); await frame() }
|
||||
const summary = values => { const sorted=[...values].sort((a,b)=>a-b); return {median:sorted[Math.floor(sorted.length/2)]??0,p95:sorted[Math.ceil(sorted.length*.95)-1]??0,max:sorted.at(-1)??0} }
|
||||
window.prepareScrollBenchmark = async (size = 1000) => {
|
||||
const params=new URLSearchParams(location.search), kind=params.get('kind')||'tasks', theme=params.get('theme')||'light'
|
||||
document.documentElement.dataset.theme=theme
|
||||
const style=document.createElement('style');style.textContent=theme==='paper-moments'?getCommunityThemePreviewCss(theme):'';document.head.append(style)
|
||||
const pinia=createPinia(), store=useTaskStore(pinia), host=document.getElementById('app'), viewport=document.getElementById('viewport')
|
||||
// App tokens normally clip #app; the real Agent page provides its own scroller.
|
||||
// This standalone Trace mount uses #viewport in that role.
|
||||
if(kind==='trace'){host.style.height='auto';host.style.overflow='visible'}
|
||||
const date='2026-09-06T00:00:00Z'
|
||||
const tasks=Array.from({length:size},(_,i)=>({task_id:`task_${i}`,title:`压测任务 ${i}`,description:'用于验证任务列表渲染与筛选,独立生成,不读取真实笔记。',status:i%3===0?'done':'todo',created_at:date,updated_at:date}))
|
||||
const events=ref(Array.from({length:size},(_,i)=>{
|
||||
const step=Math.floor(i/4), event=['ModelCallStarted','ModelCallCompleted','ToolCall','ToolResult'][i%4]
|
||||
return {run_id:'stress',sequence:i,event,timestamp:new Date(Date.parse(date)+i*10).toISOString(),data:{step,model_call_id:`m_${step}`,parent_model_call_id:`m_${step}`,tool_call_id:`t_${step}`,name:'system.echo',arguments:{text:`压力测试 ${step}`},output:{text:'工具返回内容'},success:true,duration_ms:10}}
|
||||
}))
|
||||
const originalFetch=window.fetch, requests=[]
|
||||
// Intercept every request in this isolated page: never fall through to the user's backend.
|
||||
window.fetch=async (input)=>{
|
||||
const url=new URL(typeof input==='string'?input:input.url,location.href);requests.push(url.pathname+url.search)
|
||||
if(url.pathname==='/api/tasks'){
|
||||
const limit=Number(url.searchParams.get('limit')||50),offset=Number(url.searchParams.get('offset')||0)
|
||||
return new Response(JSON.stringify({items:tasks.slice(offset,offset+limit),page:{total:size,limit,offset}}),{headers:{'Content-Type':'application/json'}})
|
||||
}
|
||||
throw Error(`Unexpected request in isolated benchmark: ${url.pathname}`)
|
||||
}
|
||||
const start=performance.now()
|
||||
const app=createApp({render:()=>kind==='tasks'?h(TasksView):h(TraceTimeline,{events:events.value,runStatus:'completed'})}).use(pinia)
|
||||
app.mount(host)
|
||||
await settle()
|
||||
while(store.isLoading) await settle()
|
||||
const result={kind,theme,size,initialRenderMs:performance.now()-start,requests,initialTaskCount:kind==='tasks'?store.tasks.length:undefined}
|
||||
if(kind==='tasks'){
|
||||
const fullStart=performance.now();store.tasks=tasks;await settle()
|
||||
result.fullListRenderMs=performance.now()-fullStart
|
||||
result.fullListIsInjected=true; result.renderedTasks=host.querySelectorAll('article.task-card').length // Diagnostic upper bound, distinct from current paginated API behavior.
|
||||
}
|
||||
result.domNodes=host.querySelectorAll('*').length
|
||||
const scroller=host.querySelector('.feature-page')||viewport
|
||||
result.scrollContainers=[...document.querySelectorAll('html,body,#app,#viewport,.trace-visualization,.timeline-view,.timeline')].map(element=>({node:element.id||element.className||element.tagName,height:element.clientHeight,scrollHeight:element.scrollHeight,overflow:getComputedStyle(element).overflow,position:getComputedStyle(element).position}))
|
||||
let maxScrollTop=0
|
||||
const trackScroll=()=>{maxScrollTop=Math.max(maxScrollTop,scroller.scrollTop)}
|
||||
scroller.addEventListener('scroll',trackScroll,{passive:true})
|
||||
const gaps=[],longTasks=[];let last=performance.now(),raf=0
|
||||
const tick=now=>{gaps.push(now-last);last=now;raf=requestAnimationFrame(tick)}
|
||||
const observer=new PerformanceObserver(list=>longTasks.push(...list.getEntries().map(t=>t.duration)))
|
||||
observer.observe({type:'longtask',buffered:false});raf=requestAnimationFrame(tick)
|
||||
window.finishScrollBenchmark=async()=>{
|
||||
cancelAnimationFrame(raf);observer.disconnect()
|
||||
result.frameGapsMs=summary(gaps);result.frames=gaps.length;result.longTasks=longTasks
|
||||
result.scrollTop=scroller.scrollTop;result.scrollHeight=scroller.scrollHeight;result.maxScrollTop=maxScrollTop
|
||||
scroller.removeEventListener('scroll',trackScroll)
|
||||
const started=performance.now()
|
||||
if(kind==='tasks'){
|
||||
store.setFilterStatus('done');await settle()
|
||||
result.filteredCount=host.querySelectorAll('article.task-card').length
|
||||
result.totalFilteredCount=store.filteredTasks.length
|
||||
result.expectedFilteredCount=Math.min(100,tasks.filter(task=>task.status==='done').length)
|
||||
if(result.filteredCount!==result.expectedFilteredCount)throw Error('Task filter lost items')
|
||||
} else {
|
||||
const input=host.querySelector('input')
|
||||
if(input){input.value='压力测试 1';input.dispatchEvent(new Event('input',{bubbles:true}));await settle()}
|
||||
result.filteredDomNodes=host.querySelectorAll('*').length
|
||||
result.filteredCount=host.querySelectorAll('.event-card').length
|
||||
result.expectedFilteredCount=Math.min(200,events.value.filter(event=>JSON.stringify(event.data).includes('压力测试 1')).length)
|
||||
if(result.filteredCount!==result.expectedFilteredCount)throw Error('Trace filter lost events')
|
||||
}
|
||||
result.filterMs=performance.now()-started
|
||||
if(kind==='trace'){
|
||||
const input=host.querySelector('input');input.value='';input.dispatchEvent(new Event('input',{bubbles:true}));await settle()
|
||||
let start=performance.now()
|
||||
;[...host.querySelectorAll('.view-toggle button')].find(button=>button.textContent==='树形').click();await settle()
|
||||
result.treeSwitchMs=performance.now()-start
|
||||
start=performance.now();input.value='压力测试';input.dispatchEvent(new Event('input',{bubbles:true}));await settle()
|
||||
result.treeFilterMs=performance.now()-start
|
||||
result.treeFilteredDomNodes=host.querySelectorAll('*').length
|
||||
}
|
||||
app.unmount();window.fetch=originalFetch;style.remove()
|
||||
return result
|
||||
}
|
||||
const bounds=viewport.getBoundingClientRect();return {x:bounds.left+bounds.width/2,y:bounds.top+bounds.height/2}
|
||||
}
|
||||
window.runBenchmark=async size=>{await window.prepareScrollBenchmark(size);return window.finishScrollBenchmark()}
|
||||
</script>
|
||||
<style>html,body{height:100%;margin:0;background:var(--color-background-primary);color:var(--color-text-primary);font-family:system-ui}#viewport{height:100vh;overflow:auto}#app{max-width:1200px;margin:auto;padding:24px;box-sizing:border-box}</style>
|
||||
</body></html>
|
||||
@@ -0,0 +1,31 @@
|
||||
<!doctype html><html><head><meta charset="utf-8"><title>日志页面视觉验收</title></head>
|
||||
<body><div id="app"></div><script type="module">
|
||||
import { createApp, nextTick } from 'vue'
|
||||
import LogsView from '/src/features/logs/LogsView.vue'
|
||||
import { getCommunityThemePreviewCss } from '/src/services/themePackageService.ts'
|
||||
import '/src/styles/tokens.css'
|
||||
import '/src/styles/features.css'
|
||||
const theme = new URLSearchParams(location.search).get('theme') || 'paper-moments'
|
||||
document.documentElement.dataset.theme = theme
|
||||
const style = document.createElement('style'); style.textContent = getCommunityThemePreviewCss(theme); document.head.append(style)
|
||||
const entries = [
|
||||
{ source: 'vectors', event: 'embedding.failed', level: 'ERROR', details: { model: 'Bge-small-zh', device: 'cuda', error_code: 'LOCAL_CUDA_OOM', fallback: 'cpu', job_id: 'index_demo', frames: 'runtime.py:180:infer' } },
|
||||
{ source: 'agent', event: 'ToolResult', level: 'INFO', details: { run_id: 'run_demo', tool: 'tasks.create', status: 'running' } },
|
||||
{ source: 'tasks', event: 'task.created', level: 'INFO', details: { task_id: 'task_demo', run_id: 'run_demo', status: 'todo' } },
|
||||
{ source: 'models', event: 'model.embedding', level: 'WARNING', details: { model: 'Bge-small-zh', fallback: 'LOCAL_CUDA_OOM', device: 'cpu' } },
|
||||
{ source: 'http', event: 'request.finished', level: 'INFO', details: { method: 'POST', route: '/api/tasks', status: 200, duration_ms: 32.5 } },
|
||||
].map((entry, index) => ({ ...entry, id: 10-index, timestamp: '2026-09-06T08:00:00Z' }))
|
||||
window.fetch = async input => {
|
||||
const url = new URL(typeof input === 'string' ? input : input.url, location.href)
|
||||
if (url.pathname !== '/api/logs') throw Error('Unexpected request in isolated log preview')
|
||||
return new Response(JSON.stringify({ items: entries, next_cursor: null, sources: entries.map(x => x.source), pending: 0, dropped: 0, write_failures: 0, retention: 20000 }), { headers: { 'Content-Type': 'application/json' } })
|
||||
}
|
||||
createApp(LogsView).mount('#app')
|
||||
window.prepareScrollBenchmark = async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 300)); await nextTick()
|
||||
document.querySelector('details').open = true
|
||||
return { x: 700, y: 400 }
|
||||
}
|
||||
window.finishScrollBenchmark = async () => ({ theme, rows: document.querySelectorAll('details').length })
|
||||
window.runBenchmark = window.prepareScrollBenchmark
|
||||
</script></body></html>
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Real Chromium benchmark. Run with backend/.venv/Scripts/python.exe; requires websockets.
|
||||
Vite must be serving the frontend. Uses an isolated disposable browser profile.
|
||||
"""
|
||||
import argparse, asyncio, json, pathlib, subprocess, tempfile, urllib.request
|
||||
import argparse, asyncio, base64, json, pathlib, subprocess, tempfile, urllib.request
|
||||
import websockets
|
||||
|
||||
async def main(args):
|
||||
@@ -44,6 +44,9 @@ async def main(args):
|
||||
response = await call('Runtime.evaluate', {'expression':expression,'awaitPromise':True,'returnByValue':True})
|
||||
if args.scroll and 'exceptionDetails' not in response:
|
||||
point = response['result']['value']
|
||||
if args.screenshot:
|
||||
capture = await call('Page.captureScreenshot', {'format': 'png'})
|
||||
pathlib.Path(args.output + f'.{size}.{repeat+1}.png').write_bytes(base64.b64decode(capture['data']))
|
||||
if args.profile:
|
||||
await call('Profiler.enable'); await call('Profiler.start')
|
||||
await call('Input.dispatchMouseEvent', {'type':'mouseMoved', **point})
|
||||
@@ -74,6 +77,7 @@ if __name__=='__main__':
|
||||
parser.add_argument('--runs',type=int,default=3)
|
||||
parser.add_argument('--output',required=True)
|
||||
parser.add_argument('--profile',action='store_true',help='Save CPU profiles for scroll runs')
|
||||
parser.add_argument('--screenshot',action='store_true',help='Save a viewport screenshot before each scroll run')
|
||||
parser.add_argument('--scroll',action='store_true',help='Dispatch real wheel events and check fold-to-top')
|
||||
args = parser.parse_args()
|
||||
if args.runs < 1 or any(size < 1 for size in args.sizes): parser.error('runs and sizes must be positive')
|
||||
|
||||
@@ -54,8 +54,12 @@ window.runBenchmark = async (size = 25000) => {
|
||||
|
||||
window.prepareScrollBenchmark = async (size = 25000) => {
|
||||
const source = makeStressDocument(size), target = document.getElementById('app')
|
||||
document.documentElement.dataset.theme = 'paper-moments'
|
||||
const style = document.createElement('style'); style.textContent = getCommunityThemePreviewCss('paper-moments'); document.head.append(style)
|
||||
const options = new URLSearchParams(location.search)
|
||||
const theme = options.get('theme') || 'paper-moments'
|
||||
document.documentElement.dataset.theme = theme
|
||||
const style = document.createElement('style'); style.textContent = theme === 'paper-moments' ? getCommunityThemePreviewCss(theme) : ''; document.head.append(style)
|
||||
const variant = options.get('variant') || 'default'
|
||||
if (variant === 'no-outline') style.textContent += '.visual-editor .milkdown-host .ProseMirror { outline: none !important; }'
|
||||
const pinia = createPinia(), component = ref()
|
||||
const app = createApp({render:()=>h(Editor,{ref:component,initialContent:source})}).use(pinia)
|
||||
app.mount(target)
|
||||
@@ -69,7 +73,7 @@ window.prepareScrollBenchmark = async (size = 25000) => {
|
||||
observer.observe({type:'longtask',buffered:false});raf=requestAnimationFrame(tick)
|
||||
window.finishScrollBenchmark = async () => {
|
||||
cancelAnimationFrame(raf);observer.disconnect()
|
||||
const result={requestedHan:size,theme:'paper-moments',frameGapsMs:summarize(gaps),frames:gaps.length,framesOver25ms:gaps.filter(g=>g>25).length,longTasks:tasks,scrollTop:scroller.scrollTop,scrollHeight:scroller.scrollHeight}
|
||||
const result={requestedHan:size,theme,variant,frameGapsMs:summarize(gaps),frames:gaps.length,framesOver25ms:gaps.filter(g=>g>25).length,longTasks:tasks,scrollTop:scroller.scrollTop,scrollHeight:scroller.scrollHeight}
|
||||
const editor=component.value.getEditor(),view=editor.action(ctx=>ctx.get(editorViewCtx))
|
||||
view.dispatch(view.state.tr.setSelection(TextSelection.near(view.state.doc.resolve(view.state.doc.content.size-1))))
|
||||
scroller.scrollTop=scroller.scrollHeight;await settle()
|
||||
|
||||
Reference in New Issue
Block a user