mingdao-harness 0.2.2 → 0.2.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/docs/ROADMAP-NEXT.md +16 -14
- package/package.json +1 -1
- package/skills/release-checklist/SKILL.md +16 -3
- package/src/agent.js +83 -11
- package/src/batch.js +67 -9
- package/src/cachestats.js +48 -2
- package/src/cli.js +4 -4
- package/src/commands/update.js +15 -3
- package/src/cost-guard.js +15 -5
- package/src/tools/index.js +70 -29
- package/src/web/app.js +43 -0
- package/src/web/index.html +12 -0
- package/src/web/server.js +1 -1
package/docs/ROADMAP-NEXT.md
CHANGED
|
@@ -26,15 +26,15 @@
|
|
|
26
26
|
9. cli.js 共享助手抽取(offpeak defer、mcpFacade 构建、run/run-worker 参数解析合一)
|
|
27
27
|
- 验收:全绿 + 新增 fs-browse 越界与限流回归断言。
|
|
28
28
|
|
|
29
|
-
### Phase B「省钱第二轮」v0.2.
|
|
30
|
-
1. **工具 Schema
|
|
31
|
-
2. **Batch 输入侧**:文本哈希去重(结果按 custom_id 回填全部位置)、单问超窗口预检报错、`--max-cost`
|
|
32
|
-
3. **费用二级分账 + 看板**:cache-stats 增 reasoning/
|
|
33
|
-
4. **护栏 downgrade**:`action:'downgrade'` —— 触顶后自动切 flash +
|
|
34
|
-
5. **评测基准扩充**:压缩质量集(5
|
|
35
|
-
-
|
|
36
|
-
|
|
37
|
-
### Phase C「结构渐进」v0.2.
|
|
29
|
+
### Phase B「省钱第二轮」v0.2.4(已完成 ✅)
|
|
30
|
+
1. ✅ **工具 Schema 按需挂载**:只读阶段只发 read/ls/glob/grep/skill/todo + 已用工具;模型表达写意图后下一轮注入全量(`cfg.schemaTier=false` 可关);已用工具深度瘦身(工具级 + 参数级描述清除,全用过降 48%)
|
|
31
|
+
2. ✅ **Batch 输入侧**:文本哈希去重(结果按 custom_id 回填全部位置)、单问超窗口预检报错、`--max-cost` 提交前估算拦截
|
|
32
|
+
3. ✅ **费用二级分账 + 看板**:cache-stats 增 reasoning / byTool 维度;WebUI 仪表盘加模型/工具 Top5 与近 14 天费用折线(零依赖 SVG);`mingdao cost report` 增按工具分账表
|
|
33
|
+
4. ✅ **护栏 downgrade**:`action:'downgrade'` —— 触顶后自动切 flash 继续执行 + 明确提示(warn/block 仍可选;已是 flash 则按 block 处理)
|
|
34
|
+
5. ✅ **评测基准扩充**:压缩质量集(5 组长会话样本 → 压缩 → 关键事实存活断言 + 尾部字节不变)、路由标注集扩至 100 条;bench 断言数 38 → 150+;覆盖率阈值 55% → 60%
|
|
35
|
+
- 版本说明:v0.2.3 被 0.2.2 桌面启动崩溃的紧急修复占用,Phase B 顺延为 v0.2.4。
|
|
36
|
+
|
|
37
|
+
### Phase C「结构渐进」v0.2.5(约 2 天)
|
|
38
38
|
1. routes/api.js 按域细分(config/sessions/sync/schedule/skills/workspace/misc 7 模块 + 共享中间件),每域配 API 契约测试
|
|
39
39
|
2. cli.js REPL → commands/repl.js、worker → tasks/worker.js(e2e-local 16 项全绿做护栏)
|
|
40
40
|
3. strict 棘轮削 300 条(先 credentials/permissions/config/sync-server/session 五个安全关键模块全量注解)
|
|
@@ -44,10 +44,12 @@
|
|
|
44
44
|
1. **跨平台沙箱补位**:Windows Job Object + 降权 token(零依赖原生 API)、macOS seatbelt 规则;敏感命令平台无关拦截清单兜底
|
|
45
45
|
2. **记忆语义检索**:零依赖实现(内存向量 + 余弦/哈希相似度;Node 22 可用 node:sqlite 持久化,Node 18 回退 JSONL 索引),记忆注入从「最近 3 条」升级为「相关 3 条」
|
|
46
46
|
3. **WebUI 看板扩展**:预算燃尽预测、费用导出 CSV
|
|
47
|
-
4. GitHub Release
|
|
47
|
+
4. GitHub Release 附件自动清理 + 发布清单文档化
|
|
48
48
|
|
|
49
|
-
##
|
|
49
|
+
## 已落地(本轮 Phase D 部分项提前完成)
|
|
50
|
+
- **GitHub Release 附件自动清理(PAT 已接入)**:新增 `scripts/github-release-cleanup.mjs`——官网同步(收割 + sha512 MATCH 校验)后删除 Release 上的全部构建转运附件,只留指向官网的正文。用法:`MINGDAO_GITHUB_TOKEN=<PAT> node scripts/github-release-cleanup.mjs <版本号> [--dry-run]`。token 存本地 gitignored `.env`(`MINGDAO_GITHUB_TOKEN`,模式 600),不入库。验证:v0.2.2 官网同步后已清理 9 个附件,官网下载不受影响。
|
|
50
51
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
## 需要你拍板的事
|
|
53
|
+
|
|
54
|
+
1. **Phase 顺序**:A→B→C→D 直接推进,还是把 B(省钱)提前到 A 之前?(PAT 已到位,Phase D 附件清理已基本完成)
|
|
55
|
+
2. **Phase D 裁剪**:跨平台沙箱与语义检索是否都进 0.3.0,还是只做其一?
|
package/package.json
CHANGED
|
@@ -11,9 +11,22 @@ description: 准备发布新版本、打包交付前使用
|
|
|
11
11
|
2. **版本**:版本号统一(package.json / 配置 / 文档互相一致),遵循语义化版本。
|
|
12
12
|
3. **变更说明**:changelog 用用户语言描述新增/修复/破坏性变更。
|
|
13
13
|
4. **构建验证**:实际构建/打包一次,并在干净环境验证产物可安装可运行。
|
|
14
|
-
5.
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
5. **桌面版打包冒烟(0.2.2 事故护栏,发版必经)**:主进程模块求值必须真实跑通打包产物——
|
|
15
|
+
- CI 已内置:desktop.yml 在 Linux 腿用 xvfb 运行 `dist/linux-unpacked/mingdao --no-sandbox`(`MINGDAO_DESKTOP_SMOKE=1`),模块求值通过输出 `MINGDAO_DESKTOP_SMOKE_OK` 并退出 0,否则 workflow 失败;
|
|
16
|
+
- 本地有显示器时可加验:`cd desktop && MINGDAO_DESKTOP_SMOKE=1 dist/linux-unpacked/mingdao --no-sandbox`(AppImage 加 `--appimage-extract-and-run`);
|
|
17
|
+
- 教训:0.2.2 三平台启动即崩(main.js 顶层调用 createLogWriter 漏导入),打包期不报错、运行期才炸——静态护栏(test/smoke.js「桌面主进程静态护栏」组)与打包冒烟双保险。
|
|
18
|
+
6. **敏感信息**:密钥、token、内网地址、个人信息不得进入仓库与产物(`grep -rE 'sk-[A-Za-z0-9]{20,}' .`)。
|
|
19
|
+
7. **回滚方案**:明确如何回退到上一版本(旧包/旧 tag 可恢复)。
|
|
20
|
+
8. **发布后冒烟**:发布完成后立即做一次端到端验证。
|
|
21
|
+
|
|
22
|
+
## 桌面版发布流水线(官网分发 + 镜像 + 转运附件回收)
|
|
23
|
+
|
|
24
|
+
1. `npm version <v>`(preversion 自动跑 smoke/e2e/bench,version 钩子自动插 changelog,postversion 自动提交)→ 推 3 远端 + tag。
|
|
25
|
+
2. `npm publish` → GitHub desktop.yml CI 全绿(含打包冒烟)。
|
|
26
|
+
3. 服务器收割:`/tmp/harvest-<ver>.sh`(下载 7 安装包 + 2 yml,sha512 交叉校验 MATCH,再生成官网 3 份 feed)。
|
|
27
|
+
4. 官网 site/index.html 刷新版本/大小/sha256 校验行并部署;BBS 随 deploy.sh 重启。
|
|
28
|
+
5. gitee/gitcode 创建 Release(正文指向官网,无附件)。
|
|
29
|
+
6. 官网同步确认后:`MINGDAO_GITHUB_TOKEN=<PAT> node scripts/github-release-cleanup.mjs <版本号>` 删除 GitHub 转运附件(先 --dry-run)。
|
|
17
30
|
|
|
18
31
|
## 输出
|
|
19
32
|
|
package/src/agent.js
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
// → PostToolUse 钩子 → 结果回填 → 循环,直到模型给出纯文本回复。
|
|
3
3
|
// 附带:子代理(task 工具)、todo 清单状态、undo 备份仓、Ctrl+C 中断。
|
|
4
4
|
|
|
5
|
-
import { trimMessages, clampText, messageTokens } from './context.js';
|
|
5
|
+
import { trimMessages, clampText, messageTokens, approxTokens } from './context.js';
|
|
6
6
|
import { compactConversation } from './compact.js';
|
|
7
|
-
import {
|
|
7
|
+
import { buildToolSchemas, dispatch } from './tools/index.js';
|
|
8
8
|
import { modelPreset } from './models.js';
|
|
9
9
|
import { makeTokenCounter } from './tokenizer.js';
|
|
10
10
|
import { createHooks } from './hooks.js';
|
|
@@ -39,6 +39,25 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
39
39
|
const count = makeTokenCounter(modelName);
|
|
40
40
|
// MCP 工具集(每次取,服务器晚就绪也能在后续轮次出现)
|
|
41
41
|
const mcpSchemas = () => (mcp ? mcp.toolSchemas() : []);
|
|
42
|
+
// 省钱 B1:本会话已调用过的工具名(内置名/MCP 前缀名)——其 schema 在后续轮次省略 description,
|
|
43
|
+
// 模型已在消息历史里见过用途;未用过的保留完整描述。省输入 token(工具 schema 按需瘦身)。
|
|
44
|
+
const usedToolNames = new Set();
|
|
45
|
+
// 省钱 B1(按需挂载):回合起始为「只读阶段」时只发只读工具(read/ls/glob/grep/skill/todo)
|
|
46
|
+
// + 已用过的工具;检测到写意图(用户消息或模型明说需要写/改/建)后注入全量工具。
|
|
47
|
+
const READONLY_TIER_SET = new Set(['read', 'ls', 'glob', 'grep', 'skill', 'todo']);
|
|
48
|
+
const WRITE_INTENT_RE = /写|建|创|改|修|删|装|加|添|增|补|换|移|部署|执行|运行|实现|重构|生成|迁移|安装|更新|升级|发布|调整|优化|修复|提交|推送|打包|编译|测试/;
|
|
49
|
+
const hasWriteIntent = (/** @type {any} */ text) => WRITE_INTENT_RE.test(String(text || ''));
|
|
50
|
+
const toolsFor = (/** @type {boolean} */ readOnlyPhase) => {
|
|
51
|
+
const schemas = buildToolSchemas(usedToolNames, mcpSchemas());
|
|
52
|
+
if (!readOnlyPhase) return schemas;
|
|
53
|
+
return schemas.filter((/** @type {any} */ t) => {
|
|
54
|
+
const n = t?.function?.name;
|
|
55
|
+
if (!n) return true;
|
|
56
|
+
if (READONLY_TIER_SET.has(n) || usedToolNames.has(n)) return true;
|
|
57
|
+
if (n.startsWith('mcp__')) return mcp ? mcp.isReadonly(n) : false;
|
|
58
|
+
return false;
|
|
59
|
+
});
|
|
60
|
+
};
|
|
42
61
|
|
|
43
62
|
// 子代理:全新上下文 + 同一 Provider/权限(提示带「子任务」标记),独立完成子任务后汇报
|
|
44
63
|
async function spawnTask(prompt, { description = '', readOnly = false } = {}) {
|
|
@@ -104,10 +123,34 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
104
123
|
let llmMsTotal = 0;
|
|
105
124
|
let toolMsTotal = 0;
|
|
106
125
|
let firstTokenAt = null;
|
|
107
|
-
|
|
126
|
+
// 省钱 B3(费用二级分账):推理 token 估算(按增量累计)与逐工具调用/耗时累加
|
|
127
|
+
let reasoningTokens = 0;
|
|
128
|
+
const toolStats = /** @type {Map<string, {calls: number, ms: number}>} */ (new Map());
|
|
129
|
+
const perf = () => ({
|
|
130
|
+
llmMs: llmMsTotal,
|
|
131
|
+
toolMs: toolMsTotal,
|
|
132
|
+
firstTokenMs: firstTokenAt == null ? null : firstTokenAt - startedAt,
|
|
133
|
+
steps,
|
|
134
|
+
reasoningTokens,
|
|
135
|
+
toolStats: [...toolStats.entries()].map(([tool, s]) => ({ tool, calls: s.calls, ms: s.ms })),
|
|
136
|
+
usedModel: activeModel, // 省钱 B4:本回合实际使用模型(降级后归属它)
|
|
137
|
+
});
|
|
108
138
|
let aborted = false;
|
|
109
139
|
let emptyRounds = 0; // 连续空/截断输出计数(防止无限续写)
|
|
110
140
|
let currentAc = null;
|
|
141
|
+
// 省钱 B4(护栏降级):action='downgrade' 超限后本回合切换到便宜模型继续执行;
|
|
142
|
+
// activeModel 是本回合实际使用的模型(分账/记录归属它),downgraded 保证只提示一次。
|
|
143
|
+
let activeModel = modelName;
|
|
144
|
+
let downgraded = false;
|
|
145
|
+
// 省钱 B1:本回合只读阶段判定——最新用户消息无写意图则先只发只读工具,
|
|
146
|
+
// 模型明确表达写意图后(下一轮)注入全量。cfg.schemaTier=false 可关。
|
|
147
|
+
let readOnlyPhase = true;
|
|
148
|
+
if (cfg.schemaTier !== false) {
|
|
149
|
+
const lastUser = [...messages].reverse().find((m) => m?.role === 'user');
|
|
150
|
+
readOnlyPhase = !hasWriteIntent(lastUser?.content);
|
|
151
|
+
} else {
|
|
152
|
+
readOnlyPhase = false;
|
|
153
|
+
}
|
|
111
154
|
// 整个回合注册一次 SIGINT:思考、工具执行、权限询问期间都能中断
|
|
112
155
|
const offSigint = io.onSigint ? io.onSigint(() => { aborted = true; currentAc?.abort(); }) : () => {};
|
|
113
156
|
const ctx = makeCtx();
|
|
@@ -168,13 +211,14 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
168
211
|
|
|
169
212
|
// 护栏前置预估(Kimi P2-E):发送前按本轮最坏成本估算(trimmed prompt × 未命中输入价
|
|
170
213
|
// + maxOutput × 输出价,峰谷按当前时段);「今日已用 + 最坏成本」超上限时发送前拦截,
|
|
171
|
-
// 而不是等 200K 上下文的贵请求发出后才 block。
|
|
214
|
+
// 而不是等 200K 上下文的贵请求发出后才 block。action='downgrade'(省钱 B4)不在此拦截——
|
|
215
|
+
// 由降级流程接管:切到便宜模型后按 flash 价格重新估算,通常即可放行。
|
|
172
216
|
if (cfg.costGuard) {
|
|
173
217
|
const g = costGuardConfig();
|
|
174
|
-
if (g && Number(g.dailyLimitYuan) > 0) {
|
|
218
|
+
if (g && Number(g.dailyLimitYuan) > 0 && g.action !== 'downgrade') {
|
|
175
219
|
let promptTokens = 0;
|
|
176
220
|
for (const m of sanitized) promptTokens += messageTokens(m, count);
|
|
177
|
-
const worst = estimateCost(
|
|
221
|
+
const worst = estimateCost(activeModel, promptTokens, maxOutput, null, new Date());
|
|
178
222
|
const used = todayCost();
|
|
179
223
|
if (used + worst >= Number(g.dailyLimitYuan)) {
|
|
180
224
|
stripOrphanCalls();
|
|
@@ -187,7 +231,8 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
187
231
|
}
|
|
188
232
|
}
|
|
189
233
|
|
|
190
|
-
// 费用护栏(A2):每轮开始前按今日实际费用检查;block
|
|
234
|
+
// 费用护栏(A2/B4):每轮开始前按今日实际费用检查;block 暂停本轮;
|
|
235
|
+
// downgrade 自动切换便宜模型继续执行(每回合只切一次,切换即粘滞)
|
|
191
236
|
if (cfg.costGuard) {
|
|
192
237
|
const guard = checkCostGuard();
|
|
193
238
|
if (guard) {
|
|
@@ -206,7 +251,23 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
206
251
|
perf: perf(),
|
|
207
252
|
};
|
|
208
253
|
}
|
|
209
|
-
|
|
254
|
+
if (guard.downgrade && !downgraded) {
|
|
255
|
+
if (guard.downgradeModel !== activeModel) {
|
|
256
|
+
activeModel = guard.downgradeModel;
|
|
257
|
+
downgraded = true;
|
|
258
|
+
io.print(style(guard.message, C.yellow));
|
|
259
|
+
} else {
|
|
260
|
+
// 已经是降级目标模型:无法再降,按 block 处理
|
|
261
|
+
stripOrphanCalls();
|
|
262
|
+
return {
|
|
263
|
+
text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: false,
|
|
264
|
+
note: `今日费用已达上限(实际 ¥${String(todayCost().toFixed(4))}),且已在最便宜模型上执行,已暂停——调整 config.costGuard 或明天自动恢复。`,
|
|
265
|
+
durationMs: Date.now() - startedAt, perf: perf(),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
} else if (!guard.downgrade) {
|
|
269
|
+
io.print(style(guard.message, C.yellow));
|
|
270
|
+
}
|
|
210
271
|
}
|
|
211
272
|
}
|
|
212
273
|
|
|
@@ -221,9 +282,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
221
282
|
const llmT0 = Date.now();
|
|
222
283
|
try {
|
|
223
284
|
res = await provider.chat({
|
|
224
|
-
model:
|
|
285
|
+
model: activeModel,
|
|
225
286
|
messages: sanitized,
|
|
226
|
-
tools:
|
|
287
|
+
tools: toolsFor(readOnlyPhase),
|
|
227
288
|
temperature,
|
|
228
289
|
maxTokens: maxOutput,
|
|
229
290
|
reasoningEffort,
|
|
@@ -232,7 +293,10 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
232
293
|
io.stopSpinner();
|
|
233
294
|
if (firstTokenAt == null) firstTokenAt = Date.now(); // 首个增量即首 token
|
|
234
295
|
if (d.text) io.writeText(d.text);
|
|
235
|
-
if (d.reasoning)
|
|
296
|
+
if (d.reasoning) {
|
|
297
|
+
reasoningTokens += approxTokens(d.reasoning); // 省钱 B3:推理 token 估算(分账维度)
|
|
298
|
+
io.writeReasoning(d.reasoning);
|
|
299
|
+
}
|
|
236
300
|
},
|
|
237
301
|
});
|
|
238
302
|
llmMsTotal += Date.now() - llmT0;
|
|
@@ -248,6 +312,8 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
248
312
|
}
|
|
249
313
|
|
|
250
314
|
finish = res.finish ?? finish;
|
|
315
|
+
// 省钱 B1:只读阶段中模型文字明确表达写意图 → 下一轮注入全量工具(多一轮,几乎无感)
|
|
316
|
+
if (readOnlyPhase && hasWriteIntent(res.text)) readOnlyPhase = false;
|
|
251
317
|
if (res.usage) {
|
|
252
318
|
usage.prompt_tokens += res.usage.prompt_tokens || 0;
|
|
253
319
|
usage.completion_tokens += res.usage.completion_tokens || 0;
|
|
@@ -339,6 +405,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
339
405
|
// 后续相同调用直接复用结果(仍逐个回填 tool 消息以保持 tool_call_id 配对)
|
|
340
406
|
async function runTool(prep) {
|
|
341
407
|
io.renderToolStart?.(prep.name, prep.args);
|
|
408
|
+
usedToolNames.add(prep.name); // 省钱 B1:执行过即标记,后续轮次省略其 description
|
|
342
409
|
const dedupKey = !prep.isMcp && READONLY_TOOLS_SET.has(prep.name) ? prep.name + ':' + JSON.stringify(prep.args || {}) : null;
|
|
343
410
|
if (dedupKey && turnToolCache.has(dedupKey)) {
|
|
344
411
|
prep.cached = true;
|
|
@@ -363,6 +430,11 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
363
430
|
function finishTool(prep, result, t0) {
|
|
364
431
|
const ms = Date.now() - t0;
|
|
365
432
|
toolMsTotal += ms;
|
|
433
|
+
// 省钱 B3:逐工具调用/耗时累加(费用二级分账的 byTool 维度)
|
|
434
|
+
const ts = toolStats.get(prep.name) || { calls: 0, ms: 0 };
|
|
435
|
+
ts.calls += 1;
|
|
436
|
+
ts.ms += ms;
|
|
437
|
+
toolStats.set(prep.name, ts);
|
|
366
438
|
io.renderTool(prep.name, prep.args, result, ms);
|
|
367
439
|
if (prep.name === 'todo' && result?.todos) io.renderTodo(result.todos);
|
|
368
440
|
hooks.post(prep.name, prep.args, typeof result === 'string' ? { output: result } : result).catch(() => {});
|
package/src/batch.js
CHANGED
|
@@ -8,9 +8,12 @@
|
|
|
8
8
|
|
|
9
9
|
import fs from 'node:fs';
|
|
10
10
|
import path from 'node:path';
|
|
11
|
+
import crypto from 'node:crypto';
|
|
11
12
|
import { resolveProviderConfig } from './providers/index.js';
|
|
12
13
|
import { estimateBatchCost, BATCH_DISCOUNT } from './pricing.js';
|
|
13
14
|
import { recordCacheStats } from './cachestats.js';
|
|
15
|
+
import { approxTokens } from './context.js';
|
|
16
|
+
import { modelPreset } from './models.js';
|
|
14
17
|
|
|
15
18
|
const DEFAULT_WINDOW = '24h';
|
|
16
19
|
const DEFAULT_ENDPOINT = '/v1/chat/completions';
|
|
@@ -84,9 +87,11 @@ async function downloadResults(base, apiKey, batch) {
|
|
|
84
87
|
throw new Error('批处理结果文件不可用(端点不支持或文件已过期)');
|
|
85
88
|
}
|
|
86
89
|
|
|
87
|
-
/** 执行一次批处理。questions: string[]。返回 { ok, outputFile, results, usage, cost, batchId }
|
|
88
|
-
*
|
|
89
|
-
|
|
90
|
+
/** 执行一次批处理。questions: string[]。返回 { ok, outputFile, results, usage, cost, batchId, deduped }
|
|
91
|
+
* 省钱 B2:①文本哈希去重(重复问题只提交一次,结果回填全部位置);②单问超窗口预检(提交前报错,不烧钱);
|
|
92
|
+
* ③maxCost 预算上限(提交前按估算费用拦截,超出直接中止不提交)。
|
|
93
|
+
* @param {{ cfg: any, model: any, questions: any, workingDir?: string, maxTokens?: number, temperature?: any, signal?: any, onStatus?: any, maxCost?: number }} opts */
|
|
94
|
+
export async function runBatch({ cfg, model, questions, workingDir = process.cwd(), maxTokens = 4096, temperature, signal, onStatus, maxCost = 0 }) {
|
|
90
95
|
const list = (questions || []).map((q) => String(q).trim()).filter(Boolean);
|
|
91
96
|
if (!list.length) return { error: '没有可批处理的问题(每行一个问题)' };
|
|
92
97
|
const pc = resolveProviderConfig(cfg, model);
|
|
@@ -97,6 +102,50 @@ export async function runBatch({ cfg, model, questions, workingDir = process.cwd
|
|
|
97
102
|
// 不再携带完整系统提示(技能清单/用户记忆/AGENTS.md 对无工具批任务毫无意义,1000 问
|
|
98
103
|
// 可白烧 50-80 万 token);改用一行精简角色提示,剩余能力损失为零。
|
|
99
104
|
const systemPrompt = '你是 MingDao Harness 编程助手。直接针对每个问题给出准确、完整的答案,不要复述问题、不要解释过程。';
|
|
105
|
+
|
|
106
|
+
// —— 省钱 B2:文本哈希去重(trim 后逐字节一致视为重复)——
|
|
107
|
+
const positions = /** @type {Map<string, number[]>} */ (new Map()); // hash → [输入行下标...]
|
|
108
|
+
const unique = /** @type {string[]} */ ([]);
|
|
109
|
+
list.forEach((/** @type {string} */ q, /** @type {number} */ i) => {
|
|
110
|
+
const h = crypto.createHash('sha256').update(q).digest('hex');
|
|
111
|
+
if (!positions.has(h)) {
|
|
112
|
+
positions.set(h, [i]);
|
|
113
|
+
unique.push(q);
|
|
114
|
+
} else {
|
|
115
|
+
positions.get(h)?.push(i);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
const posByUniqueIdx = unique.map((/** @type {string} */ q) => positions.get(crypto.createHash('sha256').update(q).digest('hex')) || []);
|
|
119
|
+
const deduped = list.length - unique.length;
|
|
120
|
+
if (deduped > 0) onStatus?.(`去重:${deduped} 条重复问题合并(${list.length} → ${unique.length} 条实际提交)`);
|
|
121
|
+
|
|
122
|
+
// —— 省钱 B2:单问超窗口预检(估算输入 + max_tokens 超过模型窗口 95% 即报错,绝不提交烧钱)——
|
|
123
|
+
const preset = modelPreset(model) || {};
|
|
124
|
+
const windowTokens = Number(preset.budgetTokens) || 128000;
|
|
125
|
+
const sysTokens = approxTokens(systemPrompt);
|
|
126
|
+
for (let i = 0; i < unique.length; i++) {
|
|
127
|
+
const est = sysTokens + approxTokens(unique[i]);
|
|
128
|
+
if (est + maxTokens > windowTokens * 0.95) {
|
|
129
|
+
return {
|
|
130
|
+
error: `第 ${posByUniqueIdx[i][0] + 1} 个问题超窗口:估算输入 ${est} + max_tokens ${maxTokens} > 模型窗口 ${windowTokens}(可 --max-tokens 调小或拆分问题)`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// —— 省钱 B2:--max-cost 预算上限(提交前估算拦截)——
|
|
136
|
+
let estimatedCost = 0;
|
|
137
|
+
if (maxCost > 0) {
|
|
138
|
+
const estPrompt = unique.reduce((s, q) => s + sysTokens + approxTokens(q), 0);
|
|
139
|
+
const estCompletion = unique.length * Math.min(maxTokens, 2048); // 保守按平均 2K 输出估算
|
|
140
|
+
estimatedCost = estimateBatchCost(model, estPrompt, estCompletion);
|
|
141
|
+
if (estimatedCost > maxCost) {
|
|
142
|
+
return {
|
|
143
|
+
error: `预计费用 ≈¥${estimatedCost.toFixed(4)}(已按半价)超过 --max-cost ¥${maxCost},已中止提交。可缩小问题集或调高上限。`,
|
|
144
|
+
estimatedCost,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
100
149
|
const bodyTemplate = {
|
|
101
150
|
model,
|
|
102
151
|
messages: null, // 逐行填充
|
|
@@ -105,7 +154,7 @@ export async function runBatch({ cfg, model, questions, workingDir = process.cwd
|
|
|
105
154
|
stream: false,
|
|
106
155
|
};
|
|
107
156
|
const jsonl =
|
|
108
|
-
|
|
157
|
+
unique
|
|
109
158
|
.map((q, i) =>
|
|
110
159
|
JSON.stringify({
|
|
111
160
|
custom_id: `md-${i}`,
|
|
@@ -164,24 +213,33 @@ export async function runBatch({ cfg, model, questions, workingDir = process.cwd
|
|
|
164
213
|
}
|
|
165
214
|
onStatus?.('下载结果…');
|
|
166
215
|
const results = await downloadResults(base, apiKey, batch);
|
|
167
|
-
// 汇总 usage 与费用(batch
|
|
216
|
+
// 汇总 usage 与费用(batch 半价);结果按 custom_id 回填到全部重复位置(省钱 B2)
|
|
168
217
|
let prompt = 0;
|
|
169
218
|
let completion = 0;
|
|
170
|
-
const outputs =
|
|
219
|
+
const outputs = /** @type {Array<{id: string, content: string}>} */ (new Array(list.length));
|
|
171
220
|
for (const r of results) {
|
|
172
221
|
const body = r?.response?.body || {};
|
|
173
222
|
prompt += body.usage?.prompt_tokens || 0;
|
|
174
223
|
completion += body.usage?.completion_tokens || 0;
|
|
175
224
|
const content = body.choices?.[0]?.message?.content ?? body.choices?.[0]?.text ?? (r?.response?.status_code !== 200 ? `(错误 ${r?.response?.status_code})` : '');
|
|
176
|
-
|
|
225
|
+
const m = /^md-(\d+)$/.exec(String(r.custom_id || ''));
|
|
226
|
+
const ui = m ? Number(m[1]) : -1;
|
|
227
|
+
const idxs = ui >= 0 && ui < posByUniqueIdx.length ? posByUniqueIdx[ui] : [];
|
|
228
|
+
for (const pos of idxs) {
|
|
229
|
+
outputs[pos] = { id: String(pos), content: String(content || '').trim() };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// 防御:服务端漏回的 custom_id 用占位补全,保证输出行数与输入行数一一对应
|
|
233
|
+
for (let i = 0; i < outputs.length; i++) {
|
|
234
|
+
if (!outputs[i]) outputs[i] = { id: String(i), content: '' };
|
|
177
235
|
}
|
|
178
236
|
const usage = { prompt_tokens: prompt, completion_tokens: completion };
|
|
179
237
|
const cost = estimateBatchCost(model, prompt, completion);
|
|
180
238
|
const outFile = path.join(workingDir, `mingdao-batch-result-${Date.now()}.jsonl`);
|
|
181
239
|
fs.writeFileSync(outFile, outputs.map((o) => JSON.stringify(o)).join('\n') + '\n');
|
|
182
240
|
recordCacheStats({ model, prompt, completion, hit: null, miss: null, cost, saved: null, batch: true });
|
|
183
|
-
onStatus?.(`完成:${outputs.length}
|
|
184
|
-
return { ok: true, batchId: batch.id, outputFile: outFile, results: outputs, usage, cost, discount: BATCH_DISCOUNT };
|
|
241
|
+
onStatus?.(`完成:${outputs.length} 条结果${deduped ? `(去重合并 ${deduped} 条)` : ''}`);
|
|
242
|
+
return { ok: true, batchId: batch.id, outputFile: outFile, results: outputs, usage, cost, discount: BATCH_DISCOUNT, deduped, estimatedCost };
|
|
185
243
|
} catch (err) {
|
|
186
244
|
// 批处理端点不支持(404/405 等)→ 明确告知,不静默
|
|
187
245
|
return { error: `批处理不可用:${err?.message || err}(该服务商可能不支持 Batch API,可用 config.batchBaseUrl 指定支持的网关)` };
|
package/src/cachestats.js
CHANGED
|
@@ -33,6 +33,8 @@ export function recordCacheStats(entry) {
|
|
|
33
33
|
llmMs: entry.llmMs ?? undefined, // 模型调用总耗时(状态栏 LLM 时长/tok-s)
|
|
34
34
|
toolMs: entry.toolMs ?? undefined, // 工具执行总耗时(状态栏 工具调用时长)
|
|
35
35
|
firstTokenMs: entry.firstTokenMs ?? undefined, // 首 token 延迟(状态栏平均首 token)
|
|
36
|
+
reasoning: entry.reasoning ?? undefined, // 省钱 B3:推理 token 估算(费用二级分账维度)
|
|
37
|
+
byTool: Array.isArray(entry.byTool) ? entry.byTool : undefined, // 省钱 B3:逐工具 {tool,calls,ms}
|
|
36
38
|
});
|
|
37
39
|
fs.appendFileSync(cacheStatsFile(), line + '\n');
|
|
38
40
|
cacheStatsCount += 1;
|
|
@@ -125,6 +127,8 @@ export function recordUsage(modelName, usage, perf = null) {
|
|
|
125
127
|
llmMs: perf?.llmMs ?? undefined,
|
|
126
128
|
toolMs: perf?.toolMs ?? undefined,
|
|
127
129
|
firstTokenMs: perf?.firstTokenMs ?? undefined,
|
|
130
|
+
reasoning: perf?.reasoningTokens ?? undefined, // 省钱 B3
|
|
131
|
+
byTool: perf?.toolStats ?? undefined, // 省钱 B3
|
|
128
132
|
});
|
|
129
133
|
}
|
|
130
134
|
|
|
@@ -139,9 +143,12 @@ export function formatCacheSummary(sum) {
|
|
|
139
143
|
}
|
|
140
144
|
|
|
141
145
|
// 分账统计(评估 /cost 升级):按模型分账、今日费用、batch 半价任务、节省归因
|
|
146
|
+
// 省钱 B3:新增 reasoning 维度、byTool 逐工具累加、byDay 近 14 天按日折线数据
|
|
142
147
|
export function costBreakdown() {
|
|
143
148
|
const entries = listCacheStats(100000);
|
|
144
149
|
const byModel = new Map();
|
|
150
|
+
const byTool = /** @type {Map<string, {calls: number, ms: number}>} */ (new Map());
|
|
151
|
+
const byDay = /** @type {Map<string, number>} */ (new Map()); // 'MM-DD' → cost(近 14 个北京日,含 0 值日)
|
|
145
152
|
const start = beijingDayStart().getTime();
|
|
146
153
|
let totalCost = 0;
|
|
147
154
|
let totalSaved = 0;
|
|
@@ -149,21 +156,45 @@ export function costBreakdown() {
|
|
|
149
156
|
let batchCost = 0;
|
|
150
157
|
let hit = 0;
|
|
151
158
|
let miss = 0;
|
|
159
|
+
let reasoning = 0;
|
|
152
160
|
for (const e of entries) {
|
|
153
161
|
totalCost += e.cost || 0;
|
|
154
162
|
totalSaved += e.saved || 0;
|
|
155
163
|
hit += e.hit || 0;
|
|
156
164
|
miss += e.miss || 0;
|
|
165
|
+
reasoning += e.reasoning || 0;
|
|
157
166
|
if (e.batch) batchCost += e.cost || 0;
|
|
158
167
|
if (e.at >= start) today += e.cost || 0;
|
|
159
|
-
const m = byModel.get(e.model) || { prompt: 0, completion: 0, cost: 0, saved: 0, turns: 0, batchTurns: 0 };
|
|
168
|
+
const m = byModel.get(e.model) || { prompt: 0, completion: 0, cost: 0, saved: 0, turns: 0, batchTurns: 0, reasoning: 0 };
|
|
160
169
|
m.prompt += e.prompt || 0;
|
|
161
170
|
m.completion += e.completion || 0;
|
|
162
171
|
m.cost += e.cost || 0;
|
|
163
172
|
m.saved += e.saved || 0;
|
|
164
173
|
m.turns += 1;
|
|
174
|
+
m.reasoning += e.reasoning || 0;
|
|
165
175
|
if (e.batch) m.batchTurns += 1;
|
|
166
176
|
byModel.set(e.model, m);
|
|
177
|
+
if (Array.isArray(e.byTool)) {
|
|
178
|
+
for (const t of e.byTool) {
|
|
179
|
+
const tt = byTool.get(t.tool) || { calls: 0, ms: 0 };
|
|
180
|
+
tt.calls += t.calls || 0;
|
|
181
|
+
tt.ms += t.ms || 0;
|
|
182
|
+
byTool.set(t.tool, tt);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (e.at) {
|
|
186
|
+
const p = beijingParts(new Date(e.at));
|
|
187
|
+
const key = `${String(p.month).padStart(2, '0')}-${String(p.day).padStart(2, '0')}`;
|
|
188
|
+
byDay.set(key, (byDay.get(key) || 0) + (e.cost || 0));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// 补全近 14 天(含无记录的 0 值日),保证折线 x 轴连续
|
|
192
|
+
const days = /** @type {Array<{day: string, cost: number}>} */ ([]);
|
|
193
|
+
for (let i = 13; i >= 0; i--) {
|
|
194
|
+
const d = new Date(start - i * 86400000);
|
|
195
|
+
const p = beijingParts(d);
|
|
196
|
+
const key = `${String(p.month).padStart(2, '0')}-${String(p.day).padStart(2, '0')}`;
|
|
197
|
+
days.push({ day: key, cost: byDay.get(key) || 0 });
|
|
167
198
|
}
|
|
168
199
|
return {
|
|
169
200
|
totalCost,
|
|
@@ -173,11 +204,15 @@ export function costBreakdown() {
|
|
|
173
204
|
hit,
|
|
174
205
|
miss,
|
|
175
206
|
rate: hit + miss > 0 ? hit / (hit + miss) : null,
|
|
207
|
+
reasoning,
|
|
176
208
|
byModel: [...byModel.entries()].map(([model, m]) => ({ model, ...m })).sort((a, b) => b.cost - a.cost),
|
|
209
|
+
byTool: [...byTool.entries()].map(([tool, t]) => ({ tool, ...t })).sort((a, b) => b.ms - a.ms),
|
|
210
|
+
byDay: days,
|
|
177
211
|
};
|
|
178
212
|
}
|
|
179
213
|
|
|
180
214
|
// 月度费用报告(/cost 导出):按北京时间月份聚合,month 形如 'YYYY-MM',缺省返回全部月份
|
|
215
|
+
// 省钱 B3:月度聚合增加 reasoning 与 byTool 维度
|
|
181
216
|
export function costMonthlyReport(month) {
|
|
182
217
|
const entries = listCacheStats(100000);
|
|
183
218
|
const want = String(month || '').trim();
|
|
@@ -188,7 +223,7 @@ export function costMonthlyReport(month) {
|
|
|
188
223
|
if (want && key !== want) continue;
|
|
189
224
|
let m = months.get(key);
|
|
190
225
|
if (!m) {
|
|
191
|
-
m = { month: key, cost: 0, saved: 0, prompt: 0, completion: 0, hit: 0, miss: 0, batchCost: 0, days: new Map(), models: new Map() };
|
|
226
|
+
m = { month: key, cost: 0, saved: 0, prompt: 0, completion: 0, hit: 0, miss: 0, batchCost: 0, reasoning: 0, days: new Map(), models: new Map(), tools: new Map() };
|
|
192
227
|
months.set(key, m);
|
|
193
228
|
}
|
|
194
229
|
m.cost += e.cost || 0;
|
|
@@ -197,6 +232,7 @@ export function costMonthlyReport(month) {
|
|
|
197
232
|
m.completion += e.completion || 0;
|
|
198
233
|
m.hit += e.hit || 0;
|
|
199
234
|
m.miss += e.miss || 0;
|
|
235
|
+
m.reasoning += e.reasoning || 0;
|
|
200
236
|
if (e.batch) m.batchCost += e.cost || 0;
|
|
201
237
|
const day = String(p.day).padStart(2, '0');
|
|
202
238
|
m.days.set(day, (m.days.get(day) || 0) + (e.cost || 0));
|
|
@@ -206,6 +242,14 @@ export function costMonthlyReport(month) {
|
|
|
206
242
|
mm.cost += e.cost || 0;
|
|
207
243
|
mm.turns += 1;
|
|
208
244
|
m.models.set(e.model, mm);
|
|
245
|
+
if (Array.isArray(e.byTool)) {
|
|
246
|
+
for (const t of e.byTool) {
|
|
247
|
+
const tt = m.tools.get(t.tool) || { calls: 0, ms: 0 };
|
|
248
|
+
tt.calls += t.calls || 0;
|
|
249
|
+
tt.ms += t.ms || 0;
|
|
250
|
+
m.tools.set(t.tool, tt);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
209
253
|
}
|
|
210
254
|
return [...months.values()]
|
|
211
255
|
.sort((a, b) => a.month.localeCompare(b.month))
|
|
@@ -219,7 +263,9 @@ export function costMonthlyReport(month) {
|
|
|
219
263
|
miss: m.miss,
|
|
220
264
|
rate: m.hit + m.miss > 0 ? m.hit / (m.hit + m.miss) : null,
|
|
221
265
|
batchCost: m.batchCost,
|
|
266
|
+
reasoning: m.reasoning,
|
|
222
267
|
days: [...m.days.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([day, cost]) => ({ day, cost })),
|
|
223
268
|
models: [...m.models.entries()].map(([model, x]) => ({ model, ...x })).sort((a, b) => b.cost - a.cost),
|
|
269
|
+
tools: [...m.tools.entries()].map(([tool, t]) => ({ tool, ...t })).sort((a, b) => b.ms - a.ms),
|
|
224
270
|
}));
|
|
225
271
|
}
|
package/src/cli.js
CHANGED
|
@@ -230,7 +230,7 @@ async function runWorkerTask(id, question, { permission, model, offpeak }) {
|
|
|
230
230
|
} catch {}
|
|
231
231
|
}
|
|
232
232
|
const finalStatus = res.truncated ? 'failed' : res.aborted ? 'killed' : 'done';
|
|
233
|
-
recordUsage(modelName, res.usage, res.perf);
|
|
233
|
+
recordUsage(res.perf?.usedModel || modelName, res.usage, res.perf);
|
|
234
234
|
finish({
|
|
235
235
|
status: finalStatus,
|
|
236
236
|
text: (res.text || '').slice(0, 2000),
|
|
@@ -565,12 +565,12 @@ async function main() {
|
|
|
565
565
|
session: session.name,
|
|
566
566
|
})
|
|
567
567
|
);
|
|
568
|
-
recordUsage(modelName, res.usage, res.perf);
|
|
568
|
+
recordUsage(res.perf?.usedModel || modelName, res.usage, res.perf);
|
|
569
569
|
process.exitCode = res.truncated || res.aborted ? 1 : 0;
|
|
570
570
|
} else {
|
|
571
571
|
io.printUsageLine({ modelName, usage: res.usage, durationMs: res.durationMs });
|
|
572
572
|
if (res.aborted) io.print(style('(已中断)', C.dim));
|
|
573
|
-
recordUsage(modelName, res.usage, res.perf);
|
|
573
|
+
recordUsage(res.perf?.usedModel || modelName, res.usage, res.perf);
|
|
574
574
|
process.exitCode = res.truncated ? 1 : 0;
|
|
575
575
|
}
|
|
576
576
|
} catch (err) {
|
|
@@ -1094,7 +1094,7 @@ async function main() {
|
|
|
1094
1094
|
lastUsage = res.usage;
|
|
1095
1095
|
lastText = res.text || lastText;
|
|
1096
1096
|
stats.turns += 1;
|
|
1097
|
-
recordUsage(modelName, res.usage, res.perf);
|
|
1097
|
+
recordUsage(res.perf?.usedModel || modelName, res.usage, res.perf);
|
|
1098
1098
|
stats.promptTokens += res.usage.prompt_tokens || 0;
|
|
1099
1099
|
stats.completionTokens += res.usage.completion_tokens || 0;
|
|
1100
1100
|
const fresh = messages.slice(persisted);
|
package/src/commands/update.js
CHANGED
|
@@ -33,7 +33,7 @@ export async function handleUpdateFamily(cmd, args) {
|
|
|
33
33
|
return true;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
// Batch API 半价批处理(A1):mingdao batch <问题文件|-> [--model 名] [--max-tokens n]
|
|
36
|
+
// Batch API 半价批处理(A1/B2):mingdao batch <问题文件|-> [--model 名] [--max-tokens n] [--max-cost 元]
|
|
37
37
|
if (cmd === 'batch') {
|
|
38
38
|
const { runBatch } = await import('../batch.js');
|
|
39
39
|
const cfgB = loadConfig();
|
|
@@ -44,16 +44,18 @@ export async function handleUpdateFamily(cmd, args) {
|
|
|
44
44
|
}
|
|
45
45
|
let model = cfgB.model || 'deepseek-v4-flash';
|
|
46
46
|
let maxTokens = 4096;
|
|
47
|
+
let maxCost = 0; // 省钱 B2:预算上限(元),提交前按估算拦截
|
|
47
48
|
let srcFile = null;
|
|
48
49
|
for (let i = 0; i < args.length; i++) {
|
|
49
50
|
const a = args[i];
|
|
50
51
|
if (a === '--model') model = args[++i];
|
|
51
52
|
else if (a === '--max-tokens') maxTokens = Number(args[++i]) || 4096;
|
|
53
|
+
else if (a === '--max-cost') maxCost = Number(args[++i]) || 0;
|
|
52
54
|
else if (a === '--out') { i += 1; /* 输出默认落工作目录 */ }
|
|
53
55
|
else if (!srcFile) srcFile = a;
|
|
54
56
|
}
|
|
55
57
|
if (!srcFile) {
|
|
56
|
-
console.log('用法:mingdao batch <问题文件|-> 每行一个问题(--model 名 --max-tokens n
|
|
58
|
+
console.log('用法:mingdao batch <问题文件|-> 每行一个问题(--model 名 --max-tokens n --max-cost 元)');
|
|
57
59
|
process.exitCode = 1;
|
|
58
60
|
return true;
|
|
59
61
|
}
|
|
@@ -80,6 +82,7 @@ export async function handleUpdateFamily(cmd, args) {
|
|
|
80
82
|
questions,
|
|
81
83
|
workingDir: process.cwd(),
|
|
82
84
|
maxTokens,
|
|
85
|
+
maxCost,
|
|
83
86
|
signal: ac.signal,
|
|
84
87
|
onStatus: (st) => console.log(' ' + st),
|
|
85
88
|
});
|
|
@@ -90,6 +93,7 @@ export async function handleUpdateFamily(cmd, args) {
|
|
|
90
93
|
return true;
|
|
91
94
|
}
|
|
92
95
|
console.log(`✓ 完成 ${r.results.length} 条 · ↑${r.usage.prompt_tokens} ↓${r.usage.completion_tokens} tokens · 费用 ≈¥${r.cost.toFixed(5)}(已按半价)`);
|
|
96
|
+
if (r.deduped) console.log(` (去重合并 ${r.deduped} 条重复问题,结果已回填全部位置)`);
|
|
93
97
|
console.log(` 结果文件:${r.outputFile}`);
|
|
94
98
|
console.log(` 任务 ID:${r.batchId}(结果已计入 /cost 分账)`);
|
|
95
99
|
return true;
|
|
@@ -123,13 +127,21 @@ export async function handleUpdateFamily(cmd, args) {
|
|
|
123
127
|
md.push('');
|
|
124
128
|
md.push(`- 实际费用 ≈¥${r.cost.toFixed(5)}${r.batchCost > 0 ? `(含 Batch 半价任务 ≈¥${r.batchCost.toFixed(5)})` : ''}`);
|
|
125
129
|
md.push(`- 相比全未命中已节省 ≈¥${r.saved.toFixed(5)}${r.rate != null ? ` · 缓存命中率 ${(r.rate * 100).toFixed(0)}%` : ''}`);
|
|
126
|
-
md.push(`- Tokens:↑${r.prompt} / ↓${r.completion}`);
|
|
130
|
+
md.push(`- Tokens:↑${r.prompt} / ↓${r.completion}${r.reasoning ? ` · 推理 ${r.reasoning}` : ''}`);
|
|
127
131
|
md.push('');
|
|
128
132
|
md.push('## 按模型分账');
|
|
129
133
|
md.push('');
|
|
130
134
|
md.push('| 模型 | 轮次 | ↑prompt | ↓completion | 费用 |');
|
|
131
135
|
md.push('| --- | --- | --- | --- | --- |');
|
|
132
136
|
for (const m of r.models) md.push(`| ${m.model} | ${m.turns} | ${m.prompt} | ${m.completion} | ≈¥${m.cost.toFixed(5)} |`);
|
|
137
|
+
if (r.tools.length) {
|
|
138
|
+
md.push('');
|
|
139
|
+
md.push('## 按工具分账');
|
|
140
|
+
md.push('');
|
|
141
|
+
md.push('| 工具 | 调用 | 耗时(ms) |');
|
|
142
|
+
md.push('| --- | --- | --- |');
|
|
143
|
+
for (const t of r.tools.slice(0, 10)) md.push(`| ${t.tool} | ${t.calls} | ${t.ms} |`);
|
|
144
|
+
}
|
|
133
145
|
md.push('');
|
|
134
146
|
md.push('## 每日费用');
|
|
135
147
|
md.push('');
|
package/src/cost-guard.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// 费用护栏(四报告共识 A2/Kimi P-1:把峰谷定价从「展示」升级为「主动约束」):
|
|
2
|
-
// config.costGuard = { dailyLimitYuan: 10, warnAtYuan: 8, action: 'warn'|'block' }
|
|
2
|
+
// config.costGuard = { dailyLimitYuan: 10, warnAtYuan: 8, action: 'warn'|'block'|'downgrade', downgradeModel?: 'deepseek-v4-flash' }
|
|
3
3
|
// 按北京时间自然日累计 cache-stats 实际费用(含 batch 半价与缓存折扣后的真实口径)。
|
|
4
|
-
// Agent 每轮开始前检查:超 warn
|
|
4
|
+
// Agent 每轮开始前检查:超 warn 线提醒;超 limit 时按 action 处理——
|
|
5
|
+
// block:暂停执行;downgrade(省钱 B4):自动切换便宜模型继续跑(默认 deepseek-v4-flash)。
|
|
5
6
|
|
|
6
7
|
import fs from 'node:fs';
|
|
7
8
|
import { listCacheStats, cacheStatsFile } from './cachestats.js';
|
|
@@ -41,18 +42,19 @@ export function costGuardStatus() {
|
|
|
41
42
|
const cost = todayCost();
|
|
42
43
|
const limit = Number(g.dailyLimitYuan) || 0;
|
|
43
44
|
const warnAt = Number(g.warnAtYuan) || (limit > 0 ? limit * 0.8 : 0);
|
|
44
|
-
const action = g.action === 'block'
|
|
45
|
+
const action = g.action === 'block' || g.action === 'downgrade' ? g.action : 'warn';
|
|
45
46
|
return {
|
|
46
47
|
cost,
|
|
47
48
|
limit,
|
|
48
49
|
warnAt,
|
|
49
50
|
action,
|
|
51
|
+
downgradeModel: String(g.downgradeModel || 'deepseek-v4-flash'),
|
|
50
52
|
overWarn: limit > 0 && cost >= warnAt,
|
|
51
53
|
overLimit: limit > 0 && cost >= limit,
|
|
52
54
|
};
|
|
53
55
|
}
|
|
54
56
|
|
|
55
|
-
// Agent 每轮开始前调用:返回 null 放行;blocked=true
|
|
57
|
+
// Agent 每轮开始前调用:返回 null 放行;blocked=true 应暂停本轮;downgrade=true 应切换便宜模型
|
|
56
58
|
export function checkCostGuard() {
|
|
57
59
|
const st = costGuardStatus();
|
|
58
60
|
if (!st) return null;
|
|
@@ -62,10 +64,18 @@ export function checkCostGuard() {
|
|
|
62
64
|
message: `今日费用已达上限 ¥${st.limit.toFixed(2)}(实际 ¥${st.cost.toFixed(4)}),已暂停执行——调整 config.costGuard 或明天自动恢复。`,
|
|
63
65
|
};
|
|
64
66
|
}
|
|
67
|
+
if (st.overLimit && st.action === 'downgrade') {
|
|
68
|
+
return {
|
|
69
|
+
blocked: false,
|
|
70
|
+
downgrade: true,
|
|
71
|
+
downgradeModel: st.downgradeModel,
|
|
72
|
+
message: `⚠ 费用护栏:今日已用 ¥${st.cost.toFixed(4)} 超上限 ¥${st.limit.toFixed(2)}——已自动降级到便宜模型 ${st.downgradeModel} 继续执行(config.costGuard.action 可改回 warn/block)。`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
65
75
|
if (st.overWarn) {
|
|
66
76
|
return {
|
|
67
77
|
blocked: false,
|
|
68
|
-
message: `⚠ 费用护栏:今日已用 ¥${st.cost.toFixed(4)} / 上限 ¥${st.limit.toFixed(2)}${st.action === 'block' ? '
|
|
78
|
+
message: `⚠ 费用护栏:今日已用 ¥${st.cost.toFixed(4)} / 上限 ¥${st.limit.toFixed(2)}${st.action !== 'warn' ? `(${st.action === 'block' ? '到达即暂停' : '到达自动降级 ' + st.downgradeModel})` : '(仅提醒)'}。`,
|
|
69
79
|
};
|
|
70
80
|
}
|
|
71
81
|
return null;
|
package/src/tools/index.js
CHANGED
|
@@ -11,9 +11,9 @@ export const READONLY_TOOLS = new Set(['read', 'glob', 'grep', 'ls', 'skill']);
|
|
|
11
11
|
const READ_SCHEMA = {
|
|
12
12
|
type: 'object',
|
|
13
13
|
properties: {
|
|
14
|
-
path: { type: 'string', description: '
|
|
14
|
+
path: { type: 'string', description: '文件路径(相对或绝对)。' },
|
|
15
15
|
offset: { type: 'integer', description: '起始行号,默认 1。' },
|
|
16
|
-
limit: { type: 'integer', description: '
|
|
16
|
+
limit: { type: 'integer', description: '最大行数,默认 400。' },
|
|
17
17
|
},
|
|
18
18
|
required: ['path'],
|
|
19
19
|
};
|
|
@@ -21,7 +21,7 @@ const READ_SCHEMA = {
|
|
|
21
21
|
const WRITE_SCHEMA = {
|
|
22
22
|
type: 'object',
|
|
23
23
|
properties: {
|
|
24
|
-
path: { type: 'string', description: '
|
|
24
|
+
path: { type: 'string', description: '文件路径。' },
|
|
25
25
|
content: { type: 'string', description: '完整文件内容(UTF-8)。' },
|
|
26
26
|
},
|
|
27
27
|
required: ['path', 'content'],
|
|
@@ -30,10 +30,10 @@ const WRITE_SCHEMA = {
|
|
|
30
30
|
const EDIT_SCHEMA = {
|
|
31
31
|
type: 'object',
|
|
32
32
|
properties: {
|
|
33
|
-
path: { type: 'string', description: '
|
|
34
|
-
old_string: { type: 'string', description: '
|
|
35
|
-
new_string: { type: 'string', description: '
|
|
36
|
-
replace_all: { type: 'boolean', description: '
|
|
33
|
+
path: { type: 'string', description: '文件路径。' },
|
|
34
|
+
old_string: { type: 'string', description: '原文片段,必须逐字一致。' },
|
|
35
|
+
new_string: { type: 'string', description: '替换后文本。' },
|
|
36
|
+
replace_all: { type: 'boolean', description: '替换所有匹配,默认 false。' },
|
|
37
37
|
},
|
|
38
38
|
required: ['path', 'old_string', 'new_string'],
|
|
39
39
|
};
|
|
@@ -41,15 +41,15 @@ const EDIT_SCHEMA = {
|
|
|
41
41
|
const LS_SCHEMA = {
|
|
42
42
|
type: 'object',
|
|
43
43
|
properties: {
|
|
44
|
-
path: { type: 'string', description: '
|
|
44
|
+
path: { type: 'string', description: '目录路径,默认当前目录。' },
|
|
45
45
|
},
|
|
46
46
|
};
|
|
47
47
|
|
|
48
48
|
const GLOB_SCHEMA = {
|
|
49
49
|
type: 'object',
|
|
50
50
|
properties: {
|
|
51
|
-
pattern: { type: 'string', description: '通配符模式,如 src/**/*.js
|
|
52
|
-
path: { type: 'string', description: '
|
|
51
|
+
pattern: { type: 'string', description: '通配符模式,如 src/**/*.js。' },
|
|
52
|
+
path: { type: 'string', description: '搜索根目录。' },
|
|
53
53
|
},
|
|
54
54
|
required: ['pattern'],
|
|
55
55
|
};
|
|
@@ -58,8 +58,8 @@ const GREP_SCHEMA = {
|
|
|
58
58
|
type: 'object',
|
|
59
59
|
properties: {
|
|
60
60
|
pattern: { type: 'string', description: '正则表达式。' },
|
|
61
|
-
path: { type: 'string', description: '
|
|
62
|
-
include: { type: 'string', description: '
|
|
61
|
+
path: { type: 'string', description: '搜索根目录。' },
|
|
62
|
+
include: { type: 'string', description: '文件名过滤,如 *.js。' },
|
|
63
63
|
},
|
|
64
64
|
required: ['pattern'],
|
|
65
65
|
};
|
|
@@ -67,7 +67,7 @@ const GREP_SCHEMA = {
|
|
|
67
67
|
const BASH_SCHEMA = {
|
|
68
68
|
type: 'object',
|
|
69
69
|
properties: {
|
|
70
|
-
command: { type: 'string', description: '
|
|
70
|
+
command: { type: 'string', description: 'shell 命令。执行前需用户授权。' },
|
|
71
71
|
timeout: { type: 'integer', description: '超时秒数,默认 120。' },
|
|
72
72
|
},
|
|
73
73
|
required: ['command'],
|
|
@@ -76,16 +76,16 @@ const BASH_SCHEMA = {
|
|
|
76
76
|
const SKILL_SCHEMA = {
|
|
77
77
|
type: 'object',
|
|
78
78
|
properties: {
|
|
79
|
-
name: { type: 'string', description: '
|
|
79
|
+
name: { type: 'string', description: '技能名;省略则列出全部。' },
|
|
80
80
|
},
|
|
81
81
|
};
|
|
82
82
|
|
|
83
83
|
const TASK_SCHEMA = {
|
|
84
84
|
type: 'object',
|
|
85
85
|
properties: {
|
|
86
|
-
description: { type: 'string', description: '
|
|
87
|
-
prompt: { type: 'string', description: '
|
|
88
|
-
readOnly: { type: 'boolean', description: '
|
|
86
|
+
description: { type: 'string', description: '子任务一句话描述。' },
|
|
87
|
+
prompt: { type: 'string', description: '子代理的完整任务说明。' },
|
|
88
|
+
readOnly: { type: 'boolean', description: '只读调研任务,可并行。' },
|
|
89
89
|
},
|
|
90
90
|
required: ['prompt'],
|
|
91
91
|
};
|
|
@@ -112,7 +112,7 @@ const TODO_SCHEMA = {
|
|
|
112
112
|
const UNDO_SCHEMA = {
|
|
113
113
|
type: 'object',
|
|
114
114
|
properties: {
|
|
115
|
-
path: { type: 'string', description: '
|
|
115
|
+
path: { type: 'string', description: '要撤销的文件;省略撤销最近一次。' },
|
|
116
116
|
},
|
|
117
117
|
};
|
|
118
118
|
|
|
@@ -121,7 +121,7 @@ const TOOLS = [
|
|
|
121
121
|
type: 'function',
|
|
122
122
|
function: {
|
|
123
123
|
name: 'read',
|
|
124
|
-
description: '
|
|
124
|
+
description: '读取文件内容(带行号)。修改文件前必须先读。',
|
|
125
125
|
parameters: READ_SCHEMA,
|
|
126
126
|
},
|
|
127
127
|
},
|
|
@@ -129,7 +129,7 @@ const TOOLS = [
|
|
|
129
129
|
type: 'function',
|
|
130
130
|
function: {
|
|
131
131
|
name: 'write',
|
|
132
|
-
description: '
|
|
132
|
+
description: '创建新文件或整体覆盖(自动建父目录)。',
|
|
133
133
|
parameters: WRITE_SCHEMA,
|
|
134
134
|
},
|
|
135
135
|
},
|
|
@@ -137,7 +137,7 @@ const TOOLS = [
|
|
|
137
137
|
type: 'function',
|
|
138
138
|
function: {
|
|
139
139
|
name: 'edit',
|
|
140
|
-
description: '
|
|
140
|
+
description: '精确文本替换:old_string 必须逐字匹配原文;多处匹配需 replace_all=true。',
|
|
141
141
|
parameters: EDIT_SCHEMA,
|
|
142
142
|
},
|
|
143
143
|
},
|
|
@@ -145,7 +145,7 @@ const TOOLS = [
|
|
|
145
145
|
type: 'function',
|
|
146
146
|
function: {
|
|
147
147
|
name: 'ls',
|
|
148
|
-
description: '
|
|
148
|
+
description: '列出目录内容(目录以 / 结尾)。',
|
|
149
149
|
parameters: LS_SCHEMA,
|
|
150
150
|
},
|
|
151
151
|
},
|
|
@@ -153,7 +153,7 @@ const TOOLS = [
|
|
|
153
153
|
type: 'function',
|
|
154
154
|
function: {
|
|
155
155
|
name: 'glob',
|
|
156
|
-
description: '
|
|
156
|
+
description: '按通配符查找文件路径(支持 * 与 **)。',
|
|
157
157
|
parameters: GLOB_SCHEMA,
|
|
158
158
|
},
|
|
159
159
|
},
|
|
@@ -161,7 +161,7 @@ const TOOLS = [
|
|
|
161
161
|
type: 'function',
|
|
162
162
|
function: {
|
|
163
163
|
name: 'grep',
|
|
164
|
-
description: '
|
|
164
|
+
description: '正则搜索文件内容,返回「文件:行号: 内容」。',
|
|
165
165
|
parameters: GREP_SCHEMA,
|
|
166
166
|
},
|
|
167
167
|
},
|
|
@@ -169,7 +169,7 @@ const TOOLS = [
|
|
|
169
169
|
type: 'function',
|
|
170
170
|
function: {
|
|
171
171
|
name: 'bash',
|
|
172
|
-
description: '执行 shell
|
|
172
|
+
description: '执行 shell 命令,返回输出与退出码。需用户授权。',
|
|
173
173
|
parameters: BASH_SCHEMA,
|
|
174
174
|
},
|
|
175
175
|
},
|
|
@@ -177,7 +177,7 @@ const TOOLS = [
|
|
|
177
177
|
type: 'function',
|
|
178
178
|
function: {
|
|
179
179
|
name: 'skill',
|
|
180
|
-
description: '
|
|
180
|
+
description: '加载技能 SKILL.md 全文;省略 name 则列出可用技能。',
|
|
181
181
|
parameters: SKILL_SCHEMA,
|
|
182
182
|
},
|
|
183
183
|
},
|
|
@@ -185,7 +185,7 @@ const TOOLS = [
|
|
|
185
185
|
type: 'function',
|
|
186
186
|
function: {
|
|
187
187
|
name: 'task',
|
|
188
|
-
description: '
|
|
188
|
+
description: '把独立子任务委托给新上下文子代理并返回汇报;readOnly 只读任务可并行。',
|
|
189
189
|
parameters: TASK_SCHEMA,
|
|
190
190
|
},
|
|
191
191
|
},
|
|
@@ -193,7 +193,7 @@ const TOOLS = [
|
|
|
193
193
|
type: 'function',
|
|
194
194
|
function: {
|
|
195
195
|
name: 'todo',
|
|
196
|
-
description: '
|
|
196
|
+
description: '维护任务清单(全量替换)。多步任务先建清单再逐项更新。',
|
|
197
197
|
parameters: TODO_SCHEMA,
|
|
198
198
|
},
|
|
199
199
|
},
|
|
@@ -201,7 +201,7 @@ const TOOLS = [
|
|
|
201
201
|
type: 'function',
|
|
202
202
|
function: {
|
|
203
203
|
name: 'undo',
|
|
204
|
-
description: '
|
|
204
|
+
description: '撤销最近一次 write/edit 修改;指定 path 撤销该文件。',
|
|
205
205
|
parameters: UNDO_SCHEMA,
|
|
206
206
|
},
|
|
207
207
|
},
|
|
@@ -211,6 +211,47 @@ export function toolSchemas() {
|
|
|
211
211
|
return TOOLS;
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
+
/**
|
|
215
|
+
* 递归去除对象中的全部 description 键(保留类型/required/enum 等结构性字段)。
|
|
216
|
+
* @param {any} obj
|
|
217
|
+
* @returns {any}
|
|
218
|
+
*/
|
|
219
|
+
function stripDescriptions(obj) {
|
|
220
|
+
if (Array.isArray(obj)) return obj.map(stripDescriptions);
|
|
221
|
+
if (obj && typeof obj === 'object') {
|
|
222
|
+
const out = /** @type {Record<string, any>} */ ({});
|
|
223
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
224
|
+
if (k === 'description') continue;
|
|
225
|
+
out[k] = stripDescriptions(v);
|
|
226
|
+
}
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
return obj;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* 按需构建工具 Schema(省钱 B1):本会话已调用过的工具省略全部描述(工具级 + 参数级,
|
|
234
|
+
* 模型已在消息历史里见过其用途,保留 name/parameters 结构与类型即可),未用过的保留完整描述。
|
|
235
|
+
* 返回浅拷贝新数组,不修改原 TOOLS;extra(如 MCP 工具)按同一规则处理。
|
|
236
|
+
* @param {Set<string>} usedNames 已调用工具名(内置名或 MCP 前缀名)
|
|
237
|
+
* @param {Array<object>} [extra] 附加工具 Schema(MCP 等)
|
|
238
|
+
* @returns {Array<any>}
|
|
239
|
+
*/
|
|
240
|
+
export function buildToolSchemas(usedNames, extra = []) {
|
|
241
|
+
const strip = (/** @type {any[]} */ arr) =>
|
|
242
|
+
arr.map((/** @type {any} */ t) => {
|
|
243
|
+
const name = t?.function?.name;
|
|
244
|
+
if (name && usedNames.has(name)) {
|
|
245
|
+
return {
|
|
246
|
+
...t,
|
|
247
|
+
function: { ...t.function, description: '', parameters: stripDescriptions(t.function?.parameters) },
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
return t;
|
|
251
|
+
});
|
|
252
|
+
return [...strip(TOOLS), ...strip(extra)];
|
|
253
|
+
}
|
|
254
|
+
|
|
214
255
|
function runSkill(args, ctx) {
|
|
215
256
|
const name = String(args.name ?? '').trim();
|
|
216
257
|
const skills = listSkills(ctx.workingDir);
|
package/src/web/app.js
CHANGED
|
@@ -763,12 +763,55 @@ $('#memDedupe').onclick=async ()=>{
|
|
|
763
763
|
};
|
|
764
764
|
// —— 缓存命中率仪表盘 ——
|
|
765
765
|
function fmtT(n){ n=Number(n||0); return n>=1000?(n/1000).toFixed(1)+'k':String(n); }
|
|
766
|
+
// 省钱 B3:费用二级分账面板(模型/工具 Top5 + 近 14 天折线,零依赖 SVG)
|
|
767
|
+
function renderCostBreakdown(bd){
|
|
768
|
+
const models=$('#costTopModels'); models.innerHTML='';
|
|
769
|
+
for(const m of ((bd.byModel||[]).slice(0,5))){
|
|
770
|
+
const bar=document.createElement('div'); bar.style.cssText='height:3px;border-radius:2px;background:var(--bg3);margin:1px 0;overflow:hidden';
|
|
771
|
+
const max=bd.byModel&&bd.byModel[0]?bd.byModel[0].cost:1;
|
|
772
|
+
bar.innerHTML='<div style="height:100%;width:'+Math.max(2,Math.round((m.cost||0)/Math.max(max,1e-9)*100))+'%;background:var(--accent2)"></div>';
|
|
773
|
+
const row=document.createElement('div'); row.style.cssText='display:flex;justify-content:space-between;font-size:11.5px;padding:1px 0';
|
|
774
|
+
row.innerHTML='<span>'+esc(m.model||'—')+'</span><span>¥'+(m.cost||0).toFixed(4)+' · '+m.turns+' 轮</span>';
|
|
775
|
+
const w=document.createElement('div'); w.appendChild(row); w.appendChild(bar); models.appendChild(w);
|
|
776
|
+
}
|
|
777
|
+
if(!(bd.byModel||[]).length) models.innerHTML='<div style="color:var(--faint);font-size:11.5px">暂无记录</div>';
|
|
778
|
+
const tools=$('#costTopTools'); tools.innerHTML='';
|
|
779
|
+
for(const t of ((bd.byTool||[]).slice(0,5))){
|
|
780
|
+
const bar=document.createElement('div'); bar.style.cssText='height:3px;border-radius:2px;background:var(--bg3);margin:1px 0;overflow:hidden';
|
|
781
|
+
const max=bd.byTool&&bd.byTool[0]?bd.byTool[0].ms:1;
|
|
782
|
+
bar.innerHTML='<div style="height:100%;width:'+Math.max(2,Math.round((t.ms||0)/Math.max(max,1e-9)*100))+'%;background:var(--cyan)"></div>';
|
|
783
|
+
const row=document.createElement('div'); row.style.cssText='display:flex;justify-content:space-between;font-size:11.5px;padding:1px 0';
|
|
784
|
+
row.innerHTML='<span>'+esc(t.tool||'—')+'</span><span>'+t.calls+' 次 · '+fmtDur(t.ms)+'</span>';
|
|
785
|
+
const w=document.createElement('div'); w.appendChild(row); w.appendChild(bar); tools.appendChild(w);
|
|
786
|
+
}
|
|
787
|
+
if(!(bd.byTool||[]).length) tools.innerHTML='<div style="color:var(--faint);font-size:11.5px">暂无记录</div>';
|
|
788
|
+
const line=$('#costLine');
|
|
789
|
+
const days=(bd.byDay||[]).slice(-14);
|
|
790
|
+
if(!days.length){ line.innerHTML=''; return; }
|
|
791
|
+
const W=320, H=56, P=4;
|
|
792
|
+
const maxC=Math.max(...days.map(d=>d.cost), 1e-9);
|
|
793
|
+
const pts=days.map((d,i)=>{
|
|
794
|
+
const x=P+i*(W-2*P)/Math.max(days.length-1,1);
|
|
795
|
+
const y=H-4-(d.cost/maxC)*(H-10);
|
|
796
|
+
return [x.toFixed(1), y.toFixed(1)];
|
|
797
|
+
});
|
|
798
|
+
const poly=pts.map(p=>p.join(',')).join(' ');
|
|
799
|
+
const last=pts[pts.length-1];
|
|
800
|
+
line.innerHTML='<div style="font-size:11.5px;color:var(--faint);margin-bottom:2px">近 14 天费用折线(¥,最高 ¥'+maxC.toFixed(4)+')</div>'
|
|
801
|
+
+'<svg viewBox="0 0 '+W+' '+H+'" style="width:100%;height:auto;background:var(--bg3);border-radius:8px">'
|
|
802
|
+
+'<polyline points="'+poly+'" fill="none" style="stroke:var(--accent2);stroke-width:1.6;stroke-linejoin:round;stroke-linecap:round"/>'
|
|
803
|
+
+'<circle cx="'+last[0]+'" cy="'+last[1]+'" r="2.6" style="fill:var(--accent)"/>'
|
|
804
|
+
+'<text x="4" y="10" font-size="8" style="fill:var(--faint)">'+days[0].day+'</text>'
|
|
805
|
+
+'<text x="'+(W-52)+'" y="10" font-size="8" style="fill:var(--faint)">'+days[days.length-1].day+'</text>'
|
|
806
|
+
+'</svg>';
|
|
807
|
+
}
|
|
766
808
|
async function refreshCache(){
|
|
767
809
|
const r=await fetch('/api/cache-stats',{cache:'no-store'}).catch(()=>null); if(!r) return;
|
|
768
810
|
const j=await r.json(); if(!j.ok) return;
|
|
769
811
|
const s=j.summary||{};
|
|
770
812
|
const rate=s.rate!=null?(s.rate*100).toFixed(0)+'%':'暂无缓存数据';
|
|
771
813
|
$('#cacheSummary').innerHTML='轮次 '+s.turns+' · ↑'+fmtT(s.prompt)+' ↓'+fmtT(s.completion)+' tokens · 命中率 <b style="color:var(--accent)">'+rate+'</b> · 累计 ≈¥'+Number(s.cost||0).toFixed(5)+'(比全未命中省 ≈¥'+Number(s.saved||0).toFixed(5)+')';
|
|
814
|
+
renderCostBreakdown(j.breakdown||{});
|
|
772
815
|
const list=$('#cacheRecent'); list.innerHTML='';
|
|
773
816
|
if(!(j.recent||[]).length){ list.innerHTML='<div style="color:var(--faint);font-size:12px;padding:6px 2px">暂无记录(每次对话后自动统计)</div>'; return; }
|
|
774
817
|
for(const e of j.recent){
|
package/src/web/index.html
CHANGED
|
@@ -372,6 +372,18 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
|
|
|
372
372
|
<h4 style="margin:4px 0;color:var(--accent2)">缓存命中率仪表盘(最近 10 次)</h4>
|
|
373
373
|
<div id="cacheSummary" style="font-size:12.5px;color:var(--dim);padding:2px"></div>
|
|
374
374
|
<div id="cacheRecent" style="max-height:130px;overflow-y:auto"></div>
|
|
375
|
+
<h4 style="margin:10px 0 4px;color:var(--accent2)">费用二级分账(模型 / 工具 Top5 · 近 14 天)</h4>
|
|
376
|
+
<div style="display:flex;gap:10px;flex-wrap:wrap">
|
|
377
|
+
<div style="flex:1;min-width:170px">
|
|
378
|
+
<div style="font-size:11.5px;color:var(--faint)">模型 Top5(费用)</div>
|
|
379
|
+
<div id="costTopModels" style="font-size:12px;color:var(--dim)"></div>
|
|
380
|
+
</div>
|
|
381
|
+
<div style="flex:1;min-width:170px">
|
|
382
|
+
<div style="font-size:11.5px;color:var(--faint)">工具 Top5(耗时)</div>
|
|
383
|
+
<div id="costTopTools" style="font-size:12px;color:var(--dim)"></div>
|
|
384
|
+
</div>
|
|
385
|
+
</div>
|
|
386
|
+
<div id="costLine" style="margin-top:8px"></div>
|
|
375
387
|
</section>
|
|
376
388
|
<section class="cfg-panel" data-panel="mcp">
|
|
377
389
|
<hr style="border:none;border-top:1px solid var(--border);margin:14px 0 8px">
|
package/src/web/server.js
CHANGED
|
@@ -435,7 +435,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
435
435
|
session.routeStats = { steps: st.steps + (io.stats().toolCount || 0), truncated: st.truncated + (r.truncated ? 1 : 0) };
|
|
436
436
|
await withSessionLock(session.file, () => appendMessages(session.file, messages.slice(persistedBefore)));
|
|
437
437
|
io.printUsageLine({ modelName: runModel, usage: r.usage, durationMs: r.durationMs });
|
|
438
|
-
recordUsage(runModel, r.usage, r.perf);
|
|
438
|
+
recordUsage(r.perf?.usedModel || runModel, r.usage, r.perf);
|
|
439
439
|
maybeAutoSync().catch(() => {});
|
|
440
440
|
// 新会话自动标题(可配置关闭)
|
|
441
441
|
srvlog('chat 标题生成前 ' + taskId);
|