@yxzpro/agent-acta 2.14.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/LOGFORMATS.md +960 -0
- package/README.md +238 -0
- package/REFERENCE.md +1428 -0
- package/agent-acta-page.html +1998 -0
- package/agent-acta-server.mjs +1066 -0
- package/agent-acta-widget.html +724 -0
- package/archive.mjs +335 -0
- package/core/log.mjs +20 -0
- package/core/service.mjs +4285 -0
- package/hooks/cursor-usage-hook.mjs +89 -0
- package/mcp-server.mjs +372 -0
- package/package.json +68 -0
- package/page/dialog-add-agent.js +65 -0
- package/page/dialog-alert.js +159 -0
- package/page/dialog-archive.js +342 -0
- package/page/dialog-compare.js +238 -0
- package/page/dialog-ctxw.js +101 -0
- package/page/dialog-diag.js +72 -0
- package/page/dialog-heatmap.js +215 -0
- package/page/dialog-selftest.js +202 -0
- package/page/dialog-sess-timeline.js +108 -0
- package/page/dialog-stats.js +694 -0
- package/page/dialog-usage.js +158 -0
- package/page/log-card.js +277 -0
- package/page/log-view.js +273 -0
- package/page/sess-view.js +382 -0
- package/page/shared.js +408 -0
- package/page/sidebar.js +129 -0
- package/page/style.css +973 -0
- package/page/topbar.js +61 -0
- package/parsers/atomcode.mjs +321 -0
- package/parsers/buddy.mjs +272 -0
- package/parsers/buddyext.mjs +582 -0
- package/parsers/claude.mjs +496 -0
- package/parsers/cline.mjs +468 -0
- package/parsers/codearts.mjs +96 -0
- package/parsers/codex.mjs +188 -0
- package/parsers/comate.mjs +195 -0
- package/parsers/copilot.mjs +308 -0
- package/parsers/cursor.mjs +493 -0
- package/parsers/devindb.mjs +358 -0
- package/parsers/discovery.mjs +1230 -0
- package/parsers/doubao.mjs +406 -0
- package/parsers/dsh.mjs +377 -0
- package/parsers/gemini.mjs +267 -0
- package/parsers/generic.mjs +494 -0
- package/parsers/hermes.mjs +398 -0
- package/parsers/kilo.mjs +42 -0
- package/parsers/kimi.mjs +249 -0
- package/parsers/mimocode.mjs +531 -0
- package/parsers/minimax.mjs +503 -0
- package/parsers/openclaw.mjs +490 -0
- package/parsers/opencode.mjs +465 -0
- package/parsers/shared.mjs +418 -0
- package/parsers/trace.mjs +66 -0
- package/parsers/tracecode.mjs +348 -0
- package/parsers/traedb.mjs +528 -0
- package/parsers/zcode.mjs +356 -0
- package/plugin/client.js +51 -0
- package/plugin/cordis.patch.yml +5 -0
- package/plugin/plugin.mjs +220 -0
- package/search.mjs +554 -0
- package/tools/grab-trae-key.ps1 +265 -0
- package/vendor/element-plus-icons.js +3 -0
- package/vendor/element-plus.css +1 -0
- package/vendor/element-plus.dark.css +1 -0
- package/vendor/element-plus.full.min.js +65 -0
- package/vendor/element-plus.zh-cn.js +2 -0
- package/vendor/svg/antgroup-color.svg +1 -0
- package/vendor/svg/bytedance-color.svg +1 -0
- package/vendor/svg/chatglm-color.svg +1 -0
- package/vendor/svg/claude-color.svg +1 -0
- package/vendor/svg/claudecode-color.svg +1 -0
- package/vendor/svg/cline.svg +1 -0
- package/vendor/svg/codebuddy-color.svg +1 -0
- package/vendor/svg/codebuddy.svg +1 -0
- package/vendor/svg/codex-color.svg +1 -0
- package/vendor/svg/copilot.svg +1 -0
- package/vendor/svg/cursor.svg +1 -0
- package/vendor/svg/deepseek-color.svg +1 -0
- package/vendor/svg/deepseek-harness-whale.svg +8 -0
- package/vendor/svg/doubao-color.svg +1 -0
- package/vendor/svg/gemini-color.svg +1 -0
- package/vendor/svg/glmv-color.svg +1 -0
- package/vendor/svg/grok.svg +1 -0
- package/vendor/svg/hermesagent.svg +1 -0
- package/vendor/svg/huawei-color.svg +1 -0
- package/vendor/svg/hunyuan-color.svg +1 -0
- package/vendor/svg/kilocode-avatar.svg +1 -0
- package/vendor/svg/kilocode.svg +1 -0
- package/vendor/svg/kimi-color.svg +1 -0
- package/vendor/svg/kimi.svg +1 -0
- package/vendor/svg/logo-AtomGit-G-red.svg +3 -0
- package/vendor/svg/logo-AtomGit-dark.svg +4 -0
- package/vendor/svg/logo-AtomGit-light.svg +4 -0
- package/vendor/svg/meta-color.svg +1 -0
- package/vendor/svg/mimo.svg +66 -0
- package/vendor/svg/minimax-code-logo.svg +8 -0
- package/vendor/svg/minimax-color.svg +1 -0
- package/vendor/svg/moonshot.svg +1 -0
- package/vendor/svg/nex-color.svg +1 -0
- package/vendor/svg/nex.svg +1 -0
- package/vendor/svg/nvidia-color.svg +1 -0
- package/vendor/svg/openai.svg +1 -0
- package/vendor/svg/openclaw-color.svg +1 -0
- package/vendor/svg/openclaw.svg +1 -0
- package/vendor/svg/opencode.svg +1 -0
- package/vendor/svg/openrouter-color.svg +1 -0
- package/vendor/svg/qoder-color.svg +1 -0
- package/vendor/svg/qwen-color.svg +1 -0
- package/vendor/svg/sensenova-color.svg +1 -0
- package/vendor/svg/stepfun.svg +1 -0
- package/vendor/svg/trae-color.svg +1 -0
- package/vendor/svg/traework-v2.svg +1 -0
- package/vendor/svg/wenxin-color.svg +1 -0
- package/vendor/svg/windsurf.svg +1 -0
- package/vendor/svg/workbuddy-color.svg +29 -0
- package/vendor/svg/xiaomimimo.svg +1 -0
- package/vendor/svg/yuanbao-color.svg +1 -0
- package/vendor/svg/yuanbao.svg +1 -0
- package/vendor/svg/zai.svg +1 -0
- package/vendor/svg/zhipu-color.svg +1 -0
- package/vendor/vue.global.prod.js +9 -0
- package/widget/main.js +202 -0
- package/widget/package.json +7 -0
- package/widget/preload.js +11 -0
|
@@ -0,0 +1,1998 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<!-- 服务端发这张页面时会把下面 content 里那个 token 换成本进程的代码指纹(见 agent-acta-server.mjs 的 '/' 路由)。
|
|
7
|
+
只放**一处**、且不参与任何 JS 判断 —— token 在整个文件里必须唯一:服务端是全文替换,
|
|
8
|
+
页面上再写一遍会被一起换掉(曾把比对写成 `PAGE_BUILD === '<token>'`,注入后左右两边一起变,永远判「不一致」)。 -->
|
|
9
|
+
<meta name="agent-log-build" content="__AGENT_LOG_BUILD__">
|
|
10
|
+
<!-- R25 批 0:页面文件指纹(html + /page/*.js + /page/style.css)。与上面那枚同理、同样只出现一处:
|
|
11
|
+
服务端 BUILD 只哈希服务端脚本,拆出 /page/ 之后「只改页面文件」不会让它变,红条就漏了这一类错配。 -->
|
|
12
|
+
<meta name="agent-acta-page-build" content="__AGENT_LOG_PAGE_BUILD__">
|
|
13
|
+
<title>Agent 请求日志</title>
|
|
14
|
+
<link rel="stylesheet" href="/vendor/element-plus.css">
|
|
15
|
+
<link rel="stylesheet" href="/page/style.css">
|
|
16
|
+
</head>
|
|
17
|
+
<body>
|
|
18
|
+
<div id="app" v-cloak>
|
|
19
|
+
<el-config-provider :locale="zhCn">
|
|
20
|
+
<!-- R25 批 2:顶栏的模板在 page/topbar.js。这里把根那份状态原样递进去(live/scanning/scanMs/idle*
|
|
21
|
+
由 SSE 与 /api/settings 回写,归组件持有就会有两份真相),三个命令回根的既有处理函数。 -->
|
|
22
|
+
<aa-topbar :client-launching="clientLaunching" @launch-client="launchClient"
|
|
23
|
+
:live="live" :paused="paused" :scanning="scanning" :live-text="liveText"
|
|
24
|
+
:scan-ms="scanMs" :scan-options="scanOptions" @set-scan="setScan"
|
|
25
|
+
:idle-exit-ms="idleExitMs" :idle-options="idleOptions" :idle-text="idleText" @set-idle="setIdle"></aa-topbar>
|
|
26
|
+
|
|
27
|
+
<!-- 页面/服务版本不一致的红条(见 versionMismatch)。
|
|
28
|
+
升级包之后旧的服务进程还在占端口时,**页面是新的、接口是旧的**:点「用量统计」请求 /api/daily 会 404,
|
|
29
|
+
.cursor 也扫不到、手动加也加不上 —— 三件事看着不相干,根子是同一个。这里说清是哪一版、以及怎么换班。 -->
|
|
30
|
+
<div v-if="versionMismatch" class="vm-bar">
|
|
31
|
+
<el-icon><Warning></Warning></el-icon>
|
|
32
|
+
<div class="vm-txt">
|
|
33
|
+
<b>页面和服务的版本不一致</b>:{{ versionMismatch }}
|
|
34
|
+
<div class="vm-fix">在终端里执行 <code>agentacta --open</code>(会自动重启旧服务),若仍不行就先 <code>agentacta --stop</code> 再 <code>agentacta --open</code>,然后刷新本页。</div>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<div class="layout">
|
|
39
|
+
<!-- R25 批 2:侧栏模板在 page/sidebar.js。渲染用的状态一律还是根的那一份(筛选喂 snapUrl、
|
|
40
|
+
深链也在根),组件只发命令。noLlm 的回写:模板写 kebab 的 @update:no-llm(编译出 onUpdate:noLlm),
|
|
41
|
+
组件发 $emit('update:no-llm') —— Vue 的 emit 在精确名之外还会按 camelize 兜底找监听器,两边对得上
|
|
42
|
+
(已在真浏览器里双向量过:点勾选改根状态、改根状态变勾选)。 -->
|
|
43
|
+
<aa-sidebar :view="view" @switch-view="switchView"
|
|
44
|
+
:ctx-unfilled="ctxUnfilled" @open-diag="diagVisible = true" @open-ctxw="ctxwVisible = true" @open-heatmap="heatmapVisible = true" @open-archive="archiveVisible = true" @open-usage="usageVisible = true" @open-selftest="selftestVisible = true" @open-add="openAdd"
|
|
45
|
+
:alert-count="alertCount" @open-alert="alertVisible = true"
|
|
46
|
+
:f-agent="fAgent" :all-count="all.length" @pick-agent="pickAgent"
|
|
47
|
+
:side-agents="sideAgents" :agents="agents" :agents-meta="agentsMeta"
|
|
48
|
+
:is-disabled="isDisabled" :is-icon-only="isIconOnly" :is-missing="isMissing" :path-tip="pathTip"
|
|
49
|
+
:trae-busy="traeBusy" :is-trae-key-agent="isTraeKeyAgent" @trae-command="traeCommandUI"
|
|
50
|
+
:cursor-hook-busy="cursorHookBusy" :is-cursor-hook-agent="isCursorHookAgent" :cursor-hook-state="cursorHookState" @cursor-hook="toggleCursorHook"
|
|
51
|
+
:toggle-loading="toggleLoading" @toggle-agent="toggleAgentUI" @remove-agent="removeAgentUI"
|
|
52
|
+
:no-llm="showNoLLM" @update:no-llm="showNoLLM = $event"></aa-sidebar>
|
|
53
|
+
|
|
54
|
+
<div class="main-outer">
|
|
55
|
+
<!-- R25 批 4:请求日志视图的挂载点(模板在 page/log-view.js + page/log-card.js,状态与请求全部留在根)。
|
|
56
|
+
不变式留在这儿:.main 是**唯一**的滚动容器,所以这一份必须 v-show 不能 v-if —— 销毁重建会丢滚动位置
|
|
57
|
+
和已展开的详情;会话视图那一份是 v-if,它自己两栏内滚,不共享这个位置。
|
|
58
|
+
滚动三件套(mainScroll / showTop 与那两个函数)是本批唯一搬进组件的状态,它们在组件内部自洽:
|
|
59
|
+
根里的 locateEntry 与 collapseCard 都按 id / closest('.card') 拿元素,不经过这个 ref。
|
|
60
|
+
其余一个都不搬 —— 筛选喂 snapUrl 与深链,详情缓存与折叠开关要跨视图切换留着。
|
|
61
|
+
props 面宽是 R25 评审时接受下的代价(见 sess-view 挂载点上面那段与 R25 条目)。 -->
|
|
62
|
+
<aa-log-view :active="view === 'log'"
|
|
63
|
+
:booting="booting" :scanning="scanning" :all="all" :filtered="filtered" :visible="visible"
|
|
64
|
+
:capped="capped" :fail-count="failCount" :range-total="rangeTotal"
|
|
65
|
+
:f-search="fSearch" @update:f-search="fSearch = $event"
|
|
66
|
+
:f-agents="fAgents" @update:f-agents="fAgents = $event" @agents-multi="pickAgentsMulti"
|
|
67
|
+
:f-agent="fAgent" :side-agents="sideAgents" :is-icon-only="isIconOnly"
|
|
68
|
+
:f-projects="fProjects" :f-project="fProject" :project-list="projectList"
|
|
69
|
+
:pick-project="pickProject" :pick-projects-multi="pickProjectsMulti"
|
|
70
|
+
:f-dates="fDates" @update:f-dates="fDates = $event" @range-change="pickRange"
|
|
71
|
+
:f-status="fStatus" @update:f-status="fStatus = $event" @status-change="updateHash"
|
|
72
|
+
:alert-only="alertOnly" @update:alert-only="alertOnly = $event" :alert-count="alertCount"
|
|
73
|
+
:f-limit="fLimit" @update:f-limit="fLimit = $event" @limit-change="loadSnapshot"
|
|
74
|
+
@fail-toggle="fStatus = fStatus==='error' ? '' : 'error'; updateHash()"
|
|
75
|
+
:search-on="searchOn" :search-q="searchQ" :search-regex="searchRegex" :search-busy="searchBusy"
|
|
76
|
+
:search-narrow="searchNarrow" :search-error="searchError" :search-meta="searchMeta"
|
|
77
|
+
:search-capped="searchCapped" :search-hits-count="searchHits.length"
|
|
78
|
+
@search-run="runSearch()" @search-clear="clearSearch()" @search-toggle-narrow="toggleSearchNarrow"
|
|
79
|
+
:shown="shown" @load-more="shown += STEP"
|
|
80
|
+
:expanded-id="expandedId" :secs="secs" :call-open="callOpen" :tool-json-fold="toolJsonFold" :full-ids="fullIds"
|
|
81
|
+
:card-memo="cardMemo" :detail-of="detailOf" :title-of="titleOf" :is-live="isLive"
|
|
82
|
+
:compare-is-of="compareIsOf"
|
|
83
|
+
:search-icon="Search" :trend-icon="TrendCharts" :download-icon="Download" :refresh-icon="Refresh"
|
|
84
|
+
@open-stats="openStats" @export-json="exportJSON" @refresh="refresh"
|
|
85
|
+
@export-detail="exportEntryDetail"
|
|
86
|
+
@export-repro="exportRepro"
|
|
87
|
+
@toggle="toggle" @open-session="openSession" @collapse="collapseCard" @load-full="loadFull"
|
|
88
|
+
@fold-json="toggleJsonFold" @fold-callout="toggleCallOut" @toggle-sec="toggleSec" @reload-detail="reloadDetail"
|
|
89
|
+
@compare="onCompare"></aa-log-view>
|
|
90
|
+
|
|
91
|
+
<!-- R25 批 3:会话视图的挂载点(模板在 page/sess-view.js,状态与请求全部留在根)。
|
|
92
|
+
不变式留在这儿:两栏各自滚动(.sv-main 把 .main 的 overflow 换成 hidden,滚动交给
|
|
93
|
+
.sv-list / .sv-cur)—— 一个会话展开后可能有上千行事件,让详情把列表一起顶走,等于每次都要重新找会话。
|
|
94
|
+
为什么 props 面这么宽、一个状态都不搬:本视图是 v-if,切去「请求日志」整棵子树就销毁了;
|
|
95
|
+
选中项/展开的轮/详情缓存只有活在根里才切回来还在(搬进组件=行为变化),理由见 sess-view.js 头部。 -->
|
|
96
|
+
<aa-sess-view v-if="view === 'sess'"
|
|
97
|
+
:sess-search="sessSearch" @update:sess-search="sessSearch = $event"
|
|
98
|
+
:sess-sort="sessSort" @update:sess-sort="sessSort = $event"
|
|
99
|
+
:sess-list="sessList" :sess-list-filtered="sessListFiltered" :sess-list-capped="sessListCapped"
|
|
100
|
+
:sess-list-error="sessListError" :sess-list-loading="sessListLoading" :search-icon="Search"
|
|
101
|
+
:sess-pick="sessPick" :sess-cur="sessCur" :sess-cur-loading="sessCurLoading" @pick-session="pickSession"
|
|
102
|
+
:sess-open="sessOpen" :sess-detail="sessDetail" :sess-full-ids="sessFullIds" :sess-hi="sessHi"
|
|
103
|
+
:sess-cursor="sessCursor" :sess-span="sessSpan" :sess-from="sessFrom" :sess-to="sessTo"
|
|
104
|
+
:sess-has-events="sessHasEvents" :sess-ov-turns="sessOvTurns" :sess-ov-events="sessOvEvents"
|
|
105
|
+
:sess-ev-index="sessEvIndex" :sess-ev-rows="sessEvRows"
|
|
106
|
+
:sess-chart="sessChart" :sess-chart-open="sessChartOpen" @update:sess-chart-open="sessChartOpen = $event"
|
|
107
|
+
:tool-json-fold="toolJsonFold" :call-open="callOpen"
|
|
108
|
+
@toggle-turn="sessToggleTurn" @event-click="sessEventClick" @ov-click="sessOvClick"
|
|
109
|
+
@chart-go="sessChartGo" @collapse-turn="sessCollapseTurn" @load-full="sessLoadFull"
|
|
110
|
+
@fold-json="toggleJsonFold" @fold-callout="toggleCallOut" @open-session="openSessionByKey"></aa-sess-view>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<!-- R25 批 1:环境诊断弹层的挂载点(模板 + 探测逻辑在 page/dialog-diag.js)。
|
|
115
|
+
开关状态留在根:侧栏「环境诊断」项与弹层自己的关闭按钮都要动它。 -->
|
|
116
|
+
<aa-dialog-diag :visible="diagVisible" @update:visible="diagVisible = $event" :page-build="pageBuild"></aa-dialog-diag>
|
|
117
|
+
|
|
118
|
+
<!-- R25 批 1:用量统计弹层的挂载点(三个 tab 的模板与全部统计逻辑在 page/dialog-stats.js)。
|
|
119
|
+
根只递「统计哪一段、按什么筛」(:req,点击那一刻拼好);弹层里那个 agent 下拉与左侧栏是
|
|
120
|
+
**同一个**筛选,所以把活状态递进去、改动报回来(@agent-change 走 pickAgent 再重算)。
|
|
121
|
+
时延分析点最慢那一行要在主列表里定位,列表状态在根上,所以报给 @locate。 -->
|
|
122
|
+
<aa-dialog-stats :visible="statsVisible" @update:visible="statsVisible = $event"
|
|
123
|
+
:req="statsReq" :f-agent="fAgent" :f-agents="fAgents" :side-agents="sideAgents"
|
|
124
|
+
@agent-change="onStatsAgentChange" @locate="locateEntry"></aa-dialog-stats>
|
|
125
|
+
|
|
126
|
+
<!-- R27 活跃热力图弹层(模板与前端聚合全在 page/dialog-heatmap.js)。
|
|
127
|
+
数据固定走 /api/daily?range=all、不跟随侧栏筛选(口径 = 任一 agent 当天有日志即活跃),
|
|
128
|
+
所以根只递「开不开」,不像统计弹层那样递 req。 -->
|
|
129
|
+
<aa-dialog-heatmap :visible="heatmapVisible" @update:visible="heatmapVisible = $event"></aa-dialog-heatmap>
|
|
130
|
+
|
|
131
|
+
<!-- R28 历史归档弹层(模板与请求逻辑全在 page/dialog-archive.js)。读的是 ~/.agent-acta/archive 下
|
|
132
|
+
被冻结的条目 —— 只读旁路,不并入主列表(数据源是归档文件,不是扫描出来的 entries)。
|
|
133
|
+
所以根只递「开不开」与 getJSON,跟热力图一样不递筛选。 -->
|
|
134
|
+
<aa-dialog-archive :visible="archiveVisible" @update:visible="archiveVisible = $event"></aa-dialog-archive>
|
|
135
|
+
|
|
136
|
+
<!-- R30 磁盘占用弹层(模板与请求逻辑全在 page/dialog-usage.js)。拉的是 /api/usage:走一遍磁盘
|
|
137
|
+
把「日志目录 / 索引片 / 归档」的占用合成一张表,与侧栏筛选无关,所以根同样只递「开不开」。 -->
|
|
138
|
+
<aa-dialog-usage :visible="usageVisible" @update:visible="usageVisible = $event"></aa-dialog-usage>
|
|
139
|
+
|
|
140
|
+
<!-- R32 解析器自检弹层(模板与请求逻辑全在 page/dialog-selftest.js)。拉的是 /api/selftest:
|
|
141
|
+
服务端只把上一次 `node test/selftest.mjs` 写的结果文件读出来,**不在这里跑测试**(一轮要串行起停
|
|
142
|
+
二十来个服务、跑两分钟,副作用挂在弹层上就没人敢刷新了)。与筛选同样无关。 -->
|
|
143
|
+
<aa-dialog-selftest :visible="selftestVisible" @update:visible="selftestVisible = $event"></aa-dialog-selftest>
|
|
144
|
+
|
|
145
|
+
<!-- R9 异常巡检弹层(模板与规则/命中列表逻辑全在 page/dialog-alert.js)。根只递「开不开」与几个格式化
|
|
146
|
+
函数;命中集合归根(供「只看异常」筛选),弹层保存后把摘要报回来更新徽章(@summary)。 -->
|
|
147
|
+
<aa-dialog-alert :visible="alertVisible" @update:visible="alertVisible = $event"
|
|
148
|
+
@locate="locateEntry" @summary="applyAlertSummary"
|
|
149
|
+
:fmt-n="fmtN" :fmt-dur="fmtDur" :fmt-time="fmtTime"></aa-dialog-alert>
|
|
150
|
+
|
|
151
|
+
<!-- R6 会话时间线弹层:同一 session 的全部轮次按时间正序串起来,逐轮懒加载详情。
|
|
152
|
+
R25 批 1 起了个组件(page/dialog-sess-timeline.js):轮的正文/展开态都在它自己肚子里,
|
|
153
|
+
根只递「开哪一个」(:req,卡片页脚点击时写)。 -->
|
|
154
|
+
<aa-dialog-sess-timeline :visible="sessVisible" @update:visible="sessVisible = $event" :req="sessReq"></aa-dialog-sess-timeline>
|
|
155
|
+
|
|
156
|
+
<!-- R25 批 1:添加 agent 弹层的挂载点(表单与 POST 逻辑在 page/dialog-add-agent.js)。
|
|
157
|
+
添加成功后由组件 emit('added'),重拉 agents/快照仍由根做(那是根的状态)。 -->
|
|
158
|
+
<aa-dialog-add-agent :visible="addVisible" @update:visible="addVisible = $event" @added="onAgentAdded"></aa-dialog-add-agent>
|
|
159
|
+
|
|
160
|
+
<!-- R25 批 1:上下文窗口弹层的挂载点(表编辑逻辑在 page/dialog-ctxw.js)。
|
|
161
|
+
表数据不算根的:弹层自己拉、自己改,把「待填几个」上报回来给侧栏徽章(@unfilled)。 -->
|
|
162
|
+
<aa-dialog-ctxw :visible="ctxwVisible" @update:visible="ctxwVisible = $event" @unfilled="ctxUnfilled = $event"></aa-dialog-ctxw>
|
|
163
|
+
|
|
164
|
+
<!-- I9 两轮 diff 弹层的挂载点(模板与对比逻辑全在 page/dialog-compare.js)。
|
|
165
|
+
根只递「开不开」与对比的两条(:req,卡片「对比」选满两条时写)。 -->
|
|
166
|
+
<aa-dialog-compare :visible="compareVisible" @update:visible="compareVisible = $event" :req="compareReq"></aa-dialog-compare>
|
|
167
|
+
</el-config-provider>
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
<div id="boot">
|
|
171
|
+
<span class="boot-logo">A</span>
|
|
172
|
+
<div class="boot-txt">正在加载 AgentActa…</div>
|
|
173
|
+
</div>
|
|
174
|
+
<script>
|
|
175
|
+
// 只干一件事:脚本迟迟不来(vendor 404、解析报错、磁盘慢)时,把占位图从「转着等」换成「一句能查的话」。
|
|
176
|
+
// 必须放在 vendor 的 <script src> 之前——它们全挂了的话,只有这里的代码一定跑得到。
|
|
177
|
+
window.setTimeout(function () {
|
|
178
|
+
var b = document.getElementById('boot');
|
|
179
|
+
if (!b) return;
|
|
180
|
+
b.className = 'fail';
|
|
181
|
+
b.querySelector('.boot-txt').textContent = '页面没加载起来 —— 打开浏览器控制台看报错(多半是 /vendor 下的脚本没取到)';
|
|
182
|
+
}, 8000);
|
|
183
|
+
</script>
|
|
184
|
+
|
|
185
|
+
<script src="/vendor/vue.global.prod.js"></script>
|
|
186
|
+
<script src="/vendor/element-plus.full.min.js"></script>
|
|
187
|
+
<script src="/vendor/element-plus.zh-cn.js"></script>
|
|
188
|
+
<script src="/vendor/element-plus-icons.js"></script>
|
|
189
|
+
<script src="/page/shared.js"></script>
|
|
190
|
+
<script>
|
|
191
|
+
// ===== 跨端共享片段(需求书 R12:防漂移)=====
|
|
192
|
+
// projNorm / projKey / rangeDayStart 的**唯一来源**是 agent-acta-server.mjs 里的 SHARED_JS,
|
|
193
|
+
// 服务端在 GET / 时把它原样注入到下面这行占位符处。本文件**不许再抄一份**——以前页面/服务端
|
|
194
|
+
// 各存一份、靠注释提醒人工同步,漂了就是静默的筛选错位(点项目 0 条、「今天」各算各的)。
|
|
195
|
+
// test/shared-snippet-test.mjs 会拦「页面里又出现本地实现」。
|
|
196
|
+
__AGENT_LOG_SHARED__
|
|
197
|
+
</script>
|
|
198
|
+
<script src="/page/dialog-diag.js"></script>
|
|
199
|
+
<script src="/page/dialog-add-agent.js"></script>
|
|
200
|
+
<script src="/page/dialog-ctxw.js"></script>
|
|
201
|
+
<script src="/page/dialog-sess-timeline.js"></script>
|
|
202
|
+
<script src="/page/dialog-stats.js"></script>
|
|
203
|
+
<script src="/page/dialog-heatmap.js"></script>
|
|
204
|
+
<script src="/page/dialog-archive.js"></script>
|
|
205
|
+
<script src="/page/dialog-usage.js"></script>
|
|
206
|
+
<script src="/page/dialog-selftest.js"></script>
|
|
207
|
+
<script src="/page/dialog-alert.js"></script>
|
|
208
|
+
<script src="/page/dialog-compare.js"></script>
|
|
209
|
+
<script src="/page/topbar.js"></script>
|
|
210
|
+
<script src="/page/sidebar.js"></script>
|
|
211
|
+
<script src="/page/sess-view.js"></script>
|
|
212
|
+
<script src="/page/log-card.js"></script>
|
|
213
|
+
<script src="/page/log-view.js"></script>
|
|
214
|
+
<script>
|
|
215
|
+
// 占位符没被替换(直接 file:// 打开本文件,或服务端太旧不认识这个占位符)时,上面那段是空转,
|
|
216
|
+
// 这里响亮地失败 —— 宁可白屏带一句能查的话,也不静默用错口径。
|
|
217
|
+
// typeof 判未声明变量不抛错;片段已注入时这三个是上一段 script 顶层的 const,跨 script 可见。
|
|
218
|
+
if (typeof projNorm !== 'function' || typeof projKey !== 'function' || typeof rangeDayStart !== 'function') {
|
|
219
|
+
var b0 = document.getElementById('boot');
|
|
220
|
+
if (b0) {
|
|
221
|
+
b0.className = 'fail';
|
|
222
|
+
b0.querySelector('.boot-txt').textContent = '页面缺少服务端注入的共享片段 —— 请通过服务端地址(http://127.0.0.1:' + (location.port || '14570') + '/)访问,不要直接打开 html 文件;若已是服务端地址,说明在跑的服务太旧,请升级后重启。';
|
|
223
|
+
}
|
|
224
|
+
throw new Error('agent-acta: shared snippet missing (projNorm/projKey/rangeDayStart) — 页面必须由服务端注入后访问');
|
|
225
|
+
}
|
|
226
|
+
// /page/shared.js 没到(404 / 服务是拆分部件的旧版,没有 /page/ 路由)时,后面的根脚本一引用就整个挂。
|
|
227
|
+
// 先在这儿把话说清楚:boot 占位图要 8 秒才变字,这里立刻改文案,别让人对着转圈猜。
|
|
228
|
+
if (typeof fmtN !== 'function' || typeof renderMd !== 'function' || typeof agentIcon !== 'function') {
|
|
229
|
+
var b1 = document.getElementById('boot');
|
|
230
|
+
if (b1) {
|
|
231
|
+
b1.className = 'fail';
|
|
232
|
+
b1.querySelector('.boot-txt').textContent = '页面缺少 /page/shared.js —— 若刚升级过,多半是在跑的服务还没有 /page/ 路由(拆分部件的旧版),重启服务后刷新即可。';
|
|
233
|
+
}
|
|
234
|
+
throw new Error('agent-acta: /page/shared.js missing — 服务端 /page/ 路由没供上共享函数(旧版服务?)');
|
|
235
|
+
}
|
|
236
|
+
</script>
|
|
237
|
+
<script>
|
|
238
|
+
const { createApp, ref, computed, reactive, nextTick, watch, provide } = Vue;
|
|
239
|
+
const { Document, Top, Bottom, Coin, Files, Lightning, SetUp, Timer, Search, Refresh, Download, QuestionFilled, Warning, TrendCharts, DataLine } = ElementPlusIconsVue || {};
|
|
240
|
+
const { ElMessage, ElMessageBox } = ElementPlus;
|
|
241
|
+
|
|
242
|
+
// 服务端在把这张页面发出来的时候,把「它是哪一版」填进这个占位符(见 agent-acta-server.mjs 的 '/' 路由)。
|
|
243
|
+
// 页面据此比对「我这张页面」和「正在伺候我的服务」是不是同一版 —— 升级包之后旧进程还在占端口时,
|
|
244
|
+
// 这两者会不一样,而症状会散成好几件看着不相干的事(新接口 404、新 agent 扫不到、图表报 JSON 解析错)。
|
|
245
|
+
//
|
|
246
|
+
// ⚠ 判断「要不要比对」看的是 **location.protocol**,不是「token 有没有被替换」:
|
|
247
|
+
// 旧服务把新页面从磁盘原样发出来时,token 是**原样留着**的 —— 而那恰恰是最需要报警的情形。
|
|
248
|
+
//
|
|
249
|
+
// token 用拼接写出来:它在整个文件里只能出现一次(<head> 那行 meta)。
|
|
250
|
+
// 服务端是**全文替换**,页面上但凡再写一遍字面量,注入后那处也会被换成指纹 ——
|
|
251
|
+
// 比对式两边一起变,就永远判「不一致」(第一版就是这么错的,页面上一直挂着红条)。
|
|
252
|
+
const AL_BUILD_TOKEN = '__AGENT_LOG_' + 'BUILD__';
|
|
253
|
+
const PAGE_BUILD = (document.querySelector('meta[name="agent-log-build"]') || {}).content || '';
|
|
254
|
+
const PAGE_BUILD_OK = !!PAGE_BUILD && PAGE_BUILD !== AL_BUILD_TOKEN; // 只有真被注入过才算数
|
|
255
|
+
// 页面文件指纹(R25 批 0):html + /page/*.js + /page/style.css,由服务端在同一个 '/' 路由注入。
|
|
256
|
+
// 服务 BUILD 管「服务端脚本换没换」,这一枚管「页面文件换没换」——拆出 /page/ 之后,改组件文件不再
|
|
257
|
+
// 触碰 BUILD,只靠它拦「浏览器里是旧页面、磁盘上已是新页面文件」。同样用拼接躲全文替换。
|
|
258
|
+
const PAGE_FILES_TOKEN = '__AGENT_LOG_' + 'PAGE_BUILD__';
|
|
259
|
+
const PAGE_FILES_BUILD = (document.querySelector('meta[name="agent-acta-page-build"]') || {}).content || '';
|
|
260
|
+
const PAGE_FILES_OK = !!PAGE_FILES_BUILD && PAGE_FILES_BUILD !== PAGE_FILES_TOKEN;
|
|
261
|
+
const PAGE_SERVED = location.protocol === 'http:' || location.protocol === 'https:';
|
|
262
|
+
|
|
263
|
+
const app = createApp({
|
|
264
|
+
setup() {
|
|
265
|
+
const all = ref([]);
|
|
266
|
+
// 版本错配的说明文字,'' = 正常。非空就在顶栏下面挂一条红条(见 .vm-bar)
|
|
267
|
+
const versionMismatch = ref('');
|
|
268
|
+
// 首份快照还没落地。false 之前列表区画骨架屏,而不是「暂无匹配的请求记录」——
|
|
269
|
+
// 后者会把「还在拉数据」说成「没数据」,刷新时能看见三段跳:占位图 → 暂无匹配 → 列表。
|
|
270
|
+
const booting = ref(true);
|
|
271
|
+
const agents = ref({});
|
|
272
|
+
const projects = ref({});
|
|
273
|
+
const fAgent = ref('');
|
|
274
|
+
// R10 独立多选:仅在对应单选为空(=「全部」态)时显示且有效,与 fAgent/fProject 互斥生效
|
|
275
|
+
const fAgents = ref([]);
|
|
276
|
+
const fProjects = ref([]);
|
|
277
|
+
const fSearch = ref('');
|
|
278
|
+
// ---- R33 全文搜索(候选池 I10)----
|
|
279
|
+
// 与 fSearch 是**两件事**,别合并:fSearch 是敲字就生效的客户端窗口过滤(免费、即时、只看已加载的
|
|
280
|
+
// 那些条目),这里这一组是回车才发的服务端全库检索(有成本、跨 agent、跨未加载历史、还能搜正文)。
|
|
281
|
+
// 两者共用一个输入框,但状态各存各的:搜完清掉搜索态,之前敲的字还在。
|
|
282
|
+
const searchOn = ref(false); // 列表当前显示的是服务端命中集
|
|
283
|
+
const searchQ = ref(''); // 提交时的词(不是输入框里的实时值)
|
|
284
|
+
const searchRegex = ref(false); // 词按正则解释(深链 re=1)
|
|
285
|
+
const searchHits = ref([]); // 命中条目(与 /api/snapshot 的 entries 同形状,卡片零改动)
|
|
286
|
+
const searchBusy = ref(false);
|
|
287
|
+
const searchNarrow = ref(false); // 勾上 = 只搜当前日期区间;默认**不套**时间范围(全文搜索的意义所在)
|
|
288
|
+
const searchError = ref('');
|
|
289
|
+
// 覆盖度说明:服务端 /api/search/status 的索引情况 + 本次查询的 stale/unreadable,合成横幅要的那几个数
|
|
290
|
+
const searchMeta = ref({ entries: 0, total: 0, building: false, stale: 0, unreadable: 0 });
|
|
291
|
+
const searchCapped = ref(false); // 这一轮的命中被服务端单轮 limit 截了(横幅必须说出来,不然「命中 200 条」看着像全库就 200 条)
|
|
292
|
+
let searchSeq = 0; // 已发出的搜索序号:连按回车时作废旧响应(同 sessSeq 的做法)
|
|
293
|
+
// 深链带来的搜索(#q=…):restoreFromHash 只登记、不请求(那时首扫可能还没完),
|
|
294
|
+
// 由启动那一批请求里的 bootstrap 真正触发一次。
|
|
295
|
+
let pendingSearch = null;
|
|
296
|
+
const fProject = ref('');
|
|
297
|
+
// R22 时间范围:具体日期区间 ['YYYY-MM-DD','YYYY-MM-DD'](本地自然日,含两端),null = 全部时间。
|
|
298
|
+
// 和 agent / 项目一样是**服务端**收窄窗口(显式 from/to,复用 customBounds):客户端过滤只能
|
|
299
|
+
// 在拿回来的窗口里挑,而「这两天」的条目完全可能一条都不在窗口里。
|
|
300
|
+
const fDates = ref(null);
|
|
301
|
+
const fStatus = ref('');
|
|
302
|
+
// 日期区间工具(与服务端 rangeDayStart / customBounds 同一套本地自然日口径,这里只做字符串往返)
|
|
303
|
+
const dayStrOf = t => { const d = new Date(t); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); };
|
|
304
|
+
const todayStr = () => dayStrOf(new Date(new Date().setHours(0, 0, 0, 0)));
|
|
305
|
+
const dayStartMs = s => { const p = String(s).split('-').map(Number); return new Date(p[0], p[1] - 1, p[2]).getTime(); };
|
|
306
|
+
const dayEndMs = s => { const d = new Date(dayStartMs(s)); d.setDate(d.getDate() + 1); return d.getTime() - 1; };
|
|
307
|
+
// 默认最近 2 天(今天 + 昨天):进入页面 / 深链没带时间参数时生效
|
|
308
|
+
const defaultRange = () => { const d = new Date(); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() - 1); return [dayStrOf(d.getTime()), todayStr()]; };
|
|
309
|
+
// 旧深链符号(today/7d/30d)→ 日期区间(rangeDayStart 是共享函数,见 SHARED_JS 注入段);
|
|
310
|
+
// 不认识的符号按「全部时间」处理(null),与服务端 rangeBounds 的保守口径一致
|
|
311
|
+
const symbolToDates = sym => {
|
|
312
|
+
const from = rangeDayStart(sym);
|
|
313
|
+
return from == null ? null : [dayStrOf(from), todayStr()];
|
|
314
|
+
};
|
|
315
|
+
const showNoLLM = ref(false); // 非 LLM 轨迹(0 token 的 workflow/skill 调用)默认隐藏
|
|
316
|
+
const fLimit = ref(200);
|
|
317
|
+
const shown = ref(200);
|
|
318
|
+
const STEP = 200;
|
|
319
|
+
const expandedId = ref(null);
|
|
320
|
+
// R21 回到顶部按钮与 .main 的滚动监听已随批 4 搬进 page/log-view.js(只服务那一棵子树;
|
|
321
|
+
// 根里的 locateEntry / collapseCard 按 id 与 closest('.card') 拿元素,不依赖这个 ref)。
|
|
322
|
+
const details = reactive({}); // id -> content | undefined(loading)
|
|
323
|
+
const live = ref(false);
|
|
324
|
+
// 扫描节奏(后端持久化,多标签页通过 settings 事件对齐)。0 = 不实时更新(暂停定时扫描)
|
|
325
|
+
const scanMs = ref(3000);
|
|
326
|
+
const scanOptions = [
|
|
327
|
+
{ v: 1000, label: '每 1 秒' },
|
|
328
|
+
{ v: 2000, label: '每 2 秒' },
|
|
329
|
+
{ v: 3000, label: '每 3 秒(默认)' },
|
|
330
|
+
{ v: 5000, label: '每 5 秒' },
|
|
331
|
+
{ v: 10000, label: '每 10 秒' },
|
|
332
|
+
{ v: 0, label: '不实时更新' },
|
|
333
|
+
];
|
|
334
|
+
const paused = computed(() => !scanMs.value);
|
|
335
|
+
// 服务端首扫进度:null = 没在扫;{done,total,agent} = 正在扫。
|
|
336
|
+
// 服务端分片首扫每扫完一个 agent 让出一次事件循环,页面才连得上、才收得到这些事件。
|
|
337
|
+
const scanning = ref(null);
|
|
338
|
+
const liveText = computed(() => {
|
|
339
|
+
if (!live.value) return '重连中…';
|
|
340
|
+
// 扫描中优先:这不是「没数据」,是「正在找数据」
|
|
341
|
+
if (scanning.value) return '扫描中 ' + scanning.value.done + '/' + scanning.value.total + (scanning.value.agent ? '(' + scanning.value.agent + ')' : '');
|
|
342
|
+
return paused.value ? '已暂停' : '实时更新 · ' + (scanMs.value / 1000) + 's';
|
|
343
|
+
});
|
|
344
|
+
// 空闲自停(后端持久化,默认关)。0 = 永不退出
|
|
345
|
+
const idleExitMs = ref(0);
|
|
346
|
+
const idleOptions = [
|
|
347
|
+
{ v: 0, label: '空闲自停:关(默认)' },
|
|
348
|
+
{ v: 1800000, label: '空闲 30 分钟后自停' },
|
|
349
|
+
{ v: 7200000, label: '空闲 2 小时后自停' },
|
|
350
|
+
{ v: 21600000, label: '空闲 6 小时后自停' },
|
|
351
|
+
];
|
|
352
|
+
const idleText = computed(() => idleExitMs.value ? '空闲 ' + (idleExitMs.value / 3600000 >= 1 ? (idleExitMs.value / 3600000) + 'h' : (idleExitMs.value / 60000) + 'min') + ' 自停' : '空闲自停关');
|
|
353
|
+
const zhCn = (typeof ElementPlusLocaleZhCn !== 'undefined') ? ElementPlusLocaleZhCn : undefined;
|
|
354
|
+
const agentsMeta = ref({}); // name -> {kind, sessions, traces, builtin}
|
|
355
|
+
// Trae 密钥按钮三态判定:kind traedb + Windows + 脚本存在 → 才显示;hasKey/traeErr 决定按钮文案与菜单项。
|
|
356
|
+
function isTraeKeyAgent(name) {
|
|
357
|
+
const m = agentsMeta.value[name];
|
|
358
|
+
return !!m && m.kind === 'traedb' && m.captureSupported === true;
|
|
359
|
+
}
|
|
360
|
+
// Cursor token 采集 hook 按钮:kind cursor 才显示。注入状态(hook.injected)由 /api/agents 带回,
|
|
361
|
+
// 所以和 trae 一样是「服务端一份真相、页面只读」——不在前端记状态,刷新/多标签页都不会说谎。
|
|
362
|
+
function isCursorHookAgent(name) {
|
|
363
|
+
const m = agentsMeta.value[name];
|
|
364
|
+
return !!m && m.kind === 'cursor';
|
|
365
|
+
}
|
|
366
|
+
const cursorHookBusy = ref(''); // 正在注入/卸载的 cursor 名(防连点 + 显示 busy 态)
|
|
367
|
+
function cursorHookState(name) {
|
|
368
|
+
const m = agentsMeta.value[name];
|
|
369
|
+
return (m && m.hook) || {};
|
|
370
|
+
}
|
|
371
|
+
// 注入 / 卸载走同一个接口(POST /api/cursor-hook,action 区分)。成功后重拉 agents 拿新的 injected。
|
|
372
|
+
async function toggleCursorHook(name) {
|
|
373
|
+
if (cursorHookBusy.value) return;
|
|
374
|
+
const injected = !!cursorHookState(name).injected;
|
|
375
|
+
const msg = injected
|
|
376
|
+
? '卸载 token 采集 hook?会从 ~/.cursor/hooks.json 里删掉 AgentActa 那一条命令(你别的钩子不动)。'
|
|
377
|
+
+ '已经采到的用量日志会保留,历史轮次的 token 照常显示。'
|
|
378
|
+
: '给 Cursor 注入 token 采集 hook?会在 ~/.cursor/hooks.json 的 stop 钩子里加一条 AgentActa 的命令'
|
|
379
|
+
+ '(你原有的钩子一个都不动)。Cursor 每轮结束时调用它,把这一轮的 token 用量记到本地 ——'
|
|
380
|
+
+ ' Cursor 的转录文件本身不含用量,这是本地拿到逐轮 token 的唯一途径。';
|
|
381
|
+
try {
|
|
382
|
+
await ElMessageBox.confirm(msg, injected ? '卸载 token 采集 hook' : '注入 token 采集 hook',
|
|
383
|
+
{ type: injected ? 'warning' : 'info', confirmButtonText: injected ? '卸载' : '注入', cancelButtonText: '取消' });
|
|
384
|
+
} catch { return; } // 用户取消:ElMessageBox 用 reject 表示取消,不是错误
|
|
385
|
+
cursorHookBusy.value = name;
|
|
386
|
+
try {
|
|
387
|
+
const j = await getJSON('/api/cursor-hook', {
|
|
388
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
389
|
+
body: JSON.stringify({ action: injected ? 'uninstall' : 'install' }),
|
|
390
|
+
});
|
|
391
|
+
if (!j.ok) throw new Error(j.error || '操作失败');
|
|
392
|
+
ElMessage.success(injected ? '已卸载 token 采集 hook' : '已注入:去 Cursor 里发一条消息就能采到用量');
|
|
393
|
+
await loadAgents();
|
|
394
|
+
} catch (e) {
|
|
395
|
+
ElMessage.error(e.message || '操作失败');
|
|
396
|
+
} finally {
|
|
397
|
+
cursorHookBusy.value = '';
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ----- 添加 agent(R25 批 1 起是 page/dialog-add-agent.js 组件)-----
|
|
402
|
+
// 根只留开关与「加完重拉」:表单、错误、逐条试过的路径、POST 都在组件里。
|
|
403
|
+
const addVisible = ref(false);
|
|
404
|
+
const toggleLoading = ref(''); // 正在切换启停的 agent 名(防连点)
|
|
405
|
+
const traeBusy = ref(''); // 正在进行抓取的 trae 名(防连点 + 显示 busy 态)
|
|
406
|
+
const capped = ref(false); // all 是否已触达前端上限被截断
|
|
407
|
+
const rangeTotal = ref(0); // 当前筛选范围(agent/项目/时间)内的条目总数,服务端给,可能大于拿回来的条数
|
|
408
|
+
const MAX_ALL = 5000; // 前端内存上限(超出丢最旧,只保留最近 5000 条)
|
|
409
|
+
const liveIds = ref(new Set()); // 进行中的轮(最后一条),SSE update 到达时自动重取详情
|
|
410
|
+
const fullIds = ref(new Set()); // 已请求过完整内容的详情(不再回退到截断版)
|
|
411
|
+
let appliedSeq = 0; // 已应用的 SSE seq,乱序保护
|
|
412
|
+
|
|
413
|
+
// 工具 JSON 折叠开关:key = id:i:in|out(会话级 Set,展开详情时自然重建)
|
|
414
|
+
const toolJsonFold = ref(new Set());
|
|
415
|
+
function toggleJsonFold(id, i, which) {
|
|
416
|
+
const s = new Set(toolJsonFold.value);
|
|
417
|
+
const k = id + ':' + i + ':' + which;
|
|
418
|
+
s.has(k) ? s.delete(k) : s.add(k);
|
|
419
|
+
toolJsonFold.value = s;
|
|
420
|
+
}
|
|
421
|
+
// 「LLM 调用明细」里每一次调用自己的正文(key = id:序号,卡片与会话详情各记一份)。
|
|
422
|
+
// ⚠ 极性**与 toolJsonFold 相反**:这里 has() = 已展开。工具入参/返回默认展开(就那么几段),
|
|
423
|
+
// 而一轮的调用明细可能有 73 条,默认全展开等于一屏全是正文,那表就没法看了。
|
|
424
|
+
const callOpen = ref(new Set());
|
|
425
|
+
function toggleCallOut(id, i) {
|
|
426
|
+
const s = new Set(callOpen.value);
|
|
427
|
+
const k = id + ':' + i;
|
|
428
|
+
s.has(k) ? s.delete(k) : s.add(k);
|
|
429
|
+
callOpen.value = s;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// R8 时延分析(最慢轮 TopN + p50/p95)的数据与请求都在 page/dialog-stats.js 里,
|
|
433
|
+
// 根只接它报上来的「定位这一条」——因为要找的条目、展开态、滚动都在主列表这边。
|
|
434
|
+
// 点最慢排行行 → 关统计框 → 在主列表里找到该条目并展开(不在当前窗口时提示)。
|
|
435
|
+
// 走 toggle 而非直接赋值 expandedId:详情懒加载挂在 toggle 里,只设 id 会一直停在骨架屏。
|
|
436
|
+
function locateEntry(id) {
|
|
437
|
+
statsVisible.value = false;
|
|
438
|
+
// R33:判的是**当前生效的那份列表**。搜索态下列表是服务端命中集,拿 all 去判会一律报
|
|
439
|
+
// 「不在当前窗口内」—— 而恰恰是搜索命中之后跳转最需要能用。
|
|
440
|
+
// 另有「条/页」这一层:命中集可能几千条,只有前 shown 条真的渲染了卡片,滑出视口的那些
|
|
441
|
+
// DOM 上并不存在,所以这里同时把 shown 提到该条之前,否则 scrollIntoView 拿不到元素。
|
|
442
|
+
const src = searchOn.value ? searchHits.value : all.value;
|
|
443
|
+
const idx = src.findIndex(e => e.id === id);
|
|
444
|
+
if (idx < 0) {
|
|
445
|
+
ElMessage.warning(searchOn.value
|
|
446
|
+
? '该条目不在本次搜索结果里(可清除搜索后重试)'
|
|
447
|
+
: '该条目不在当前窗口内(可调大「条/页」或收窄时间范围后重试)');
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
if (idx >= shown.value) shown.value = Math.ceil((idx + 1) / STEP) * STEP;
|
|
451
|
+
if (expandedId.value !== id) toggle(id);
|
|
452
|
+
nextTick(() => document.getElementById('card-' + id)?.scrollIntoView({ block: 'center' }));
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ---- R33 全文搜索(服务端检索,候选池 I10)----
|
|
456
|
+
// 与 fSearch 的分工:敲字 = 客户端窗口过滤(免费、即时);回车 = 这里,发一次服务端全库检索。
|
|
457
|
+
// 搜索**尊重 agent / 项目 / 状态筛选,但默认不套时间范围** —— 这正是全文搜索存在的理由
|
|
458
|
+
//(「上周那次报错」多半在默认的最近两天之外)。想收窄就勾横幅上的「限定当前时间范围」。
|
|
459
|
+
// qOverride:勾「限定时间范围」时按**已提交的词**重搜,而不是输入框里那个可能被改过的实时值。
|
|
460
|
+
async function runSearch(qOverride) {
|
|
461
|
+
const q = String(qOverride != null ? qOverride : (fSearch.value || '')).trim();
|
|
462
|
+
if (!q) { clearSearch(); return; }
|
|
463
|
+
const seq = ++searchSeq;
|
|
464
|
+
searchBusy.value = true; searchError.value = '';
|
|
465
|
+
try {
|
|
466
|
+
const p = new URLSearchParams();
|
|
467
|
+
p.set('q', q);
|
|
468
|
+
if (searchRegex.value) p.set('re', '1');
|
|
469
|
+
// 与 snapUrl 同源的筛选口径(多选键 agents=/projects=,单选优先)
|
|
470
|
+
if (fAgent.value) p.set('agent', fAgent.value);
|
|
471
|
+
else if (fAgents.value.length) p.set('agents', fAgents.value.join(','));
|
|
472
|
+
if (fProject.value) p.set('project', fProject.value);
|
|
473
|
+
else if (fProjects.value.length) p.set('projects', fProjects.value.join(','));
|
|
474
|
+
if (fStatus.value) p.set('status', fStatus.value);
|
|
475
|
+
// 时间范围**只在勾了收窄时**才带上;不带 = 服务端不加时间条件(filterEntries 的 customBounds 为 null)
|
|
476
|
+
if (searchNarrow.value && fDates.value) { p.set('from', fDates.value[0]); p.set('to', fDates.value[1]); }
|
|
477
|
+
const j = await getJSON('/api/search?' + p.toString());
|
|
478
|
+
if (seq !== searchSeq) return; // 连按回车:旧响应直接丢,别让它盖掉新结果
|
|
479
|
+
if (!j.ok) { searchError.value = j.error || '搜索失败'; searchOn.value = true; searchHits.value = []; searchCapped.value = false; return; }
|
|
480
|
+
searchQ.value = q;
|
|
481
|
+
searchHits.value = j.entries || [];
|
|
482
|
+
searchCapped.value = !!j.capped; // 服务端算的是「命中数 > 这一轮的 limit」,取回来的这批是被截后的
|
|
483
|
+
searchMeta.value = {
|
|
484
|
+
entries: (j.index && j.index.entries) || 0, total: (j.index && j.index.total) || 0,
|
|
485
|
+
building: !!(j.index && j.index.building), stale: j.stale || 0, unreadable: (j.index && j.index.unreadable) || 0,
|
|
486
|
+
};
|
|
487
|
+
searchError.value = '';
|
|
488
|
+
searchOn.value = true;
|
|
489
|
+
shown.value = STEP; // 换了数据集,条/页回到第一屏
|
|
490
|
+
expandedId.value = null; // 旧展开态属于旧列表,留着会指向一张不在列表里的卡片
|
|
491
|
+
updateHash();
|
|
492
|
+
} catch (err) {
|
|
493
|
+
if (seq === searchSeq) { searchError.value = err.message; searchOn.value = true; searchHits.value = []; searchCapped.value = false; }
|
|
494
|
+
} finally {
|
|
495
|
+
if (seq === searchSeq) searchBusy.value = false;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
// 清除:搜索结果整份丢掉,回到普通窗口列表。
|
|
499
|
+
// **连输入框一起清**(fSearch 也置空)。原先只清搜索态、留着输入框里的词,想着「用户还要拿它
|
|
500
|
+
// 继续在窗口里过滤」—— 实测那是个死胡同:搜的词本来就多半不在当前窗口里(这正是全文搜索的意义),
|
|
501
|
+
// 于是清完立刻显示「匹配 0 条」,看着像清除按钮坏了。清除就该把用户送回原来的样子。
|
|
502
|
+
function clearSearch() {
|
|
503
|
+
searchSeq++; // 作废在途响应,否则慢回来的那次会把列表又切成命中集
|
|
504
|
+
searchOn.value = false; searchHits.value = []; searchError.value = ''; searchCapped.value = false;
|
|
505
|
+
searchQ.value = ''; searchBusy.value = false;
|
|
506
|
+
fSearch.value = ''; // 同一次「返回」的一部分,见上面的理由
|
|
507
|
+
shown.value = STEP;
|
|
508
|
+
updateHash();
|
|
509
|
+
}
|
|
510
|
+
// 勾/取消「限定当前时间范围」:已搜过就直接重搜一次(否则开关看着没反应),没搜过只记状态
|
|
511
|
+
function toggleSearchNarrow(v) {
|
|
512
|
+
searchNarrow.value = !!v;
|
|
513
|
+
if (searchOn.value && searchQ.value) runSearch(searchQ.value);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// 点卡片页脚的 session → /api/session 拉会话聚合(正序轮次 + 汇总)。
|
|
517
|
+
// 轮次正文仍走 /api/entry 懒加载:点开哪轮才取哪轮,大会话不一次读爆。
|
|
518
|
+
const sessVisible = ref(false);
|
|
519
|
+
// R25 批 1:弹层里那一坨(轮次数据 / 展开态 / 逐轮详情缓存)搬进了 page/dialog-sess-timeline.js,
|
|
520
|
+
// 根只留「开哪个」这一个请求。点一次卡片页脚写一份,弹层 watch visible 起来时按它去拉。
|
|
521
|
+
const sessReq = ref(null);
|
|
522
|
+
// agent / project 是**可选**的精确化参数:session 值跨 agent、跨项目都不保证唯一
|
|
523
|
+
// (各解析器口径不同,本机实测 atomcode 就有不同项目下的同名 session)。
|
|
524
|
+
// 不传 == 老行为(只按 key 匹配),所以弹层原来的调用点不受影响。
|
|
525
|
+
function openSession(key, agent, project) {
|
|
526
|
+
if (!key) { ElMessage.warning('该条目没有会话标识'); return; }
|
|
527
|
+
// project 传**原始写法**:服务端按 projKey 两边归一后比(projKey 幂等),两种写法都能命中
|
|
528
|
+
sessReq.value = { key, agent, project };
|
|
529
|
+
sessVisible.value = true;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// ---- R6 会话视图:按会话浏览 + 逐轮逐事件轨迹(view='sess')----
|
|
533
|
+
// 为什么单开一屏、而不是把上面那个弹层做大:轨迹要按**真实时间比例**画概览条、要看上千行事件、
|
|
534
|
+
// 还要在同一屏里来回切会话,弹层那点高度装不下。弹层保留给卡片页脚的「会话时间线」,两者数据同源。
|
|
535
|
+
const view = ref('log'); // 'log' | 'sess'
|
|
536
|
+
// 左栏:会话列表(/api/sessions;limit=500,服务端上限 2000,够一屏用)
|
|
537
|
+
const sessList = ref([]);
|
|
538
|
+
const sessListLoading = ref(false);
|
|
539
|
+
const sessListCapped = ref(false);
|
|
540
|
+
const sessListError = ref('');
|
|
541
|
+
const sessSearch = ref('');
|
|
542
|
+
const sessSort = ref('to'); // to | from | turns | dur | total(客户端排,见 sessListFiltered)
|
|
543
|
+
// 右栏:当前会话
|
|
544
|
+
const sessPick = ref(null); // 选中的那条列表项(带 key / agent / project / projectKey / hasTimeline)
|
|
545
|
+
const sessCur = ref(null); // /api/session 的返回:{count,tin...,from,to,models,entries[]}
|
|
546
|
+
const sessCurLoading = ref(false);
|
|
547
|
+
const sessOpen = ref(new Set()); // 已展开的轮 id(点开才拉 /api/entry)
|
|
548
|
+
const sessDetail = ref({}); // 轮 id -> /api/entry 返回(含 events)。**不复用** details:
|
|
549
|
+
// 那是卡片详情的缓存,混用会让两个视图互相覆盖
|
|
550
|
+
const sessFullIds = ref(new Set()); // 已按 full=1 取过的轮(截断提示据此收起)
|
|
551
|
+
const sessHi = ref(''); // 当前高亮的逐事件行 key = 轮 id + ':' + 事件下标
|
|
552
|
+
const sessCursor = ref(null); // 概览条上的指针位置 0~1(null = 不显示)
|
|
553
|
+
let sessSeq = 0; // 已发出的 /api/session 序号:快速连点会话时作废旧响应
|
|
554
|
+
|
|
555
|
+
function switchView(v) {
|
|
556
|
+
if (view.value === v) return;
|
|
557
|
+
view.value = v;
|
|
558
|
+
updateHash(); // 视图也进深链(见 updateHash)
|
|
559
|
+
// 切过去时列表还是空的才拉;「已经有数据、再切回来刷新一次」交给下面 watch(view),
|
|
560
|
+
// 两条路径互斥,任何一次切换最多发一个请求。
|
|
561
|
+
if (v === 'sess' && !sessList.value.length) loadSessions();
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async function loadSessions() {
|
|
565
|
+
sessListLoading.value = true; sessListError.value = '';
|
|
566
|
+
try {
|
|
567
|
+
// 与 /api/daily、/api/models **同一套筛选口径**(agent / 项目归一化 key / 时间范围:
|
|
568
|
+
// 有日期区间传显式 from/to,否则不限),所以左侧栏一收窄,会话列表跟着收窄,页面不需要再过滤一遍。
|
|
569
|
+
const q = new URLSearchParams();
|
|
570
|
+
q.set('limit', '500');
|
|
571
|
+
if (fDates.value) { q.set('from', fDates.value[0]); q.set('to', fDates.value[1]); }
|
|
572
|
+
// R10:多选态走 agents=/projects= 合集键(与单选互斥,同 snapUrl)
|
|
573
|
+
if (fAgent.value) q.set('agent', fAgent.value);
|
|
574
|
+
else if (fAgents.value.length) q.set('agents', fAgents.value.join(','));
|
|
575
|
+
if (fProject.value) q.set('project', fProject.value);
|
|
576
|
+
else if (fProjects.value.length) q.set('projects', fProjects.value.join(','));
|
|
577
|
+
const j = await getJSON('/api/sessions?' + q.toString());
|
|
578
|
+
if (!j.ok) throw new Error(j.error || '会话列表加载失败');
|
|
579
|
+
// _sid = agent + projectKey + key:选中态必须按三者一起比 —— session 值跨 agent、跨项目都不唯一
|
|
580
|
+
sessList.value = (j.sessions || []).map(s => ({ ...s, _sid: s.agent + '\u0000' + s.projectKey + '\u0000' + s.key }));
|
|
581
|
+
sessListCapped.value = !!j.capped;
|
|
582
|
+
} catch (e) {
|
|
583
|
+
sessListError.value = '会话列表加载失败:' + e.message;
|
|
584
|
+
sessList.value = [];
|
|
585
|
+
} finally { sessListLoading.value = false; }
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// 排序与搜索都在客户端做(服务端只按「最近活跃」返回一份),换排序维度不必再跑一趟。
|
|
589
|
+
const sessListFiltered = computed(() => {
|
|
590
|
+
const q = sessSearch.value.trim().toLowerCase();
|
|
591
|
+
let list = sessList.value;
|
|
592
|
+
if (q) list = list.filter(s => (s.name || '').toLowerCase().includes(q) || String(s.key).toLowerCase().includes(q)
|
|
593
|
+
|| (s.project || '').toLowerCase().includes(q) || (s.agent || '').toLowerCase().includes(q)
|
|
594
|
+
|| modelText(s.models).toLowerCase().includes(q));
|
|
595
|
+
const k = sessSort.value;
|
|
596
|
+
const cmp = k === 'from' ? (a, b) => (a.from || 0) - (b.from || 0)
|
|
597
|
+
: k === 'turns' ? (a, b) => (b.turns || 0) - (a.turns || 0)
|
|
598
|
+
: k === 'dur' ? (a, b) => (b.dur || 0) - (a.dur || 0)
|
|
599
|
+
: k === 'total' ? (a, b) => (b.total || 0) - (a.total || 0)
|
|
600
|
+
: (a, b) => (b.to || 0) - (a.to || 0);
|
|
601
|
+
return [...list].sort(cmp);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
// 加载一个会话:三个参数都传。只按 key 匹配会把同一 agent 下不同项目的同名会话并成一个
|
|
605
|
+
// (服务端 sessionAgg 的可选收窄就是为这个加的)。
|
|
606
|
+
async function pickSession(s) {
|
|
607
|
+
const my = ++sessSeq;
|
|
608
|
+
sessPick.value = s;
|
|
609
|
+
sessCurLoading.value = true; sessCur.value = null;
|
|
610
|
+
sessOpen.value = new Set(); sessDetail.value = {}; sessFullIds.value = new Set();
|
|
611
|
+
sessHi.value = ''; sessCursor.value = null;
|
|
612
|
+
try {
|
|
613
|
+
const j = await getJSON('/api/session?key=' + encodeURIComponent(s.key)
|
|
614
|
+
+ '&agent=' + encodeURIComponent(s.agent)
|
|
615
|
+
+ '&project=' + encodeURIComponent(s.projectKey || ''));
|
|
616
|
+
if (my !== sessSeq) return; // 又点了别的会话,这份响应作废
|
|
617
|
+
if (!j.ok) throw new Error(j.error || '会话加载失败');
|
|
618
|
+
sessCur.value = j;
|
|
619
|
+
} catch (e) {
|
|
620
|
+
if (my !== sessSeq) return;
|
|
621
|
+
sessCur.value = null;
|
|
622
|
+
ElMessage.error('会话加载失败:' + e.message);
|
|
623
|
+
} finally { if (my === sessSeq) sessCurLoading.value = false; }
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// I16 子 agent 拓扑:从「父会话 / 子 agent」那两条链接跳过去。
|
|
627
|
+
// 传的是一个**会话 key**(不是列表项),所以 agent / project 从当前这一屏拿 —— session 值跨
|
|
628
|
+
// agent、跨项目都不唯一(server 的聚合键就带着这两维),只按 key 打开会跳错人。
|
|
629
|
+
// 优先复用列表里那一项(能连带把左栏选中态对上);列表里没有(父会话被时间范围筛掉了)时
|
|
630
|
+
// 就拿当前会话的 agent/projectKey 兜一个最小项出来 —— 跳过去仍能看,只是左栏不一定高亮。
|
|
631
|
+
function openSessionByKey(key) {
|
|
632
|
+
if (!key) return;
|
|
633
|
+
const k = String(key);
|
|
634
|
+
const hit = sessList.value.find(s => s.key === k && s.agent === (sessPick.value && sessPick.value.agent)
|
|
635
|
+
&& s.projectKey === (sessPick.value && sessPick.value.projectKey));
|
|
636
|
+
if (hit) return pickSession(hit);
|
|
637
|
+
const c = sessCur.value;
|
|
638
|
+
if (!c) return;
|
|
639
|
+
pickSession({
|
|
640
|
+
key: k, agent: c.agent || (sessPick.value && sessPick.value.agent) || '',
|
|
641
|
+
project: c.project || '', projectKey: c.projectKey || '', name: null,
|
|
642
|
+
_sid: (c.agent || '') + '\u0000' + (c.projectKey || '') + '\u0000' + k,
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// 展开 / 折叠一轮。forceOpen = 从概览条点过来的「定位」:已经展开就只滚动,不把它又折回去。
|
|
647
|
+
const scrollToTurn = id => { const el = document.getElementById('sv-turn-' + id); if (el) el.scrollIntoView({ block: 'start' }); };
|
|
648
|
+
async function sessToggleTurn(tk, forceOpen) {
|
|
649
|
+
const id = typeof tk === 'string' ? tk : (tk && tk.id);
|
|
650
|
+
if (!id) return;
|
|
651
|
+
const s = new Set(sessOpen.value);
|
|
652
|
+
if (s.has(id)) { if (!forceOpen) s.delete(id); }
|
|
653
|
+
else s.add(id);
|
|
654
|
+
sessOpen.value = s;
|
|
655
|
+
if (forceOpen) nextTick(() => scrollToTurn(id));
|
|
656
|
+
if (s.has(id) && !sessDetail.value[id]) {
|
|
657
|
+
try {
|
|
658
|
+
sessDetail.value = { ...sessDetail.value, [id]: prepDetail(await fetchDetail(id, false)) };
|
|
659
|
+
// 详情到位后**再滚一次**:上面那次滚的时候这一轮还只是骨架屏,等高线完全不同
|
|
660
|
+
// (实测:从概览条点过来时展开是对的、指针也对,但 pane 的 scrollTop 还是 0,
|
|
661
|
+
// 而该轮已经落到 350px 处、内容涨到 8363px —— 等于「跳过去了」但没跳)。
|
|
662
|
+
if (forceOpen) { await nextTick(); scrollToTurn(id); }
|
|
663
|
+
}
|
|
664
|
+
catch (e) { ElMessage.error('详情加载失败:' + e.message); }
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// 点某条事件:高亮它,并把概览条的指针移到它的时间位置上(与概览条的 pctOf 同一套换算)。
|
|
669
|
+
function sessEventClick(tk, row) {
|
|
670
|
+
sessHi.value = row.key;
|
|
671
|
+
const sp = sessSpan.value;
|
|
672
|
+
if (sp) sessCursor.value = Math.max(0, Math.min(1, ((row.ev.t || 0) - sp.from) / (sp.to - sp.from)));
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// 点概览条:按点击位置反算时间 → 选时间上最近的一轮 → 展开并滚过去。
|
|
676
|
+
// 这里的 f 与 pctOf 是**同一套映射**(pctOf 给 0~100,f = pctOf/100):对不上的话,
|
|
677
|
+
// 看上去落在那一段、点下去却跳到别处。
|
|
678
|
+
function sessOvClick(ev) {
|
|
679
|
+
const sp = sessSpan.value, c = sessCur.value;
|
|
680
|
+
if (!sp || !c) return;
|
|
681
|
+
const r = ev.currentTarget.getBoundingClientRect();
|
|
682
|
+
const f = r.width ? Math.max(0, Math.min(1, (ev.clientX - r.left) / r.width)) : 0;
|
|
683
|
+
sessCursor.value = f; // 指针跟手
|
|
684
|
+
sessHi.value = ''; // 这里选中的是「一轮」,不是某条事件,事件高亮让位
|
|
685
|
+
const t = sp.from + f * (sp.to - sp.from);
|
|
686
|
+
let best = null, bd = Infinity;
|
|
687
|
+
for (const e of c.entries) {
|
|
688
|
+
const a = e.time || 0, b = a + (e.dur || 0);
|
|
689
|
+
const d = t < a ? a - t : (t > b ? t - b : 0); // 落在两轮之间的空档里也算距离
|
|
690
|
+
if (d < bd) { bd = d; best = e; }
|
|
691
|
+
}
|
|
692
|
+
if (best) sessToggleTurn(best.id, true);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// 会话详情里的「查看完整内容」:写进 sessDetail。
|
|
696
|
+
// **不能**直接复用 loadFull(id) —— 那个写的是卡片缓存 details,会话视图读不到,点了等于没点。
|
|
697
|
+
async function sessLoadFull(id) {
|
|
698
|
+
if (sessFullIds.value.has(id)) return;
|
|
699
|
+
sessFullIds.value = new Set(sessFullIds.value).add(id);
|
|
700
|
+
try { sessDetail.value = { ...sessDetail.value, [id]: prepDetail(await fetchDetail(id, true)) }; }
|
|
701
|
+
catch (e) { ElMessage.error('加载完整内容失败:' + e.message); }
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// 概览条的横轴 = 会话的 [from, to],宽度 = **真实时间占比**(不给任何段画等宽)。
|
|
705
|
+
// ⚠ 概览条、逐事件行、点击定位**必须共用同一个 pctOf 换算**:视觉落点与点击反算走的是一套映射,
|
|
706
|
+
// 两处各写一份的话,看上去在那一段、点下去却跳到别处。
|
|
707
|
+
const sessSpan = computed(() => {
|
|
708
|
+
const c = sessCur.value;
|
|
709
|
+
if (!c || !c.entries || !c.entries.length) return null;
|
|
710
|
+
const from = c.from;
|
|
711
|
+
// 右端取「最后一轮的**结束**」,而不是服务端给的 to ——
|
|
712
|
+
// 服务端的 to = max(轮.time),也就是**最后一轮的起点**,于是最后一轮的 [time, time+dur]
|
|
713
|
+
// 天生就越过右端:pctOf 会把它的终点夹成 100,段宽只剩可见下限 0.4%(~5px)。
|
|
714
|
+
// 结果是**不管最后一轮实际跑了 5 秒还是 5 分钟,图上都是右端一根细条** ——
|
|
715
|
+
// 跟它 tooltip 里的真实耗时自相矛盾,而这条轴的全部意义就是「谁占了多少时间」。
|
|
716
|
+
// 只在这里把**作图的**跨度补到真实结束;**不动服务端口径**(to 还要给会话列表按「最近活跃」排序)。
|
|
717
|
+
let to = c.to;
|
|
718
|
+
for (const e of c.entries) to = Math.max(to, (e.time || 0) + (e.dur || 0));
|
|
719
|
+
if (!(to > from)) return null; // 单轮 / 同一毫秒:没有横向跨度可画,整条概览条不画
|
|
720
|
+
return { from, to };
|
|
721
|
+
});
|
|
722
|
+
// 汇总条与轴刻度上的「起止」跟作图跨度用**同一个数**,否则会出现「标题写 14:34、轴右端写 14:39」
|
|
723
|
+
// 这种自己跟自己打架的显示。sessSpan 不可画(单轮 / 同一毫秒)时退回服务端的 from/to。
|
|
724
|
+
const sessFrom = computed(() => (sessSpan.value ? sessSpan.value.from : ((sessCur.value || {}).from || 0)));
|
|
725
|
+
const sessTo = computed(() => (sessSpan.value ? sessSpan.value.to : ((sessCur.value || {}).to || 0)));
|
|
726
|
+
const pctOf = t => {
|
|
727
|
+
const sp = sessSpan.value;
|
|
728
|
+
if (!sp) return 0;
|
|
729
|
+
return Math.max(0, Math.min(100, ((t || 0) - sp.from) / (sp.to - sp.from) * 100));
|
|
730
|
+
};
|
|
731
|
+
// 段位:位置与宽度都由真实时间装出来,只做两件不改变比例的事 ——
|
|
732
|
+
// 1) 段宽给一个可见下限(零耗时的段在 100% 宽度里就是一根看不见的空气,同 .st-seg.hit 的 3px 下限);
|
|
733
|
+
// 2) 把整段收进 [0,100]。**这一条是必需的,不是修饰**:服务端的会话 to = max(轮.time),
|
|
734
|
+
// 也就是**最后一轮的起点**,所以最后一轮(以及它里面最后那次调用)的起点正好等于轴的右端,
|
|
735
|
+
// 不收的话 left:100% 会被容器的 overflow:hidden 整段裁掉 —— 每次都会发生。
|
|
736
|
+
const segOf = (t0, t1, minW) => {
|
|
737
|
+
const left0 = pctOf(t0);
|
|
738
|
+
const width = Math.max(pctOf(t1) - left0, minW);
|
|
739
|
+
return { left: (left0 + width > 100) ? Math.max(0, 100 - width) : left0, width };
|
|
740
|
+
};
|
|
741
|
+
// 背景轨:一轮一段。所有轮都有真实的 time / dur,与是否展开过无关。
|
|
742
|
+
// 轮与轮之间的挂机空档就真的是空的(位置由时间决定,不等分)。
|
|
743
|
+
const sessOvTurns = computed(() => {
|
|
744
|
+
const c = sessCur.value;
|
|
745
|
+
if (!c || !sessSpan.value) return [];
|
|
746
|
+
return c.entries.map((e, i) => {
|
|
747
|
+
const { left, width } = segOf(e.time, (e.time || 0) + (e.dur || 0), 0.4);
|
|
748
|
+
return {
|
|
749
|
+
id: e.id, left, width, err: e.status !== 'ok', mgap: width >= 1,
|
|
750
|
+
tip: '#' + (i + 1) + ' ' + fmtTime(e.time) + ' ' + fmtDur(e.dur) + (e.preview ? '\n' + e.preview : ''),
|
|
751
|
+
};
|
|
752
|
+
});
|
|
753
|
+
});
|
|
754
|
+
// 前景轨:只画**已经展开取过 events 的轮**(其余轮没有逐事件时间,画不出来也不该猜)。
|
|
755
|
+
// llm 拆成「等待(ttft)」与「解码(dur)」两段;工具画 call→result 的真实区间
|
|
756
|
+
// (result.dur = 服务端算的 结果时刻 − 发起时刻)。
|
|
757
|
+
// ttft/dur 为 0(失败调用、配不上的 result)时落一根斜纹记号:不画 0 宽空气、也不编造时长。
|
|
758
|
+
const sessOvEvents = computed(() => {
|
|
759
|
+
const c = sessCur.value;
|
|
760
|
+
if (!c || !sessSpan.value) return [];
|
|
761
|
+
const out = [];
|
|
762
|
+
for (const e of c.entries) {
|
|
763
|
+
const evs = sessEvIndex.value[e.id];
|
|
764
|
+
if (!evs) continue;
|
|
765
|
+
let open = null; // 当前打开的 call,等它的 result 来配对
|
|
766
|
+
for (const v of evs) {
|
|
767
|
+
if (v.k === 'llm') {
|
|
768
|
+
const t0 = v.t || e.time, w = v.ttft || 0, d = v.dur || 0;
|
|
769
|
+
if (w) { const g = segOf(t0, (t0 || 0) + w, 0.3); out.push({ cls: 'wait', left: g.left, width: g.width, mgap: false, tip: 'TTFT 等待 ' + w + 'ms(排队 + prefill)' }); }
|
|
770
|
+
if (d) { const g = segOf((t0 || 0) + w, (t0 || 0) + w + d, 0.3); out.push({ cls: 'llm', left: g.left, width: g.width, mgap: w > 0, tip: '解码 ' + d + 'ms' }); }
|
|
771
|
+
if (!w && !d) { const g = segOf(t0, t0, 0.4); out.push({ cls: 'zero', left: g.left, width: g.width, mgap: false, tip: '这次调用没有流式时间戳(ttft / decode 都是 0),只标一个位置' }); }
|
|
772
|
+
} else if (v.k === 'call') {
|
|
773
|
+
open = { t0: v.t || e.time };
|
|
774
|
+
} else if (v.k === 'result') {
|
|
775
|
+
if (!open) continue;
|
|
776
|
+
const t0 = open.t0;
|
|
777
|
+
if (v.dur) {
|
|
778
|
+
const g = segOf(t0, (t0 || 0) + v.dur, 0.3);
|
|
779
|
+
out.push({ cls: v.error ? 'tool err' : 'tool', left: g.left, width: g.width, mgap: true, tip: '工具 ' + fmtDur(v.dur) + (v.error ? '(失败)' : '') });
|
|
780
|
+
} else {
|
|
781
|
+
const g = segOf(v.t || t0, v.t || t0, 0.4);
|
|
782
|
+
out.push({ cls: 'zero', left: g.left, width: g.width, mgap: false, tip: '这次工具调用没有可配对的真实耗时(dur=0),只标一个位置' });
|
|
783
|
+
}
|
|
784
|
+
open = null;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
return out;
|
|
789
|
+
});
|
|
790
|
+
// ---------------- 逐轮用量图 ----------------
|
|
791
|
+
// 模式由**数据**决定,不按 agent 名硬编码:
|
|
792
|
+
// qoder 模式 —— 任一轮带 credits(qoder 的 token 恒为 0,积分与上下文占比才是真信号,
|
|
793
|
+
// 见 emitClaudeTurns):积分柱 + 上下文占比折线(分母=1,不用再查窗口表);
|
|
794
|
+
// token 模式 —— 有非 0 的 token/耗时:「缓存+输入+输出」堆叠柱 + **一根线**,画哪根同样看数据:
|
|
795
|
+
// 会话里只要有任意一轮拿得到窗口容量(e.ctx),线就是**上下文占用率**(ctxUsed ÷ 窗口,
|
|
796
|
+
// 0–100 绝对刻度、顶格=满窗)—— 柱高读的是「这轮比别的轮猛多少」,读不出「离阈值还有多远」,
|
|
797
|
+
// 两者各有尺;一根都没有(kimi / gemini / workbuddy 这类缺分母的)退回画**耗时折线**(按本会话峰值归一),
|
|
798
|
+
// 占用只留在 tooltip 里。耗时退到 tooltip 不是删信息:轮头那列一直有它。
|
|
799
|
+
// 压缩红点 —— 两种模式同一画法:谁落了 compacts 就点谁(不再 qoder 专属),高度落在该轮占用率上;
|
|
800
|
+
// 拿不到分母时落基线并画成空心(bare),只回答「这轮压过」,不带占比含义。
|
|
801
|
+
// 全 0(cursor 这类源头就没有用量,连时间戳都没有)—— null,整块不渲染。
|
|
802
|
+
// 柱高按本图内最大值归一;耗时线为 0 的会话(拿不到耗时)不画线,别贴一条假直线在底部。
|
|
803
|
+
const sessChartOpen = ref(true);
|
|
804
|
+
const sessChart = computed(() => {
|
|
805
|
+
const c = sessCur.value;
|
|
806
|
+
if (!c || !c.entries || !c.entries.length) return null;
|
|
807
|
+
const es = c.entries;
|
|
808
|
+
const qoder = es.some(e => e.credits != null);
|
|
809
|
+
if (!qoder && !es.some(e => (e.tin || 0) + (e.tout || 0) + (e.tcache || 0) + (e.dur || 0) > 0)) return null;
|
|
810
|
+
const N = es.length;
|
|
811
|
+
const x = i => ((i + 0.5) / N * 100).toFixed(3); // 列中心,与 flex 等分列严格对齐(见 .sv-chart 注释)
|
|
812
|
+
const tickEvery = Math.max(1, Math.ceil(N / 12));
|
|
813
|
+
// 该轮的上下文占用率(0–1);null = 没有分母,只能画占用、画不出百分比(与卡片同一口径)
|
|
814
|
+
const ratio = qoder
|
|
815
|
+
? e => (e.ctxRatio > 0 ? Math.min(1, e.ctxRatio) : null)
|
|
816
|
+
: e => (e.ctx > 0 ? Math.min(1, ctxUsed(e) / e.ctx) : null);
|
|
817
|
+
// I12 压缩画像(2026-09-28):tooltip 一行说清「压了几次、自动还是手动、前后 token、
|
|
818
|
+
// 一共丢了多少、等了多久」。compDrop 只有 claude 源有 ⇒ 没有这一位就不说这句,
|
|
819
|
+
// 绝不补一个 0(qoder 会被读成「压过、却一个 token 都没丢」)。
|
|
820
|
+
const compTip = e => {
|
|
821
|
+
if (!e.compacts) return '';
|
|
822
|
+
const tg = (e.compAuto || e.compManual)
|
|
823
|
+
? '(' + (e.compAuto ? '自动 ' + e.compAuto : '') + (e.compManual ? (e.compAuto ? ' / ' : '') + '手动 ' + e.compManual : '') + ')'
|
|
824
|
+
: '';
|
|
825
|
+
let s = '\n压缩 ×' + e.compacts + tg;
|
|
826
|
+
if (e.compPre) s += '(' + fmtN(e.compPre) + ' → ' + fmtN(e.compPost) + ' tok)';
|
|
827
|
+
if (e.compDrop) s += ' 丢 ' + fmtN(e.compDrop) + ' tok';
|
|
828
|
+
if (e.compMs) s += ' 等 ' + fmtDur(e.compMs);
|
|
829
|
+
return s;
|
|
830
|
+
};
|
|
831
|
+
const mark = (e, r) => e.compacts ? { y: r == null ? 0 : r * 100, bare: r == null } : null;
|
|
832
|
+
const hasMark = es.some(e => e.compacts);
|
|
833
|
+
// 会话级压缩小结(I12):逐轮累加,回答「这个会话一共压掉多少 / 等了多久」。
|
|
834
|
+
// 一个都没压过就是 null,sess-view 那一行整个不出现(不印「压缩 0 次」)。
|
|
835
|
+
const comp = (() => {
|
|
836
|
+
let n = 0, drop = 0, ms = 0, auto = 0, manual = 0;
|
|
837
|
+
for (const e of es) {
|
|
838
|
+
if (!e.compacts) continue;
|
|
839
|
+
n += e.compacts; auto += e.compAuto || 0; manual += e.compManual || 0;
|
|
840
|
+
drop += e.compDrop || 0; ms += e.compMs || 0;
|
|
841
|
+
}
|
|
842
|
+
return n ? { n, drop, ms, auto, manual } : null;
|
|
843
|
+
})();
|
|
844
|
+
if (qoder) {
|
|
845
|
+
const maxCr = Math.max(1e-9, ...es.map(e => e.credits || 0));
|
|
846
|
+
const rows = es.map((e, i) => {
|
|
847
|
+
const cr = e.credits || 0, r = ratio(e);
|
|
848
|
+
return {
|
|
849
|
+
id: e.id,
|
|
850
|
+
stack: cr > 0 ? [{ h: Math.max(1.5, cr / maxCr * 100), c: 'var(--s-out)' }] : [],
|
|
851
|
+
mark: mark(e, r),
|
|
852
|
+
tip: '#' + (i + 1) + ' ' + fmtTime(e.time) + '\n积分 ' + fmtCredits(cr) + ' 上下文占比 ' + fmtPct(e.ctxRatio)
|
|
853
|
+
+ compTip(e) + (e.dur ? '\n耗时 ' + fmtDur(e.dur) : ''),
|
|
854
|
+
};
|
|
855
|
+
});
|
|
856
|
+
const linePts = es.map((e, i) => x(i) + ',' + (100 - Math.min(100, (e.ctxRatio || 0) * 100)).toFixed(2)).join(' ');
|
|
857
|
+
return { mode: 'qoder', rows, segs: [linePts], lineColor: 'var(--s-in)', hasMark, tickEvery, comp };
|
|
858
|
+
}
|
|
859
|
+
const maxTok = Math.max(1, ...es.map(e => (e.tin || 0) + (e.tout || 0) + (e.tcache || 0)));
|
|
860
|
+
const maxDur = Math.max(0, ...es.map(e => e.dur || 0));
|
|
861
|
+
const win = es.some(e => e.ctx > 0); // 有任何一轮带窗口容量,才谈得上「÷ 窗口」这条线
|
|
862
|
+
const winMax = Math.max(0, ...es.map(e => e.ctx || 0));
|
|
863
|
+
const rows = es.map((e, i) => {
|
|
864
|
+
const tot = (e.tin || 0) + (e.tout || 0) + (e.tcache || 0);
|
|
865
|
+
const p = v => v > 0 ? Math.max(1.5, v / maxTok * 100) : 0;
|
|
866
|
+
const stack = [];
|
|
867
|
+
if (e.tcache) stack.push({ h: p(e.tcache), c: 'var(--s-cache)' });
|
|
868
|
+
if (e.tin) stack.push({ h: p(e.tin), c: 'var(--s-in)' });
|
|
869
|
+
if (e.tout) stack.push({ h: p(e.tout), c: 'var(--s-out)' });
|
|
870
|
+
const cu = ctxUsed(e);
|
|
871
|
+
return {
|
|
872
|
+
id: e.id, stack, mark: mark(e, ratio(e)),
|
|
873
|
+
tip: '#' + (i + 1) + ' ' + fmtTime(e.time) + '\n输入 ' + fmtN(e.tin) + ' · 输出 ' + fmtN(e.tout) + ' · 缓存 ' + fmtN(e.tcache) + '(合计 ' + fmtN(tot) + ')'
|
|
874
|
+
+ '\n耗时 ' + fmtDur(e.dur) + compTip(e)
|
|
875
|
+
+ (cu ? '\n上下文占用 ' + fmtN(cu) + (e.ctx ? ' / ' + fmtN(e.ctx) + '(' + ctxPct(e) + '%)' : '') : ''),
|
|
876
|
+
};
|
|
877
|
+
});
|
|
878
|
+
// 占用率线**按连续有分母的轮分段**:断点处宁可断开,也不把线连到一个根本没有窗口的轮上。
|
|
879
|
+
// 单点不成线(一根孤零零的点是画不出「趋势」的),所以长度 < 2 的段直接丢。
|
|
880
|
+
let segs = [];
|
|
881
|
+
if (win) {
|
|
882
|
+
let run = [];
|
|
883
|
+
for (let i = 0; i <= N; i++) {
|
|
884
|
+
const r = i < N ? ratio(es[i]) : null;
|
|
885
|
+
if (r == null) { if (run.length > 1) segs.push(run.join(' ')); run = []; continue; }
|
|
886
|
+
run.push(x(i) + ',' + (100 - r * 100).toFixed(2));
|
|
887
|
+
}
|
|
888
|
+
} else if (maxDur > 0) {
|
|
889
|
+
segs = [es.map((e, i) => x(i) + ',' + (100 - (e.dur || 0) / maxDur * 100).toFixed(2)).join(' ')];
|
|
890
|
+
}
|
|
891
|
+
const note = win && segs.length
|
|
892
|
+
? '绿线=上下文占用率(该轮结束时占用 ÷ 窗口 ' + fmtN(winMax) + ',顶格=满窗)'
|
|
893
|
+
+ (es.some(e => e.ctxTbl) ? ';这个窗口值来自「上下文窗口」查表,不是日志给的,表里填错曲线就跟着错' : '')
|
|
894
|
+
+ ';柱子按本会话最大那轮归一,与线不是同一把尺'
|
|
895
|
+
: '';
|
|
896
|
+
return { mode: 'token', rows, segs, lineColor: 'var(--green)', hasMark, tickEvery, lineKind: win ? 'ctx' : 'dur', note, comp };
|
|
897
|
+
});
|
|
898
|
+
// 点柱子 = 定位并展开那一轮(与概览条 sessOvClick 同一语义:已展开就只滚动,不折回去)
|
|
899
|
+
function sessChartGo(id) { sessToggleTurn(id, true); }
|
|
900
|
+
|
|
901
|
+
// 轮详情底部的「收起」:与卡片的 collapseCard 同款 —— 折叠这一轮并把轮头滚回视野
|
|
902
|
+
// (详情可能有十几屏高,收起后滚动位置会停在别处;等 DOM 变矮再量,否则算的是收起前的高度)。
|
|
903
|
+
async function sessCollapseTurn(id, ev) {
|
|
904
|
+
const turn = ev && ev.target && ev.target.closest ? ev.target.closest('.sess-turn') : null;
|
|
905
|
+
const s = new Set(sessOpen.value);
|
|
906
|
+
s.delete(id);
|
|
907
|
+
sessOpen.value = s;
|
|
908
|
+
await nextTick();
|
|
909
|
+
if (turn) turn.scrollIntoView({ block: 'nearest' });
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// 轮 id -> 该轮已加载的 events[](只含展开过、且确实有逐事件的轮)
|
|
913
|
+
const sessEvIndex = computed(() => {
|
|
914
|
+
const o = {};
|
|
915
|
+
for (const id of sessOpen.value) {
|
|
916
|
+
const d = sessDetail.value[id];
|
|
917
|
+
if (d && d.events && d.events.length) o[id] = d.events;
|
|
918
|
+
}
|
|
919
|
+
return o;
|
|
920
|
+
});
|
|
921
|
+
// 逐事件的行模型:时间偏移 / 标签 / 摘要 / 迷你条宽度都在这里一次算好。
|
|
922
|
+
// 不在模板里逐个算 —— 那种写法每次重渲染都会把整表重算一遍(同 secs 的注释)。
|
|
923
|
+
const EV_LABEL = { user: '用户输入', sys: '系统注入', step: '步骤', llm: '模型调用', call: '工具调用', result: '工具返回', end: '本轮结束' };
|
|
924
|
+
const sessEvRows = computed(() => {
|
|
925
|
+
const out = {};
|
|
926
|
+
const c = sessCur.value;
|
|
927
|
+
if (!c) return out;
|
|
928
|
+
for (const tk of c.entries) {
|
|
929
|
+
const d = sessDetail.value[tk.id];
|
|
930
|
+
const evs = (d && d.events) || null;
|
|
931
|
+
if (!evs || !evs.length) continue;
|
|
932
|
+
// 迷你条的归一分母 = 该轮 LLM 调用里最长的那次(等待 + 解码),所以同一轮内各次调用可比,
|
|
933
|
+
// 又不会被别的轮的巨型调用压成一条线。
|
|
934
|
+
let den = 0;
|
|
935
|
+
for (const v of evs) if (v.k === 'llm') den = Math.max(den, (v.ttft || 0) + (v.dur || 0));
|
|
936
|
+
out[tk.id] = evs.map((v, j) => {
|
|
937
|
+
const row = {
|
|
938
|
+
key: tk.id + ':' + j, k: v.k, ev: v, label: EV_LABEL[v.k] || v.k, sub: '',
|
|
939
|
+
bar: null, zero: false, err: false, tool: null,
|
|
940
|
+
rel: '+' + Math.max(0, (v.t || 0) - (tk.time || 0)) + 'ms',
|
|
941
|
+
};
|
|
942
|
+
if (v.k === 'llm') {
|
|
943
|
+
const w = v.ttft || 0, dd = v.dur || 0;
|
|
944
|
+
row.bar = { w: den ? w / den * 100 : 0, d: den ? dd / den * 100 : 0 };
|
|
945
|
+
row.zero = !w && !dd; // 失败调用(err:true)常常两个都是 0:画占位 + 文案里的「?」
|
|
946
|
+
row.err = !!v.err;
|
|
947
|
+
row.sub = (v.model || '(未知模型)') + ' · ↑' + fmtN(v.tin) + ' ↓' + fmtN(v.tout)
|
|
948
|
+
+ (v.tcache ? ' · 缓存 ' + fmtN(v.tcache) : '')
|
|
949
|
+
+ (v.blocks && v.blocks.length ? ' · ' + v.blocks.join(' / ') : '')
|
|
950
|
+
+ (row.err ? ' · 调用失败' : '');
|
|
951
|
+
} else if (v.k === 'user') {
|
|
952
|
+
row.sub = '(正文见下方「用户输入」)';
|
|
953
|
+
} else if (v.k === 'sys') {
|
|
954
|
+
row.sub = '系统提示注入 · ' + (v.len || 0).toLocaleString() + ' 字符(轨迹里只记长度,不存正文)';
|
|
955
|
+
} else if (v.k === 'step') {
|
|
956
|
+
row.sub = (v.n == null ? '' : '第 ' + v.n + ' 步 · ') + (v.ph === 'end' ? '结束' : '开始');
|
|
957
|
+
} else if (v.k === 'call') {
|
|
958
|
+
row.tool = (v.i >= 0 && d.tools) ? (d.tools[v.i] || null) : null;
|
|
959
|
+
row.sub = (v.name || '?') + (v.i >= 0 ? ' · #' + (v.i + 1) : ' · 配不到 tools 下标') + '(点开看入参)';
|
|
960
|
+
} else if (v.k === 'result') {
|
|
961
|
+
row.tool = (v.i >= 0 && d.tools) ? (d.tools[v.i] || null) : null;
|
|
962
|
+
row.err = !!v.error;
|
|
963
|
+
row.sub = (v.error ? '返回失败 · ' : '返回 · ')
|
|
964
|
+
+ (v.dur ? fmtDur(v.dur) : '耗时不可得(配不上调用或 dur=0)')
|
|
965
|
+
+ (v.i >= 0 ? ' · #' + (v.i + 1) : '') + '(点开看返回)';
|
|
966
|
+
} else if (v.k === 'end') {
|
|
967
|
+
row.sub = '原因:' + (v.reason || '—');
|
|
968
|
+
}
|
|
969
|
+
return row;
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
return out;
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
// 服务端的 hasTimeline 是**能力声明**、不是探测(见 server 里 EVENT_AGENTS 的注释):万一某个 agent
|
|
976
|
+
// 的解析器后来也产出了 events、而那份名单没跟着更新,就会自相矛盾 ——「上面写着没有时间轴,下面却在画轨迹」。
|
|
977
|
+
// 这条自纠正让页面以**事实**为准:只要真加载到了 events,那条告警就不显示。
|
|
978
|
+
// (反方向是安全的:不在 EVENT_AGENTS 的 agent 永远加载不到 events,告警照常显示。)
|
|
979
|
+
const sessHasEvents = computed(() => Object.keys(sessEvRows.value).length > 0);
|
|
980
|
+
|
|
981
|
+
// 会话列表跟着左侧筛选走:在会话视图里改 agent / 项目 / 时间范围,列表必须跟着收窄
|
|
982
|
+
// (服务端 /api/sessions 与 /api/daily 同口径,传的就是这三个参数)。
|
|
983
|
+
// 只在会话视图里重拉:日志视图下改筛选由 loadSnapshot 负责,这里再拉一次纯属浪费。
|
|
984
|
+
watch([fAgent, fProject, fDates], () => { if (view.value === 'sess') loadSessions(); });
|
|
985
|
+
// 切回会话视图时刷新一次(列表已经有数据才走到这里;第一次切过去由 switchView 拉,两条路径互斥)
|
|
986
|
+
watch(view, () => { if (view.value === 'sess' && sessList.value.length) loadSessions(); });
|
|
987
|
+
// 无模型条目的标题:trace 显示轨迹名;codebuddy/workbuddy 显示 ai-title 会话标题;
|
|
988
|
+
// 旧格式无统计;claude 未收到回复的轮显示进行中
|
|
989
|
+
const titleOf = e => {
|
|
990
|
+
const mt = modelText(e.models);
|
|
991
|
+
if (mt) return mt;
|
|
992
|
+
if (e.name) return e.name;
|
|
993
|
+
if (e.nollm) return '(非 LLM 调用)';
|
|
994
|
+
if (e.legacy) return '(旧格式 · 无统计)';
|
|
995
|
+
if (e.aborted) return '(已打断)';
|
|
996
|
+
if (e.agent === 'claude' && !e.calls && isLive(e.id)) return '(进行中)';
|
|
997
|
+
return '(无模型信息)';
|
|
998
|
+
};
|
|
999
|
+
|
|
1000
|
+
const detailOf = id => details[id];
|
|
1001
|
+
|
|
1002
|
+
// 项目名归一化(projNorm / projKey)与时间窗口起点(rangeDayStart)**不在本文件里** ——
|
|
1003
|
+
// 唯一来源是 agent-acta-server.mjs 的 SHARED_JS,服务端在 GET / 时注入(见上方「跨端共享片段」)。
|
|
1004
|
+
// 同一个项目被各家 agent 写成不同样子——claude/atomcode/codex 写 F:\centos\next-admin,
|
|
1005
|
+
// codebuddy/gemini 写小写盘符,cursor 只有 slug f-centos-next-admin(盘符和分隔符都退化成 -,反推不回来)。
|
|
1006
|
+
// 归一**只作用在「项目」这个筛选维度**:条目上的 project 一个字节都不改,卡片页脚照旧显示真实来源。
|
|
1007
|
+
// ⚠️ 想改口径只改服务端那份,两端同时生效;在本文件里重新定义同名函数 = 回退漂移,
|
|
1008
|
+
// test/shared-snippet-test.mjs 会拦。
|
|
1009
|
+
|
|
1010
|
+
// 时间范围判定:页面只在「直播增量该不该进当前窗口」这一处用它 —— 真正的过滤在服务端
|
|
1011
|
+
// (传显式 from/to,边界由服务端现算,与这里同一套本地自然日口径,挂过午夜也不会两端各算各的)。
|
|
1012
|
+
const rangeTest = t => {
|
|
1013
|
+
const d = fDates.value; // null = 全部时间(不限)
|
|
1014
|
+
if (!d) return true;
|
|
1015
|
+
return (t || 0) >= dayStartMs(d[0]) && (t || 0) <= dayEndMs(d[1]);
|
|
1016
|
+
};
|
|
1017
|
+
|
|
1018
|
+
const filtered = computed(() => {
|
|
1019
|
+
const q = fSearch.value.toLowerCase();
|
|
1020
|
+
// R33:搜索态下列表显示的是**服务端的命中集**,不是窗口条目。
|
|
1021
|
+
// · 不再重跑下面那段客户端关键词匹配:服务端已经按同一个词匹配过了(而且它连正文都搜了),
|
|
1022
|
+
// 再用只认 preview/会话/模型 的窄口径筛一遍,会把「命中的是 AI 回复正文」的条目全滤掉 ——
|
|
1023
|
+
// 症状是「横幅写着命中 30 条、列表空的」。
|
|
1024
|
+
// · 但**非 LLM 轨迹**这个开关照旧生效:它是显示偏好,与服务端怎么匹配无关。
|
|
1025
|
+
// · agent/项目/状态也不再重筛:服务端那套就是同一套口径(同一份 filterEntries),重筛是白做。
|
|
1026
|
+
const src = searchOn.value ? searchHits.value : all.value;
|
|
1027
|
+
return src.filter(e => {
|
|
1028
|
+
if (!showNoLLM.value && e.nollm) return false;
|
|
1029
|
+
// R9「只看异常」:优先于一切其它筛选,只留命中集合里的条目(定位/核实时用,常跟搜索搭配)
|
|
1030
|
+
if (alertOnly.value && !alertHits.value.has(e.id)) return false;
|
|
1031
|
+
if (searchOn.value) return true;
|
|
1032
|
+
if (fAgent.value && e.agent !== fAgent.value) return false;
|
|
1033
|
+
if (!fAgent.value && fAgents.value.length && !fAgents.value.includes(e.agent)) return false; // R10 多选合集
|
|
1034
|
+
if (fProject.value && projKey(e.project) !== fProject.value) return false; // 选中一组 = 该项目的所有写法
|
|
1035
|
+
if (!fProject.value && fProjects.value.length && !fProjects.value.includes(projKey(e.project))) return false; // R10 多选合集
|
|
1036
|
+
if (fStatus.value && e.status !== fStatus.value) return false;
|
|
1037
|
+
if (q && !((e.preview||'').toLowerCase().includes(q) || (e.session||'').toLowerCase().includes(q)
|
|
1038
|
+
|| modelText(e.models).toLowerCase().includes(q) || (e.project||'').toLowerCase().includes(q))) return false;
|
|
1039
|
+
return true;
|
|
1040
|
+
});
|
|
1041
|
+
});
|
|
1042
|
+
// 项目下拉按归一化后的项目分组(projKey 见上)
|
|
1043
|
+
const projectGroups = computed(() => {
|
|
1044
|
+
const g = new Map();
|
|
1045
|
+
for (const [name, cnt] of Object.entries(projects.value)) {
|
|
1046
|
+
const k = projKey(name);
|
|
1047
|
+
let o = g.get(k);
|
|
1048
|
+
if (!o) { o = { key: k, names: [], count: 0, label: name, best: -1 }; g.set(k, o); }
|
|
1049
|
+
o.names.push(name);
|
|
1050
|
+
o.count += cnt;
|
|
1051
|
+
// 显示名取条目最多的那个写法(并列取字典序小的,保证每次算出来一致)
|
|
1052
|
+
if (cnt > o.best || (cnt === o.best && name < o.label)) { o.best = cnt; o.label = name; }
|
|
1053
|
+
}
|
|
1054
|
+
return [...g.values()].sort((a, b) => a.label.localeCompare(b.label));
|
|
1055
|
+
});
|
|
1056
|
+
const visible = computed(() => filtered.value.slice(0, shown.value));
|
|
1057
|
+
// R5:当前窗口内的失败条数(不受状态筛选影响,作为「失败 N 条」入口的计数)
|
|
1058
|
+
const failCount = computed(() => all.value.filter(e => e.status !== 'ok').length);
|
|
1059
|
+
// 曾经这里有一条「计数写着有 N 条、但都早于当前加载的窗口」的提示。
|
|
1060
|
+
// 现在 agent / 项目两个维度都在**服务端**把窗口收窄了(见 snapUrl),列表为空 = 真的没有,
|
|
1061
|
+
// 不再存在「有数据但落在窗口外」这种状态,所以那条提示连同它的分支一起删掉了。
|
|
1062
|
+
const projectList = projectGroups; // 下拉列的是「归一化后的项目」,不再是原始字符串
|
|
1063
|
+
const sideAgents = computed(() => [...new Set([...Object.keys(agentsMeta.value), ...Object.keys(agents.value)])].sort());
|
|
1064
|
+
|
|
1065
|
+
// ----- 图标 / 颜色 -----
|
|
1066
|
+
const isDisabled = name => { const m = agentsMeta.value[name]; return !!m && m.enabled === false; };
|
|
1067
|
+
const isMissing = name => { const m = agentsMeta.value[name]; return !!m && !!m.missing; };
|
|
1068
|
+
// R16:图标级 agent —— 只有图标、没有解析器,永远不会被自动发现(侧栏与「添加 agent」都要明示)
|
|
1069
|
+
const isIconOnly = name => ICON_ONLY_AGENTS.has(name);
|
|
1070
|
+
// 进行中的轮:只认「最新一条 + 服务端明确未结束 + 最近仍有活动」;trace 为一次性写入,不参与。
|
|
1071
|
+
const LIVE_STALE_MS = 5 * 60 * 1000;
|
|
1072
|
+
const isLive = id => liveIds.value.has(id);
|
|
1073
|
+
|
|
1074
|
+
const pathTip = name => {
|
|
1075
|
+
const m = agentsMeta.value[name];
|
|
1076
|
+
if (!m) return '';
|
|
1077
|
+
return ['格式:' + m.kind, m.source ? '来源:' + m.source : '', m.enabled === false ? '已禁用' : '', m.missing ? '目录不存在' : '',
|
|
1078
|
+
m.sessions, m.traces].filter(Boolean).join('\n');
|
|
1079
|
+
};
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
// ----- agent 管理 -----
|
|
1084
|
+
async function loadAgents() {
|
|
1085
|
+
try {
|
|
1086
|
+
const arr = await getJSON('/api/agents');
|
|
1087
|
+
const m = {}; for (const a of arr) m[a.name] = a;
|
|
1088
|
+
agentsMeta.value = m;
|
|
1089
|
+
} catch {}
|
|
1090
|
+
}
|
|
1091
|
+
// Trae 密钥抓取:POST /api/trae/capture-key → poll GET until done/error → ElMessage + reload
|
|
1092
|
+
async function startTraeCapture(agentName) {
|
|
1093
|
+
if (traeBusy.value) return;
|
|
1094
|
+
traeBusy.value = agentName;
|
|
1095
|
+
let polling = true;
|
|
1096
|
+
let tries = 0;
|
|
1097
|
+
try {
|
|
1098
|
+
ElMessage.info('请在 Trae 里发一条 AI 消息(触发它打开数据库),然后等待抓取完成...');
|
|
1099
|
+
// POST 起抓
|
|
1100
|
+
const postJ = await getJSON('/api/trae/capture-key', { method: 'POST' });
|
|
1101
|
+
if (!postJ.ok || !postJ.state) throw new Error(postJ.error || '启动失败');
|
|
1102
|
+
// Poll GET every 1.5s until phase≠running
|
|
1103
|
+
while (polling && tries < 200) { // ~5 分钟上限(200×1.5s)
|
|
1104
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
1105
|
+
const st = await getJSON('/api/trae/capture-key');
|
|
1106
|
+
if (!st.ok) throw new Error(st.error || '状态查询失败');
|
|
1107
|
+
if (st.state.phase === 'done') {
|
|
1108
|
+
polling = false;
|
|
1109
|
+
ElMessage.success('密钥已写入,正在扫描...');
|
|
1110
|
+
await Promise.all([loadAgents(), loadSnapshot()]);
|
|
1111
|
+
break;
|
|
1112
|
+
}
|
|
1113
|
+
if (st.state.phase === 'error') {
|
|
1114
|
+
polling = false;
|
|
1115
|
+
const msg = st.state.message || '抓取失败';
|
|
1116
|
+
ElMessage.error(msg);
|
|
1117
|
+
break;
|
|
1118
|
+
}
|
|
1119
|
+
tries++;
|
|
1120
|
+
}
|
|
1121
|
+
if (polling) {
|
|
1122
|
+
ElMessage.error('抓取超时,请重试');
|
|
1123
|
+
}
|
|
1124
|
+
} catch (e) {
|
|
1125
|
+
ElMessage.error(e.message || '抓取失败');
|
|
1126
|
+
} finally {
|
|
1127
|
+
traeBusy.value = '';
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
// Trae 密钥清除:POST /api/trae/clear-key → confirm → reload
|
|
1131
|
+
async function clearTraeKey(agentName) {
|
|
1132
|
+
if (traeBusy.value) return;
|
|
1133
|
+
try {
|
|
1134
|
+
const ok = await ElMessageBox.confirm('清除该 agent 的密钥配置?下一轮扫描会回退到 renderer.log 解析。', '确认清除密钥', { type: 'warning' });
|
|
1135
|
+
if (!ok) return;
|
|
1136
|
+
const j = await getJSON('/api/trae/clear-key', { method: 'POST' });
|
|
1137
|
+
if (!j.ok) throw new Error(j.error || '清除失败');
|
|
1138
|
+
ElMessage.success('密钥已清除');
|
|
1139
|
+
await Promise.all([loadAgents(), loadSnapshot()]);
|
|
1140
|
+
} catch (e) {
|
|
1141
|
+
ElMessage.error(e.message || '清除失败');
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
// 页面命令分发器(el-dropdown @command)
|
|
1145
|
+
function traeCommandUI(command, name) {
|
|
1146
|
+
if (!name) return;
|
|
1147
|
+
if (command === 'trae-retry') startTraeCapture(name);
|
|
1148
|
+
else if (command === 'trae-clear') clearTraeKey(name);
|
|
1149
|
+
}
|
|
1150
|
+
function openAdd() {
|
|
1151
|
+
addVisible.value = true; // 表单重置在组件里(打开时清空)
|
|
1152
|
+
}
|
|
1153
|
+
// 组件加完 agent 的收尾:重拉列表与快照(改前这段在 submitAdd 里)
|
|
1154
|
+
async function onAgentAdded() {
|
|
1155
|
+
await Promise.all([loadAgents(), loadSnapshot()]);
|
|
1156
|
+
}
|
|
1157
|
+
// ----- 环境诊断(R25 批 1 起是 page/dialog-diag.js 组件)-----
|
|
1158
|
+
// 根只留开关:探测结果、加载态、错误都在组件里(改前那个 reactive 一并搬走了)。
|
|
1159
|
+
const diagVisible = ref(false);
|
|
1160
|
+
// getJSON 是组件们唯一需要从根借的东西:接口 404 要点亮顶部版本红条,那段判断只有根这一份。
|
|
1161
|
+
// 谁去 fetch 都行,但错误口径不能各写一套(弹层里重写一遍 = 红条对弹层失效)。
|
|
1162
|
+
provide('getJSON', getJSON);
|
|
1163
|
+
|
|
1164
|
+
// ---- 上下文窗口表 ----
|
|
1165
|
+
// 只服务于 claude 卡片的进度条分母(别的 agent 自带窗口字段)。页面只管编辑,
|
|
1166
|
+
// 查表与重算都在服务端 —— 改完表要重算已经扫出来的条目,那是服务端的事(见 setCtxWindows)。
|
|
1167
|
+
// R25 批 1:表本身(rows/unknown/编辑态)搬进了 page/dialog-ctxw.js,根只留两样东西:
|
|
1168
|
+
// · visible —— 开关(侧栏项点开、弹层关闭时回写)
|
|
1169
|
+
// · ctxUnfilled —— 侧栏那枚「待填 N」徽章的数(弹层拉完/保存完上报,改动前也是同一份数)
|
|
1170
|
+
const ctxwVisible = ref(false);
|
|
1171
|
+
const ctxUnfilled = ref(0);
|
|
1172
|
+
// 侧栏徽章用:只拉一次、失败静默(打开弹层时会再拉一次并上报)
|
|
1173
|
+
async function refreshCtxUnfilled() {
|
|
1174
|
+
try {
|
|
1175
|
+
const j = await getJSON('/api/ctxwindows');
|
|
1176
|
+
if (j.ok) ctxUnfilled.value = (j.unknown || []).length;
|
|
1177
|
+
} catch {}
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
async function toggleAgentUI(name, enabled) {
|
|
1181
|
+
toggleLoading.value = name;
|
|
1182
|
+
try {
|
|
1183
|
+
const j = await getJSON('/api/agents/toggle', {
|
|
1184
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
1185
|
+
body: JSON.stringify({ name, enabled }),
|
|
1186
|
+
});
|
|
1187
|
+
if (!j.ok) { ElMessage.error(j.error || '操作失败'); return; }
|
|
1188
|
+
// 禁用:立刻本地摘掉该 agent 的条目(不等 SSE,3 秒内必消失)
|
|
1189
|
+
if (!enabled) all.value = all.value.filter(e => e.agent !== name);
|
|
1190
|
+
ElMessage.success((enabled ? '已启用 ' : '已禁用 ') + name);
|
|
1191
|
+
await Promise.all([loadAgents(), loadSnapshot()]);
|
|
1192
|
+
} catch (e) { ElMessage.error(e.message); }
|
|
1193
|
+
finally { toggleLoading.value = ''; }
|
|
1194
|
+
}
|
|
1195
|
+
async function removeAgentUI(name) {
|
|
1196
|
+
try {
|
|
1197
|
+
await ElMessageBox.confirm('移除 agent「' + name + '」?列表条目会被清除(不删本地日志文件)。', '移除 agent',
|
|
1198
|
+
{ type: 'warning', confirmButtonText: '移除', cancelButtonText: '取消' });
|
|
1199
|
+
} catch { return; }
|
|
1200
|
+
try {
|
|
1201
|
+
const j = await getJSON('/api/agents?name=' + encodeURIComponent(name), { method: 'DELETE' });
|
|
1202
|
+
if (j.ok) {
|
|
1203
|
+
ElMessage.success('已移除 ' + name);
|
|
1204
|
+
if (fAgent.value === name) fAgent.value = '';
|
|
1205
|
+
await Promise.all([loadAgents(), loadSnapshot()]);
|
|
1206
|
+
} else ElMessage.error(j.error || '移除失败');
|
|
1207
|
+
} catch (e) { ElMessage.error(e.message); }
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// 快照 URL:**选中某个 agent / 项目 / 时间范围时窗口跟着收窄**(服务端取「该筛选集内的最近 N 条」)。
|
|
1211
|
+
// 不能「取全局最近 N 条再在页面上过滤」—— 那样日志年代久的 agent / 项目永远落在窗口外,点进去只能是空列表。
|
|
1212
|
+
// project 传的是归一化 key(projKey),服务端按同一个 key 过滤,见 projNorm 上方的提醒。
|
|
1213
|
+
// R22:时间范围改传显式 from/to(YYYY-MM-DD),服务端 /api/snapshot 已支持与 /api/daily 同一套 filterEntries。
|
|
1214
|
+
const snapUrl = forceScan => {
|
|
1215
|
+
const q = new URLSearchParams();
|
|
1216
|
+
q.set('limit', fLimit.value);
|
|
1217
|
+
if (fDates.value) { q.set('from', fDates.value[0]); q.set('to', fDates.value[1]); }
|
|
1218
|
+
// R10:多选生效仅当单选为空(互斥),服务端 agents=/projects= 逗号合集
|
|
1219
|
+
if (fAgent.value) q.set('agent', fAgent.value);
|
|
1220
|
+
else if (fAgents.value.length) q.set('agents', fAgents.value.join(','));
|
|
1221
|
+
if (fProject.value) q.set('project', fProject.value);
|
|
1222
|
+
else if (fProjects.value.length) q.set('projects', fProjects.value.join(','));
|
|
1223
|
+
if (forceScan) q.set('scan', '1');
|
|
1224
|
+
return '/api/snapshot?' + q.toString();
|
|
1225
|
+
};
|
|
1226
|
+
|
|
1227
|
+
// 点侧栏切 agent:窗口的定义变了,必须整份重拉(不是只改个筛选条件)。
|
|
1228
|
+
// 顺带把条/页也重置回第一屏(loadSnapshot 里做)。
|
|
1229
|
+
async function pickAgent(name) {
|
|
1230
|
+
if (fAgent.value === name && !fAgents.value.length) return;
|
|
1231
|
+
fAgent.value = name;
|
|
1232
|
+
if (name) fAgents.value = []; // R10:单选生效时清多选,互斥(单选优先)
|
|
1233
|
+
await loadSnapshot();
|
|
1234
|
+
}
|
|
1235
|
+
// 选项目同理:清空(clearable 会给到 '' / undefined)也要重拉回全局窗口。
|
|
1236
|
+
// **这里不能加「值没变就 return」的短路**:el-select 的 @change 是在 v-model 更新**之后**才发的,
|
|
1237
|
+
// 拿 fProject.value 跟新值比一定相等,加了就永远不会重拉(踩过:窗口不缩、列表空着)。
|
|
1238
|
+
// 重复选中本来就不会触发 change,不需要再自己挡一道。
|
|
1239
|
+
async function pickProject(key) {
|
|
1240
|
+
fProject.value = key || '';
|
|
1241
|
+
if (key) fProjects.value = []; // R10:单选生效时清多选,互斥(单选优先)
|
|
1242
|
+
await loadSnapshot();
|
|
1243
|
+
}
|
|
1244
|
+
// R10 多选 change:与单选同款整份重拉。多选生效的前提是对应单选为空(模板 v-show 已保证不可见),
|
|
1245
|
+
// 但保险起见 pickAgent / pickProject 触发时清掉多选,保证互斥语义(单选优先)。
|
|
1246
|
+
async function pickAgentsMulti(v) {
|
|
1247
|
+
fAgents.value = v || [];
|
|
1248
|
+
await loadSnapshot();
|
|
1249
|
+
}
|
|
1250
|
+
async function pickProjectsMulti(v) {
|
|
1251
|
+
fProjects.value = v || [];
|
|
1252
|
+
await loadSnapshot();
|
|
1253
|
+
}
|
|
1254
|
+
// 换时间范围同样整份重拉(清空也要,回到全部时间)。同 pickProject:**不要**加「值没变就 return」的短路,
|
|
1255
|
+
// el-date-picker 的 @change 在 v-model 更新之后才发,比一定相等(踩过,见 §11.5)。
|
|
1256
|
+
// 选区非法(只选了半边 / 空数组)一律回落 null = 全部时间,宁可全给也不静默给错区间。
|
|
1257
|
+
async function pickRange(v) {
|
|
1258
|
+
fDates.value = (v && v.length === 2 && v[0] && v[1]) ? [v[0], v[1]] : null;
|
|
1259
|
+
await loadSnapshot();
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
// ---- R4 筛选状态深链(hash):agent / project / 时间范围 / status 四个主要筛选 ----
|
|
1263
|
+
// 用 hash 而非 query:页面也可能被 file:// 直开(PAGE_SERVED 场景),hash 两种方式都不产生路径问题。
|
|
1264
|
+
// fSearch 是纯客户端过滤且高频输入,不进 URL(避免每敲一个字改一次地址栏)。
|
|
1265
|
+
// R22:时间范围在 hash 里写显式 from=/to=(YYYY-MM-DD);也兼容旧链接的 range=today/7d/30d 符号。
|
|
1266
|
+
function updateHash() {
|
|
1267
|
+
const p = new URLSearchParams();
|
|
1268
|
+
if (fAgent.value) p.set('agent', fAgent.value);
|
|
1269
|
+
else if (fAgents.value.length) p.set('agents', fAgents.value.join(',')); // R10:多选态写合集键
|
|
1270
|
+
if (fProject.value) p.set('project', fProject.value);
|
|
1271
|
+
else if (fProjects.value.length) p.set('projects', fProjects.value.join(','));
|
|
1272
|
+
if (fDates.value) { p.set('from', fDates.value[0]); p.set('to', fDates.value[1]); }
|
|
1273
|
+
if (fStatus.value) p.set('status', fStatus.value);
|
|
1274
|
+
// R33:**只有已提交的搜索**才进深链(searchOn 为真),输入框里正在敲的字不进 ——
|
|
1275
|
+
// 与 fSearch 不进 URL 是同一条理由,避免每敲一个字改一次地址栏。
|
|
1276
|
+
if (searchOn.value && searchQ.value) {
|
|
1277
|
+
p.set('q', searchQ.value);
|
|
1278
|
+
if (searchRegex.value) p.set('re', '1');
|
|
1279
|
+
if (searchNarrow.value) p.set('narrow', '1');
|
|
1280
|
+
}
|
|
1281
|
+
// R6:视图也进深链('log' 是默认值,不写,保持老 URL 干净)
|
|
1282
|
+
if (view.value !== 'log') p.set('view', view.value);
|
|
1283
|
+
const h = p.toString();
|
|
1284
|
+
const next = location.pathname + location.search + (h ? '#' + h : '');
|
|
1285
|
+
if (location.hash !== (h ? '#' + h : '')) history.replaceState(null, '', next);
|
|
1286
|
+
}
|
|
1287
|
+
// 打开带参 URL 时还原筛选:在首次 loadSnapshot 之前应用,服务端窗口按参数收窄。
|
|
1288
|
+
// 时间范围三态:显式 from/to → 直接用;旧 range 符号 → 换算成区间(与服务端符号档等价);
|
|
1289
|
+
// 一个都没有 → 默认最近 2 天(R22 需求)。from/to 缺一个时补齐(to 缺省今天,from 缺省 to)。
|
|
1290
|
+
(function restoreFromHash() {
|
|
1291
|
+
const p = location.hash ? new URLSearchParams(location.hash.slice(1)) : null;
|
|
1292
|
+
if (p) {
|
|
1293
|
+
if (p.get('agent')) fAgent.value = p.get('agent');
|
|
1294
|
+
else if (p.get('agents')) fAgents.value = p.get('agents').split(',').filter(Boolean); // R10:多选深链,与单值键互斥(单值优先)
|
|
1295
|
+
if (p.get('project')) fProject.value = p.get('project');
|
|
1296
|
+
else if (p.get('projects')) fProjects.value = p.get('projects').split(',').filter(Boolean);
|
|
1297
|
+
if (p.get('status')) fStatus.value = p.get('status');
|
|
1298
|
+
// 视图还原只改状态、**不发请求**:这个 IIFE 在第一份快照之前跑,此刻服务端可能还在首扫,
|
|
1299
|
+
// 会话列表要等启动那一批请求之后再去拉(见文件下方 bootstrap 里的注释)。
|
|
1300
|
+
if (p.get('view') === 'sess') view.value = 'sess';
|
|
1301
|
+
// R33 搜索态:同一条理由,这里只把状态备好、**不发请求** —— 首扫还没完时搜出来的索引是空
|
|
1302
|
+
// 的,会白搜一次还落个「命中 0 条」。真正触发在 bootstrap 里(见 pendingSearch)。
|
|
1303
|
+
if (p.get('q')) {
|
|
1304
|
+
pendingSearch = { q: p.get('q'), regex: p.get('re') === '1', narrow: p.get('narrow') === '1' };
|
|
1305
|
+
fSearch.value = pendingSearch.q; // 输入框里也摆上,清除搜索后还能接着在窗口里过滤
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
const from = p && p.get('from'), to = p && p.get('to'), sym = p && p.get('range');
|
|
1309
|
+
if (from || to) {
|
|
1310
|
+
const fromV = from || to, toV = to || from;
|
|
1311
|
+
fDates.value = [fromV, toV];
|
|
1312
|
+
} else if (sym) {
|
|
1313
|
+
fDates.value = symbolToDates(sym) || defaultRange();
|
|
1314
|
+
} else {
|
|
1315
|
+
fDates.value = defaultRange(); // 默认最近 2 天(今天 + 昨天)
|
|
1316
|
+
}
|
|
1317
|
+
})();
|
|
1318
|
+
|
|
1319
|
+
async function loadSnapshot(forceScan) {
|
|
1320
|
+
snapPending = true; // 在途期间到达的增量消息先排队(理由见 connect 上方注释)
|
|
1321
|
+
try {
|
|
1322
|
+
const j = await getJSON(snapUrl(forceScan));
|
|
1323
|
+
all.value = trimAll(j.entries);
|
|
1324
|
+
// 服务端告诉我们「当前筛选范围内一共多少条」——不一定等于拿回来的这些(条/页窗口会截断)。
|
|
1325
|
+
// 不显示它的话,30 天范围内有 2672 条、页面写着「共 2000 条」,用户会以为那就是全部。
|
|
1326
|
+
rangeTotal.value = typeof j.rangeTotal === 'number' ? j.rangeTotal : all.value.length;
|
|
1327
|
+
agents.value = j.agents; projects.value = j.projects;
|
|
1328
|
+
if (typeof j.scanMs === 'number') scanMs.value = j.scanMs;
|
|
1329
|
+
if (typeof j.idleExitMs === 'number') idleExitMs.value = j.idleExitMs;
|
|
1330
|
+
appliedSeq = Math.max(appliedSeq, j.seq || 0);
|
|
1331
|
+
recomputeLive();
|
|
1332
|
+
shown.value = STEP;
|
|
1333
|
+
updateHash(); // R4:筛选状态随每次快照同步进 hash(pickAgent/Project/Range 都经过这里)
|
|
1334
|
+
} finally {
|
|
1335
|
+
// 骨架屏只在「首份快照」这段时间里挂着;之后 loadSnapshot 还会被刷新/resync 多次调用,
|
|
1336
|
+
// 那时候走的是正常的空态文案,不能再画骨架。
|
|
1337
|
+
// 放在 finally:fetch 抛错也得摘掉,否则骨架屏会一直转下去。
|
|
1338
|
+
booting.value = false;
|
|
1339
|
+
// 快照应用完再补放排队的增量:seq 守卫会把「已含在快照里」的那些自动丢掉,不会重复计数。
|
|
1340
|
+
// 失败路径(fetch 抛错)也要补放,否则直播流会永久卡在这里。
|
|
1341
|
+
snapPending = false;
|
|
1342
|
+
for (const m of snapQueue.splice(0)) { try { handleMsg(m); } catch {} }
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
// 手动刷新:即使处于「不实时更新」也先扫一轮(后端 ?scan=1),否则点了等于没点
|
|
1346
|
+
async function refresh() { shown.value = STEP; await loadSnapshot(true); ElMessage.success('已刷新'); }
|
|
1347
|
+
|
|
1348
|
+
// 调整实时刷新频率(0 = 不实时更新)。服务端落盘并广播,多标签页保持一致
|
|
1349
|
+
async function setScan(v) {
|
|
1350
|
+
const ms = Number(v);
|
|
1351
|
+
if (ms === scanMs.value) return;
|
|
1352
|
+
try {
|
|
1353
|
+
const j = await getJSON('/api/settings', {
|
|
1354
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
1355
|
+
body: JSON.stringify({ scanMs: ms }),
|
|
1356
|
+
});
|
|
1357
|
+
if (j.ok) {
|
|
1358
|
+
scanMs.value = j.scanMs;
|
|
1359
|
+
ElMessage.success(ms ? '刷新频率已改为每 ' + (ms / 1000) + ' 秒' : '已暂停实时更新(仍可手动刷新)');
|
|
1360
|
+
} else ElMessage.error(j.error || '设置失败');
|
|
1361
|
+
} catch (e) { ElMessage.error('设置失败:' + e.message); }
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
// 空闲自停(服务端落盘并广播)。改完立刻生效,不影响当前打开着的页面
|
|
1365
|
+
async function setIdle(v) {
|
|
1366
|
+
const ms = Number(v);
|
|
1367
|
+
if (ms === idleExitMs.value) return;
|
|
1368
|
+
try {
|
|
1369
|
+
const j = await getJSON('/api/settings', {
|
|
1370
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
1371
|
+
body: JSON.stringify({ idleExitMs: ms }),
|
|
1372
|
+
});
|
|
1373
|
+
if (j.ok) {
|
|
1374
|
+
idleExitMs.value = j.idleExitMs;
|
|
1375
|
+
ElMessage.success(ms ? '已开启:页面全关且无新日志超过 ' + (ms / 3600000 >= 1 ? (ms / 3600000) + ' 小时' : (ms / 60000) + ' 分钟') + '后服务自动退出' : '已关闭空闲自停');
|
|
1376
|
+
} else ElMessage.error(j.error || '设置失败');
|
|
1377
|
+
} catch (e) { ElMessage.error('设置失败:' + e.message); }
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// 唤出桌面悬浮卡片(顶栏「悬浮卡片」按钮):POST /api/client 让服务端跑 runClient(),
|
|
1381
|
+
// 等价命令行 agentacta --client。卡片已被 ✕ 关掉时壳靠 second-instance 分支重建(widget/main.js)。
|
|
1382
|
+
const clientLaunching = ref(false);
|
|
1383
|
+
async function launchClient() {
|
|
1384
|
+
if (clientLaunching.value) return;
|
|
1385
|
+
clientLaunching.value = true;
|
|
1386
|
+
try {
|
|
1387
|
+
const j = await getJSON('/api/client', { method: 'POST' });
|
|
1388
|
+
if (j.ok) ElMessage.success('悬浮卡片已唤出(重复点击只是聚焦)');
|
|
1389
|
+
else ElMessage.error(j.error || '唤出失败');
|
|
1390
|
+
} catch (e) { ElMessage.error('唤出失败:' + e.message); }
|
|
1391
|
+
finally { clientLaunching.value = false; }
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
// 前端上限:all 超过 MAX_ALL 丢弃最旧(服务端已按时间降序返回)
|
|
1395
|
+
function trimAll(list) {
|
|
1396
|
+
if (list.length <= MAX_ALL) { capped.value = false; return list; }
|
|
1397
|
+
capped.value = true;
|
|
1398
|
+
return list.slice(0, MAX_ALL);
|
|
1399
|
+
}
|
|
1400
|
+
// 进行中的轮 = 每个 agent 最新一条,且服务端明确未结束;老/无结束标志的最后一轮只在最近 5 分钟内兜底。
|
|
1401
|
+
function recomputeLive() {
|
|
1402
|
+
const latest = {};
|
|
1403
|
+
for (const e of all.value) {
|
|
1404
|
+
const t = e.time || 0;
|
|
1405
|
+
if (!latest[e.agent] || t > (latest[e.agent].time || 0)) latest[e.agent] = e;
|
|
1406
|
+
}
|
|
1407
|
+
const now = Date.now();
|
|
1408
|
+
const s = new Set();
|
|
1409
|
+
for (const k in latest) {
|
|
1410
|
+
const e = latest[k];
|
|
1411
|
+
if (e.agent === 'trace' || e.finished || e.aborted) continue;
|
|
1412
|
+
if (now - (e.time || 0) <= LIVE_STALE_MS) s.add(e.id);
|
|
1413
|
+
}
|
|
1414
|
+
liveIds.value = s;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
async function fetchDetail(id, full) {
|
|
1418
|
+
return await getJSON('/api/entry?id=' + encodeURIComponent(id) + (full ? '&full=1' : ''));
|
|
1419
|
+
}
|
|
1420
|
+
async function toggle(id) {
|
|
1421
|
+
expandedId.value = expandedId.value === id ? null : id;
|
|
1422
|
+
if (expandedId.value && !details[id]) {
|
|
1423
|
+
try { details[id] = prepDetail(await fetchDetail(id, false)); }
|
|
1424
|
+
catch (err) { details[id] = { user: '[加载失败: ' + err.message + ']', assistant: '', tools: [] }; }
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
// 懒加载完整内容(后端按 800 字符截断时用)
|
|
1428
|
+
async function loadFull(id) {
|
|
1429
|
+
if (fullIds.value.has(id)) return;
|
|
1430
|
+
fullIds.value = new Set(fullIds.value).add(id);
|
|
1431
|
+
try { details[id] = prepDetail(await fetchDetail(id, true)); } catch (e) { ElMessage.error(e.message); }
|
|
1432
|
+
}
|
|
1433
|
+
// 进行中的轮手动重取(SSE 到达时也会自动重取已展开的轮)
|
|
1434
|
+
async function reloadDetail(id) {
|
|
1435
|
+
try {
|
|
1436
|
+
const d = await fetchDetail(id, fullIds.value.has(id));
|
|
1437
|
+
// 内容没变就不替换:替换 = 整张详情的 DOM 重 diff。进行中的轮每次 SSE 都会走到这里,
|
|
1438
|
+
// 不拦截的话展开着的卡片会每 3 秒卡一下(「展开后整个页面持续卡」的来源之一)
|
|
1439
|
+
if (details[id] && detailSig(details[id]) === detailSig(d)) return;
|
|
1440
|
+
details[id] = prepDetail(d);
|
|
1441
|
+
} catch (e) { ElMessage.error(e.message); }
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
// ----- 长内容的收放(「内容很多」时的那套:先露几条 + 「展开全部 N 条」,展开了在末尾给「收起」) -----
|
|
1445
|
+
//
|
|
1446
|
+
// 为什么需要:本机实测单轮工具调用最多 **81 个**、LLM 调用 73 次,逐条铺开是十几屏 ——
|
|
1447
|
+
// 想往下翻下一张卡片,得先滑过这整整一屏。这与知乎「展开全部评论 / 收起评论」是同一个问题。
|
|
1448
|
+
//
|
|
1449
|
+
// 阈值按每块的实际高度定,不是拍脑袋:工具块一条占 ~200px(上下各一段 <pre>,各自封顶 150px),
|
|
1450
|
+
// 6 条就约等于一屏;调用明细与 span 是单行 ~30px,给 12/10 行才到半屏。
|
|
1451
|
+
const SEC_N = { tools: 6, calls: 12, spans: 10 };
|
|
1452
|
+
// 点开过的区块记在这儿(key = 条目 id + 区块名)。用对象而不是一个布尔值:
|
|
1453
|
+
// 收起卡片再展开、或在几条记录之间来回点,不该把「我刚展开过这一块」的意图弄丢。
|
|
1454
|
+
const secOpen = reactive({});
|
|
1455
|
+
// 当前展开条目的三个长列表视图。做成 computed 而不是在模板里逐个算:模板里写函数,
|
|
1456
|
+
// 每次重渲染都会把每个列表重新切一遍,而这里的结果只跟「哪张卡片展开着 + 点开过哪些区块」有关。
|
|
1457
|
+
const secs = computed(() => {
|
|
1458
|
+
const id = expandedId.value, d = id ? details[id] : null;
|
|
1459
|
+
if (!id || !d) return null;
|
|
1460
|
+
const mk = (k, list) => {
|
|
1461
|
+
const n = SEC_N[k], more = list.length > n, open = !!secOpen[id + ':' + k];
|
|
1462
|
+
// 不够 n 条就不切、也不显示按钮 —— 只有真的「内容很多」时才多出这一行控件
|
|
1463
|
+
return { more, open, items: (open || !more) ? list : list.slice(0, n), rest: more ? list.length - n : 0 };
|
|
1464
|
+
};
|
|
1465
|
+
return { spans: mk('spans', d.spans || []), calls: mk('calls', d.calls || []), tools: mk('tools', d.tools || []) };
|
|
1466
|
+
});
|
|
1467
|
+
function toggleSec(k) { const id = expandedId.value; if (id) secOpen[id + ':' + k] = !secOpen[id + ':' + k]; }
|
|
1468
|
+
|
|
1469
|
+
// 卡片 v-memo 的依赖清单(数组长度必须固定,长度变化会被 Vue 当成「memo 失效」强制重渲)。
|
|
1470
|
+
// 收起的卡片:只依赖条目本体(applyUpdate 会整体换对象)和 live 标志(titleOf 的「(进行中)」)。
|
|
1471
|
+
// 展开的卡片:把详情区读到的响应式状态**全**列上 —— 漏一个,那个操作就会「点了没反应」
|
|
1472
|
+
// (memo 相等 → 整棵子树跳过重渲)。所以这里宁多勿漏:
|
|
1473
|
+
// details[id] 详情本体(拉取/重取时整体替换,引用变化即重渲)
|
|
1474
|
+
// secOpen 三个区块的「展开全部」开关
|
|
1475
|
+
// callOpen / toolJsonFold 两个 ref<Set>:toggle 时整体换新 Set,引用比较就够
|
|
1476
|
+
// fullIds 是否已拉过完整版(「查看完整内容」按钮的显隐)
|
|
1477
|
+
const cardMemo = e => {
|
|
1478
|
+
const liveBit = liveIds.value.has(e.id);
|
|
1479
|
+
const cmpBit = compareSet.value.has(e.id); // I9:对比选中态变了卡片要跟着重渲(按钮高亮)
|
|
1480
|
+
if (expandedId.value !== e.id) return [e, false, liveBit, cmpBit, 0, 0, 0, 0, 0, 0, 0];
|
|
1481
|
+
const id = e.id;
|
|
1482
|
+
return [e, true, liveBit, cmpBit,
|
|
1483
|
+
details[id],
|
|
1484
|
+
!!secOpen[id + ':spans'], !!secOpen[id + ':calls'], !!secOpen[id + ':tools'],
|
|
1485
|
+
callOpen.value, toolJsonFold.value,
|
|
1486
|
+
fullIds.value.has(id)];
|
|
1487
|
+
};
|
|
1488
|
+
|
|
1489
|
+
// 详情底部的「收起」:折叠这张卡片,并把卡片滚回视野。
|
|
1490
|
+
// 为什么要滚:详情可能有十几屏高,收起后页面骤然变短,滚动位置会停在别处 ——
|
|
1491
|
+
// 用户上一秒还在读 AI 输出,下一秒眼前是几张不相干的卡片,看着像点错了。
|
|
1492
|
+
// 用 instant(不给 behavior)而不是 smooth:跨越上万像素的平滑滚动要好几秒,比直接跳更像卡死。
|
|
1493
|
+
async function collapseCard(id, ev) {
|
|
1494
|
+
const card = ev && ev.target && ev.target.closest ? ev.target.closest('.card') : null;
|
|
1495
|
+
if (expandedId.value === id) expandedId.value = null;
|
|
1496
|
+
await nextTick(); // 等 DOM 变矮再量位置,否则算的是收起前的高度
|
|
1497
|
+
if (card) card.scrollIntoView({ block: 'nearest' });
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
function applyUpdate(entries, agentCounts, projectCounts) {
|
|
1501
|
+
// 计数以服务端为准(本地累加会因增量/淘汰而漂移)。放在下面过滤之前:
|
|
1502
|
+
// 收窄到某个 agent 时别的 agent 的条目会被丢掉,但侧栏数字仍要跟着更新。
|
|
1503
|
+
if (agentCounts) agents.value = agentCounts;
|
|
1504
|
+
if (projectCounts) projects.value = projectCounts;
|
|
1505
|
+
// 收窄到某个 agent / 项目 / 时间范围时,窗口外的增量不进 all:all 的语义是「当前视图的窗口」,
|
|
1506
|
+
// 混进来会让「共 N 条」虚高,长时间挂着的标签页还会用窗口外的条目把本视图的老条目顶出上限。
|
|
1507
|
+
// 切回「全部」时整份重拉,这里丢掉的不影响正确性。
|
|
1508
|
+
// R10 多选态同样要收窄(fAgents / fProjects 与单选互斥,单选为空时生效)—— 服务端 broadcast 对所有
|
|
1509
|
+
// SSE 客户端推的是同一份全量脏条目,不认前端的筛选(首屏/刷新走 snapUrl 由服务端收窄,只有这里靠本地)。
|
|
1510
|
+
let list = entries;
|
|
1511
|
+
if (fAgent.value) list = (list || []).filter(e => e.agent === fAgent.value);
|
|
1512
|
+
else if (fAgents.value.length) list = (list || []).filter(e => fAgents.value.includes(e.agent)); // R10 多选合集,与 filtered 同口径
|
|
1513
|
+
if (fProject.value) list = (list || []).filter(e => projKey(e.project) === fProject.value);
|
|
1514
|
+
else if (fProjects.value.length) list = (list || []).filter(e => fProjects.value.includes(projKey(e.project))); // R10 多选合集
|
|
1515
|
+
if (fDates.value) list = (list || []).filter(e => rangeTest(e.time));
|
|
1516
|
+
if (!list || !list.length) return;
|
|
1517
|
+
const map = new Map(all.value.map((x, i) => [x.id, i]));
|
|
1518
|
+
const added = [];
|
|
1519
|
+
for (const e of list) {
|
|
1520
|
+
const i = map.get(e.id);
|
|
1521
|
+
if (i !== undefined) all.value[i] = e;
|
|
1522
|
+
else added.push(e);
|
|
1523
|
+
}
|
|
1524
|
+
if (added.length) {
|
|
1525
|
+
all.value = added.concat(all.value).sort((a, b) => (b.time||0) - (a.time||0));
|
|
1526
|
+
} else if (list.some(e => map.get(e.id) !== undefined)) {
|
|
1527
|
+
// 原地更新后顺序可能变(时间戳被回填),仅在有变化时重排
|
|
1528
|
+
all.value = [...all.value].sort((a, b) => (b.time||0) - (a.time||0));
|
|
1529
|
+
}
|
|
1530
|
+
if (all.value.length > MAX_ALL) all.value = trimAll(all.value);
|
|
1531
|
+
recomputeLive();
|
|
1532
|
+
// 进行中且已展开的轮:自动重取详情(首屏刷新页面也能看到新内容)
|
|
1533
|
+
for (const e of list) if (expandedId.value === e.id && liveIds.value.has(e.id)) reloadDetail(e.id);
|
|
1534
|
+
}
|
|
1535
|
+
// 统一的取 JSON 入口,替掉裸 `fetch(...).then(r => r.json())`。
|
|
1536
|
+
//
|
|
1537
|
+
// 裸写有个很难查的失败模式:服务端对不认识的路由走兜底 `res.statusCode = 404; res.end('not found')`,
|
|
1538
|
+
// 于是 r.json() 抛的是 **「Unexpected token 'o', "not found" is not valid JSON」**——
|
|
1539
|
+
// 用户拿着这句话既看不出是哪个接口、也猜不到是「服务是旧版本」,只能来问人。
|
|
1540
|
+
// 这里把状态码和响应体截一段带回错误信息里,并且尽量说清楚下一步做什么。
|
|
1541
|
+
async function getJSON(url, opt) {
|
|
1542
|
+
const r = await fetch(url, opt);
|
|
1543
|
+
const text = await r.text();
|
|
1544
|
+
let j = null;
|
|
1545
|
+
try { j = JSON.parse(text); } catch {}
|
|
1546
|
+
if (j) return j;
|
|
1547
|
+
const head = text.replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
1548
|
+
if (r.status === 404) {
|
|
1549
|
+
// 只在还没有更准确的说明时才写:checkVersion 问的是 /api/version,它给出的「服务是 1.4.0 之前的旧版」
|
|
1550
|
+
// 比这里是哪个接口 404 有用得多。启动时几个请求并行,谁先回来不定,所以要定优先级而不是比手速。
|
|
1551
|
+
if (/^not found$/i.test(head) && !versionMismatch.value) {
|
|
1552
|
+
versionMismatch.value = '服务上不存在这个接口(' + url.split('?')[0] + ')—— 多半是升级后旧的服务进程还在跑。';
|
|
1553
|
+
}
|
|
1554
|
+
throw new Error('接口 ' + url.split('?')[0] + ' 不存在(HTTP 404)');
|
|
1555
|
+
}
|
|
1556
|
+
throw new Error('接口 ' + url.split('?')[0] + ' 返回了 HTTP ' + r.status + ' 且不是 JSON:' + (head || '(空响应体)'));
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
// 比对页面与服务是不是同一版。三种情况要分开,别把「连不上」也说成「版本旧」:
|
|
1560
|
+
// 请求失败 → 什么都不说(顶栏的「重连中…」已经在说这件事了,重复喊只会乱)
|
|
1561
|
+
// 404 → 没有 /api/version 这个接口 = 老于本接口的服务,**最典型的错配**,必须说出来
|
|
1562
|
+
// 答了但指纹不同 → 明确的版本错配
|
|
1563
|
+
async function checkVersion() {
|
|
1564
|
+
if (!PAGE_SERVED) return; // file:// 直接打开的本地 HTML:没有服务,无从比对
|
|
1565
|
+
let r;
|
|
1566
|
+
try { r = await fetch('/api/version'); } catch { return; }
|
|
1567
|
+
if (r.status === 404) { versionMismatch.value = '服务没有 /api/version 接口,说明它是 1.4.0 之前的旧版本。'; return; }
|
|
1568
|
+
let j = null; try { j = await r.json(); } catch {}
|
|
1569
|
+
if (!j || !j.build) return;
|
|
1570
|
+
const mine = PAGE_BUILD_OK ? PAGE_BUILD : '';
|
|
1571
|
+
if (!(mine && j.build === mine)) {
|
|
1572
|
+
versionMismatch.value = '服务在跑 ' + (j.version || '?') + '/' + j.build
|
|
1573
|
+
+ (mine ? ',这张页面来自 ' + mine : ',而这张页面没有版本标记(服务是旧版、没往页面里注入,或这页是浏览器缓存的旧副本)') + '。';
|
|
1574
|
+
return;
|
|
1575
|
+
}
|
|
1576
|
+
// 服务版对上了,再看页面文件(html + /page/*.js + /page/style.css):
|
|
1577
|
+
// 编辑页面文件后没刷新、或浏览器里留着旧标签页时,这一枚会对不上 —— 提示刷新即可(不是错配事故)。
|
|
1578
|
+
const mineFiles = PAGE_FILES_OK ? PAGE_FILES_BUILD : '';
|
|
1579
|
+
versionMismatch.value = (j.pageBuild && mineFiles && j.pageBuild !== mineFiles)
|
|
1580
|
+
? '页面文件更新过(本页 ' + mineFiles + ',服务现在发的是 ' + j.pageBuild + ')—— 刷新一下即可。'
|
|
1581
|
+
: '';
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
function applyRemove(ids, agentCounts, projectCounts) {
|
|
1585
|
+
const gone = new Set(ids);
|
|
1586
|
+
all.value = all.value.filter(e => !gone.has(e.id));
|
|
1587
|
+
for (const id of ids) delete details[id];
|
|
1588
|
+
if (agentCounts) agents.value = agentCounts; // 权威计数(否则侧栏数字会停在旧值)
|
|
1589
|
+
if (projectCounts) projects.value = projectCounts;
|
|
1590
|
+
recomputeLive();
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
// 首份快照在途时到达的增量消息先排队,等快照应用完再补放。
|
|
1594
|
+
// 原因:loadSnapshot 是整体替换(all.value = j.entries),先连 SSE 再拉快照时,
|
|
1595
|
+
// 期间到达的 update 会被随后到达的快照覆盖掉;而被丢掉的那几条文件没变,
|
|
1596
|
+
// 下一轮扫描也不会再推(不是脏条目),会一直缺到用户手动刷新为止。
|
|
1597
|
+
let snapPending = false;
|
|
1598
|
+
const snapQueue = [];
|
|
1599
|
+
const QUEUED_TYPE = { update: 1, remove: 1, resync: 1, agents: 1, alert: 1 };
|
|
1600
|
+
|
|
1601
|
+
function handleMsg(j) {
|
|
1602
|
+
// hello 是「本连接」的元信息,必须无条件处理:冷启动时首个连接会先收到一条 scanning 事件、
|
|
1603
|
+
// 再收到 hello,两条 seq 相同,走下面的乱序守卫会被误丢(hello 带初始 scanMs/空闲自停/扫描进度)。
|
|
1604
|
+
if (j.type === 'hello') {
|
|
1605
|
+
appliedSeq = Math.max(appliedSeq, j.seq || 0);
|
|
1606
|
+
if (typeof j.scanMs === 'number') scanMs.value = j.scanMs;
|
|
1607
|
+
if (typeof j.idleExitMs === 'number') idleExitMs.value = j.idleExitMs;
|
|
1608
|
+
scanning.value = j.scanning || null;
|
|
1609
|
+
// R9:hello 携带当前异常巡检摘要与命中 id,首屏即可点亮徽章 / 生效「只看异常」
|
|
1610
|
+
if (j.alert) { applyAlertSummary(j.alert); applyAlertIds(j.alertIds); }
|
|
1611
|
+
// 服务自报的指纹(见服务端 hello)。页面开着的时候服务被换过班时,这一条能立刻发现错配。
|
|
1612
|
+
// 老服务的 hello 里没有 build 字段 —— 那正是「旧服务在跑」,交给 checkVersion 去问清楚(别在这儿下结论)。
|
|
1613
|
+
if (j.build !== undefined && PAGE_SERVED) {
|
|
1614
|
+
const mine = PAGE_BUILD_OK ? PAGE_BUILD : '';
|
|
1615
|
+
if (!mine || j.build !== mine) {
|
|
1616
|
+
versionMismatch.value = '服务换成了 ' + j.build + '(本页来自 ' + (mine || '未知版本') + '),刷新一下即可。';
|
|
1617
|
+
} else {
|
|
1618
|
+
// build 一致才轮到页面文件指纹:BUILD 不哈希 page/ 下的文件(R25 批 0),
|
|
1619
|
+
// 只改了页面文件的话 build 不变 —— 少了这一段,「编辑页面 + 重启服务」开着的老标签页永远发现不了。
|
|
1620
|
+
const mineFiles = PAGE_FILES_OK ? PAGE_FILES_BUILD : '';
|
|
1621
|
+
versionMismatch.value = (j.pageBuild && mineFiles && j.pageBuild !== mineFiles)
|
|
1622
|
+
? '页面文件更新过(本页 ' + mineFiles + ',服务现在发的是 ' + j.pageBuild + ')—— 刷新一下即可。'
|
|
1623
|
+
: '';
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
if (j.seq != null && j.seq <= appliedSeq) return; // 乱序/重复,忽略
|
|
1629
|
+
if (j.type === 'settings') { appliedSeq = j.seq; scanMs.value = j.scanMs; if (typeof j.idleExitMs === 'number') idleExitMs.value = j.idleExitMs; return; }
|
|
1630
|
+
// 服务端首扫进度(不进 dirty、不保证重发):phase='done' 就是扫完了
|
|
1631
|
+
if (j.type === 'scanning') {
|
|
1632
|
+
appliedSeq = j.seq;
|
|
1633
|
+
scanning.value = j.phase === 'done' ? null : { done: j.done || 0, total: j.total || 0, agent: j.agent || '' };
|
|
1634
|
+
return;
|
|
1635
|
+
}
|
|
1636
|
+
if (j.type === 'agents') {
|
|
1637
|
+
appliedSeq = j.seq;
|
|
1638
|
+
if (j.added) { ElMessage.info('发现新 agent:' + j.added); loadAgents().then(loadSnapshot); }
|
|
1639
|
+
if (j.removed) { ElMessage.info('agent 已失效移除:' + j.removed); loadAgents().then(loadSnapshot); }
|
|
1640
|
+
// Trae 密钥写入/清除后,服务端广播 agents(含 hasKey 变化),页面需要刷新侧栏状态
|
|
1641
|
+
if ('traeKey' in j || 'traeKeyCleared' in j) { loadAgents(); return; }
|
|
1642
|
+
return;
|
|
1643
|
+
}
|
|
1644
|
+
// R9:异常命中集合变化(新日志扫完 / 规则被改 / 弹层保存)→ 更新侧栏徽章与「只看异常」集合
|
|
1645
|
+
if (j.type === 'alert') {
|
|
1646
|
+
appliedSeq = j.seq;
|
|
1647
|
+
applyAlertSummary(j.summary);
|
|
1648
|
+
applyAlertIds(j.ids);
|
|
1649
|
+
return;
|
|
1650
|
+
}
|
|
1651
|
+
if (j.type === 'update') { appliedSeq = j.seq; applyUpdate(j.entries, j.agents, j.projects); return; }
|
|
1652
|
+
if (j.type === 'resync') { appliedSeq = j.seq; loadSnapshot(); return; }
|
|
1653
|
+
if (j.type === 'remove') { appliedSeq = j.seq; applyRemove(j.ids || [], j.agents, j.projects); return; }
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
let es = null, retry = 0;
|
|
1657
|
+
function connect() {
|
|
1658
|
+
es = new EventSource('/api/events');
|
|
1659
|
+
es.onopen = () => { live.value = true; retry = 0; };
|
|
1660
|
+
es.onerror = () => { live.value = false; es.close(); setTimeout(connect, Math.min(15000, 1000 * ++retry)); };
|
|
1661
|
+
es.onmessage = ev => {
|
|
1662
|
+
try {
|
|
1663
|
+
const j = JSON.parse(ev.data);
|
|
1664
|
+
if (!j.type) return;
|
|
1665
|
+
if (snapPending && QUEUED_TYPE[j.type]) { snapQueue.push(j); return; }
|
|
1666
|
+
handleMsg(j);
|
|
1667
|
+
} catch {}
|
|
1668
|
+
};
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
// 先连 SSE 再拉快照(不是 .then 串行):首扫分片期间页面必须尽早连上,
|
|
1672
|
+
// 否则服务端发的 scanning 进度事件没有人收 —— 那就白写了。
|
|
1673
|
+
connect();
|
|
1674
|
+
loadSnapshot();
|
|
1675
|
+
loadAgents();
|
|
1676
|
+
checkVersion(); // 与上面三条并行,不阻塞首屏
|
|
1677
|
+
refreshCtxUnfilled(); // 只为侧栏那个「待填 N」徽章,失败静默(打开弹层时会再拉一次并上报)
|
|
1678
|
+
// R6:首屏就带 #view=sess 打开时,视图在 restoreFromHash 里已经还原成 'sess' 了(那是第一份快照之前跑
|
|
1679
|
+
// 的 IIFE,那里只改状态、不发请求)。会话列表放在这一批启动请求之后拉:几个请求都是并行的、服务端
|
|
1680
|
+
// 统一 whenReady 等首扫就绪,谁先回来都不影响列表的正确性,但放在快照**之前**会让「还没就绪」的空
|
|
1681
|
+
// 结果先落地一次。
|
|
1682
|
+
if (view.value === 'sess') loadSessions();
|
|
1683
|
+
|
|
1684
|
+
// R33:深链带了 #q=… 时,先把搜索态**立起来**再发请求。
|
|
1685
|
+
// 先立态的理由是排序:上面 loadSnapshot() 落地后会调 updateHash,而 updateHash 只在 searchOn 为真时
|
|
1686
|
+
// 才把 q 写回地址栏 —— 不先立态的话,它会先把深链带来的 q 抹掉、再被 runSearch 加回来,地址栏闪一下。
|
|
1687
|
+
// 请求本身不急:/api/search 服务端挂 whenReady,首扫没好之前它自己会在那边排队。
|
|
1688
|
+
if (pendingSearch) {
|
|
1689
|
+
searchQ.value = pendingSearch.q;
|
|
1690
|
+
searchRegex.value = pendingSearch.regex;
|
|
1691
|
+
searchNarrow.value = pendingSearch.narrow;
|
|
1692
|
+
searchOn.value = true;
|
|
1693
|
+
runSearch(pendingSearch.q);
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
// R11 导出增强:三件事 —— ① 多格式(JSON / CSV / Excel);② CSV/Excel 带上导出时的筛选条件,
|
|
1697
|
+
// 能自证「按什么条件导出」(验收)③ 单条详情导出(含完整未截断内容)。不再硬截断 5000 条,
|
|
1698
|
+
// 全量导出当前筛选集(filtered),JSON/CSV/Excel 一致。序列化与下载在 shared.js(零依赖纯函数)。
|
|
1699
|
+
function exportFilename(ext) {
|
|
1700
|
+
// 用**本地时间**:文件名要和筛选区间(本地自然日)、卡面上的时间对得上;toISOString 落的是 UTC,
|
|
1701
|
+
// 东八区会出现「9 点导出的文件叫 01 点」。
|
|
1702
|
+
const d = new Date(), p = n => String(n).padStart(2, '0');
|
|
1703
|
+
return 'agent-acta-' + d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate())
|
|
1704
|
+
+ p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds()) + '.' + ext;
|
|
1705
|
+
}
|
|
1706
|
+
// 「按什么条件导出」的完整说明:时间 / agent / 项目 / 状态 / 关键字 / 只看异常 / 条·页。
|
|
1707
|
+
// 服务于导出附注(表头、说明行与 JSON 的 meta)。字段名与筛选含义照实写,不自欺。
|
|
1708
|
+
function exportFilterText() {
|
|
1709
|
+
const parts = [];
|
|
1710
|
+
if (fDates.value) parts.push('时间: ' + fDates.value.join(' ~ '));
|
|
1711
|
+
else parts.push('时间: 全部');
|
|
1712
|
+
if (fAgent.value) parts.push('agent: ' + fAgent.value);
|
|
1713
|
+
else if (fAgents.value.length) parts.push('agent: ' + fAgents.value.join(', '));
|
|
1714
|
+
else parts.push('agent: 全部');
|
|
1715
|
+
const projLab = fProject.value ? fProject.value : (fProjects.value.length ? fProjects.value.join(', ') : '全部');
|
|
1716
|
+
parts.push('项目: ' + projLab);
|
|
1717
|
+
parts.push('状态: ' + (fStatus.value || '全部'));
|
|
1718
|
+
parts.push('关键字: ' + (fSearch.value || '(无)'));
|
|
1719
|
+
if (alertOnly.value) parts.push('只看异常: 是');
|
|
1720
|
+
parts.push('显示档位: ' + fLimit.value + ' 条/页');
|
|
1721
|
+
return parts;
|
|
1722
|
+
}
|
|
1723
|
+
// 单条导出行的公共字段(卡片条目上全都有)。CSV 列即这些标量值。
|
|
1724
|
+
const EXPORT_FIELDS = ['time', 'agent', 'project', 'session', 'status', 'preview', 'models', 'dur', 'tin', 'tout', 'tcache', 'total', 'calls', 'tools', 'nollm'];
|
|
1725
|
+
function exportRows() {
|
|
1726
|
+
return filtered.value.map(e => ({
|
|
1727
|
+
time: fmtTime(e.time), agent: e.agent == null ? '' : e.agent, project: e.project == null ? '' : e.project,
|
|
1728
|
+
session: e.session == null ? '' : e.session, status: e.status == null ? '' : e.status,
|
|
1729
|
+
preview: e.preview == null ? '' : e.preview, models: modelText(e.models),
|
|
1730
|
+
dur: e.dur == null ? '' : e.dur, tin: e.tin || 0, tout: e.tout || 0, tcache: e.tcache || 0, total: e.total || 0,
|
|
1731
|
+
calls: e.calls == null ? '' : e.calls, tools: e.tools == null ? '' : e.tools, nollm: e.nollm ? '是' : '',
|
|
1732
|
+
}));
|
|
1733
|
+
}
|
|
1734
|
+
function exportData(format) {
|
|
1735
|
+
const rows = exportRows();
|
|
1736
|
+
const note = exportFilterText();
|
|
1737
|
+
const f = String(format || 'json').toLowerCase();
|
|
1738
|
+
if (f === 'csv') {
|
|
1739
|
+
const lines = [['筛选条件: ' + note.join(';')], ['# 共 ' + rows.length + ' 条'],
|
|
1740
|
+
EXPORT_FIELDS];
|
|
1741
|
+
for (const r of rows) lines.push(EXPORT_FIELDS.map(k => r[k]));
|
|
1742
|
+
downloadBlob(new Blob([buildCSV(lines)], { type: 'text/csv;charset=utf-8' }), exportFilename('csv'));
|
|
1743
|
+
} else if (f === 'xls' || f === 'excel') {
|
|
1744
|
+
const header = EXPORT_FIELDS;
|
|
1745
|
+
const xls = buildExcelXLSText('agent-acta', note, header, rows);
|
|
1746
|
+
downloadBlob(new Blob([xls], { type: 'application/vnd.ms-excel;charset=utf-8' }), exportFilename('xls'));
|
|
1747
|
+
} else {
|
|
1748
|
+
const payload = { meta: { exportedAt: new Date().toISOString(), count: rows.length, filters: note }, entries: rows };
|
|
1749
|
+
downloadBlob(new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }), exportFilename('json'));
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
// 保持 exportJSON 名(历史事件仍叫 export-json),转发到 JSON 分支;缺省参数按 json。
|
|
1753
|
+
function exportJSON(format) { exportData(format || 'json'); }
|
|
1754
|
+
|
|
1755
|
+
// R11 导出单条详情:拉取 /api/entry 的**完整**版(full=1,未截断),连同条目元信息一起导出为 JSON。
|
|
1756
|
+
// 详情里工具入参/返回/逐次输出都是完整内容(服务端 full=1 就是为此存在的)。异步拉取,失败给提示。
|
|
1757
|
+
async function exportEntryDetail(id) {
|
|
1758
|
+
const e = id == null ? null : all.value.find(x => String(x.id) === String(id));
|
|
1759
|
+
if (!e) { ElementPlus.ElMessage.warning('无法导出:找不到该条目'); return; }
|
|
1760
|
+
try {
|
|
1761
|
+
const d = await getJSON('/api/entry?id=' + encodeURIComponent(e.id) + '&full=1');
|
|
1762
|
+
const payload = {
|
|
1763
|
+
meta: {
|
|
1764
|
+
exportedAt: new Date().toISOString(),
|
|
1765
|
+
id: e.id, agent: e.agent, project: e.project, session: e.session,
|
|
1766
|
+
time: fmtTime(e.time), status: e.status, durationMs: e.dur, models: modelText(e.models),
|
|
1767
|
+
totalTokens: e.total || 0, note: '单条完整详情导出(full=1,内容未截断)',
|
|
1768
|
+
},
|
|
1769
|
+
entry: d,
|
|
1770
|
+
};
|
|
1771
|
+
downloadBlob(new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }), exportFilename('entry-' + String(e.id).replace(/[^A-Za-z0-9_\-.]/g, '_').slice(0, 40) + '.json'));
|
|
1772
|
+
} catch (err) {
|
|
1773
|
+
ElementPlus.ElMessage.error('详情导出失败:' + (err && err.message ? err.message : String(err)));
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
// I8 单轮「复现包」:一键导出 zip(零依赖手写,见 shared.js 的 makeZip),内含:
|
|
1778
|
+
// repro-meta.json —— 条目元信息 + build 指纹(version/build/pageBuild/parserRev)+ 导出时的筛选条件
|
|
1779
|
+
// repro-raw.jsonl —— 该轮在原始日志里的片段(按解析器同款开轮判定切出;不可提取的 kind 诚实说明)
|
|
1780
|
+
// repro-parsed.json —— 该轮完整解析结果(full=1,含工具明细、逐次调用)
|
|
1781
|
+
// README.txt —— 这个包是什么、怎么用、原始片段怎么定位
|
|
1782
|
+
// 服务端没有筛选态,所以「当前筛选条件」由页面贡献(exportFilterText),原样嵌进 meta.filters。
|
|
1783
|
+
async function exportRepro(id) {
|
|
1784
|
+
const e = id == null ? null : all.value.find(x => String(x.id) === String(id));
|
|
1785
|
+
if (!e) { ElementPlus.ElMessage.warning('无法导出复现包:找不到该条目'); return; }
|
|
1786
|
+
try {
|
|
1787
|
+
const filters = exportFilterText();
|
|
1788
|
+
const url = '/api/repro?id=' + encodeURIComponent(e.id) + '&filters=' + encodeURIComponent(JSON.stringify(filters));
|
|
1789
|
+
const data = await getJSON(url);
|
|
1790
|
+
if (!data || !data.ok) throw new Error((data && data.error) || '服务端返回异常');
|
|
1791
|
+
const safeId = String(e.id).replace(/[^A-Za-z0-9_\-.]/g, '_').slice(0, 40);
|
|
1792
|
+
const rawName = 'repro-raw.jsonl'; // 里面就是磁盘上那几行原文(逐行 JSON),所以带 .jsonl
|
|
1793
|
+
const rawText = (data.raw && data.raw.lines)
|
|
1794
|
+
? data.raw.lines.join('\n')
|
|
1795
|
+
: ('# 原始片段不可用:' + ((data.raw && data.raw.note) || '未知原因') +
|
|
1796
|
+
(data.raw && data.raw.file ? '\n# 原始路径:' + data.raw.file : ''));
|
|
1797
|
+
const readme =
|
|
1798
|
+
'AgentActa 单轮「复现包」\n' +
|
|
1799
|
+
'====================\n' +
|
|
1800
|
+
'这是某一轮 agent 请求日志的一键复现包,用于贴进聊天 / 提 bug 时带上完整上下文。\n\n' +
|
|
1801
|
+
'包含文件:\n' +
|
|
1802
|
+
'- repro-meta.json 条目元信息 + 服务/页面 build 指纹(version/build/pageBuild/parserRev)+ 导出时的筛选条件\n' +
|
|
1803
|
+
'- repro-raw.jsonl 该轮在原始日志文件里的原始片段(行号与磁盘一致,可直接拷出复现)\n' +
|
|
1804
|
+
'- repro-parsed.json 该轮完整解析结果(含工具明细、逐次调用,未截断)\n' +
|
|
1805
|
+
'- README.txt 本说明\n\n' +
|
|
1806
|
+
'筛选条件(导出那一刻的页面状态,自证「当时按什么条件看的」):\n' +
|
|
1807
|
+
(filters && filters.length ? filters.map(f => ' - ' + f).join('\n') : ' (无)') + '\n\n' +
|
|
1808
|
+
'原始片段说明:' + ((data.raw && data.raw.note) || '') + '\n';
|
|
1809
|
+
const files = [
|
|
1810
|
+
{ name: 'repro-meta.json', text: JSON.stringify(data.meta, null, 2) },
|
|
1811
|
+
{ name: rawName, text: rawText },
|
|
1812
|
+
{ name: 'repro-parsed.json', text: JSON.stringify(data.parsed, null, 2) },
|
|
1813
|
+
{ name: 'README.txt', text: readme },
|
|
1814
|
+
];
|
|
1815
|
+
downloadBlob(makeZip(files), exportFilename('repro-' + safeId + '.zip'));
|
|
1816
|
+
ElementPlus.ElMessage.success('复现包已导出(' + (rawText.startsWith('#') ? '原始片段不可用,' : '') + '含解析结果 + build 指纹 + 筛选条件)');
|
|
1817
|
+
} catch (err) {
|
|
1818
|
+
ElementPlus.ElMessage.error('复现包导出失败:' + (err && err.message ? err.message : String(err)));
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
// ----- 用量统计(C2;R25 批 1 起模板与统计逻辑全在 page/dialog-stats.js)-----
|
|
1823
|
+
// 数据来自 /api/daily:服务端在同一套筛选集(agent / 项目 / 时间范围)上按自然日分桶,
|
|
1824
|
+
// 且**不受「条/页」窗口限制**——所以这里的合计常常比列表标题那句「共 N 条」大,那不是 bug,
|
|
1825
|
+
// 是「统计口径不跟着显示档位变」的刻意选择(按天聚合要是被 2000 条窗口截断,换个档位数字就变,没法信)。
|
|
1826
|
+
// 根只剩三样:开不开(statsVisible)、递什么(statsReq)、弹层里换 agent 走侧栏那条路(onStatsAgentChange)。
|
|
1827
|
+
// 统计图与列表用的是**同一份**条目,只是聚合维度不同:统计里没有「状态 / 搜索」这两个**客户端**筛选
|
|
1828
|
+
// (它们是「在当前窗口里挑」,不是「看某个东西的全部」),要按它们统计得先说清楚语义。
|
|
1829
|
+
const statsVisible = ref(false);
|
|
1830
|
+
const statsReq = ref(null);
|
|
1831
|
+
let statsReqN = 0; // 只用来给 req 一个身份:开着弹层换 agent 时 visible 不变,组件靠 n 变化重算
|
|
1832
|
+
|
|
1833
|
+
// 「统计的是哪一份数据」必须写清楚:用户刚在侧栏点过 agent / 项目,看到的数字得能对上他那一下点击。
|
|
1834
|
+
// 留在根是因为它读的是侧栏那份活状态(含项目分组名)——组件那边没有 projectGroups。
|
|
1835
|
+
function statsFilterText() {
|
|
1836
|
+
const parts = [fAgent.value ? 'agent = ' + fAgent.value
|
|
1837
|
+
: (fAgents.value.length ? 'agent ∈ ' + fAgents.value.join(', ') : '全部 agent')]; // R10:多选态如实写进统计口径
|
|
1838
|
+
if (fProject.value) {
|
|
1839
|
+
const g = projectGroups.value.find(x => x.key === fProject.value);
|
|
1840
|
+
parts.push('项目 = ' + (g ? g.label : fProject.value));
|
|
1841
|
+
} else parts.push('全部项目');
|
|
1842
|
+
return parts.join(' · ');
|
|
1843
|
+
}
|
|
1844
|
+
// 点击那一刻把「筛哪一份」拼成请求参数递给弹层(组件自己不认识侧栏状态)。
|
|
1845
|
+
// keepRange:弹层里换 agent 时保住手选的日期区间(同一段时间跨 agent 对比才有意义)。
|
|
1846
|
+
function openStats(keepRange) {
|
|
1847
|
+
const qs = new URLSearchParams();
|
|
1848
|
+
if (fDates.value) { qs.set('from', fDates.value[0]); qs.set('to', fDates.value[1]); }
|
|
1849
|
+
// R22:时间范围与列表同一区间(显式 from/to,customBounds 口径),统计跟随列表筛选,不再各自为政。
|
|
1850
|
+
// R10:多选态走 agents=/projects= 合集键(与单选互斥,同 snapUrl)
|
|
1851
|
+
if (fAgent.value) qs.set('agent', fAgent.value);
|
|
1852
|
+
else if (fAgents.value.length) qs.set('agents', fAgents.value.join(','));
|
|
1853
|
+
if (fProject.value) qs.set('project', fProject.value);
|
|
1854
|
+
else if (fProjects.value.length) qs.set('projects', fProjects.value.join(','));
|
|
1855
|
+
statsReq.value = { qs: qs.toString(), filterText: statsFilterText(), keepRange: !!keepRange, n: ++statsReqN };
|
|
1856
|
+
statsVisible.value = true;
|
|
1857
|
+
}
|
|
1858
|
+
// 弹层里的 agent 下拉与左侧栏是**同一个**筛选:改了它,背后的请求列表也跟着变,然后重算统计(保住日期区间)
|
|
1859
|
+
async function onStatsAgentChange(name) {
|
|
1860
|
+
await pickAgent(name || '');
|
|
1861
|
+
openStats(true);
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
// R27 活跃热力图:根只管开不开(侧栏项与弹层关闭按钮都要动它),数据与聚合全在组件里
|
|
1865
|
+
const heatmapVisible = ref(false);
|
|
1866
|
+
// R28 历史归档:同上(数据源是归档文件,连筛选都不跟随)
|
|
1867
|
+
const archiveVisible = ref(false);
|
|
1868
|
+
// R30 磁盘占用:同上(自己拉 /api/usage,走一遍磁盘的只读统计)
|
|
1869
|
+
const usageVisible = ref(false);
|
|
1870
|
+
// R32 解析器自检:同上(自己拉 /api/selftest,只读上一次回归跑的结果 —— 跑测试不在页面里)
|
|
1871
|
+
const selftestVisible = ref(false);
|
|
1872
|
+
// R9 异常巡检:visible 控制弹层开合;alertCount 是侧栏徽章数;alertHits 是「命中 id 集合」
|
|
1873
|
+
//(供「只看异常」筛选与弹层定位用,随 SSE alert 事件 / hello / 弹层保存回报而更新)。
|
|
1874
|
+
const alertVisible = ref(false);
|
|
1875
|
+
const alertCount = ref(0);
|
|
1876
|
+
const alertHits = ref(new Set());
|
|
1877
|
+
const alertOnly = ref(false); // 「只看异常」开关:true 时 filtered 只留 alertHits 里的条目
|
|
1878
|
+
function applyAlertSummary(s) { if (s && typeof s.count === 'number') alertCount.value = s.count; }
|
|
1879
|
+
function applyAlertIds(ids) { alertHits.value = new Set(ids || []); }
|
|
1880
|
+
|
|
1881
|
+
// ---- I9 两轮 diff ----
|
|
1882
|
+
// 卡片「对比」按钮的选中组:id 集合(最多 2 条)。选满两条自动弹开对比弹层,并清掉选中。
|
|
1883
|
+
// compareReq 与选中组分开持有:关掉弹层不清它,所以重选同一对时不会先闪一下空态。
|
|
1884
|
+
// ⚠ 弹层里**没有**「换条目」入口 —— 要换就得回列表重选两条(这条是设计边界,不是漏接线)。
|
|
1885
|
+
const compareSet = ref(new Set());
|
|
1886
|
+
const compareVisible = ref(false);
|
|
1887
|
+
const compareReq = ref(null);
|
|
1888
|
+
// 判一条在不在对比组里(作函数 prop 递给卡片,读的是根这份 Set)
|
|
1889
|
+
const compareIsOf = id => compareSet.value.has(id);
|
|
1890
|
+
function onCompare(e) {
|
|
1891
|
+
const id = e.id;
|
|
1892
|
+
const s = new Set(compareSet.value);
|
|
1893
|
+
if (s.has(id)) { s.delete(id); compareSet.value = s; return; } // 取消选中
|
|
1894
|
+
if (s.size >= 2) { ElMessage.warning('一次只对比两条轮,先把已选的取消一条'); return; }
|
|
1895
|
+
s.add(id); compareSet.value = s;
|
|
1896
|
+
if (s.size === 2) { // 凑满两条 → 弹开 + 清选中
|
|
1897
|
+
const [aid, bid] = [...s];
|
|
1898
|
+
const src = searchOn.value ? searchHits.value : all.value;
|
|
1899
|
+
const a = src.find(x => x.id === aid), b = src.find(x => x.id === bid);
|
|
1900
|
+
// 选第一条与点第二条之间列表会换(SSE 增量、改筛选、切进搜索结果集)—— 那条可能就掉出去了。
|
|
1901
|
+
// 宁可留着刚点的这条让人重选,也不要把 {a:undefined,b:undefined} 递进弹层:
|
|
1902
|
+
// 那会弹出一个写着「还没有选够两条轮」的空层,看着像功能坏了。
|
|
1903
|
+
if (!a || !b) {
|
|
1904
|
+
compareSet.value = new Set([id]);
|
|
1905
|
+
ElMessage.warning('先选的那条已不在当前列表里,请重新选两条');
|
|
1906
|
+
return;
|
|
1907
|
+
}
|
|
1908
|
+
compareReq.value = { a, b };
|
|
1909
|
+
compareVisible.value = true;
|
|
1910
|
+
compareSet.value = new Set();
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
return {
|
|
1915
|
+
all, booting, agents, versionMismatch, pageBuild: PAGE_BUILD_OK ? PAGE_BUILD : '', fAgent, fSearch, fProject, fDates, fStatus, fLimit, shown, STEP, showNoLLM, rangeTotal, failCount,
|
|
1916
|
+
renderMd, prettyJson, copyRaw, toolJsonFold, toggleJsonFold, callOpen, toggleCallOut, toolText, fullIds,
|
|
1917
|
+
sessVisible, sessReq, openSession, fmtDurBig,
|
|
1918
|
+
locateEntry,
|
|
1919
|
+
// R33 全文搜索:状态与两个动作交给 aa-log-view 渲染与转发(数据本身仍在根里,见头部契约)
|
|
1920
|
+
searchOn, searchQ, searchRegex, searchBusy, searchNarrow, searchError, searchMeta, searchCapped, searchHits,
|
|
1921
|
+
runSearch, clearSearch, toggleSearchNarrow,
|
|
1922
|
+
pickAgent, pickProject, pickRange, updateHash,
|
|
1923
|
+
statsVisible, statsReq, openStats, onStatsAgentChange, TrendCharts,
|
|
1924
|
+
heatmapVisible,
|
|
1925
|
+
archiveVisible,
|
|
1926
|
+
usageVisible,
|
|
1927
|
+
selftestVisible,
|
|
1928
|
+
alertVisible,
|
|
1929
|
+
alertCount,
|
|
1930
|
+
alertOnly,
|
|
1931
|
+
applyAlertSummary,
|
|
1932
|
+
alertHits,
|
|
1933
|
+
// I9 两轮 diff:选中组、弹层开关、递给弹层的两条、判选 + 收集回调
|
|
1934
|
+
compareVisible, compareReq, compareSet, compareIsOf, onCompare,
|
|
1935
|
+
expandedId, live, liveText, scanning, zhCn, filtered, visible, projectList, sideAgents,
|
|
1936
|
+
scanMs, scanOptions, paused, setScan,
|
|
1937
|
+
idleExitMs, idleOptions, idleText, setIdle,
|
|
1938
|
+
clientLaunching, launchClient,
|
|
1939
|
+
agentsMeta, addVisible, toggleLoading, capped,
|
|
1940
|
+
// Trae 解密按钮(模板里 v-if / @command / :disabled 直接用了这几个名字):漏在这儿的话
|
|
1941
|
+
// _ctx.isTraeKeyAgent 是 undefined,v-if 一调用就抛 TypeError,整个 app.mount 失败 —— 表现是**整页白屏**,
|
|
1942
|
+
// 而且是渲染期崩的,顶栏的版本错配提示也不会出现,别往缓存/旧进程上查。
|
|
1943
|
+
isTraeKeyAgent, traeBusy, traeCommandUI, startTraeCapture, clearTraeKey,
|
|
1944
|
+
// Cursor token 采集 hook 按钮(同上:模板里 v-if / :class / @click 直接用了这几个名字,
|
|
1945
|
+
// 漏在这儿同样会整页白屏)
|
|
1946
|
+
isCursorHookAgent, cursorHookBusy, cursorHookState, toggleCursorHook,
|
|
1947
|
+
// R10 多选筛选:fAgents / pickAgentsMulti 是活的(661/663 行的 v-model 与 @change),
|
|
1948
|
+
// fProjects / pickProjectsMulti 目前只在注释掉的那段里(项目筛选 2026-09-17 起停用),一起导出是为了恢复时不用再想这件事。
|
|
1949
|
+
// 漏了不白屏,但多选一直不工作,统计弹窗里的 fAgents.length 还会抛同样的 TypeError。
|
|
1950
|
+
fAgents, fProjects, pickAgentsMulti, pickProjectsMulti,
|
|
1951
|
+
secs, toggleSec, collapseCard, cardMemo,
|
|
1952
|
+
fmtN, fmtDur, fmtTime, agentLetter, ctxUsed, ctxPct, detailOf, titleOf, modelText, toolList,
|
|
1953
|
+
isQoder, fmtCredits, fmtPct,
|
|
1954
|
+
agentColor, agentIcon, iconOf, pathTip, isDisabled, isMissing, isIconOnly, isLive,
|
|
1955
|
+
loadSnapshot, refresh, toggle, loadFull, reloadDetail, openAdd, onAgentAdded, removeAgentUI, toggleAgentUI, exportJSON, exportData, exportEntryDetail, exportRepro,
|
|
1956
|
+
diagVisible,
|
|
1957
|
+
ctxwVisible, refreshCtxUnfilled, ctxUnfilled,
|
|
1958
|
+
// R6 会话视图(view='sess'):状态、派生量、以及那几个动作都要在这里露出去
|
|
1959
|
+
view, switchView, loadSessions, sessList, sessListLoading, sessListCapped, sessListError,
|
|
1960
|
+
sessSearch, sessSort, sessListFiltered, sessPick, pickSession, openSessionByKey, sessCur, sessCurLoading,
|
|
1961
|
+
sessOpen, sessToggleTurn, sessDetail, sessFullIds, sessLoadFull, sessHi, sessEventClick,
|
|
1962
|
+
sessCursor, sessSpan, sessFrom, sessTo, sessHasEvents, sessOvTurns, sessOvEvents, sessEvIndex, sessEvRows, sessOvClick,
|
|
1963
|
+
sessChart, sessChartOpen, sessChartGo, sessCollapseTurn,
|
|
1964
|
+
fmtAgo, sumTok,
|
|
1965
|
+
Search, Refresh, Download, QuestionFilled, Warning, DataLine,
|
|
1966
|
+
};
|
|
1967
|
+
},
|
|
1968
|
+
});
|
|
1969
|
+
|
|
1970
|
+
app.use(ElementPlus);
|
|
1971
|
+
if (ElementPlusIconsVue) {
|
|
1972
|
+
for (const [k, c] of Object.entries(ElementPlusIconsVue)) app.component(k, c);
|
|
1973
|
+
}
|
|
1974
|
+
// R25 批 1/2/3:组件注册一律在这儿做(方案 C 拍板)—— 根 script 最后执行,时序天然安全,
|
|
1975
|
+
// 组件之间互相引用(组件模板里再写组件标签)不需要各自的 components: 声明,也就没有
|
|
1976
|
+
// 「文件加载顺序错 → 引用到 undefined → prod 静默空白」这个坑。名字必须与组件文件的 name 一致
|
|
1977
|
+
// (test/page-components-test.mjs 两头对)。
|
|
1978
|
+
app.component('aa-dialog-diag', AACTA.DialogDiag);
|
|
1979
|
+
app.component('aa-dialog-add-agent', AACTA.DialogAddAgent);
|
|
1980
|
+
app.component('aa-dialog-ctxw', AACTA.DialogCtxw);
|
|
1981
|
+
app.component('aa-dialog-sess-timeline', AACTA.DialogSessTimeline);
|
|
1982
|
+
app.component('aa-dialog-stats', AACTA.DialogStats);
|
|
1983
|
+
app.component('aa-dialog-heatmap', AACTA.DialogHeatmap);
|
|
1984
|
+
app.component('aa-dialog-archive', AACTA.DialogArchive);
|
|
1985
|
+
app.component('aa-dialog-usage', AACTA.DialogUsage);
|
|
1986
|
+
app.component('aa-dialog-selftest', AACTA.DialogSelftest);
|
|
1987
|
+
app.component('aa-dialog-alert', AACTA.DialogAlert);
|
|
1988
|
+
app.component('aa-dialog-compare', AACTA.DialogCompare);
|
|
1989
|
+
app.component('aa-topbar', AACTA.Topbar);
|
|
1990
|
+
app.component('aa-sidebar', AACTA.Sidebar);
|
|
1991
|
+
app.component('aa-sess-view', AACTA.SessView);
|
|
1992
|
+
// 批 4:卡片先登记(aa-log-view 的模板里用它)—— 注册都在 app.mount 之前,解析时机安全
|
|
1993
|
+
app.component('aa-log-card', AACTA.LogCard);
|
|
1994
|
+
app.component('aa-log-view', AACTA.LogView);
|
|
1995
|
+
app.mount('#app');
|
|
1996
|
+
</script>
|
|
1997
|
+
</body>
|
|
1998
|
+
</html>
|