mingdao-harness 0.6.11 → 0.6.13

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/presets.js CHANGED
@@ -25,6 +25,43 @@ import { fileURLToPath } from 'node:url';
25
25
  import { mingdaoHome, ensureHome } from './config.js';
26
26
 
27
27
  const PRESET_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // v0.6.15(C,负责人实测定位纠偏):内置预设的**名字要与内容一致**。
31
+ //
32
+ // 原来的内置 local-audit 一个名字扛了三件事:本地模型的保守参数 + 审计人格 + 只读工具白名单。
33
+ // 负责人的原话:"本地模型只是用来代替云模型 API 而已,功能是一样的;预设的目的是预设上下文窗口/
34
+ // 最大输出 tokens 等,让本地模型正常工作——它并不是专为代码审计而设。" 于是拆成两个:
35
+ // · local-model —— 只放"让本地模型跑得动"的参数(contextBudget/maxOutputTokens/maxRounds)
36
+ // · readonly-audit —— 审计人格 + 只读工具白名单 + recommendedPermission(建议,不覆盖)
37
+ //
38
+ // 老名字 `local-audit` 的兼容:**保留为别名**(不是删掉、也不是不再提)。
39
+ // 为什么选"别名"而不是"只在加载时提示已更名":
40
+ // · 下游与测试按名引用过它(config.preset / 计划任务 / 会话粘滞 / 脚本),改成"报个提示然后不给"
41
+ // 等于把这些调用点从"能用"变成"用不了"——一个改名不该造成运行时中断;
42
+ // · 别名仍然**必须说出来**(loadPreset 里一次性 warn + WebUI banner),所以不会变成静默改名;
43
+ // · 别名指向 local-model:老名字的语义本来就是"本地模型预设",审计那部分另有新家。
44
+ // 若用户/项目自己写了同名 `local-audit.json`,**以磁盘上的为准**(别名只在"没找到同名预设"时才生效),
45
+ // 遮蔽语义与既有一致。
46
+ const PRESET_ALIASES = /** @type {Record<string, string>} */ ({ 'local-audit': 'local-model' });
47
+
48
+ /** 规范名 → 别名列表(listPresets 透出用)。 */
49
+ const PRESET_ALIAS_INDEX = Object.entries(PRESET_ALIASES).reduce((acc, [from, to]) => {
50
+ (acc[to] = acc[to] || []).push(from);
51
+ return acc;
52
+ }, /** @type {Record<string, string[]>} */ ({}));
53
+
54
+ /** 别名 → 规范名(无别名时原样返回)。 */
55
+ export function canonicalPresetName(/** @type {any} */ name) {
56
+ const n = String(name ?? '');
57
+ return PRESET_ALIASES[n] || n;
58
+ }
59
+
60
+ /** 该名字是否是内置别名(供调用方在界面上说明"已更名")。 */
61
+ export function presetAliasOf(/** @type {any} */ name) {
62
+ const n = String(name ?? '');
63
+ return PRESET_ALIASES[n] ? { from: n, to: PRESET_ALIASES[n] } : null;
64
+ }
28
65
  // 合法字段白名单:未知字段报错(防拼写错误静默失效——契约化核心)
29
66
  const KNOWN_FIELDS = new Set([
30
67
  'name', 'label', 'description', 'systemPrompt', 'tools',
@@ -110,7 +147,7 @@ export function listPresets(/** @type {any} */ workingDir) {
110
147
  if (!v.ok) continue; // 非法预设跳过并静默(不阻塞会话);diagnose 可查
111
148
  const key = String(obj.name);
112
149
  if (seen.has(key)) continue; // 同名遮蔽:项目 → 用户 → 内置(先发现者胜)
113
- seen.set(key, { obj, source, file });
150
+ seen.set(key, { obj, source, file, aliases: PRESET_ALIAS_INDEX[key] || undefined });
114
151
  // v0.6.3(M-8):记录"这个名字是否把更低优先级来源遮蔽了"——静默遮蔽是注入面
115
152
  // (项目级预设可以按名顶掉内置预设,同时注入自己的 systemPrompt/tools)
116
153
  const shadowedFrom = [];
@@ -131,13 +168,16 @@ export function listPresets(/** @type {any} */ workingDir) {
131
168
  if (shadowedFrom.length) seen.get(key).shadowed = shadowedFrom;
132
169
  }
133
170
  }
134
- return [...seen.values()].map(({ obj, source, file, shadowed }) => ({
171
+ return [...seen.values()].map(({ obj, source, file, shadowed, aliases }) => ({
135
172
  name: String(obj.name),
136
173
  label: String(obj.label || obj.name),
137
174
  description: String(obj.description || ''),
138
175
  source,
139
176
  file,
140
177
  ...(shadowed ? { shadowed } : {}),
178
+ // v0.6.15(C):老名字以别名形式保留时,列表里写出来(UI 可显示"别名:local-audit"),
179
+ // 免得用户到处找不到曾经用过的名字、或以为它被静默删了。
180
+ ...(aliases ? { aliases } : {}),
141
181
  ...(obj.systemPrompt ? { systemPrompt: obj.systemPrompt } : {}),
142
182
  ...(Array.isArray(obj.tools) ? { tools: obj.tools } : {}),
143
183
  ...(obj.permission ? { permission: String(obj.permission) } : {}),
@@ -155,7 +195,13 @@ export function listPresets(/** @type {any} */ workingDir) {
155
195
  const shadowWarned = new Set();
156
196
  export function loadPreset(/** @type {any} */ workingDir, /** @type {any} */ name) {
157
197
  const all = listPresets(workingDir);
158
- const hit = all.find((/** @type {any} */ p) => p.name === name || path.basename(String(p.file), '.json') === name);
198
+ let hit = all.find((/** @type {any} */ p) => p.name === name || path.basename(String(p.file), '.json') === name);
199
+ // v0.6.15(C):老名字 → 别名解析(磁盘上有同名预设时以磁盘为准,别名不生效)
200
+ const alias = presetAliasOf(name);
201
+ if (!hit && alias) {
202
+ hit = all.find((/** @type {any} */ p) => p.name === alias.to);
203
+ if (hit) warnPresetAlias(alias);
204
+ }
159
205
  if (!hit) return null;
160
206
  // v0.6.3(M-8):项目级预设按名遮蔽内置/用户级预设时**必须说出来**。
161
207
  // 场景:clone 一个仓库 → cd 进去 → `mingdao --preset reviewer`:拿到的是仓库里那份
@@ -173,12 +219,23 @@ export function loadPreset(/** @type {any} */ workingDir, /** @type {any} */ nam
173
219
  }
174
220
  }
175
221
  try {
176
- return JSON.parse(fs.readFileSync(hit.file, 'utf8'));
222
+ const obj = JSON.parse(fs.readFileSync(hit.file, 'utf8'));
223
+ // 别名调用:把"实际用的是哪一份"标在返回值上,调用方(WebUI banner)据此告诉用户已更名
224
+ return alias && hit.name === alias.to ? { ...obj, aliasedFrom: alias.from } : obj;
177
225
  } catch {
178
226
  return null;
179
227
  }
180
228
  }
181
229
 
230
+ /** 别名提示只打一次(同一个 from→to 不重复刷屏,但要留下痕迹)。 */
231
+ const aliasWarned = new Set();
232
+ function warnPresetAlias(/** @type {{from: string, to: string}} */ alias) {
233
+ const key = `${alias.from}->${alias.to}`;
234
+ if (aliasWarned.has(key)) return;
235
+ aliasWarned.add(key);
236
+ console.warn(`[MingDao] ⚠ 预设「${alias.from}」已更名为「${alias.to}」(本次已按 ${alias.to} 执行;老名字保留为别名,建议尽快改用新名字)。`);
237
+ }
238
+
182
239
  /**
183
240
  * 预设 → cfg 覆盖:只返回预设声明的参数键(其余键保持调用方当前配置)。
184
241
  * tools 单独走 cfg.presetTools(白名单在 agent 的 toolsFor 处生效)。
package/src/web/app.js CHANGED
@@ -444,7 +444,22 @@ async function send(){
444
444
  await handleEvents(resp.body, ev=>{
445
445
  if(ev.taskId){ taskId=ev.taskId; curTaskId=taskId; } // 质检 L1:主停止按钮只断本任务
446
446
  if(ev.type==='ask') disarm(); else arm(); // 有活动→重置;权限等待→暂停计时
447
- if(ev.type==='progress'){ const p=ev.seconds||0; if(ev.phase) curPhase=String(ev.phase); curTools=Number(ev.steps)||0; curTasks=Number(ev.tasks)||0; showThink(); think.textContent='💭 正在工作 '+Math.floor(p/60)+' 分 '+Math.round(p%60)+' 秒 · 第 '+msg._steps+' 回合 · '+curPhase+' · 已执行 '+ev.steps+' 步…'; renderWorkStatus(); }
447
+ if(ev.type==='progress'){ const p=ev.seconds||0; if(ev.phase) curPhase=String(ev.phase); curTools=Number(ev.steps)||0; curTasks=Number(ev.tasks)||0; showThink();
448
+ // v0.6.13(A):本地模型场景把「在等首帧 / 在解码 / 在等哪个工具 / TTFT / tok/s / 0 次工具调用」直接摆在状态行上——
449
+ // 真机实测的痛点是"跑了几小时,界面上什么都看不出来"。0 次工具调用必须显式显示为 0,不能省略。
450
+ const bits=[];
451
+ bits.push('模型请求 '+(ev.modelRounds||0)+' 轮');
452
+ bits.push('工具调用 '+(ev.toolCalls||0)+' 次');
453
+ if(ev.ttftMs!=null) bits.push('首帧 '+(ev.ttftMs/1000).toFixed(1)+'s');
454
+ if(ev.tokensPerSec!=null) bits.push('≈'+Number(ev.tokensPerSec).toFixed(1)+' tok/s');
455
+ if(ev.contentChars) bits.push('正文 '+ev.contentChars+' 字');
456
+ if(ev.pendingTool) bits.push('等待工具 '+ev.pendingTool.name+' 已 '+Math.round((ev.pendingTool.ms||0)/1000)+'s');
457
+ // v0.6.13(问题 3):等 `task` 子代理时把**子代理自己的进度**摆出来——
458
+ // 真机现场界面/日志只有"等待工具 task 已 620s",与"卡死 620s"完全同形。
459
+ if(ev.subagent) bits.push('子 Agent'+(ev.subagent.label?'「'+ev.subagent.label+'」':'')+'已跑 '+(ev.subagent.modelRounds||0)+' 轮 / '+(ev.subagent.toolCalls||0)+' 次工具调用'+(ev.subagent.ms!=null?'(最后进展 '+Math.round(ev.subagent.ms/1000)+'s 前)':''));
460
+ if(ev.stalled) bits.push('已判定无进展,正在中止');
461
+ think.textContent='💭 正在工作 '+Math.floor(p/60)+' 分 '+Math.round(p%60)+' 秒 · 第 '+msg._steps+' 回合 · '+curPhase+' · '+bits.join(' · ')+'…';
462
+ renderWorkStatus(); }
448
463
  if(ev.type==='text'){ curPhase='模型输出中'; onActivity(); raw+=ev.delta; update(); }
449
464
  else if(ev.type==='reasoning'){ if(!reason){ reason=addReasoning(msg); reason.dataset.full=''; } if(!reason.parentElement.open) reason.parentElement.open=true; reason.parentElement.querySelector('summary').classList.add('live'); reason.dataset.full+=ev.delta; const full=reason.dataset.full; reason.textContent=(full.length>9000?full.slice(-9000)+'\n…(思考内容较长,仅显示末尾;已 '+full.length+' 字符)':full); reason.scrollTop=reason.scrollHeight; scroll(); }
450
465
  else if(ev.type==='turnStart'){ stepsCount+=1; curSteps+=1; msg._steps=curSteps; curPhase='模型推理中'; msg._traj.push({kind:'turn', t:Date.now()}); showThink(); think.textContent='💭 第 '+msg._steps+' 回合:模型推理中…'; renderWorkStatus(); scroll(); }
@@ -462,7 +477,23 @@ async function send(){
462
477
  // 这里把作废的确认收掉:该确认已经死了,界面上不该再留一个能点的假弹窗。
463
478
  if(/权限确认超时/.test(String(ev.message||''))) dismissAskModals();
464
479
  const d=document.createElement('div'); d.className='errline'; d.textContent=ev.message; msg.appendChild(d); scroll(); }
465
- else if(ev.type==='done'){ console.log('[MingDao] done 事件:session=' + ev.session); onActivity(); if(ev.budget){ const b=ev.budget; if(hintTextEl) hintTextEl.textContent='预算 '+Math.round(b.used/1000)+'K/'+Math.round(b.total/1000)+'K('+Math.round(b.used/b.total*100)+'%)· 本轮完成 · 提示栏右侧为今日费用与命中率'; } refreshStatusBar(); if(ev.stats&&ev.stats.deliverables&&ev.stats.deliverables.length){ const card=document.createElement('div'); card.className='deliver'; card.innerHTML='<div class="t">📦 交付物('+ev.stats.deliverables.length+' 个文件)</div>'+ev.stats.deliverables.map(f=>'<div class="i">'+esc(f)+(f.toLowerCase().endsWith('.html')?' <span style="color:var(--accent2)">— 浏览器打开即可运行</span>':'')+'</div>').join(''); msg.appendChild(card); } if(ev.note){ const d=document.createElement('div'); d.className='errline'; d.style.color='var(--warn)'; d.textContent=ev.note; msg.appendChild(d); } currentSession=ev.session; update(); refreshSessions(); updateTasksPanel(); }
480
+ else if(ev.type==='done'){
481
+ // v0.6.13(问题 4):把**结局**写进控制台。桌面壳的日志就是渲染进程的 console(转存到
482
+ // ~/Library/Application Support/mingdao-desktop/logs/mingdao.log),此前那里只有
483
+ // 「回合收尾:generating=false,按钮恢复发送」——同一时刻内核写的是 `status=stalled`,
484
+ // 事后取证完全对不上(真机 2026-10-09T03:13:16 两条日志并列就是证据)。
485
+ const endStatus=ev.stalled?(ev.stallCause==='inherited'?'stalled(父回合整轮无进展)':'stalled(长时间无进展)'):ev.aborted?'aborted':(ev.truncated?'capped':'done');
486
+ console.log('[MingDao] done 事件:session=' + ev.session + ' status=' + endStatus + ' durationMs=' + (ev.durationMs||0));
487
+ onActivity();
488
+ if(ev.budget){ const b=ev.budget; if(hintTextEl) hintTextEl.textContent='预算 '+Math.round(b.used/1000)+'K/'+Math.round(b.total/1000)+'K('+Math.round(b.used/b.total*100)+'%)· 本轮完成 · 提示栏右侧为今日费用与命中率'; }
489
+ refreshStatusBar();
490
+ if(ev.stats&&ev.stats.deliverables&&ev.stats.deliverables.length){ const card=document.createElement('div'); card.className='deliver'; card.innerHTML='<div class="t">📦 交付物('+ev.stats.deliverables.length+' 个文件)</div>'+ev.stats.deliverables.map(f=>'<div class="i">'+esc(f)+(f.toLowerCase().endsWith('.html')?' <span style="color:var(--accent2)">— 浏览器打开即可运行</span>':'')+'</div>').join(''); msg.appendChild(card); }
491
+ // v0.6.13(问题 4):stalled 必须**用户可见**,而且与 capped/aborted 区分开(复用既有 done.note 渲染)。
492
+ // 内核已发了一条同文案的告警 banner(服务端 `if (r.stalled) send({type:'banner'…})`),
493
+ // 这里再在消息下方留一条——用户滚动回看时不必去翻日志才知道"这轮为什么停了"。
494
+ if(ev.stalled){ const d=document.createElement('div'); d.className='errline'; d.style.color='var(--warn)'; d.textContent='⏹ 本轮因长时间无进展已中止(不是步数上限 capped、也不是你点的停止 aborted)——'+(ev.note||'详情见上方告警条。'); msg.appendChild(d); }
495
+ else if(ev.note){ const d=document.createElement('div'); d.className='errline'; d.style.color='var(--warn)'; d.textContent=ev.note; msg.appendChild(d); }
496
+ currentSession=ev.session; update(); refreshSessions(); updateTasksPanel(); }
466
497
  });
467
498
  }catch(e){ onActivity(); // 诊断(v0.4.2 network error 排查):静默中断此前无任何日志,无法区分
468
499
  // 「手点停止 / 看门狗 / fetch 流静默断裂」。v0.4.3 修:第二参改 JSON 字符串——Electron 日志
package/src/web/server.js CHANGED
@@ -23,13 +23,15 @@ import { createLogWriter } from '../log-writer.js';
23
23
  import { createApiDispatch } from './routes/api.js';
24
24
  import { ensureHome, loadConfig, saveConfig, mingdaoHome } from '../config.js';
25
25
  import { setStoredKey, removeStoredKey, getStoredKey, maskKey } from '../credentials.js';
26
- import { availableModels, fetchProviderModels, providerHasKey } from '../model-discovery.js';
26
+ import { availableModels, fetchProviderModels, providerHasKey, probeEndpoint, adaptiveTimeouts } from '../model-discovery.js';
27
+ import { isLocalBaseUrl, resolveModelCaps } from '../model-caps.js';
27
28
  import { createProvider, resolveProviderConfig, helperProvider, resolveVisionSupport } from '../providers/index.js';
28
29
  import { MODELS, modelPreset, PROVIDERS } from '../models.js';
29
30
  import { routeTask, routingConfig } from '../routing.js';
30
31
  import { buildUserContent } from './attachments.js';
31
32
  import { MAX_CONCURRENT, SECURITY_HEADERS } from './constants.js';
32
- import { createAgent } from '../agent.js';
33
+ // v0.6.13(A):进度行文案与 Agent 共用同一份纯函数(口径不漂移)
34
+ import { createAgent, formatTurnProgress } from '../agent.js';
33
35
  import { createPermission } from '../permissions.js';
34
36
  import { isPrivateHost as sharedIsPrivateHost, isMetadataHost, resolveHostGuarded } from '../ssrf-guard.js';
35
37
  import { buildSystemPrompt } from '../prompts.js';
@@ -285,11 +287,80 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
285
287
  let workingDir = process.cwd(); // 工作空间切换时随之更新(后续会话/工具都跟随新目录)
286
288
  const startupCwd = workingDir; // 质检 A3:启动工作目录恒为合法浏览根(工作空间切换后仍可浏览原位)
287
289
  // Provider 按模型懒加载缓存:界面切换模型后即时生效
290
+ // —— 服务端诊断日志(第二问无反应排查 + 长期运维):<mingdao-home>/logs/web-server.log ——
291
+ // 记录每次对话的关键阶段与耗时;2MB 滚动。桌面版与 WebUI 共用同一日志。
292
+ // 质检 A6:统一日志写入器(append + 按行截断 + 原子替换),与桌面 appLog 同款实现。
293
+ // v0.6.13(A):定义提前到 provider 创建之前——本地端点的**预检**在 getProviderFor 里就要落日志,
294
+ // 而启动期 `await getProviderFor(cfg.model)` 早于原先的定义位置(TDZ:会直接 ReferenceError)。
295
+ const srvlog = createLogWriter(path.join(mingdaoHome(), 'logs', 'web-server.log'));
296
+
288
297
  const providerCache = new Map();
298
+ // v0.6.13(A 追加):本地端点的**能力/延迟预检**结果(每次建 provider / 每轮对话开头的提示都读它)。
299
+ // 只对"本地端点"做:远程服务商不需要(也不该)每次开聊都多打一次请求。
300
+ const probeByModel = new Map();
301
+ /** 该模型是否属于"本地端点"(baseUrl 是回环/内网,或 customModels 显式声明 local:true)。 */
302
+ const isLocalModel = (/** @type {any} */ m) => {
303
+ try {
304
+ const pc = resolveProviderConfig(cfg, m) || {};
305
+ return isLocalBaseUrl(pc.baseUrl) || cfg?.customModels?.[m]?.local === true || cfg?.customModels?.[m]?.isLocal === true;
306
+ } catch {
307
+ return false;
308
+ }
309
+ };
310
+ /** 取(或首次执行)预检;失败绝不影响主流程。 */
311
+ async function ensureProbe(/** @type {any} */ m) {
312
+ if (probeByModel.has(m)) return probeByModel.get(m);
313
+ if (!isLocalModel(m)) return null;
314
+ let probe = null;
315
+ try {
316
+ probe = await probeEndpoint(cfg, m, { timeoutMs: 15000 });
317
+ // v0.6.13(问题 2):这行日志此前用 `probe.toolCalls` 判工具支持——该字段**从来不存在**
318
+ // (真机日志里恒为 `tools=未知`,见 web-server.log:48/169/303),预检结论等于没落日志。
319
+ // 现在取真实的四态 toolState,并把**引擎自述上下文 vs 配置**也写进来。
320
+ const toolText =
321
+ probe.toolState === 'stable-tools'
322
+ ? `支持(${probe.toolCallHits}/${probe.toolCallRuns})`
323
+ : probe.toolState === 'unstable-tools'
324
+ ? `不稳定(${probe.toolCallHits}/${probe.toolCallRuns})`
325
+ : probe.toolState === 'no-tools'
326
+ ? `不返回 tool_calls(${probe.toolCallHits}/${probe.toolCallRuns})`
327
+ : '未知/不可判定';
328
+ srvlog(
329
+ `预检 ${m} ${probe.state} ttft=${probe.ttftMs == null ? 'n/a' : Math.round(probe.ttftMs) + 'ms'} ` +
330
+ `prefill2000=${probe.prefillMs == null ? 'n/a' : Math.round(probe.prefillMs) + 'ms'} ` +
331
+ `tools=${toolText} endpoint=${probe.endpoint || '(无)'} ` +
332
+ `引擎自述上下文=${probe.engineCtx == null ? '未读到' : probe.engineCtx} 配置=${probe.configuredWindow ?? '?'}`
333
+ );
334
+ } catch (/** @type {any} */ e) {
335
+ srvlog(`预检 ${m} 失败(忽略,按默认超时继续):${String(e?.message || e)}`);
336
+ }
337
+ probeByModel.set(m, probe);
338
+ if (probeByModel.size > 50) probeByModel.delete(probeByModel.keys().next().value);
339
+ return probe;
340
+ }
289
341
  /** @param {any} m */
290
342
  async function getProviderFor(m) {
291
343
  if (!providerCache.has(m)) {
292
- providerCache.set(m, await createProvider(cfg, m));
344
+ // 建 provider 之前先预检:本地端点按实测 TTFT 自适应 config.timeout.firstTokenMs
345
+ // (providers/index.js 只认 cfg.timeout,因此这里把自适应值**合进 cfg**再建实例)。
346
+ // 取舍:用户显式配的 firstTokenMs 优先;没配时"地板"仍是既有默认(本地 600s),
347
+ // 只有实测很慢才会放宽(×20,封顶 1800s)——绝不比现状更激进。
348
+ let cfgForProvider = cfg;
349
+ try {
350
+ const probe = await ensureProbe(m);
351
+ if (probe) {
352
+ const adapt = adaptiveTimeouts({
353
+ probeTtftMs: probe.ttftMs,
354
+ probePrefillMs: probe.prefillMs,
355
+ isLocal: true,
356
+ timeoutCfg: cfg.timeout,
357
+ noProgressCfg: cfg.noProgressTimeoutMs,
358
+ });
359
+ cfgForProvider = { ...cfg, timeout: { ...(cfg.timeout || {}), firstTokenMs: adapt.firstTokenMs } };
360
+ srvlog(`预检自适应 ${m} firstTokenMs=${adapt.firstTokenMs}(${adapt.basis})`);
361
+ }
362
+ } catch {}
363
+ providerCache.set(m, await createProvider(cfgForProvider, m));
293
364
  }
294
365
  return providerCache.get(m);
295
366
  }
@@ -374,11 +445,6 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
374
445
  // 放在这里而不是模块级:同一进程多次 runWebServer(测试/嵌入)不互相串味。
375
446
  const permissionNotices = new Map();
376
447
 
377
- // —— 服务端诊断日志(第二问无反应排查 + 长期运维):<mingdao-home>/logs/web-server.log ——
378
- // 记录每次对话的关键阶段与耗时;2MB 滚动。桌面版与 WebUI 共用同一日志。
379
- // 质检 A6:统一日志写入器(append + 按行截断 + 原子替换),与桌面 appLog 同款实现
380
- const srvlog = createLogWriter(path.join(mingdaoHome(), 'logs', 'web-server.log'));
381
-
382
448
  // —— SSRF 防护(质检 S1):远端地址校验 ——
383
449
  // v0.4.6 P1:本文件的 isPrivateHost 副本已删除,改用单一实现。
384
450
  // 副本只识别 `::ffff:` + 点分四段,而 URL 解析器会把该形态规范化成十六进制
@@ -457,18 +523,81 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
457
523
  // 进度心跳(审计:长时间健康生成的可感知性——模型持续输出工具参数期间可能长时间
458
524
  // 无任何 SSE 事件,界面看似卡死;每 8 秒发一次 progress,前端实时显示已工作时长/步数,
459
525
  // 同时充当客户端无活动看门狗的活动源)
526
+ //
527
+ // v0.6.13(A / 真机实测:本地 35B 审计回合跑了 4+ 小时、web-server.log 零行):
528
+ // · SSE 心跳照旧 5s 一次,但**日志必须定期留痕**(30s 一行)——否则"在跑"与"卡死"
529
+ // 在事后取证时完全无法区分(本次现场就是如此:日志只有"chat 开始"一行)。
530
+ // · 首帧时延(TTFT)与发送前规模预告各留一行:本地模型 prefill 是几十分钟级的事件,
531
+ // 它必须自己说话,而不是等用户来问"是不是死了"。
532
+ let lastProgressLogAt = 0;
533
+ let forecastLogged = false;
534
+ let ttftLogged = false;
535
+ const logTurnProgress = () => {
536
+ const tp = /** @type {any} */ (io?.turnProgress);
537
+ if (!tp) return;
538
+ if (!forecastLogged && tp.forecast) {
539
+ forecastLogged = true;
540
+ srvlog(`chat 预告 ${taskId} ${String(tp.forecast.text).replace(/\s*\n\s*/g, ' | ')}`);
541
+ }
542
+ if (!ttftLogged && tp.ttftMs != null) {
543
+ ttftLogged = true;
544
+ srvlog(`chat 首帧 ${taskId} ${(tp.ttftMs / 1000).toFixed(1)}s(请求发出→第一帧;本地模型此项直接反映 prefill 快慢)`);
545
+ }
546
+ if (Date.now() - lastProgressLogAt < 30000) return;
547
+ lastProgressLogAt = Date.now();
548
+ const seconds = Math.max(1, (Date.now() - entry.startedAt) / 1000);
549
+ const ttftS = tp.ttftMs != null ? tp.ttftMs / 1000 : null;
550
+ const decodeS = ttftS != null ? Math.max(1, seconds - ttftS) : null;
551
+ srvlog(
552
+ `chat 进度 ${taskId} ` +
553
+ formatTurnProgress({
554
+ phase: entry.pendingAsk ? '等待权限确认' : tp.phase,
555
+ elapsedMs: Date.now() - entry.startedAt,
556
+ modelRounds: tp.modelRounds || 0,
557
+ toolCalls: tp.toolCalls || 0,
558
+ toolExecuted: tp.toolExecuted || 0,
559
+ contentChars: tp.contentChars || 0,
560
+ ttftMs: tp.ttftMs ?? null,
561
+ tokensPerSec: decodeS && tp.contentTokens ? tp.contentTokens / decodeS : null,
562
+ pendingTool: tp.pendingTool ? { name: tp.pendingTool.name, ms: Date.now() - tp.pendingTool.startedAt } : null,
563
+ // v0.6.13(问题 3):等 `task` 子代理时把子代理自己的进度也写进日志——
564
+ // 真机现场只有"正在等待工具 task(已 620s)",看不出子代理是在干活还是卡死。
565
+ subagent: tp.subagent ?? null,
566
+ })
567
+ );
568
+ };
460
569
  const progressTimer = setInterval(() => {
461
570
  try {
571
+ const tp = /** @type {any} */ (io?.turnProgress);
462
572
  // 质检(静默深度优化):progress 附带阶段语义与子代理数,客户端顶部常驻活动条据此实时播报
463
- const phase = entry.pendingAsk ? '等待权限确认' : io?._turnActive ? '模型推理中' : '执行工具中';
573
+ const phase = entry.pendingAsk ? '等待权限确认' : tp?.phase || (io?._turnActive ? '模型推理中' : '执行工具中');
574
+ const seconds = Math.max(1, (Date.now() - entry.startedAt) / 1000);
575
+ const ttftS = tp?.ttftMs != null ? tp.ttftMs / 1000 : null;
576
+ const decodeS = ttftS != null ? Math.max(1, seconds - ttftS) : null;
464
577
  send({
465
578
  type: 'progress',
466
- seconds: Math.round((Date.now() - entry.startedAt) / 1000),
579
+ seconds: Math.round(seconds),
467
580
  steps: io?.stats?.().toolCount || 0,
468
581
  tasks: io?.stats?.().taskCount || 0,
469
582
  phase,
583
+ // v0.6.13(A):本地模型场景最要紧的几个数(0 次工具调用必须显示为 0,不能省)
584
+ toolCalls: tp?.toolCalls ?? 0,
585
+ modelRounds: tp?.modelRounds ?? 0,
586
+ ttftMs: tp?.ttftMs ?? null,
587
+ lastTtftMs: tp?.lastTtftMs ?? null,
588
+ contentChars: tp?.contentChars ?? 0,
589
+ tokensPerSec: decodeS && tp?.contentTokens ? Number((tp.contentTokens / decodeS).toFixed(1)) : null,
590
+ pendingTool: tp?.pendingTool ? { name: tp.pendingTool.name, ms: Date.now() - tp.pendingTool.startedAt } : null,
591
+ // v0.6.13(问题 3):正在跑的子代理快照——界面状态条据此显示"子 Agent 已跑 N 轮 / M 次工具调用"
592
+ subagent: tp?.subagent
593
+ ? { label: tp.subagent.label ?? '', modelRounds: tp.subagent.modelRounds ?? 0, toolCalls: tp.subagent.toolCalls ?? 0, ms: tp.subagent.at ? Date.now() - tp.subagent.at : null }
594
+ : null,
595
+ stalled: Boolean(tp?.stalled),
470
596
  });
471
597
  } catch {}
598
+ try {
599
+ logTurnProgress();
600
+ } catch {}
472
601
  }, 5000);
473
602
  const userMessage = String(body.message ?? '').trim();
474
603
  entry.message = (userMessage || '[附件]').slice(0, 40);
@@ -596,6 +725,11 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
596
725
  const { loadPreset, presetConfigOverrides, presetSystemBlock } = await import('../presets.js');
597
726
  chatPreset = loadPreset(taskDir, body.preset);
598
727
  if (chatPreset) {
728
+ // v0.6.15(C):老名字(local-audit)走到别名时**必须说出来**——改名可以兼容,但不能静默。
729
+ if (chatPreset.aliasedFrom) {
730
+ send({ type: 'banner', text: `ℹ 预设「${chatPreset.aliasedFrom}」已更名为「${chatPreset.name}」(本次已按 ${chatPreset.name} 执行;老名字保留为别名)。` });
731
+ srvlog(`预设别名 ${taskId} ${chatPreset.aliasedFrom} → ${chatPreset.name}`);
732
+ }
599
733
  const over = presetConfigOverrides(chatPreset);
600
734
  // P0(v0.4.1):预设 permission 提权防护——不得把 ask/readonly 静默改成 auto
601
735
  turnPerm = resolveTurnPermission({ preset: chatPreset, explicit: explicitPermission, configPermission: cfg.permission ?? 'ask' });
@@ -699,8 +833,59 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
699
833
  throw new Error(`模型 ${runModel} 尚未配置 API Key:请点击右上角 ⚙ 设置 →「模型与 API Key」填入密钥。`);
700
834
  }
701
835
  providerNow = await getProviderFor(runModel); // 审计 P1-1:失败时清理任务占位,避免僵尸 running 耗尽并发
702
- } catch (/** @type {any} */ err) {
703
- entry.status = 'failed';
836
+ // v0.6.13(A 追加):**本地端点先预检、再开工**——"这个端点能不能跑这种任务"必须几秒内有答案。
837
+ // 预检结果:① 打到界面上(banner,发送前可见);② 落 web-server.log;③ 随 chatCfg 传给 agent,
838
+ // 让"发送前规模预告"把实测 TTFT 与本任务上下文一起说出来;④ 没配超时时按它自适应看门狗阈值。
839
+ try {
840
+ const probe = await ensureProbe(runModel);
841
+ if (probe) {
842
+ const adapt = adaptiveTimeouts({
843
+ probeTtftMs: probe.ttftMs,
844
+ probePrefillMs: probe.prefillMs,
845
+ isLocal: true,
846
+ timeoutCfg: cfg.timeout,
847
+ noProgressCfg: cfg.noProgressTimeoutMs,
848
+ });
849
+ send({ type: 'banner', text: `🔎 本地端点预检:${probe.note}` });
850
+ // v0.6.13(问题 2):**配置超出引擎能力**必须是一条独立的告警 banner,而且要在开工前出现。
851
+ // 真机根因(web-server.log:229):配置 contextWindow=131072,引擎(llama.cpp)自述 n_ctx=32768,
852
+ // 长上下文在 prefill 阶段被引擎直接拒绝(insufficient memory);用户当时反复"换预设"无效——
853
+ // 因为预设只改 contextBudget,**改不了引擎的 n_ctx**。所以这条要说清"换预设也没用"。
854
+ try {
855
+ const capsNow = resolveModelCaps(cfg, runModel);
856
+ if (Number(probe.engineCtx) > 0 && Number(capsNow.contextWindow) > Number(probe.engineCtx)) {
857
+ send({
858
+ type: 'banner',
859
+ warn: true,
860
+ text:
861
+ `⚠ 引擎自述上下文 ${probe.engineCtx} vs 配置 ${capsNow.contextWindow} —— 配置超出引擎能力,长上下文将直接被引擎拒绝` +
862
+ `(真机现场报的是 insufficient memory / 显存不足)。换预设、调 config.contextBudget、调 timeout.* 都**改不了引擎上限**:` +
863
+ `要么在引擎侧把上下文窗口调大(llama.cpp 的 --ctx-size / --context-window),要么把 customModels.${runModel}.contextWindow 改成 ≤ ${probe.engineCtx}。`,
864
+ });
865
+ } else if (probe.engineProbed && !(Number(probe.engineCtx) > 0)) {
866
+ // 真的读了却没读到 → 也要说一句("读不到就跳过并说明"),否则用户会以为内核核对过引擎能力了。
867
+ // 条件是 engineProbed:压根没读(非本地端点 / 端点未自报模型名)时不刷这条噪音,
868
+ // 那种情形的原因已经写在 probe.note 的"引擎自述上下文:跳过(…)"里。
869
+ send({
870
+ type: 'banner',
871
+ text: 'ℹ 引擎自述上下文:未读到(该端点不提供 /props 或其中没有 n_ctx 字段)——无法核对配置是否超出引擎能力;不影响继续使用。',
872
+ });
873
+ }
874
+ } catch {}
875
+ send({
876
+ type: 'banner',
877
+ text:
878
+ ` 自适应:首帧上限 ${Math.round(adapt.firstTokenMs / 1000)}s、无进展看门狗 ${Math.round(adapt.noProgressTimeoutMs / 60000)} 分钟` +
879
+ `(依据:${adapt.basis}${adapt.adapted ? ',未显式配置 config.timeout' : ';你已显式配置,按你的值'})`,
880
+ });
881
+ chatCfg = { ...chatCfg, endpointProbe: probe };
882
+ if (!(Number(cfg.noProgressTimeoutMs) > 0)) chatCfg.noProgressTimeoutMs = adapt.noProgressTimeoutMs;
883
+ srvlog(`chat 预检 ${taskId} ${probe.state} ${probe.note}`);
884
+ }
885
+ } catch (/** @type {any} */ e) {
886
+ srvlog(`chat 预检失败(忽略) ${taskId} ${String(e?.message || e)}`);
887
+ }
888
+ } catch (/** @type {any} */ err) { entry.status = 'failed';
704
889
  notifyBusy();
705
890
  entry.durationMs = Date.now() - entry.startedAt;
706
891
  send({ type: 'error', message: `模型 ${runModel} 不可用:${String(err?.message || err)}` });
@@ -823,7 +1008,8 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
823
1008
  // 'failed' —— 同一件事两个说法。统一口径:**capped 不是成功**(任务未真正完成、可续跑)。
824
1009
  // 这里用 'capped'(前端按非 done 渲染成警示色 + 未完成任务横幅),worker 侧维持其既有词表
825
1010
  // 里的 'failed'(任务状态机 status 取值受 task-state/schedule 依赖判定约束,不新增取值)。
826
- entry.status = r.aborted ? 'aborted' : r.capHit ? 'capped' : 'done';
1011
+ // v0.6.13(A):无进展中止是**独立结局**(不是 done、也不是 capped)——前端按非 done 呈现
1012
+ entry.status = r.aborted ? 'aborted' : r.stalled ? 'stalled' : r.capHit ? 'capped' : 'done';
827
1013
  notifyBusy();
828
1014
  entry.durationMs = Date.now() - entry.startedAt;
829
1015
  srvlog('chat 发送 done ' + taskId + ' status=' + entry.status + ' 总耗时=' + entry.durationMs + 'ms');
@@ -838,6 +1024,25 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
838
1024
  const { resolveModelCaps, safeBudget } = await import('../model-caps.js');
839
1025
  budgetInfo = { used, total: safeBudget(cfg, resolveModelCaps(cfg, runModel)) };
840
1026
  } catch {}
1027
+ // v0.6.13(问题 4):**stalled 必须有用户可见的说明**。
1028
+ // 真机现场:内核写了 `chat 发送 done … status=stalled`,而桌面壳日志里只有
1029
+ // `[renderer] [MingDao] 回合收尾:generating=false,按钮恢复发送` —— 用户完全不知道
1030
+ // 这一轮是"跑满了步数(可续跑)""我点了停止"还是"再等下去也不会有新东西"。
1031
+ // 这里发一条**告警 banner**(与 done.note 同一份文案,单一来源就是 agent 的 r.note),
1032
+ // 前端 renderBanner 直接渲染;done.note 仍照旧带上,两条通道都不丢。
1033
+ if (r.stalled) {
1034
+ send({
1035
+ type: 'banner',
1036
+ warn: true,
1037
+ text:
1038
+ `⏹ 本轮因**长时间无进展**已中止(不是步数上限 capped、也不是你点的停止 aborted)。\n` +
1039
+ String(r.note || '').trim(),
1040
+ });
1041
+ }
1042
+ // v0.6.13(A):**"0 次工具调用"必须出现在收尾里**——否则端点不返回 tool_calls 时,
1043
+ // 界面看起来一切正常(ok:true + 正文),实际一次工具都没调("看起来在工作、其实什么都没做")。
1044
+ const zeroNote = String(r.perf?.zeroToolCalls || '');
1045
+ const baseNote = r.note || (r.text ? '' : `(任务已执行 ${io.stats().toolCount} 步工具操作${io.stats().deliverables.length ? `、交付 ${io.stats().deliverables.length} 个文件` : ''},自动收尾总结未能生成——可追问「总结一下刚才的工作」)`);
841
1046
  send({
842
1047
  type: 'done',
843
1048
  budget: budgetInfo,
@@ -847,7 +1052,12 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
847
1052
  durationMs: r.durationMs,
848
1053
  truncated: r.truncated,
849
1054
  aborted: r.aborted,
850
- note: r.note || (r.text ? '' : `(任务已执行 ${io.stats().toolCount} 步工具操作${io.stats().deliverables.length ? `、交付 ${io.stats().deliverables.length} 个文件` : ''},自动收尾总结未能生成——可追问「总结一下刚才的工作」)`),
1055
+ stalled: Boolean(r.stalled),
1056
+ // v0.6.13(问题 3/4):stalled 的**原因**也一并下发——'inherited' = 父回合整轮零进展
1057
+ //(子代理被一起收口),'own' = 本回合自己零进展。前端据此给出不同的处置建议。
1058
+ stallCause: r.perf?.stallCause ?? null,
1059
+ perf: r.perf ? { llmCalls: r.perf.llmCalls ?? null, toolCalls: r.perf.toolCalls ?? null, ttftMs: r.perf.ttftMs ?? null, noToolCalls: Boolean(r.perf.noToolCalls) } : null,
1060
+ note: [baseNote, zeroNote].filter(Boolean).join('\n'),
851
1061
  stats: io.stats(),
852
1062
  session: path.basename(session.file),
853
1063
  });
@@ -1,10 +0,0 @@
1
- {
2
- "name": "local-audit",
3
- "label": "只读代码审计(本地模型)",
4
- "description": "面向本地/资源受限模型(如 131k 窗口 q8 量化)的只读代码审计预设:只读工具集 + 保守参数。两点先说清:① 本预设**不启用审计日志/账本**——那是 config.audit 与 `mingdao ledger`,与本预设无关,别把它当合规审计开关;② 本预设**不覆盖你的权限档**——只读效果由 tools 白名单保证(里面没有 write/edit,模型根本看不到写工具),权限档始终以你的显式选择为准(recommendedPermission=readonly 只是建议,不参与判定)。",
5
- "systemPrompt": "你是一名代码审计员。任务是对给定代码库做只读审计:先 ls/glob 摸清结构,再 read/grep 逐文件审查,可用 git 查看历史与 diff、fetch 抓取相关文档。对于大仓库可分模块派发只读子代理并行审查(task 工具传 readOnly:true,子代理同样只读:read/ls/glob/grep/skill)。输出审计报告:发现的缺陷按严重度分级,每条给出文件:行号证据;不做任何修改。若上下文紧张,先审查最关键的部分并明示未覆盖区域。",
6
- "tools": ["read", "ls", "glob", "grep", "skill", "git", "fetch", "todo", "task"],
7
- "recommendedPermission": "readonly",
8
- "maxRounds": 4,
9
- "maxOutputTokens": 4096
10
- }