mingdao-harness 0.6.13 → 0.6.15

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/src/agent.js CHANGED
@@ -18,15 +18,16 @@ import { redactSecrets } from './redact.js';
18
18
  import { checkCostGuard, costGuardConfig, todayCost, preflightBlockMessage, roundGuardAction } from './cost-guard.js';
19
19
  import { recordCacheStats, packDailyCost } from './cachestats.js';
20
20
  import { estimateCost, cacheSplit, isPeakHour } from './pricing.js';
21
- import { resolveProviderConfig, createProvider } from './providers/index.js';
21
+ import { resolveProviderConfig, createProvider, localGateSnapshot, localConcurrencyLimit } from './providers/index.js';
22
22
  import { compileConstraints, checkPreTool, checkPostTool, checkOutput, blockedOutputText } from './constraints.js';
23
23
  import { createLedger, newRunId } from './ledger.js';
24
24
  import { registerEgressSink } from './net-guard.js';
25
25
  import { getActivePackContext } from './packs.js';
26
26
  // v0.6.11(审计 P1-1 拆分第三刀):工具编排两端的判据单源在 tools-flow.js——
27
27
  // ① 本轮可见哪些工具(只读档 × 预设白名单 × 已用工具快照 × MCP 只读标注);
28
- // ② 工具结果怎么回填(序列化 / 复用前缀 / 约束拒绝文案 / 按窗口截断)。
29
- import { READONLY_TIER_SET, visibleToolsFor, serializeToolResult, toolResultMessage } from './tools-flow.js';
28
+ // ② 工具结果怎么回填(序列化 / 复用前缀 / 约束拒绝文案 / 按窗口截断);
29
+ // ③ 工具参数怎么解析(v0.6.17 第四刀:JSON + 容错 + 归一化 + 错误文案)。
30
+ import { READONLY_TIER_SET, visibleToolsFor, serializeToolResult, toolResultMessage, parseToolArgs } from './tools-flow.js';
30
31
 
31
32
  const MAX_STEPS = 24;
32
33
  // 子代理步数上限:审计/精读类只读子任务需要读多个文件 + 交叉引用,12 步易在「读不全」时被截断
@@ -144,11 +145,16 @@ const fmtTok = (/** @type {any} */ n) => Number(n || 0).toLocaleString('en-US');
144
145
  *
145
146
  * v0.6.13(问题 1 修法):**有预检实测就必须由它推导**,并在文案里如实标注
146
147
  * ①这是外推、②长上下文下外推会低估(附实测差值);只有在**没有预检数据**时才退回保守文案。
148
+ *
149
+ * v0.6.15(§3.59 第 3 条):本地端点**已有在途请求**(父回合/子代理/预检之一)时加一句强警告——
150
+ * 真机 8081 的内存拒绝就是"父子回合同时 prefill"撞出来的;串行化已经兜住资源,但用户仍应
151
+ * 知道"并发会加剧内存压力、应减少子代理"。
147
152
  * @param {{ promptTokens: number, contextWindow?: number, isLocal?: boolean,
148
- * firstTokenMs?: number, budget?: number, probe?: any }} input
153
+ * firstTokenMs?: number, budget?: number, probe?: any,
154
+ * localBusy?: boolean, localActive?: number, localQueued?: number, localConcurrency?: number }} input
149
155
  * @returns {{ text: string, warn: boolean, promptTokens: number, ratio: number, estimateMs: number|null }}
150
156
  */
151
- export function prefillForecast({ promptTokens, contextWindow, isLocal, firstTokenMs, budget, probe = null }) {
157
+ export function prefillForecast({ promptTokens, contextWindow, isLocal, firstTokenMs, budget, probe = null, localBusy = false, localActive = 0, localQueued = 0, localConcurrency = 1 }) {
152
158
  const p = Math.max(0, Math.round(Number(promptTokens) || 0));
153
159
  const win = Number(contextWindow) > 0 ? Number(contextWindow) : 0;
154
160
  const ratio = win > 0 ? p / win : 0;
@@ -187,10 +193,22 @@ export function prefillForecast({ promptTokens, contextWindow, isLocal, firstTok
187
193
  `建议缩小任务(新建会话、只给必要文件、先 /compact)或调大 config.timeout.firstTokenMs——` +
188
194
  `但注意调大它只会让"失败"更晚被发现,不会让 prefill 变快。`
189
195
  : '';
196
+ // v0.6.15(§3.59 第 3 条):本地端点已有在途请求 → **强警告**(不是提示)。
197
+ // 真机证据:父回合 + 子代理同时向同一台 8081 引擎发大 prefill,引擎在 prefill 阶段报
198
+ // insufficient memory("engine shed its caches and stays up");串行化解决资源冲突,
199
+ // 但用户仍必须知道"并发会加剧内存压力",否则会继续开并行子代理/多会话。
200
+ const limit = Number(localConcurrency) >= 0 ? Math.floor(Number(localConcurrency)) : 1;
201
+ const active = Math.max(0, Math.floor(Number(localActive) || 0));
202
+ const queued = Math.max(0, Math.floor(Number(localQueued) || 0));
203
+ const concurrencyNote = Boolean(isLocal) && Boolean(localBusy)
204
+ ? `\n ⚠ 本地端点当前已有 ${active} 个在途请求${queued > 0 ? `、${queued} 个在排队` : ''}(并发上限 ${limit}):` +
205
+ `**并发会加剧内存压力**(父子回合同时 prefill 会被引擎以 insufficient memory 拒绝);` +
206
+ `${limit <= 1 ? '已串行化,后来的请求会排队等待' : `已限制为 ${limit} 并发,仍可能叠加内存压力`}——建议减少子代理数量、避免多个长任务会话同时跑。`
207
+ : '';
190
208
  // v0.6.13(A 追加):把**端点预检**结果并进这条预告——"这个端点行不行"与"这次要 prefill 多少"
191
209
  // 必须一起出现在发送前,而不是等出事后翻日志(引擎实际加载的模型名与配置名不一致也在这里说)。
192
210
  const probeLine = probe && probe.note ? `\n 端点预检:${probe.note}` : '';
193
- return { text: `${head}\n ${timing}${probeLine}${advice}`, warn, promptTokens: p, ratio, estimateMs: est ? est.ms : null };
211
+ return { text: `${head}\n ${timing}${probeLine}${advice}${concurrencyNote}`, warn: warn || Boolean(concurrencyNote), promptTokens: p, ratio, estimateMs: est ? est.ms : null };
194
212
  }
195
213
 
196
214
  /**
@@ -276,6 +294,15 @@ export function describeUpstreamError(/** @type {any} */ err) {
276
294
  hint =
277
295
  '上游拒绝的是**它的 prompt 模板**(不是网络问题):该端点的模板不接受当前 messages 形态(典型:按单轮 query 设计的 chatflow/模板,收到的是 OpenAI 多轮 messages)。' +
278
296
  '这类端点通常也**不返回 tool_calls**——请改用支持 function calling 的模型/端点,或让网关把 messages 映射成它要的单轮入参。';
297
+ } else if (/engine shed its caches and stays up|allocator wall/.test(t)) {
298
+ // v0.6.15(§3.59 第 2 条):**引擎活着但丢了缓存**——与"引擎崩了/连接被拒"是两件事。
299
+ // 真机原文(8081):`insufficient memory: … aborted before the allocator wall.
300
+ // The engine shed its caches and stays up; this request failed.` 引擎自己丢了 KV cache
301
+ // 并继续服务,**没有崩溃**;此时建议"重启引擎"是错的(重启只会把模型重新加载一遍)。
302
+ hint =
303
+ '本地引擎**内存/显存不足**,但它**未崩溃**(原文:engine shed its caches and stays up——引擎自己丢弃缓存后仍在运行)。' +
304
+ '**不要重启引擎**(重启不会增加显存,反而要重新加载模型)。处置:压缩上下文(减小 config.contextBudget 或 /compact)、' +
305
+ '减少并发子任务/并行会话(同一本地端点已按 config.localConcurrency 串行化),然后可直接重试——引擎通常会自行恢复。';
279
306
  } else if (/memory_refusal|insufficient memory|gib|内存不足|内存拒绝/.test(t)) {
280
307
  hint =
281
308
  '本地引擎**内存/显存不足**(在 prefill 前就拒绝了)。处置:压缩上下文(减小 config.contextBudget 或 /compact)、减少并发子任务,或重启模型服务释放内存后重试。';
@@ -302,9 +329,10 @@ export function describeUpstreamError(/** @type {any} */ err) {
302
329
  * @param {{ phase?: string, elapsedMs: number, modelRounds?: number, toolCalls?: number,
303
330
  * toolExecuted?: number, contentChars?: number, ttftMs?: number|null,
304
331
  * pendingTool?: { name: string, ms: number }|null, tokensPerSec?: number|null,
305
- * subagent?: { label?: string, modelRounds?: number, toolCalls?: number, at?: number }|null }} p
332
+ * subagent?: { label?: string, modelRounds?: number, toolCalls?: number, at?: number }|null,
333
+ * localQueue?: { active?: number, queued?: number, limit?: number }|null }} p
306
334
  */
307
- export function formatTurnProgress({ phase, elapsedMs, modelRounds = 0, toolCalls = 0, toolExecuted = 0, contentChars = 0, ttftMs = null, pendingTool = null, tokensPerSec = null, subagent = null }) {
335
+ export function formatTurnProgress({ phase, elapsedMs, modelRounds = 0, toolCalls = 0, toolExecuted = 0, contentChars = 0, ttftMs = null, pendingTool = null, tokensPerSec = null, subagent = null, localQueue = null }) {
308
336
  const s = Math.max(0, Math.round(Number(elapsedMs) / 1000));
309
337
  const parts = [
310
338
  `已跑 ${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`,
@@ -316,6 +344,15 @@ export function formatTurnProgress({ phase, elapsedMs, modelRounds = 0, toolCall
316
344
  if (tokensPerSec != null) parts.push(`≈${Number(tokensPerSec).toFixed(1)} tok/s`);
317
345
  parts.push(`已收正文=${contentChars} 字`);
318
346
  if (pendingTool) parts.push(`正在等待工具 ${pendingTool.name}(已 ${Math.round(Number(pendingTool.ms) / 1000)}s)`);
347
+ // v0.6.15(§3.59):本地端点因内存压力串行化时,进度行必须能看出来——
348
+ // 否则"在排队"与"引擎卡住"在事后日志里完全同形(真机取证最缺的就是这一行)。
349
+ if (localQueue) {
350
+ parts.push(
351
+ `本地端点已串行化(防内存压力):等待 ${Math.max(0, Number(localQueue.active) || 0)} 个在途请求` +
352
+ (Number(localQueue.queued) > 0 ? `、${Number(localQueue.queued)} 个排队` : '') +
353
+ `,并发上限 ${Number(localQueue.limit) || 1}`
354
+ );
355
+ }
319
356
  // v0.6.13(问题 3):等 `task` 子代理时必须能看出"这个子任务跑到哪一步"——
320
357
  // 否则"等子代理 620s"与"卡死 620s"在日志上完全同形(真机现场正是如此)。
321
358
  if (subagent) {
@@ -753,6 +790,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
753
790
  session: sessionRef?.name ?? null,
754
791
  cwd: workingDir,
755
792
  permission: cfg.permission ?? permission?.mode ?? null,
793
+ // v0.6.17 §3.64:来源由入口(WebUI/CLI)算好随 cfg 传入;agent 只透传,不猜。
794
+ // 后台任务/REPL 内切换预设等未传来源的路径写 null(来源未知),绝不冒充 config。
795
+ permissionSource: cfg.permissionSource ?? null,
756
796
  preset: preset?.name ?? cfg.preset ?? null,
757
797
  // v0.6.7(报告二 P3-1):activeCtx 的形状是 { mounted, warnings, promptSections, constraints, toolCount },
758
798
  // **没有 `.packs` 键** —— 这里此前恒为 [],账本里"本次运行挂了哪些 Pack"永远记空。
@@ -859,6 +899,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
859
899
  // 界面/日志据此显示"在等哪个子任务、它跑到第几轮 / 几次工具调用"
860
900
  subagent: null,
861
901
  forecast: null,
902
+ // v0.6.15(§3.59):本地端点串行化快照(排队时由 provider 的 onQueueWait 填入)
903
+ localQueue: null,
904
+ localQueueNoticed: false,
862
905
  noProgressMs,
863
906
  lastProgressAt: Date.now(),
864
907
  progressReason: '回合开始',
@@ -1278,6 +1321,12 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1278
1321
  try {
1279
1322
  let promptTokens = 0;
1280
1323
  for (const m of sanitized) promptTokens += messageTokens(m, count);
1324
+ // v0.6.15(§3.59 第 3 条):本地端点是否已有在途/排队请求(父回合、子代理、预检共用同一队列)。
1325
+ // 这里读的是**进程级闸门快照**(provider.config.baseUrl),不是猜——云端端点恒为 0。
1326
+ let gate = { active: 0, queued: 0, limit: 1 };
1327
+ try {
1328
+ if (caps.isLocal) gate = localGateSnapshot(provider?.config?.baseUrl);
1329
+ } catch {}
1281
1330
  const f = prefillForecast({
1282
1331
  promptTokens,
1283
1332
  contextWindow: caps.contextWindow,
@@ -1285,6 +1334,10 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1285
1334
  firstTokenMs: Number(cfg?.timeout?.firstTokenMs) > 0 ? Number(cfg.timeout.firstTokenMs) : effFirstTokenMs,
1286
1335
  budget,
1287
1336
  probe: cfg?.endpointProbe || null,
1337
+ localBusy: Boolean(caps.isLocal && (gate.active > 0 || gate.queued > 0)),
1338
+ localActive: gate.active,
1339
+ localQueued: gate.queued,
1340
+ localConcurrency: localConcurrencyLimit(cfg),
1288
1341
  });
1289
1342
  turnProgress.forecast = f;
1290
1343
  // 首次请求一定说;之后只在命中告警线时说(否则每步一条横幅会刷屏)
@@ -1309,6 +1362,31 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1309
1362
  // MiniMax P0:仅当 activeModel 声明支持 reasoning 才发送(否则 400 终止回合);'off' 显式禁用例外
1310
1363
  reasoningEffort: modelPreset(activeModel)?.supportsReasoning || reasoningEffort === 'off' ? reasoningEffort : undefined,
1311
1364
  signal: ac.signal,
1365
+ // v0.6.15(§3.59 第 1 条):本地端点串行化必须**在界面上可见**——
1366
+ // 排队时状态条/进度行写"排队等待本地端点(已串行化防内存压力)",
1367
+ // 拿到名额后恢复"发请求(等首帧)"(onDelta 之后会覆盖成"解码中")。
1368
+ onQueueWait(/** @type {any} */ ev) {
1369
+ if (!ev) return;
1370
+ if (ev.waiting) {
1371
+ turnProgress.localQueue = { active: ev.active, queued: ev.queued, limit: ev.limit };
1372
+ turnProgress.phase = `排队等待本地端点(已串行化防内存压力:${ev.active} 个在途)`;
1373
+ if (!turnProgress.localQueueNoticed) {
1374
+ turnProgress.localQueueNoticed = true;
1375
+ try {
1376
+ io.print(
1377
+ style(
1378
+ `⏳ 本地端点串行化:等待 ${ev.active} 个在途请求(并发上限 ${ev.limit})——` +
1379
+ `本地模型内存压力,父回合与子代理不再同时 prefill`,
1380
+ C.dim
1381
+ )
1382
+ );
1383
+ } catch {}
1384
+ }
1385
+ } else {
1386
+ turnProgress.localQueue = null;
1387
+ if (String(turnProgress.phase || '').startsWith('排队等待本地端点')) turnProgress.phase = '发请求(等首帧)';
1388
+ }
1389
+ },
1312
1390
  onDelta(/** @type {any} */ d) {
1313
1391
  io.stopSpinner();
1314
1392
  // v0.6.13(A):帧/字符计数 + 首帧时延(TTFT)——本地模型最关键的指标
@@ -1362,7 +1440,11 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1362
1440
  // MacBook 本地 507 memory_refusal 根因(v0.4.5):服务端内存拒绝不是「空输出」——
1363
1441
  // 直接终结合合并透出降级提示,不计入空轮、不注入续写重试(内存未释放必再 507,空烧请求)。
1364
1442
  const e = /** @type {any} */ (err);
1365
- if (e?.status === 507 || /memory_refusal|内存不足|内存拒绝/i.test(String(e?.message || ''))) {
1443
+ // v0.6.15(§3.59 第 2 条):`engine shed its caches and stays up` / `allocator wall`
1444
+ // 说明引擎**活着**(自己丢缓存后继续服务)——不走这条"507 旧文案"(那里建议重启引擎,
1445
+ // 对活着但内存吃紧的引擎是错误处置);交给下面 describeUpstreamError 的独立分型。
1446
+ const engineShedCaches = /engine shed its caches and stays up|allocator wall/i.test(String(e?.message || ''));
1447
+ if (!engineShedCaches && (e?.status === 507 || /memory_refusal|内存不足|内存拒绝/i.test(String(e?.message || '')))) {
1366
1448
  stripOrphanCalls();
1367
1449
  markOutcome('done');
1368
1450
  return {
@@ -1515,22 +1597,18 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1515
1597
  ...extra,
1516
1598
  });
1517
1599
  let args = /** @type {any} */ (null);
1518
- try {
1519
- args = JSON.parse(tc.function?.arguments || '{}');
1520
- } catch {
1600
+ // 参数解析/容错/归一化单源在 tools-flow.js 的 parseToolArgs()(v0.6.17,P1-1 第四刀):
1601
+ // 本函数只返回判据,下面三条副作用(渲染拒绝 / 审计 / 回填分块指引)仍按原顺序执行。
1602
+ const parsedArgs = parseToolArgs(tc.function?.arguments);
1603
+ if (!parsedArgs.ok) {
1521
1604
  // 参数 JSON 解析失败:回填错误给模型,不拿空参数去执行工具
1522
- io.renderToolDenied(name, {}, '参数解析失败(输出超限被截断?建议分块)');
1523
- if (auditOn) auditEntry({ denied: true, reason: '参数解析失败' });
1605
+ io.renderToolDenied(name, {}, parsedArgs.deniedReason);
1606
+ if (auditOn) auditEntry({ denied: true, reason: parsedArgs.auditReason });
1524
1607
  // 审计(长生成截断循环):给出明确分块指引,避免模型反复用单个超大 write 撞输出上限
1525
- messages.push({
1526
- role: 'tool',
1527
- tool_call_id: tc.id,
1528
- content:
1529
- '工具参数 JSON 解析失败(大概率是输出超过模型单次上限被截断)。请把内容拆分成多个较小文件或多次调用逐步写入:单个 write 的参数总长控制在 6000 字符以内;先写核心骨架,再逐文件补充。',
1530
- });
1608
+ messages.push({ role: 'tool', tool_call_id: tc.id, content: parsedArgs.message });
1531
1609
  return null;
1532
1610
  }
1533
- if (!args || typeof args !== 'object' || Array.isArray(args)) args = {};
1611
+ args = parsedArgs.args;
1534
1612
 
1535
1613
  // PreToolUse 钩子
1536
1614
  const hook = await hooks.pre(name, args);
package/src/cli.js CHANGED
@@ -403,7 +403,16 @@ async function main() {
403
403
  }
404
404
  }
405
405
  // agent 使用的配置 = cfg + 预设 overlay(presetTools/permission/参数按预设生效,cfg 本体保持干净)
406
- const agentCfg = Object.keys(presetOverlay).length ? { ...cfg, ...presetOverlay } : cfg;
406
+ const agentCfgBase = Object.keys(presetOverlay).length ? { ...cfg, ...presetOverlay } : cfg;
407
+ // v0.6.17 §3.64:本进程的权限来源随 cfg 进账本(`run.start.permissionSource`)。
408
+ // 顺序与生效值一致:预设 overlay 里有 permission > config.json 的 permission > 内置缺省。
409
+ // 注意**不写回 cfg**(agentCfg 是副本):repl 的 saveConfig(cfg) 绝不能把来源字段持久化。
410
+ const permissionSource = Object.prototype.hasOwnProperty.call(presetOverlay, 'permission')
411
+ ? 'preset'
412
+ : cfg.permission !== undefined && cfg.permission !== null
413
+ ? 'config'
414
+ : 'default';
415
+ const agentCfg = { ...agentCfgBase, permissionSource };
407
416
 
408
417
  const pc0 = resolveProviderConfig(cfg, modelName);
409
418
  // v0.6.0 C4:内网/本机端点(vLLM、Ollama、OneAPI 等)通常不校验密钥,很多也不发放密钥。
package/src/ledger.js CHANGED
@@ -36,6 +36,21 @@ import { redactSecrets, redactSensitive } from './redact.js';
36
36
  * 不是「新读者多了字段」。
37
37
  */
38
38
  export const LEDGER_VERSION = 1;
39
+ /**
40
+ * `run.start.permissionSource` 的合法取值(v0.6.17 §3.64)。
41
+ *
42
+ * 为什么只增字段不动 `LEDGER_VERSION`:`permission` 仍然是**生效值**,本字段只是补上
43
+ * 「这个生效值是谁定的」——旧读者按原样忽略它,旧账本(没有它)也照常校验通过。
44
+ * 一次"来源不可见"的坑(界面选了自动、却被只读预设压着跑)迫使我们把来源写进账本:
45
+ * 事后自查时,`permission:"auto"` 单看无法区分"用户显式选的"与"config 里本来就是 auto"。
46
+ *
47
+ * - `explicit`:用户/调用方在本回合显式选择(WebUI 的权限档、CLI 的显式参数);
48
+ * - `preset`:由预设声明的 permission 生效(反提权判定已通过);
49
+ * - `config`:来自 `config.json` 的 `permission`;
50
+ * - `cli`:后台/调度任务由 CLI 参数指定(`mingdao run --permission ...`);
51
+ * - `default`:以上都没有,落到内置缺省(`ask`)。
52
+ */
53
+ export const PERMISSION_SOURCES = Object.freeze(['explicit', 'preset', 'config', 'cli', 'default']);
39
54
  /** 默认保留最近多少次运行(超出按 mtime 删最旧) */
40
55
  const DEFAULT_MAX_RUNS = 200;
41
56
  const GENESIS = '0'.repeat(16);
@@ -560,6 +575,10 @@ export function createLedger(runId, { enabled = true, maxRuns = DEFAULT_MAX_RUNS
560
575
  session: f.session ?? null,
561
576
  cwd: f.cwd ?? null,
562
577
  permission: f.permission ?? null,
578
+ // v0.6.17 §3.64:生效值(permission)与来源分开记——只记生效值无法回答
579
+ // "这是用户选的、预设压的,还是 config 默认的"。非法/缺失一律 null(诚实:来源未知),
580
+ // 不猜、不落成 'config'(猜错比缺失更难自查)。
581
+ permissionSource: PERMISSION_SOURCES.includes(String(f.permissionSource)) ? String(f.permissionSource) : null,
563
582
  preset: f.preset ?? null,
564
583
  packs: Array.isArray(f.packs) ? f.packs : [],
565
584
  });
@@ -876,6 +895,9 @@ export function exportRun(/** @type {any} */ runId, { format = 'json' } = {}) {
876
895
  lines.push(`# 执行账本 ${runId}`);
877
896
  lines.push('');
878
897
  lines.push(`- 模型:${start?.model ?? '—'} · 会话:${start?.session ?? '—'} · 权限:${start?.permission ?? '—'}`);
898
+ // v0.6.17 §3.64:来源是**可选**字段——旧账本没有它,这一行就整行不出现(输出契约不变);
899
+ // 新账本才有,且只增行、不改上面那行「权限:…」的既有形状。
900
+ if (start?.permissionSource) lines.push(`- 权限来源:${start.permissionSource}(explicit=显式选择 / preset=预设 / config=配置 / cli=命令行 / default=内置缺省)`);
879
901
  lines.push(`- 开始:${start?.at ? new Date(start.at).toISOString() : '—'}`);
880
902
  lines.push(`- 结束:${end ? `${end.status}(${end.ms}ms,${end.steps} 步)` : '未结束'}`);
881
903
  lines.push(`- 费用:${end?.priced ? `¥${Number(end.yuanTotal ?? 0).toFixed(4)}` : '**无法估算**(模型无价,不是 0 元)'}`);