ソースを参照

修复 Inspector 最新记录排序与顶部定位

Samien 1 ヶ月 前
親
コミット
6e32039d41

+ 16 - 0
docs/verification/2026-09-13-inspector-host-environment/README.md

@@ -0,0 +1,16 @@
+# Inspector 最新记录优先验收
+
+日期:2026-09-13。
+
+Events / Raw 都按时间从新到旧展示。先按时间合并连续且同 Agent / run 的流式片段,再反转记录组;组内正文和原始数据仍按时间正序,组时间取最后一个片段。相同时间以返回顺序作为先后顺序。
+
+打开面板、切换视图回到顶部。位于顶部时持续显示新记录,即使记录总数已达到上限;向下查看旧记录时不强制跳回顶部。
+
+验证通过:
+
+- `npm run typecheck`、`npm run build`。
+- Vitest:Inspector、电脑选项及服务端权限相关 21 项测试。
+- Playwright:`playwright.bot-panels.config.ts` 的 `inspector latest` 用例;包含乱序响应、桌面视图、375px 深色手机视图、切换视图和固定数量刷新。
+- Web 和 macOS 桌面壳使用同一 Vue 组件;本次未重新打包桌面安装器。
+
+截图:[桌面 Raw](inspector-desktop.png)、[手机 Events](inspector-phone.png)。

BIN
docs/verification/2026-09-13-inspector-host-environment/inspector-desktop.png


BIN
docs/verification/2026-09-13-inspector-host-environment/inspector-phone.png


+ 6 - 5
src/client/components/workspace/WorkspaceInspectorPanel.vue

@@ -11,29 +11,30 @@ const follow=ref(true)
 const visible=computed(()=>entries.value.filter(e=>lens.value==='events'?e.direction==='event':e.direction!=='event'))
 const rows=computed(()=>{
  const result:Array<{id:string;at:number;tag:string;summary:string;tone:string;count:number;data:WorkspaceInspectorEntry[];duration?:number}>=[]
- for(const entry of visible.value){
+ for(const entry of [...visible.value].sort((a,b)=>a.at-b.at)){
   const data=entry.data as Record<string,unknown>|null,text=String(data?.text??data?.delta??data?.content??data?.error??data?.message??'').replace(/\s+/g,' ').slice(0,120)
   const tag=lens.value==='raw'?(entry.direction==='request'?'→ out':entry.direction==='error'?'! error':'← in'):entry.method
   const tone=entry.direction==='error'||/error|failed/.test(entry.method)?'error':/start|complete|finish/.test(entry.method)?'boundary':'plain'
   const last=result.at(-1)
-  if(lens.value==='events'&&/delta$/.test(entry.method)&&last?.tag===tag&&last.data[0]?.runId===entry.runId&&last.data[0]?.agentId===entry.agentId){last.count++;last.data.push(entry);last.summary=(last.summary+text).slice(0,120);continue}
+  if(lens.value==='events'&&/delta$/.test(entry.method)&&last?.tag===tag&&last.data[0]?.runId===entry.runId&&last.data[0]?.agentId===entry.agentId){last.count++;last.at=entry.at;last.data.push(entry);last.summary=(last.summary+text).slice(0,120);continue}
   result.push({id:entry.id,at:entry.at,tag,summary:lens.value==='raw'?entry.method+(entry.requestId?' #'+entry.requestId.slice(0,8):''):(text||entry.method),tone,count:1,data:[entry],duration:entry.durationMs})
  }
- return result
+ return result.reverse()
 })
 const time=(at:number)=>new Date(at).toLocaleTimeString('zh-CN',{hour12:false})+'.'+String(at%1000).padStart(3,'0')
 async function load(){const current=generation;loading.value=true
  try{const value=await apiRequest<{entries:WorkspaceInspectorEntry[]}>(`/api/app/conversations/${props.conversationId}/inspector${props.taskId?'?taskId='+encodeURIComponent(props.taskId):''}`);if(current===generation&&!closed){entries.value=value.entries;error.value=''}}catch(e){if(current===generation&&!closed)error.value=e instanceof Error?e.message:'无法读取请求记录'}finally{if(current===generation)loading.value=false}}
 async function cycle(){const version=generation;if(!document.hidden)await load();if(!closed&&version===generation)timer=setTimeout(cycle,3000)}
 watch(()=>[props.conversationId,props.taskId],()=>{generation++;entries.value=[];follow.value=true;clearTimeout(timer);void cycle()},{immediate:true})
-watch(()=>[rows.value.length,lens.value],async()=>{await nextTick();if(follow.value&&list.value)list.value.scrollTop=list.value.scrollHeight})
+watch(lens,async()=>{follow.value=true;await nextTick();if(list.value)list.value.scrollTop=0})
+watch(rows,async()=>{const following=follow.value;await nextTick();if(following&&list.value)list.value.scrollTop=0})
 onBeforeUnmount(()=>{closed=true;generation++;clearTimeout(timer)})
 </script>
 <template>
  <section class="inspector" aria-label="Inspector 请求查看器">
   <header><AppIcon name="bug" :size="16"/><strong>Inspector</strong><button v-if="showClose" class="close" aria-label="关闭 Inspector" title="关闭 Inspector" @click="emit('close')"><AppIcon name="close" :size="16"/></button></header>
   <div class="inspector-toolbar"><div class="lenses" role="group" aria-label="Inspector 视图"><button :aria-pressed="lens==='events'" @click="lens='events'">Events</button><button :aria-pressed="lens==='raw'" @click="lens='raw'">Raw</button></div><span>{{loading&&!entries.length?'读取中…':visible.length+' 条记录'}}</span><button class="reload" aria-label="刷新请求记录" title="重新读取记录" :disabled="loading" @click="load"><AppIcon name="refresh" :size="14"/></button></div>
-  <div ref="list" class="inspector-list" @scroll="follow=!!list&&list.scrollHeight-list.scrollTop-list.clientHeight<40">
+  <div ref="list" class="inspector-list" @scroll="follow=!!list&&list.scrollTop<40">
    <p v-if="error" role="alert" class="error">{{error}}</p>
    <p v-else-if="!rows.length&&!loading" class="empty">{{lens==='raw'?'此聊天尚未记录网关请求与响应。':'此聊天尚未产生运行事件。'}}</p>
    <details v-for="row in rows" :key="row.id" :class="['inspector-row',row.tone]">

+ 49 - 0
tests/client/workspaceInspector.test.ts

@@ -0,0 +1,49 @@
+import {afterEach,expect,it,vi} from 'vitest'
+import {flushPromises,mount} from '@vue/test-utils'
+import Panel from '../../src/client/components/workspace/WorkspaceInspectorPanel.vue'
+import {apiRequest} from '../../src/client/api/client'
+import type {WorkspaceInspectorEntry} from '../../src/shared/workspacePanels'
+vi.mock('../../src/client/api/client',()=>({apiRequest:vi.fn()}))
+const entry=(id:string,at:number,method='message.delta',direction:WorkspaceInspectorEntry['direction']='event',agentId='agent'):WorkspaceInspectorEntry=>({id,at,method,direction,agentId,runId:'run',data:{text:id}})
+afterEach(()=>{vi.restoreAllMocks();vi.clearAllMocks()})
+function setup(entries:WorkspaceInspectorEntry[]){
+ vi.spyOn(document,'hidden','get').mockReturnValue(false)
+ vi.mocked(apiRequest).mockResolvedValue({entries})
+ return mount(Panel,{props:{conversationId:'conversation'},global:{stubs:{AppIcon:true}}})
+}
+it('sorts by timestamp before grouping, shows newest groups first and keeps delta text in order',async()=>{
+ const wrapper=setup([entry('done',400,'message.complete'),entry('B',300),entry('A',200),entry('other',100,'message.delta','event','other')])
+ try{
+  await flushPromises()
+  const rows=wrapper.findAll('details')
+  expect(rows.map(row=>row.find('.summary').text())).toEqual(['done','AB','other'])
+  expect(rows[1]!.find('time').text()).toMatch(/\.300$/)
+  expect(rows[1]!.find('.tag').text()).toContain('×2')
+  expect(JSON.parse(rows[1]!.find('pre').text()).map((e:WorkspaceInspectorEntry)=>e.id)).toEqual(['A','B'])
+ }finally{wrapper.unmount()}
+})
+it('sorts Raw responses and requests newest first, including equal-time arrival order',async()=>{
+ const wrapper=setup([entry('response',300,'session.usage','response'),entry('request',100,'prompt.submit','request'),entry('same-1',200,'first','request'),entry('same-2',200,'second','response')])
+ try{
+  await flushPromises();await wrapper.get('.lenses button:last-child').trigger('click');await flushPromises()
+  expect(wrapper.findAll('pre').map(row=>JSON.parse(row.text()).id)).toEqual(['response','same-2','same-1','request'])
+ }finally{wrapper.unmount()}
+})
+it('opens at the top, follows new records at capacity, and resets a changed lens after reading older records',async()=>{
+ const wrapper=setup([entry('old',100,'message.complete'),entry('new',200,'message.complete')])
+ try{
+  await flushPromises()
+  const list=wrapper.get('.inspector-list').element as HTMLElement
+  expect(list.scrollTop).toBe(0)
+  list.scrollTop=150;await wrapper.get('.inspector-list').trigger('scroll')
+  vi.mocked(apiRequest).mockResolvedValue({entries:[entry('new',200,'message.complete'),entry('latest',300,'message.complete')]})
+  await wrapper.get('.reload').trigger('click');await flushPromises()
+  expect(list.scrollTop).toBe(150)
+  await wrapper.get('.lenses button:last-child').trigger('click');await flushPromises();expect(list.scrollTop).toBe(0)
+  await wrapper.get('.lenses button:first-child').trigger('click');await flushPromises()
+  list.scrollTop=20;await wrapper.get('.inspector-list').trigger('scroll')
+  vi.mocked(apiRequest).mockResolvedValue({entries:[entry('latest',300,'message.complete'),entry('live',400,'message.complete')]})
+  await wrapper.get('.reload').trigger('click');await flushPromises();expect(list.scrollTop).toBe(0)
+  expect(wrapper.find('.summary').text()).toBe('live')
+ }finally{wrapper.unmount()}
+})

+ 35 - 0
tests/e2e/bot-panels.spec.ts

@@ -8,6 +8,41 @@ async function create(page:Page,name:string){
  await editor.getByRole('button',{name:'保存',exact:true}).click()
  await expect(page.getByRole('heading',{name,exact:true})).toBeVisible()
 }
+test('inspector latest records stay at the top on desktop and phone',async({page},testInfo)=>{
+ await page.goto('/conversations')
+ await page.getByRole('textbox',{name:'账号',exact:true}).fill('fixture')
+ await page.getByRole('textbox',{name:'密码',exact:true}).fill('fixture-pass')
+ await page.getByRole('button',{name:'登录',exact:true}).click()
+ await expect(page.locator('.desktop-sidebar .sidebar-create-trigger')).toBeVisible()
+ await create(page,'排序验收')
+ let revision=0
+ await page.route('**/api/app/conversations/*/inspector',route=>{
+  const entries=Array.from({length:90},(_,index)=>['event','response'].map(direction=>({id:`${direction}-${index+revision}`,at:1726200000000+(index+revision)*1000,direction,method:direction==='event'?'message.complete':'session.usage',agentId:'agent',runId:'run',data:{text:`记录 ${index+revision}`}}))).flat()
+  // A later request batch can arrive before older execution records.
+  return route.fulfill({json:{entries:[...entries.slice(120),...entries.slice(0,120)]}})
+ })
+ await page.getByRole('button',{name:'Inspector',exact:true}).click()
+ const panel=page.locator('.inspector'),list=panel.locator('.inspector-list')
+ await expect(panel.locator('summary').first()).toContainText('记录 89')
+ expect(await list.evaluate(el=>el.scrollTop)).toBe(0)
+ const times=await panel.locator('pre').allTextContents()
+ expect(times.map(text=>JSON.parse(text).at)).toEqual(times.map(text=>JSON.parse(text).at).sort((a,b)=>b-a))
+ await list.evaluate(el=>{el.scrollTop=500;el.dispatchEvent(new Event('scroll'))})
+ await panel.getByRole('button',{name:'Raw',exact:true}).click()
+ await expect.poll(()=>list.evaluate(el=>el.scrollTop)).toBe(0)
+ expect(JSON.parse(await panel.locator('pre').first().textContent()||'{}').id).toBe('response-89')
+ revision=1
+ await panel.getByRole('button',{name:'刷新请求记录',exact:true}).click()
+ await expect.poll(async()=>JSON.parse(await panel.locator('pre').first().textContent()||'{}').id).toBe('response-90')
+ expect(await list.evaluate(el=>el.scrollTop)).toBe(0)
+ await page.screenshot({path:testInfo.outputPath('inspector-desktop.png')})
+ await page.setViewportSize({width:375,height:812});await page.emulateMedia({colorScheme:'dark',reducedMotion:'reduce'})
+ await panel.getByRole('button',{name:'Events',exact:true}).click()
+ await expect(panel.locator('summary').first()).toBeInViewport()
+ await expect(panel.locator('summary').first()).toContainText('记录 90')
+ expect(await list.evaluate(el=>el.scrollTop)).toBe(0)
+ await page.screenshot({path:testInfo.outputPath('inspector-phone.png')})
+})
 test('robot sidebar, source changes, requests, routines and group boundaries work on desktop and phone',async({page})=>{
  mkdirSync(evidence,{recursive:true})
  await page.goto('/conversations')