fix: stabilize background operations and large embedding results

This commit is contained in:
2026-09-06 16:26:17 +08:00
parent 874e916106
commit 3b9490e3fb
73 changed files with 3847 additions and 149 deletions
+12
View File
@@ -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>
+31
View File
@@ -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>
+5 -1
View File
@@ -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')
+7 -3
View File
@@ -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()