@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,694 @@
|
|
|
1
|
+
// 用量统计弹层(R25 批 1 第五个组件,也是批 1 里最重的一个)。
|
|
2
|
+
// 契约见 page/shared.js 头部「R25 组件文件契约」;顶层只许有那一处 AACTA 赋值,Vue / Element Plus 的 API 写全名。
|
|
3
|
+
//
|
|
4
|
+
// 数据进出 —— 这是批 1 里唯一「一个弹层装三个 tab」的:
|
|
5
|
+
// · visible ← 根(顶栏 TrendCharts 按钮 / 弹层自己关闭时回写)
|
|
6
|
+
// · req ← 根:{ qs, filterText, keepRange, n },含义见下
|
|
7
|
+
// · fAgent / fAgents / sideAgents ← 根:弹层里那个 agent 下拉要与左侧栏**同一个**筛选,
|
|
8
|
+
// 所以值不从这儿算,直接把根的活状态递进来(多选态要禁用,见模板上的 R10 注释)
|
|
9
|
+
// · getJSON ← 根 provide(404 要点亮版本红条)
|
|
10
|
+
// · 天桶 / 可视窗 / 按模型 / 时延 —— 全是弹层自己的
|
|
11
|
+
//
|
|
12
|
+
// 为什么 req 里带一个自增的 n 而不是「visible 变化就 load」:
|
|
13
|
+
// 弹层里的 agent 下拉会**在开着的时候**改筛选(根 pickAgent 后重新发一份 req),
|
|
14
|
+
// visible 全程是 true,光看 visible 就漏了这一次。n 每次都变,watch 它最省事。
|
|
15
|
+
// qs 是根在点击那一刻按活筛选拼好的(含 from/to + agent(s) + project(s)),
|
|
16
|
+
// filterText 同理 —— 数字显示期间用户改了筛选,口径说明也得跟着那份数字走,不能现算。
|
|
17
|
+
window.AACTA = window.AACTA || {};
|
|
18
|
+
AACTA.DialogStats = {
|
|
19
|
+
name: 'aa-dialog-stats',
|
|
20
|
+
template: `
|
|
21
|
+
<el-dialog :model-value="visible" @update:model-value="v => $emit('update:visible', v)" title="用量统计(按天)" width="min(1080px, 94vw)" append-to-body class="st-dialog">
|
|
22
|
+
<div class="st-body">
|
|
23
|
+
<div class="st-note">
|
|
24
|
+
统计区间:<b>{{ statsRange ? statsRange[0] + ' ~ ' + statsRange[1] : '—' }}</b> · {{ stats.filterText }} · 区间内共 <b>{{ statsViewTotals.count }}</b> 条
|
|
25
|
+
—— 卡片、明细、按模型都是这段区间内的数字;改区间只用上方日期选择器(不受右上角「条/页」影响)。
|
|
26
|
+
<span v-if="stats.label !== '全部时间'">(可统计的数据本身受左侧「时间范围 = {{ stats.label }}」限制)</span>
|
|
27
|
+
<!-- 跨度大到只统计了最近 400 天时说出来:上限本身是防病态跨度的,但少算了哪一段必须让用户知道 -->
|
|
28
|
+
<span v-if="stats.daysCapped" style="color:#c26a1b">⚠ 跨度超过 400 天,只统计了最近 400 天。</span>
|
|
29
|
+
<!-- I16/D2:时间未知的轮不进这张按天图(它们没有可用的时间戳)。不说明的话,
|
|
30
|
+
合计会比上面那个条数少一截,读起来像漏算 —— 那是把「不知道」读成了「丢了」。 -->
|
|
31
|
+
<span v-if="stats.timeUnknown" style="color:#c26a1b" title="源里没有这些轮的时间戳(atomcode 的 .jsonl 0 字节 / turn_id 在 jsonl 里不存在)。它们仍在列表与卡片里,只是不参与按天统计与时间排序——不拿会话更新时刻冒充轮时间。">⚠ 另有 <b>{{ stats.timeUnknown }}</b> 轮时间未知,未计入下面的按天统计。</span>
|
|
32
|
+
</div>
|
|
33
|
+
<el-skeleton v-if="stats.loading" :rows="7" animated></el-skeleton>
|
|
34
|
+
<el-alert v-else-if="stats.error" :title="stats.error" type="error" :closable="false" show-icon></el-alert>
|
|
35
|
+
<template v-else>
|
|
36
|
+
<div class="st-tiles">
|
|
37
|
+
<div v-for="t in statsTiles" :key="t.k" class="st-tile">
|
|
38
|
+
<div class="tl"><span v-if="t.color" class="st-chip" :style="{background:t.color}"></span>{{ t.label }}</div>
|
|
39
|
+
<div class="tv">{{ fmtN(t.value) }}</div>
|
|
40
|
+
<div class="ts">{{ t.sub }}</div>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
<div class="st-line">
|
|
44
|
+
<!-- 条数在顶部 st-note 已说过「区间内共 N 条」,这里不重复 -->
|
|
45
|
+
<span class="st-meta">{{ statsViewTotals.calls }} 次 LLM 调用 · {{ statsViewTotals.tools }} 个工具 · 总耗时 {{ fmtDurBig(statsViewTotals.dur) }}</span>
|
|
46
|
+
<!-- R10:多选态(fAgents 非空且无单选)时下拉禁用为「跟随列表筛选」,避免与主列表多选打架 -->
|
|
47
|
+
<el-select :model-value="fAgent" size="small" style="width:132px;flex:none"
|
|
48
|
+
:disabled="!fAgent && fAgents.length > 0"
|
|
49
|
+
:title="(!fAgent && fAgents.length) ? '当前为 agent 多选筛选(' + fAgents.join(', ') + '),统计跟随列表' : '只看某个 agent —— 与左侧栏是同一个筛选(背后的请求列表也跟着变);切换时保留当前日期区间'"
|
|
50
|
+
@change="onAgentChange">
|
|
51
|
+
<el-option label="全部 agent" value=""></el-option>
|
|
52
|
+
<el-option v-for="n in sideAgents" :key="n" :label="n" :value="n"></el-option>
|
|
53
|
+
</el-select>
|
|
54
|
+
<el-date-picker v-model="statsRange" type="daterange" size="small" value-format="YYYY-MM-DD"
|
|
55
|
+
range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" :clearable="false"
|
|
56
|
+
:disabled-date="statsDisabledDate" :shortcuts="statsShortcuts"
|
|
57
|
+
style="width:240px" title="统计区间:从几号到几号;卡片、明细、按模型都跟着这段区间变"></el-date-picker>
|
|
58
|
+
<el-radio-group v-model="stats.withCache" size="small">
|
|
59
|
+
<el-radio-button :value="false">输入 + 输出</el-radio-button>
|
|
60
|
+
<el-radio-button :value="true">含缓存</el-radio-button>
|
|
61
|
+
</el-radio-group>
|
|
62
|
+
</div>
|
|
63
|
+
<!-- 读数独占一行(不是跟上面挤一条会折行的 flex 行):见 .st-read 的注释 -->
|
|
64
|
+
<div class="st-read" :title="statsReadout">{{ statsReadout }}</div>
|
|
65
|
+
<div class="st-chart">
|
|
66
|
+
<div v-for="b in statsBars" :key="b.day" class="st-bar" :class="{on: hoverDay===b.day}"
|
|
67
|
+
@mouseenter="hoverDay = b.day" @mouseleave="hoverDay = ''" :title="b.tip">
|
|
68
|
+
<!-- DOM 顺序 = 视觉从上到下:缓存 → 输出 → 输入(输入贴基线)。段间的 2px 缝只在**够高**的段之间留,
|
|
69
|
+
否则 3px 的下限里再挖掉 2px 就只剩一条 1px 的线了 -->
|
|
70
|
+
<div v-if="b.hCache" class="st-seg" :class="{top:true, sep:b.hOut>ST_GAP}" :style="{height:b.hCache+'%',background:'var(--s-cache)'}"></div>
|
|
71
|
+
<div v-if="b.hOut" class="st-seg" :class="{top:!b.hCache, sep:b.hIn>ST_GAP, hit:true}" :style="{height:b.hOut+'%',background:'var(--s-out)'}"></div>
|
|
72
|
+
<div v-if="b.hIn" class="st-seg" :class="{top:!b.hCache && !b.hOut, hit:true}" :style="{height:b.hIn+'%',background:'var(--s-in)'}"></div>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
<div class="st-x">
|
|
76
|
+
<span v-for="(b, i) in statsBars" :key="b.day">{{ i % statsTickEvery === 0 ? b.short : '' }}</span>
|
|
77
|
+
</div>
|
|
78
|
+
<div class="st-legend">
|
|
79
|
+
<span v-if="stats.withCache" class="li"><i class="st-chip" style="background:var(--s-cache)"></i>缓存命中</span>
|
|
80
|
+
<span class="li"><i class="st-chip" style="background:var(--s-out)"></i>输出</span>
|
|
81
|
+
<span class="li"><i class="st-chip" style="background:var(--s-in)"></i>输入(不含缓存)</span>
|
|
82
|
+
<span style="margin-left:auto">每根柱子 = 一天;悬停看当天明细</span>
|
|
83
|
+
</div>
|
|
84
|
+
<div class="st-tabs">
|
|
85
|
+
<span class="st-tab" :class="{on: stats.tab==='day'}" @click="stats.tab='day'">按天明细({{ stats.days.length }})</span>
|
|
86
|
+
<span class="st-tab" :class="{on: stats.tab==='model'}" @click="stats.tab='model'">按模型({{ statsModels.length }})</span>
|
|
87
|
+
<!-- I15 工作指纹:git 分支 / 权限模式 / 产品版本。切入时才拉 /api/work(复用当前筛选),
|
|
88
|
+
不随统计打开自动请求 —— 与「时延分析」同款懒加载 -->
|
|
89
|
+
<span class="st-tab" :class="{on: stats.tab==='work'}" @click="stats.tab='work'; loadWork()">工作指纹</span>
|
|
90
|
+
<!-- R8:时延分析 tab,切入时才拉 /api/analyze(复用当前筛选),不随统计打开自动请求 -->
|
|
91
|
+
<span class="st-tab" :class="{on: stats.tab==='lat'}" @click="stats.tab='lat'; loadAnalyze()">时延分析</span>
|
|
92
|
+
</div>
|
|
93
|
+
<div class="st-pane">
|
|
94
|
+
<template v-if="stats.tab==='day'">
|
|
95
|
+
<div class="st-note">
|
|
96
|
+
<template v-if="statsHeavyNote">⚠ <b>{{ statsHeavyNote }}</b><br></template>
|
|
97
|
+
耗时 = 各轮「首条记录 → 末条记录」的时间差之和,<b>不是真实工作时间</b>。
|
|
98
|
+
<el-popover placement="top-start" :width="400" trigger="hover">
|
|
99
|
+
<template #reference><span class="st-info">?</span></template>
|
|
100
|
+
挂机、隔夜的轮会把一整天算爆(本机实测有单轮 63 小时)。表里「耗时」列打 ⚠ 的天,
|
|
101
|
+
当天最长单轮超过全天总耗时的一半(且全天 ≥ 1 小时才标记,样本太小不算)——那种天别当工作量看。
|
|
102
|
+
</el-popover>
|
|
103
|
+
</div>
|
|
104
|
+
<div class="st-table">
|
|
105
|
+
<table>
|
|
106
|
+
<thead><tr><th>日期</th><th>条目</th><th>输入</th><th>输出</th><th>缓存命中</th><th>合计</th><th title="各轮「首条→末条记录」时间差之和。打 ⚠ = 当天最长单轮超过全天一半(多为挂机/隔夜)">耗时</th></tr></thead>
|
|
107
|
+
<tbody>
|
|
108
|
+
<tr v-for="b in statsTableRows" :key="b.day" :class="{zero: !b.count}">
|
|
109
|
+
<td>{{ b.day }}</td>
|
|
110
|
+
<td>{{ b.count || '—' }}</td>
|
|
111
|
+
<td>{{ b.count ? fmtN(b.tin) : '—' }}</td>
|
|
112
|
+
<td>{{ b.count ? fmtN(b.tout) : '—' }}</td>
|
|
113
|
+
<td>{{ b.count ? fmtN(b.tcache) : '—' }}</td>
|
|
114
|
+
<td>{{ b.count ? fmtN(b.total) : '—' }}</td>
|
|
115
|
+
<td>
|
|
116
|
+
<span v-if="!b.count">—</span>
|
|
117
|
+
<span v-else-if="isHeavyDay(b)" :title="'当天最长单轮 ' + fmtDurBig(b.maxDur) + ',占全天 ' + Math.round(b.maxDur / b.dur * 100) + '%——多半是挂机/隔夜的那一轮'">
|
|
118
|
+
{{ fmtDurBig(b.dur) }} <span class="st-warn">⚠</span>
|
|
119
|
+
</span>
|
|
120
|
+
<span v-else>{{ fmtDurBig(b.dur) }}</span>
|
|
121
|
+
</td>
|
|
122
|
+
</tr>
|
|
123
|
+
</tbody>
|
|
124
|
+
</table>
|
|
125
|
+
</div>
|
|
126
|
+
</template>
|
|
127
|
+
<template v-else-if="stats.tab==='model'">
|
|
128
|
+
<div class="st-note">
|
|
129
|
+
条形长度 = 该模型 token 占合计的比例(<b>绝对占比</b>,不按第一名归一)· 无模型记录
|
|
130
|
+
<b>{{ stats.noModel }}</b> 条(token 恒为 0,只占条数、不占 token)
|
|
131
|
+
<template v-if="stats.multi">· {{ stats.multi }} 条轮次用了多个模型,token 全归第一个</template>
|
|
132
|
+
<el-popover placement="top-start" :width="400" trigger="hover">
|
|
133
|
+
<template #reference><span class="st-info">?</span></template>
|
|
134
|
+
同一个模型被不同 agent 写成两种大小写时(Deepseek-V4-Flash / deepseek-v4-flash)按小写并成一行;
|
|
135
|
+
大小写之外一个字都不动 —— 去空格/去连字符会把 hy4 preview 和 hy4-preview 并掉,
|
|
136
|
+
但同样的规则也会把 gpt-5.4 和 gpt-54 并错,宁可少并也不并错。
|
|
137
|
+
</el-popover>
|
|
138
|
+
</div>
|
|
139
|
+
<div v-if="stats.modelError" class="st-note" style="color:var(--red)">{{ stats.modelError }}</div>
|
|
140
|
+
<div class="st-mlist" v-loading="stats.modelsLoading">
|
|
141
|
+
<div v-for="m in statsModels" :key="m.key" class="st-mrow" :class="{none: m.none}">
|
|
142
|
+
<span class="st-mname" :title="m.tip">{{ m.label }}<i v-if="m.names.length > 1">({{ m.names.length }} 种写法)</i></span>
|
|
143
|
+
<span class="st-mtrack">
|
|
144
|
+
<i v-if="m.wIn" class="st-mseg" :class="{mgap: m.wOut > ST_GAP}" :style="{width:m.wIn+'%',background:'var(--s-in)'}"></i>
|
|
145
|
+
<i v-if="m.wOut" class="st-mseg" :class="{mgap: m.wCache > ST_GAP}" :style="{width:m.wOut+'%',background:'var(--s-out)'}"></i>
|
|
146
|
+
<i v-if="m.wCache" class="st-mseg" :style="{width:m.wCache+'%',background:'var(--s-cache)'}"></i>
|
|
147
|
+
</span>
|
|
148
|
+
<span class="st-mnum">{{ m.total ? fmtN(m.total) : '—' }}</span>
|
|
149
|
+
<span class="st-mshare">{{ m.shareText }}</span>
|
|
150
|
+
<span class="st-mcnt">{{ m.count }} 条</span>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
</template>
|
|
154
|
+
<template v-else-if="stats.tab==='work'">
|
|
155
|
+
<!-- I15 工作指纹:三个维度各自的分桶表。要点都在 note 里说清:
|
|
156
|
+
① 只有 claude 源有这三样(covered 如实列出);别家「无此维度」不印 0;
|
|
157
|
+
② 值取轮内最后一次声明,未声明的轮落在「未声明」行;
|
|
158
|
+
③ token 列是绝对数,条形按本次最大的一档归一。 -->
|
|
159
|
+
<div class="st-note" v-if="workData">
|
|
160
|
+
<template v-if="workData.covered.length">数据源:<b>{{ workData.covered.join(' / ') }}</b>(工作指纹只有这些 agent 的日志里带;其余 agent 一律不印 0,如实标「无此维度」)。</template>
|
|
161
|
+
<template v-else>当前筛选里没有带工作指纹的 agent(工作指纹只在 claude 日志里)。</template>
|
|
162
|
+
· 值取轮内<em>最后一次声明</em>(会话中途切分支/切权限,后面的才是当前值)· 未声明/无此维度的轮单列「未声明」,不冒充默认值。
|
|
163
|
+
<span class="ana-refresh" @click="loadWork">重新统计</span>
|
|
164
|
+
</div>
|
|
165
|
+
<div class="wf-dims" v-if="workData">
|
|
166
|
+
<!-- 维度选择:三个维度的表格共用一块屏,点标签切换(与 ana.sub 同一模式) -->
|
|
167
|
+
<div class="ana-tabs">
|
|
168
|
+
<span class="st-tab" :class="{on: work.dim==='branch'}" @click="work.dim='branch'">git 分支</span>
|
|
169
|
+
<span class="st-tab" :class="{on: work.dim==='permMode'}" @click="work.dim='permMode'">权限模式</span>
|
|
170
|
+
<span class="st-tab" :class="{on: work.dim==='cliVer'}" @click="work.dim='cliVer'">产品版本</span>
|
|
171
|
+
</div>
|
|
172
|
+
<div v-loading="work.loading" class="ana-body">
|
|
173
|
+
<template v-if="workRows.length">
|
|
174
|
+
<div class="sess-sum">
|
|
175
|
+
<span>共 <b>{{ workData.totals.count }}</b> 轮</span>
|
|
176
|
+
<span>合计 <b>{{ fmtN(workData.totals.total) }}</b> token</span>
|
|
177
|
+
<span>总耗时 <b>{{ fmtDurBig(workData.totals.dur) }}</b></span>
|
|
178
|
+
<span v-if="curDim.noVal">未声明 <b>{{ curDim.noVal }}</b> 轮</span>
|
|
179
|
+
</div>
|
|
180
|
+
<div class="wf-note">条形长度 = 该档 token 占合计的比例(绝对占比,不按第一名归一);失败轮数只数 status=error 的轮。</div>
|
|
181
|
+
<div class="ana-list">
|
|
182
|
+
<div v-for="(r, i) in workRows" :key="r.key" class="ana-row wf-row">
|
|
183
|
+
<span class="sn">#{{ i + 1 }}</span>
|
|
184
|
+
<span class="st-preview wf-key" :title="r.key">{{ r.key }}</span>
|
|
185
|
+
<span class="st-mtrack"><i class="st-mseg" :style="{ width: r.w + '%', background: 'var(--s-in)' }"></i></span>
|
|
186
|
+
<span class="wf-num">{{ fmtN(r.total) }}</span>
|
|
187
|
+
<span class="wf-share">{{ r.shareText }}</span>
|
|
188
|
+
<span class="wf-cnt">{{ r.count }} 条<template v-if="r.err"> · <b class="wf-err">{{ r.err }} 失败</b></template></span>
|
|
189
|
+
<span class="wf-dur">{{ fmtDurBig(r.dur) }}</span>
|
|
190
|
+
</div>
|
|
191
|
+
<!-- 未声明/无此维度单列一行:token 恒为 0(无此维度的 agent 本来就没有这些字段的 token 也一样被算进 total?)
|
|
192
|
+
注意:noValTotal 是这些轮的 token,照样要显示出来,否则「各行 + 未声明」对不上 totals -->
|
|
193
|
+
<div v-if="curDim.noVal" class="ana-row wf-row wf-noval">
|
|
194
|
+
<span class="sn">—</span>
|
|
195
|
+
<span class="st-preview wf-key">(未声明 / 无此维度)</span>
|
|
196
|
+
<span class="st-mtrack"></span>
|
|
197
|
+
<span class="wf-num">{{ fmtN(curDim.noValTotal) }}</span>
|
|
198
|
+
<span class="wf-share">{{ curDim.noValShareText }}</span>
|
|
199
|
+
<span class="wf-cnt">{{ curDim.noVal }} 条</span>
|
|
200
|
+
<span class="wf-dur">{{ fmtDurBig(curDim.noValDur) }}</span>
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
</template>
|
|
204
|
+
<div v-else-if="!work.loading" class="empty">当前筛选里没有带工作指纹的条目</div>
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
</template>
|
|
208
|
+
<template v-else>
|
|
209
|
+
<div class="ana-tabs">
|
|
210
|
+
<span class="st-tab" :class="{on: ana.sub==='lat'}" @click="ana.sub='lat'">最慢轮</span>
|
|
211
|
+
<span class="st-tab" :class="{on: ana.sub==='tools'}" @click="ana.sub='tools'">工具调用</span>
|
|
212
|
+
<span class="st-tab" :class="{on: ana.sub==='cache'}" @click="ana.sub='cache'">缓存命中率</span>
|
|
213
|
+
<span class="st-tab" :class="{on: ana.sub==='fail'}" @click="ana.sub='fail'">工具失败画像</span>
|
|
214
|
+
</div>
|
|
215
|
+
<div class="st-note">
|
|
216
|
+
耗时 = 各轮「首条记录 → 末条记录」的时间差(含挂机/隔夜,p95 会被少数超长轮拉高)。
|
|
217
|
+
有真实值的 agent(claude 的 turn_duration)另给「实际」=<b>产品自报的真实工作时间</b>,
|
|
218
|
+
已剥掉用户离开/隔夜的部分 —— 差额就是「人等的」那部分(本机实测虚高约 2.5 倍)。
|
|
219
|
+
<span class="ana-refresh" @click="loadAnalyze">刷新</span>
|
|
220
|
+
</div>
|
|
221
|
+
<div v-loading="ana.loading" class="ana-body">
|
|
222
|
+
<template v-if="ana.data">
|
|
223
|
+
<!-- 最慢轮:p50/p95 + 最慢 TopN;claude 另给「实际」真实工作时间 -->
|
|
224
|
+
<template v-if="ana.sub==='lat'">
|
|
225
|
+
<div class="sess-sum">
|
|
226
|
+
<span>共 <b>{{ ana.data.count }}</b> 轮</span>
|
|
227
|
+
<span>p50 <b>{{ fmtDur(ana.data.p50) }}</b></span>
|
|
228
|
+
<span>p95 <b>{{ fmtDur(ana.data.p95) }}</b></span>
|
|
229
|
+
<span>最长 <b>{{ fmtDur(ana.data.maxDur) }}</b></span>
|
|
230
|
+
</div>
|
|
231
|
+
<div v-if="ana.data.aCount > 0" class="sess-sum ana-sum-actual">
|
|
232
|
+
<span>实际 p95 <b>{{ fmtDur(ana.data.aP95) }}</b></span>
|
|
233
|
+
<span>实际最长 <b>{{ fmtDur(ana.data.aMax) }}</b></span>
|
|
234
|
+
<span class="st-meta">仅算拿到真实值的 <b>{{ ana.data.aCount }}</b> 轮(别家回落墙钟,不计入实际)</span>
|
|
235
|
+
</div>
|
|
236
|
+
<div class="ana-list">
|
|
237
|
+
<div v-for="(s, i) in ana.data.slowest" :key="s.id" class="ana-row" :title="'点击在列表中定位该条目'" @click="gotoEntry(s.id)">
|
|
238
|
+
<span class="sn">#{{ i + 1 }}</span>
|
|
239
|
+
<span class="ana-dur">{{ fmtDur(s.dur) }}<span v-if="s.rdur" class="ana-rdur">实际 {{ fmtDur(s.rdur) }}</span></span>
|
|
240
|
+
<span class="st-preview">{{ s.preview || '(无文本预览)' }}</span>
|
|
241
|
+
<span v-if="s.status !== 'ok'" class="tag tag-err">ERR</span>
|
|
242
|
+
<span class="ana-meta">{{ s.agent }} · {{ s.project || '—' }} · {{ fmtTime(s.time) }}</span>
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
</template>
|
|
246
|
+
<!-- 工具调用排行:轮内工具调用次数 TopN(同一套下钻) -->
|
|
247
|
+
<template v-else-if="ana.sub==='tools'">
|
|
248
|
+
<div class="sess-sum">
|
|
249
|
+
<span>共 <b>{{ ana.data.count }}</b> 轮</span>
|
|
250
|
+
<span>有工具调用的轮 <b>{{ ana.data.byTools.length }}</b> 轮(取 Top {{ ana.data.byTools.length }})</span>
|
|
251
|
+
</div>
|
|
252
|
+
<div class="ana-list">
|
|
253
|
+
<div v-for="(s, i) in ana.data.byTools" :key="s.id" class="ana-row" :title="'本轮 ' + s.tools + ' 次工具调用 · 点击在列表中定位该条目'" @click="gotoEntry(s.id)">
|
|
254
|
+
<span class="sn">#{{ i + 1 }}</span>
|
|
255
|
+
<span class="ana-dur">{{ s.tools }}×</span>
|
|
256
|
+
<span class="st-preview">{{ s.preview || '(无文本预览)' }}</span>
|
|
257
|
+
<span v-if="s.status !== 'ok'" class="tag tag-err">ERR</span>
|
|
258
|
+
<span class="ana-meta">{{ s.agent }} · {{ s.project || '—' }} · {{ fmtTime(s.time) }}</span>
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
</template>
|
|
262
|
+
<!-- 缓存命中率排行:命中率最高的 TopN(命中率 = 缓存 / 合计) -->
|
|
263
|
+
<template v-else-if="ana.sub==='cache'">
|
|
264
|
+
<div class="sess-sum">
|
|
265
|
+
<span>共 <b>{{ ana.data.count }}</b> 轮</span>
|
|
266
|
+
<span>有 token 记录的轮 <b>{{ ana.data.byCacheRate.length }}</b> 轮(取命中率 Top {{ ana.data.byCacheRate.length }})</span>
|
|
267
|
+
</div>
|
|
268
|
+
<div class="ana-list">
|
|
269
|
+
<div v-for="(s, i) in ana.data.byCacheRate" :key="s.id" class="ana-row" :title="'缓存命中率 ' + (s.rate*100).toFixed(1) + '% · 点击在列表中定位该条目'" @click="gotoEntry(s.id)">
|
|
270
|
+
<span class="sn">#{{ i + 1 }}</span>
|
|
271
|
+
<span class="ana-pct" :style="cachePctColor(s.rate)">{{ fmtPct(s.rate) }}</span>
|
|
272
|
+
<span class="st-preview">{{ s.preview || '(无文本预览)' }}</span>
|
|
273
|
+
<span v-if="s.status !== 'ok'" class="tag tag-err">ERR</span>
|
|
274
|
+
<span class="ana-meta">{{ s.agent }} · {{ s.project || '—' }} · {{ fmtTime(s.time) }} · 缓存 {{ fmtN(s.tcache) }}</span>
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
</template>
|
|
278
|
+
<!-- I6/I13 工具失败画像:分子 = 搜索分片里逐轮的 tools[].error / .timeout / .soft 分类计数,
|
|
279
|
+
分母 = 同一批轮上的 toolNames 次数 -->
|
|
280
|
+
<template v-else>
|
|
281
|
+
<div class="st-note">
|
|
282
|
+
只认解析器落好的<b>结构化标志</b>(<code>tools[].error</code> / <code>.timeout</code> /
|
|
283
|
+
<code>.soft</code>),一个字正文都不扫 —— 「输出里恰好写着 error」不算失败。
|
|
284
|
+
<b>超时算失败</b>(它就是失败);<b>软失败单列一档、不进失败率</b>(<code>No matches found</code>
|
|
285
|
+
这种"rg 没搜到"是正常结果)。失败数按<b>工具名 × agent</b> 分桶,<b>不跨 agent 并名字</b>
|
|
286
|
+
(同一家里 <code>Edit</code> 与 <code>edit</code> 是两种写法,跨家同名更是两回事)。
|
|
287
|
+
<span class="ana-refresh" @click="loadAnalyze">重新统计</span>
|
|
288
|
+
</div>
|
|
289
|
+
<div class="sess-sum">
|
|
290
|
+
<span>共 <b>{{ ana.data.count }}</b> 轮</span>
|
|
291
|
+
<span>带工具调用的轮 <b>{{ tfSum.turns }}</b></span>
|
|
292
|
+
<span>已核对 <b>{{ tfSum.known }}</b><i v-if="tfSum.unknown"> · 另有 <b>{{ tfSum.unknown }}</b> 轮还没索引到(不算进分母)</i></span>
|
|
293
|
+
<span>含失败工具的轮 <b>{{ tfSum.failTurns }}</b><template v-if="tfSum.known">({{ fmtPct(tfSum.failTurns / tfSum.known) }})</template></span>
|
|
294
|
+
<span v-if="tfSum.totals.timeout">其中超时 <b class="tf-to-n">{{ tfSum.totals.timeout }}</b> 次</span>
|
|
295
|
+
<span v-if="tfSum.totals.soft">软失败 <b>{{ tfSum.totals.soft }}</b> 次<i class="tf-soft-hint">(单列,不算失败)</i></span>
|
|
296
|
+
</div>
|
|
297
|
+
<div v-if="tfSum.building" class="st-note">后台正在建索引,「已核对」还会往上涨 —— 现在这张画像是不全的。</div>
|
|
298
|
+
<div v-if="tfRowsView.length" class="st-note tf-howto">
|
|
299
|
+
条形 = 失败率(该工具失败次数 ÷ 它在这一批轮里被调用的次数),<b>按本次最高的那个率归一</b>,
|
|
300
|
+
所以最长的条不一定等于「最该修的」。左边 <b>N×</b> 才是绝对失败次数;
|
|
301
|
+
分母 < {{ tfData.minTotal }} 的行标了「样本不足」,别拿它的率做结论。
|
|
302
|
+
</div>
|
|
303
|
+
<div class="ana-list">
|
|
304
|
+
<div v-for="(s, i) in tfRowsView" :key="s.agent + '\u0000' + s.name" class="ana-row tf-row"
|
|
305
|
+
:title="s.tip">
|
|
306
|
+
<span class="sn">#{{ i + 1 }}</span>
|
|
307
|
+
<span class="ana-dur">{{ s.fail }}×</span>
|
|
308
|
+
<span class="ana-pct tf-rate" :style="{ opacity: s.lowDenom ? .45 : 1 }">{{ s.rateText }}</span>
|
|
309
|
+
<span class="st-preview">{{ s.name }}<i v-if="s.lowDenom" class="tf-low">样本不足</i><i v-if="s.timeout" class="tf-low tf-to">超时 {{ s.timeout }}</i><i v-if="s.soft" class="tf-low tf-soft">软失败 {{ s.soft }}</i></span>
|
|
310
|
+
<span class="st-mtrack tf-track"><i class="st-mseg" :style="{ width: s.w + '%', background: 'var(--red)' }"></i></span>
|
|
311
|
+
<span class="ana-meta">{{ s.agent }} · 调用 {{ s.total }} 次</span>
|
|
312
|
+
</div>
|
|
313
|
+
<div v-if="!tfRowsView.length" class="empty">{{ tfEmptyText }}</div>
|
|
314
|
+
</div>
|
|
315
|
+
<!-- 覆盖度按 agent×来源如实摊开:没有信号的档位一律说明「这里空着的原因」,不画 0 -->
|
|
316
|
+
<div v-if="tfCovView.length" class="tf-cov">
|
|
317
|
+
<div class="tf-cov-h">覆盖度(按来源;这一层决定哪些格子是空的、为什么空)</div>
|
|
318
|
+
<div v-for="c in tfCovView" :key="c.agent + '\u0000' + c.kind" class="tf-cov-row">
|
|
319
|
+
<span class="tf-cov-a">{{ c.agent }}</span>
|
|
320
|
+
<span class="tf-cov-k">{{ c.kind || '—' }}</span>
|
|
321
|
+
<span class="tf-cov-t" :class="'tier-' + c.tier">{{ c.tierText }}</span>
|
|
322
|
+
<span class="tf-cov-n">带工具 {{ c.turns }} 轮 · 已核对 {{ c.known }}<template v-if="c.unknown"> · 未索引 {{ c.unknown }}</template><template v-if="c.softTurns"> · 仅软失败 {{ c.softTurns }} 轮</template></span>
|
|
323
|
+
<span class="tf-cov-ts" :class="c.tsSig ? 'ts-yes' : 'ts-no'">{{ c.tsText }}</span>
|
|
324
|
+
<span class="tf-cov-r">{{ c.rateText }}</span>
|
|
325
|
+
</div>
|
|
326
|
+
</div>
|
|
327
|
+
</template>
|
|
328
|
+
</template>
|
|
329
|
+
<div v-else-if="!ana.loading" class="empty">暂无数据</div>
|
|
330
|
+
</div>
|
|
331
|
+
</template>
|
|
332
|
+
</div>
|
|
333
|
+
</template>
|
|
334
|
+
</div>
|
|
335
|
+
<template #footer>
|
|
336
|
+
<el-button @click="$emit('update:visible', false)">关闭</el-button>
|
|
337
|
+
<el-button type="primary" :loading="stats.loading" @click="reload">重新统计</el-button>
|
|
338
|
+
</template>
|
|
339
|
+
</el-dialog>`,
|
|
340
|
+
props: {
|
|
341
|
+
visible: Boolean,
|
|
342
|
+
// 「统计哪一段、按什么筛」:根在点击那一刻拼好递进来(qs 已 toString)。
|
|
343
|
+
// n 只用来触发重算(自增),keepRange = 切 agent 时保住手选的日期区间。
|
|
344
|
+
req: { type: Object, default: null },
|
|
345
|
+
// 弹层里那个 agent 下拉要与左侧栏同一个筛选:值/禁用/选项都从根递,改动也上报给根(emit agent-change)
|
|
346
|
+
fAgent: { type: String, default: '' },
|
|
347
|
+
fAgents: { type: Array, default: () => [] },
|
|
348
|
+
sideAgents: { type: Array, default: () => [] },
|
|
349
|
+
},
|
|
350
|
+
emits: ['update:visible', 'agent-change', 'locate'],
|
|
351
|
+
setup(props, { emit }) {
|
|
352
|
+
const getJSON = Vue.inject('getJSON');
|
|
353
|
+
const stats = Vue.reactive({
|
|
354
|
+
// visible 不在这个对象里:开关归根(与别的弹层一致),放这儿会有两份真相
|
|
355
|
+
loading: false, error: '', label: '全部时间', filterText: '', days: [], totals: {}, withCache: true,
|
|
356
|
+
tab: 'day', // 明细区一次只显示一屏:按天表格 / 按模型列表(同时摆出来每块只剩百来像素)
|
|
357
|
+
viewN: 7, viewEnd: null, // 图表可视窗口:默认最近 7 天;viewEnd=null = 贴最新一侧
|
|
358
|
+
modelsLoading: false, modelError: '', // 按模型跟着窗口重新取数(服务端聚合,前端切不了)
|
|
359
|
+
models: [], modelTotals: {}, noModel: 0, multi: 0, // 按模型那一块(/api/models)
|
|
360
|
+
daysCapped: 0, timeUnknown: 0, // 按天统计的两个「少算了什么」:跨度截断 / 时间未知的轮(I16/D2)
|
|
361
|
+
});
|
|
362
|
+
const hoverDay = Vue.ref('');
|
|
363
|
+
// 段高(%)超过它才在段之间留 2px 缝:约等于 5px(图表内容高 123px)。
|
|
364
|
+
// 比这矮的段本来就只有 3px 的显示下限,再挖掉 2px 缝隙就只剩一根 1px 的线了。
|
|
365
|
+
const ST_GAP = 4;
|
|
366
|
+
|
|
367
|
+
// 按天桶一次取回**整个筛选集**(窗口在前端切,缩放/拖动不用回服务端);
|
|
368
|
+
// 「按模型」是另一套聚合维度,前端没有 天×模型 明细,跟着窗口单独取(见 fetchModels)。
|
|
369
|
+
async function load(keepRange) {
|
|
370
|
+
// 切 agent 时保住手选的日期区间(keepRange):同一段时间跨 agent 对比才有意义。
|
|
371
|
+
// 先记下日期(下标在新数据里会移位,日期不会),取回新数据后再按日期恢复(越界自动收进新范围)。
|
|
372
|
+
const r = props.req || {};
|
|
373
|
+
const prevRange = keepRange ? statsRange.value : null;
|
|
374
|
+
stats.loading = true;
|
|
375
|
+
stats.error = '';
|
|
376
|
+
try {
|
|
377
|
+
const j = await getJSON('/api/daily?' + (r.qs || ''));
|
|
378
|
+
if (!j.ok) throw new Error(j.error || '统计失败');
|
|
379
|
+
stats.days = j.days || [];
|
|
380
|
+
stats.totals = j.totals || {};
|
|
381
|
+
stats.label = j.label || '全部时间';
|
|
382
|
+
stats.daysCapped = j.daysCapped || 0;
|
|
383
|
+
stats.timeUnknown = j.timeUnknown || 0; // I16/D2:未计入按天统计的时间未知轮数(如实说明)
|
|
384
|
+
stats.filterText = r.filterText || ''; // 记下「这份数字是按什么筛出来的」,中途改筛选也不会让已显示的数字对不上
|
|
385
|
+
hoverDay.value = '';
|
|
386
|
+
stats.tab = 'day';
|
|
387
|
+
stats.viewN = 7; stats.viewEnd = null;
|
|
388
|
+
if (prevRange) setViewByDates(prevRange[0], prevRange[1]);
|
|
389
|
+
stats.modelError = '';
|
|
390
|
+
queueModels(true);
|
|
391
|
+
} catch (e) { stats.error = '统计失败:' + e.message; }
|
|
392
|
+
finally { stats.loading = false; }
|
|
393
|
+
}
|
|
394
|
+
// 弹窗里的 agent 下拉:与左侧栏是**同一个**筛选(改了它,背后的请求列表也跟着变),
|
|
395
|
+
// 重算统计但保住日期区间 —— 改根的状态是根的事,这儿只把「选了谁」报上去。
|
|
396
|
+
function onAgentChange(name) { emit('agent-change', name); }
|
|
397
|
+
// 点最慢排行行 → 关统计框 → 在主列表里定位(跨组件的动作,归根做:列表状态在那儿)
|
|
398
|
+
function gotoEntry(id) { emit('locate', id); }
|
|
399
|
+
function reload() { load(false); }
|
|
400
|
+
|
|
401
|
+
// 柱高按「当前这一档里的最大值」归一,而不是跟另一档共用纵轴:
|
|
402
|
+
// 含缓存时缓存占 95%+,共用一个分母的话「输入+输出」那档会整片贴地看不见。
|
|
403
|
+
// 代价是两档的纵轴刻度不同 —— 所以每档都自带「峰值」说明,别跨档比柱高。
|
|
404
|
+
const stDenom = Vue.computed(() => {
|
|
405
|
+
const f = stats.withCache ? (d => d.tin + d.tout + d.tcache) : (d => d.tin + d.tout);
|
|
406
|
+
return Math.max(1, ...stats.days.map(f));
|
|
407
|
+
});
|
|
408
|
+
// 统计区间(= 图表可视窗):默认最近 7 天,**只由日期选择器改** —— 区间驱动卡片/明细/按模型全部数字,
|
|
409
|
+
// 曾在图上支持滚轮缩放、拖动平移,结果随手一滚底下数字全跳,撤了。
|
|
410
|
+
// 归一分母用**全部天数**的最大值(stDenom),不切区间重标 —— 跨区间比柱高才成立。
|
|
411
|
+
const statsView = Vue.computed(() => {
|
|
412
|
+
const days = stats.days, T = days.length;
|
|
413
|
+
if (!T) return { days: [], start: 0, end: 0, total: 0 };
|
|
414
|
+
const n = Math.min(Math.max(3, stats.viewN), T);
|
|
415
|
+
const end = stats.viewEnd == null ? T : Math.min(Math.max(Math.round(stats.viewEnd), n), T);
|
|
416
|
+
return { days: days.slice(end - n, end), start: end - n, end, total: T };
|
|
417
|
+
});
|
|
418
|
+
const statsBars = Vue.computed(() => statsView.value.days.map(d => {
|
|
419
|
+
const p = v => Math.max(0, Math.min(100, v / stDenom.value * 100));
|
|
420
|
+
const total = d.tin + d.tout + d.tcache;
|
|
421
|
+
return {
|
|
422
|
+
...d, short: d.day.slice(5).replace('-', '/'),
|
|
423
|
+
hIn: p(d.tin), hOut: p(d.tout), hCache: stats.withCache ? p(d.tcache) : 0,
|
|
424
|
+
tip: d.day + ' 合计 ' + fmtN(total) + '(输入 ' + fmtN(d.tin) + ' / 输出 ' + fmtN(d.tout) + ' / 缓存 ' + fmtN(d.tcache)
|
|
425
|
+
+ ') ' + d.count + ' 条 · ' + fmtDurBig(d.dur),
|
|
426
|
+
};
|
|
427
|
+
}));
|
|
428
|
+
// x 轴标签抽稀:太密全写会糊成一团(柱本身仍是一天一根,不合并)
|
|
429
|
+
const statsTickEvery = Vue.computed(() => Math.max(1, Math.ceil(statsView.value.days.length / 9)));
|
|
430
|
+
// 悬停读数:没悬停时落在可视窗内**最近一个有数据的天**,这样打开就能看见「今天」的数字
|
|
431
|
+
const statsReadout = Vue.computed(() => {
|
|
432
|
+
const v = statsView.value;
|
|
433
|
+
const d = (hoverDay.value && v.days.find(x => x.day === hoverDay.value))
|
|
434
|
+
|| [...v.days].reverse().find(x => x.count);
|
|
435
|
+
if (!d) return '可视范围内没有条目';
|
|
436
|
+
const total = d.tin + d.tout + d.tcache;
|
|
437
|
+
return (d.day === hoverDay.value ? '' : '最近:') + d.day.slice(5).replace('-', '/') + ' 合计 ' + fmtN(total)
|
|
438
|
+
+ '(输入 ' + fmtN(d.tin) + ' / 输出 ' + fmtN(d.tout) + ' / 缓存 ' + fmtN(d.tcache) + ') ' + d.count + ' 条 · ' + fmtDurBig(d.dur);
|
|
439
|
+
});
|
|
440
|
+
// 卡片 / 概况 / 明细表的数字全部按**可视窗**现算:天桶里字段是全的,切片求和即可,不用回服务端。
|
|
441
|
+
// 选的区间就是统计口径 —— 卡片、图表、明细表、按模型,四块永远在说同一段时间。
|
|
442
|
+
const statsViewTotals = Vue.computed(() => {
|
|
443
|
+
const t = { count: 0, tin: 0, tout: 0, tcache: 0, total: 0, dur: 0, calls: 0, tools: 0 };
|
|
444
|
+
for (const d of statsView.value.days) {
|
|
445
|
+
t.count += d.count; t.tin += d.tin; t.tout += d.tout; t.tcache += d.tcache;
|
|
446
|
+
t.dur += d.dur; t.calls += d.calls; t.tools += d.tools;
|
|
447
|
+
}
|
|
448
|
+
t.total = t.tin + t.tout + t.tcache;
|
|
449
|
+
return t;
|
|
450
|
+
});
|
|
451
|
+
// 「按模型」按可视窗从服务端现取。窗口每次变(选区间/快捷键)都重新取:
|
|
452
|
+
// 防抖 250ms + 序号作废旧响应,快速连改不会打架。
|
|
453
|
+
let modelTimer = null, modelSeq = 0;
|
|
454
|
+
async function fetchModels() {
|
|
455
|
+
const v = statsView.value;
|
|
456
|
+
if (!v.days.length) { stats.models = []; stats.modelTotals = {}; stats.noModel = 0; stats.multi = 0; return; }
|
|
457
|
+
// 「按模型」跟着统计弹窗的**可视窗**走(显式 from/to 就是可视窗),不重复带左侧区间:
|
|
458
|
+
// 服务端 customBounds(from,to) 优先于 range,即使带了符号也是这个区间算,不如不传 ——
|
|
459
|
+
// 所以下面两行 set 会把 req.qs 里可能已有的 from/to **覆盖**掉,只剩可视窗那一段。
|
|
460
|
+
const qs = new URLSearchParams((props.req || {}).qs || '');
|
|
461
|
+
qs.set('from', v.days[0].day);
|
|
462
|
+
qs.set('to', v.days[v.days.length - 1].day);
|
|
463
|
+
const my = ++modelSeq;
|
|
464
|
+
stats.modelsLoading = true;
|
|
465
|
+
try {
|
|
466
|
+
const m = await getJSON('/api/models?' + qs.toString());
|
|
467
|
+
if (my !== modelSeq) return; // 窗口又变了,旧响应作废
|
|
468
|
+
if (!m.ok) throw new Error(m.error || '按模型统计失败');
|
|
469
|
+
stats.models = m.models || [];
|
|
470
|
+
stats.modelTotals = m.totals || {};
|
|
471
|
+
stats.noModel = m.noModel || 0;
|
|
472
|
+
stats.multi = m.multi || 0;
|
|
473
|
+
stats.modelError = '';
|
|
474
|
+
} catch (e) { if (my === modelSeq) stats.modelError = '按模型统计失败:' + e.message; }
|
|
475
|
+
finally { if (my === modelSeq) stats.modelsLoading = false; }
|
|
476
|
+
}
|
|
477
|
+
function queueModels(immediate) {
|
|
478
|
+
clearTimeout(modelTimer);
|
|
479
|
+
if (immediate) { fetchModels(); return; }
|
|
480
|
+
modelTimer = setTimeout(fetchModels, 250);
|
|
481
|
+
}
|
|
482
|
+
// 统计区间 ↔ 日期选择器:picker 显示的就是当前区间;手选区间直接定位。
|
|
483
|
+
// days 是按自然日连续分桶的,用字符串比较就能换算下标。
|
|
484
|
+
function setViewByDates(from, to) {
|
|
485
|
+
const days = stats.days, T = days.length;
|
|
486
|
+
if (!T || !from || !to) return;
|
|
487
|
+
const i0 = days.findIndex(d => d.day >= from);
|
|
488
|
+
let i1 = days.findIndex(d => d.day > to);
|
|
489
|
+
if (i1 === -1) i1 = T;
|
|
490
|
+
const start = Math.max(0, i0 === -1 ? 0 : i0);
|
|
491
|
+
const end = Math.min(T, Math.max(i1, start + 1));
|
|
492
|
+
stats.viewN = end - start;
|
|
493
|
+
stats.viewEnd = end;
|
|
494
|
+
hoverDay.value = '';
|
|
495
|
+
}
|
|
496
|
+
const statsRange = Vue.computed({
|
|
497
|
+
get() {
|
|
498
|
+
const v = statsView.value;
|
|
499
|
+
return v.days.length ? [v.days[0].day, v.days[v.days.length - 1].day] : null;
|
|
500
|
+
},
|
|
501
|
+
set(val) {
|
|
502
|
+
if (!val) return;
|
|
503
|
+
setViewByDates(val[0], val[1]);
|
|
504
|
+
queueModels();
|
|
505
|
+
},
|
|
506
|
+
});
|
|
507
|
+
// 数据范围外的日期禁选(第一天之前 / 最后一天之后)
|
|
508
|
+
function statsDisabledDate(t) {
|
|
509
|
+
const days = stats.days;
|
|
510
|
+
if (!days.length) return false;
|
|
511
|
+
const s = t.getFullYear() + '-' + String(t.getMonth() + 1).padStart(2, '0') + '-' + String(t.getDate()).padStart(2, '0');
|
|
512
|
+
return s < days[0].day || s > days[days.length - 1].day;
|
|
513
|
+
}
|
|
514
|
+
const statsShortcuts = ['近7天', '近30天', '近90天'].map((text, i) => ({
|
|
515
|
+
text,
|
|
516
|
+
value: () => {
|
|
517
|
+
const d = stats.days, T = d.length;
|
|
518
|
+
return T ? [d[Math.max(0, T - [7, 30, 90][i])].day, d[T - 1].day] : [new Date(), new Date()];
|
|
519
|
+
},
|
|
520
|
+
})).concat([{ text: '全部', value: () => { const d = stats.days; return d.length ? [d[0].day, d[d.length - 1].day] : [new Date(), new Date()]; } }]);
|
|
521
|
+
const statsTiles = Vue.computed(() => {
|
|
522
|
+
const t = statsViewTotals.value, all = t.total || 0; // 卡片跟可视窗走,不看全量 totals
|
|
523
|
+
// 占比:小于 10% 的留一位小数 —— 输出占 0.4% 时四舍五入成「0%」会被读成「这个数没意义/坏了」
|
|
524
|
+
const pct = v => { const p = all ? v / all * 100 : 0; return all ? '占比 ' + (p >= 10 ? Math.round(p) : p.toFixed(1)) + '%' : ''; };
|
|
525
|
+
return [
|
|
526
|
+
{ k: 'in', label: '输入(不含缓存)', color: 'var(--s-in)', value: t.tin || 0, sub: pct(t.tin || 0) },
|
|
527
|
+
{ k: 'out', label: '输出', color: 'var(--s-out)', value: t.tout || 0, sub: pct(t.tout || 0) },
|
|
528
|
+
{ k: 'cache', label: '缓存命中', color: 'var(--s-cache)', value: t.tcache || 0, sub: pct(t.tcache || 0) },
|
|
529
|
+
// 合计按三项现算(服务端就是这么给的):四个数字必须自洽,不然又是「合计 < 输入」那种卡片(§12)
|
|
530
|
+
{ k: 'total', label: '合计', value: all, sub: '= 输入 + 输出 + 缓存' },
|
|
531
|
+
];
|
|
532
|
+
});
|
|
533
|
+
const statsTableRows = Vue.computed(() => [...statsView.value.days].reverse()); // 只列可视窗内的天,新 → 旧,和列表一个方向
|
|
534
|
+
|
|
535
|
+
// 按模型:条形长度用**绝对占比**(该模型合计 / 全部合计),不是按第一名归一 ——
|
|
536
|
+
// 「deepseek 占七成」这种结论要一眼看得出来,归一化会把长尾拉得一样长。
|
|
537
|
+
// 三个分段各自 = 该分量 / 全部合计,所以三段加起来正好等于这一行的占比。
|
|
538
|
+
const statsModels = Vue.computed(() => {
|
|
539
|
+
const tot = stats.modelTotals.total || 1;
|
|
540
|
+
const rows = (stats.models || []).map(m => {
|
|
541
|
+
const share = m.total / tot * 100;
|
|
542
|
+
return {
|
|
543
|
+
...m, wIn: m.tin / tot * 100, wOut: m.tout / tot * 100, wCache: m.tcache / tot * 100,
|
|
544
|
+
shareText: (share >= 1 ? share.toFixed(1) : share.toFixed(2)) + '%',
|
|
545
|
+
tip: m.names.join(' / ') + '\n' + m.count + ' 条 · 输入 ' + m.tin.toLocaleString()
|
|
546
|
+
+ ' · 输出 ' + m.tout.toLocaleString() + ' · 缓存 ' + m.tcache.toLocaleString()
|
|
547
|
+
+ '\nagent:' + Object.entries(m.agents).map(([k, v]) => k + ' ' + v).join('、'),
|
|
548
|
+
};
|
|
549
|
+
});
|
|
550
|
+
// 没有模型记录的条目单列一行(token 恒为 0,条形是空的)——不列出来的话,
|
|
551
|
+
// 「各行条数之和」会比对话框顶上的「共 N 条」少一千多,看着像漏数据
|
|
552
|
+
if (stats.noModel) rows.push({
|
|
553
|
+
key: '__none__', none: true, label: '(无模型记录)', names: [], count: stats.noModel,
|
|
554
|
+
total: 0, wIn: 0, wOut: 0, wCache: 0, shareText: '0%',
|
|
555
|
+
tip: '这些条目的日志里没有模型字段(gemini 的 logs.json、读不到 Cursor IDE 会话库的 cursor 转录、atomcode 元信息缺失等)。\n本机实测它们的 token 恒为 0,所以只在「条数」上占位。',
|
|
556
|
+
});
|
|
557
|
+
return rows;
|
|
558
|
+
});
|
|
559
|
+
// 「耗时」这个量本机数据上是不干净的:一个挂机轮能占掉当天 95%(实测单轮 63 小时)。
|
|
560
|
+
// 与其把总耗时当工作量报出去,不如把这种天数出来 —— 用户要拿它做决定,得先知道哪几天不能信。
|
|
561
|
+
// 「最长单轮占一半以上」要再加一道下限(当天总耗时 ≥ 1 小时)才成立:只有 4 条、合计 2 分钟的一天,
|
|
562
|
+
// 随手一条就能占一半,那不是挂机、是样本太小 —— 不加下限的话 20 个标记里大半是这种噪声。
|
|
563
|
+
const isHeavyDay = d => !!d.count && d.dur >= 3600000 && d.maxDur * 2 > d.dur;
|
|
564
|
+
const statsHeavyNote = Vue.computed(() => {
|
|
565
|
+
const bad = statsView.value.days.filter(isHeavyDay); // 只数可视窗里的天,跟表格看到的行一致
|
|
566
|
+
if (!bad.length) return '';
|
|
567
|
+
const list = bad.slice(-3).map(d => d.day.slice(5) + '(' + Math.round(d.maxDur / d.dur * 100) + '%)').join('、');
|
|
568
|
+
return '本次统计里有 ' + bad.length + ' 天的耗时由单个挂轮主导,别当工作量看:' + list + (bad.length > 3 ? ' 等' : '') + '。';
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
// ---- R8 排行分析(时延维度:最慢轮 TopN + p50/p95)+ I6 工具失败画像 ----
|
|
572
|
+
// 打开「时延分析」tab 时按当前筛选拉一次 /api/analyze(不随统计打开自动请求),
|
|
573
|
+
// 四个子 tab(最慢轮 / 工具调用 / 缓存命中率 / 工具失败画像)共用同一份 ana.data,切换不再取数。
|
|
574
|
+
const ana = Vue.reactive({ loading: false, data: null, sub: 'lat' }); // sub: lat|tools|cache|fail
|
|
575
|
+
// ---- I15 工作指纹(git 分支 / 权限模式 / 产品版本)----
|
|
576
|
+
// 打开「工作指纹」tab 时按当前筛选拉一次 /api/work(懒加载,与时延分析同款)。三个维度
|
|
577
|
+
// (branch / permMode / cliVer)共用一份 work.data,切换维度只换视图不动请求。
|
|
578
|
+
const work = Vue.reactive({ loading: false, data: null, dim: 'branch' });
|
|
579
|
+
async function loadWork() {
|
|
580
|
+
work.loading = true;
|
|
581
|
+
try {
|
|
582
|
+
// 用 req.qs 打底(左侧区间 + 筛选):/api/work 与 /api/daily 同口径(agent/project/range/from/to)
|
|
583
|
+
const q = new URLSearchParams((props.req || {}).qs || '');
|
|
584
|
+
const j = await getJSON('/api/work?' + q.toString());
|
|
585
|
+
work.data = j.ok ? j : null;
|
|
586
|
+
} catch { work.data = null; }
|
|
587
|
+
finally { work.loading = false; }
|
|
588
|
+
}
|
|
589
|
+
// 当前维度分桶的视图行:条形按**本次最大 token**归一(与统计主图同理),token 并列按条数、再按名字
|
|
590
|
+
const workData = Vue.computed(() => work.data || null);
|
|
591
|
+
const curDim = Vue.computed(() => {
|
|
592
|
+
const d = workData.value;
|
|
593
|
+
if (!d || !d[work.dim]) return { rows: [], noVal: 0, noValTotal: 0, noValDur: 0, noValShareText: '' };
|
|
594
|
+
const dim = d[work.dim];
|
|
595
|
+
const tot = (d.totals && d.totals.total) || 1;
|
|
596
|
+
return {
|
|
597
|
+
...dim,
|
|
598
|
+
noValShareText: dim.noValTotal ? (dim.noValTotal / tot * 100).toFixed(1) + '%' : '',
|
|
599
|
+
};
|
|
600
|
+
});
|
|
601
|
+
const workRows = Vue.computed(() => {
|
|
602
|
+
const dim = curDim.value;
|
|
603
|
+
const maxTotal = dim.rows.reduce((m, r) => Math.max(m, r.total || 0), 0) || 1;
|
|
604
|
+
const tot = (workData.value && workData.value.totals && workData.value.totals.total) || 1;
|
|
605
|
+
return dim.rows.map(r => ({
|
|
606
|
+
...r,
|
|
607
|
+
w: Math.max(2, Math.round((r.total || 0) / maxTotal * 100)),
|
|
608
|
+
shareText: r.total ? (r.total / tot * 100).toFixed(1) + '%' : '0%',
|
|
609
|
+
}));
|
|
610
|
+
});
|
|
611
|
+
async function loadAnalyze() {
|
|
612
|
+
ana.loading = true;
|
|
613
|
+
try {
|
|
614
|
+
// 与按动物模型同理:用 req.qs 打底(左侧区间 + 筛选),只加一个 top
|
|
615
|
+
const q = new URLSearchParams((props.req || {}).qs || '');
|
|
616
|
+
q.set('top', '10');
|
|
617
|
+
const j = await getJSON('/api/analyze?' + q.toString());
|
|
618
|
+
ana.data = j.ok ? j : null;
|
|
619
|
+
} catch { ana.data = null; }
|
|
620
|
+
finally { ana.loading = false; }
|
|
621
|
+
}
|
|
622
|
+
// 缓存命中率条目的颜色:命中率越高越偏绿(var(--s-cache) 系),0~100% 线性映射到 —> 用不透明度表达强弱
|
|
623
|
+
function cachePctColor(rate) {
|
|
624
|
+
const p = Math.max(0, Math.min(1, rate || 0));
|
|
625
|
+
return { background: 'var(--s-cache)', opacity: Math.max(0.25, 0.25 + 0.75 * p) };
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// ---- I6/I13 工具失败画像(同一个 /api/analyze 返回里的 toolFails;不另发请求、不前端二次聚合)----
|
|
629
|
+
const tfData = Vue.computed(() => (ana.data && ana.data.toolFails)
|
|
630
|
+
|| { rows: [], agents: [], minTotal: 0, totals: { err: 0, timeout: 0, soft: 0 } });
|
|
631
|
+
const tfSum = Vue.computed(() => {
|
|
632
|
+
const o = { turns: 0, known: 0, unknown: 0, failTurns: 0, softTurns: 0, building: 0,
|
|
633
|
+
totals: tfData.value.totals || { err: 0, timeout: 0, soft: 0 } };
|
|
634
|
+
for (const c of tfData.value.agents || []) {
|
|
635
|
+
o.turns += c.turns || 0; o.known += c.known || 0; o.unknown += c.unknown || 0;
|
|
636
|
+
o.failTurns += c.failTurns || 0; o.softTurns += c.softTurns || 0;
|
|
637
|
+
}
|
|
638
|
+
o.building = tfData.value.building || 0;
|
|
639
|
+
return o;
|
|
640
|
+
});
|
|
641
|
+
// 条形按**本次最高的那个率**归一,不按 100% 归一:真实失败率都在个位数百分比,
|
|
642
|
+
// 按满刻度画会得到一排看不见的空条;而相对归一的代价在下面那行小字里写明了。
|
|
643
|
+
const tfRowsView = Vue.computed(() => {
|
|
644
|
+
const rows = tfData.value.rows || [];
|
|
645
|
+
const maxRate = rows.reduce((m, r) => Math.max(m, r.rate || 0), 0) || 1;
|
|
646
|
+
return rows.map(r => ({ ...r,
|
|
647
|
+
w: Math.max(2, Math.round((r.rate || 0) / maxRate * 100)),
|
|
648
|
+
rateText: r.rate == null ? '—' : fmtPct(r.rate),
|
|
649
|
+
// I13:构成里超时是**算进失败**的,软失败单列(不进 fail / 不进率)。没有这一层的来源
|
|
650
|
+
// (timeout/soft 为 null)就整段不印 —— 印成「超时 0 次」会被读成"这个 agent 一次没超时"。
|
|
651
|
+
tip: r.agent + ' 的 ' + r.name + ':失败 ' + r.fail + ' 次 / 调用 ' + r.total + ' 次'
|
|
652
|
+
+ (r.rate == null ? '' : '(' + fmtPct(r.rate) + ')')
|
|
653
|
+
+ '\n构成:出错 ' + (r.err || 0) + ' 次'
|
|
654
|
+
+ (r.timeout == null ? '' : ' · 超时 ' + r.timeout + ' 次(算失败)')
|
|
655
|
+
+ (r.soft == null ? '' : ' · 软失败 ' + r.soft + ' 次(单列,不算失败)')
|
|
656
|
+
+ (r.lowDenom ? '\n分母不足 ' + tfData.value.minTotal + ' 次,这个率是偶发而不是倾向' : '')
|
|
657
|
+
+ '\n失败判定 = 解析器落在 tools[].error / .timeout / .soft 上的结构化标志(不扫正文)',
|
|
658
|
+
}));
|
|
659
|
+
});
|
|
660
|
+
// 空列表有两种完全不同的原因:核对过确实没失败 vs 根本没核对到。并成一句就是把「还不知道」
|
|
661
|
+
// 说成「没问题」,这条画像最容易造的假绿就在这儿。I13 补第三种:只有软失败时排行也是空的,
|
|
662
|
+
// 但那是「看到了、判定为正常」,得说出来,别让它读成「什么都没发生」。
|
|
663
|
+
const tfEmptyText = Vue.computed(() => tfSum.value.known
|
|
664
|
+
? '已核对的 ' + tfSum.value.known + ' 轮里,没有一个工具带失败标志。'
|
|
665
|
+
+ (tfSum.value.totals.soft ? '(另有 ' + tfSum.value.totals.soft + ' 次软失败,单列一档、不参与这个排行)' : '')
|
|
666
|
+
: (tfSum.value.unknown
|
|
667
|
+
? '带工具的轮还一轮都没索引到(' + tfSum.value.unknown + ' 轮待索引)—— 现在给不出失败画像,等后台建完索引或点「重新统计」。'
|
|
668
|
+
: '这一批筛选里没有带工具调用的轮。'));
|
|
669
|
+
const TF_TIER_TEXT = { signal: '有失败信号', noDetail: '源头不给工具明细', noFlag: '工具行没有失败标志', unknown: '来源未登记' };
|
|
670
|
+
const tfCovView = Vue.computed(() => (tfData.value.agents || []).map(c => ({ ...c,
|
|
671
|
+
tierText: TF_TIER_TEXT[c.tier] || c.tier,
|
|
672
|
+
// I13:超时/软失败是**另一份能力**,与上面那三档正交 —— claude 系有、别家源里没有。
|
|
673
|
+
tsText: c.tsSig ? '超时/软失败:有信号' : '超时/软失败:源里没有',
|
|
674
|
+
rateText: c.tier !== 'signal' ? '不画(' + (TF_TIER_TEXT[c.tier] || '未知来源') + ')'
|
|
675
|
+
: c.known > 0 ? fmtPct(c.failTurns / c.known) + ' 的轮含失败工具'
|
|
676
|
+
: c.unknown > 0 ? '未核对' : '无数据',
|
|
677
|
+
})));
|
|
678
|
+
|
|
679
|
+
// 打开时按递进来的 req 拉一次;开着的时候 agent/筛选变了会再递一份(n 变),也走这儿。
|
|
680
|
+
// visible 关掉时源值归 0 —— 不会误触发。
|
|
681
|
+
Vue.watch(() => (props.visible && props.req ? props.req.n : 0), n => { if (n) load(!!(props.req && props.req.keepRange)); });
|
|
682
|
+
|
|
683
|
+
// 模板只能看见 setup return 里的键:shared.js 的格式化函数在本文件作用域里能直接调(经典 script 全局),
|
|
684
|
+
// 但模板编译成 with(_ctx) 后只认 props / return / 已注册组件 / JS 内建。要用的必须列出来。
|
|
685
|
+
return {
|
|
686
|
+
stats, hoverDay, ST_GAP, statsRange, statsTiles, statsBars, statsTickEvery, statsReadout,
|
|
687
|
+
statsViewTotals, statsTableRows, statsModels, isHeavyDay, statsHeavyNote,
|
|
688
|
+
statsDisabledDate, statsShortcuts, ana, loadAnalyze, cachePctColor, onAgentChange, gotoEntry, reload,
|
|
689
|
+
tfData, tfSum, tfRowsView, tfEmptyText, tfCovView,
|
|
690
|
+
work, loadWork, workData, curDim, workRows,
|
|
691
|
+
fmtN, fmtDur, fmtDurBig, fmtTime, fmtPct,
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
};
|