mingdao-harness 0.6.38 → 0.6.39

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.
@@ -209,30 +209,45 @@ node scripts/update-sign.mjs --verify desktop/dist/mingdao-setup-<ver>-x64.exe -
209
209
  下载 → 取到 `.sig` → 用旧公钥验 → 失败 → **永久拒绝更新**。
210
210
  这比"没有签名"严重得多——用户会被卡死在旧版本,只能手动重装。
211
211
 
212
- ### 6.2 两版
212
+ ### 6.2 三个阶段
213
213
 
214
214
  | 版本 | 代码 | 发布侧 |
215
215
  | --- | --- | --- |
216
- | **v0.6.38(本版)** | 密钥环/签名脚本/文案均沿用 v0.6.37(本版只修 macOS CI 测试判据) | **仍然不发布 `.sig`**(CI 开关关闭) |
217
- | **v0.6.39(下一版)** | 不需再改代码(密钥环已能认新钥) | 用新私钥签 `.sig` → 发布到 GitHub Release **与** 站点 `/updates/<文件名>.sig`;CI 开关打开 |
216
+ | **v0.6.38** | 密钥环/签名脚本/文案均沿用 v0.6.37(本版只修 macOS CI 测试判据) | **仍然不发布 `.sig`**(CI 开关关闭) |
217
+ | **v0.6.39(阶段一,已上线)** | 无需改代码(密钥环已能认新钥) | 用新私钥签 `.sig`,**只上传 GitHub Release**;站点 `/updates/` **绝不放 `.sig`** |
218
+ | **阶段二(0.6.40+,未上线)** | 无需改代码 | 把 Release 里的 `.sig` **复制到站点 `/updates/`**(客户端真正读取的位置) |
219
+
220
+ > **阶段一为什么不放站点**:存量客户端(≤0.6.35)只内置旧公钥,拿到用新私钥签的 `.sig` 会
221
+ > "有签名但验不过"→ `decideUpdatePolicy` 判 **reject** → **永久拒绝更新**,只能手动重装。
222
+ > 阶段一先把 `.sig` 关在 Release 里(可审计、客户端不读),等 0.6.36+(含密钥环)覆盖绝大多数
223
+ > 活跃用户后再进入阶段二。
224
+ >
225
+ > **阶段二切换条件与回滚**:0.6.36+ 覆盖绝大多数活跃用户后,把 `.sig` 复制到 `/updates/` 即可;
226
+ > 切换前必须实测 0.6.35 / 0.6.37 / 0.6.38 客户端能从新版正常更新。若旧版拒绝,**立即删除
227
+ > `/updates/*.sig`**(回滚只需删文件,不动代码)。
218
228
 
219
229
  ### 6.3 开关与密钥
220
230
 
221
231
  - 私钥**不入库**:`~/.mingdao/update-signing-key.pem`(0600),CI 里放 `secrets.UPDATE_SIGNING_KEY`(PEM 全文);
222
- - CI 开关:仓库变量 `UPDATE_SIGNING_ENABLED=true` 才执行签名与上传(默认关闭);
232
+ - CI 开关:**`secrets.UPDATE_SIGNING_KEY` 存在**即执行签名与上传(v0.6.39 起;此前文档写的是仓库变量
233
+ `UPDATE_SIGNING_ENABLED`,实际未接线)。`secrets` **不能**直接用在 step 级 `if`(整份 workflow 会判无效),
234
+ 所以在 job 级 `env` 折算成 `HAS_UPDATE_SIGNING_KEY` 再判;
223
235
  - 生成/轮换:`node desktop/update-verify.js --keygen`(沿用既有实现,私钥不打印、拒绝入库),
224
236
  然后把新公钥追加到 `UPDATE_PUBLIC_KEYS`(**旧钥必须保留至少一个大版本**,否则又会断掉存量客户端)。
225
237
 
226
238
  ### 6.4 发布时 `.sig` 放哪里
227
239
 
228
240
  客户端按 **`https://harness.mingdao.ai/updates/<安装包文件名>.sig`** 取(`collectUpdateSignature` 的第三来源)。
229
- 所以发版脚本要把 `scripts/sign-release-assets.mjs --out <sig 目录>` 的产物同时:
230
- 1. 传到 GitHub Release(便于审计与镜像);
231
- 2. **复制到站点 `updates/` 目录**(客户端真正读取的位置)。
241
+ `scripts/sign-release-assets.mjs artifacts` 会**递归**扫描(CI 的产物在 `artifacts/<name>/` 子目录)
242
+ 并**保留相对目录**产出 `.sig`(不同平台的 `latest.yml` 不会互相覆盖)。发布侧:
243
+
244
+ 1. **阶段一(v0.6.39)**:只传到 GitHub Release(便于审计与镜像);**站点 `updates/` 不放**。
245
+ 2. **阶段二(未上线)**:再把同样的 `.sig` **复制到站点 `updates/` 目录**(客户端真正读取的位置)。
232
246
 
233
247
  ### 6.5 验收
234
248
 
235
- - `node test/update-signing.js`(10 项):密钥环多钥、篡改必拒、签名脚本产物可验、缺钥 CLI 非零退出、文案分层;
236
- - `batch77`(8 条变异)必须全中;
237
- - 启用后的首个版本,必须实测"旧版客户端(0.6.35 或 0.6.37/0.6.38)能从 0.6.39 正常更新"——
238
- 若旧版拒绝,说明 `.sig` 已发布但旧版不在密钥环内,**立即撤下 `/updates/*.sig`**(回滚只要删文件,不动代码)。
249
+ - `node test/update-signing.js`(**11 项**):密钥环多钥、篡改必拒、签名脚本产物可验(含子目录递归/相对路径/
250
+ 扁平化重名告警)、缺钥 CLI 非零退出、文案分层;
251
+ - `batch77`(**9 条变异**)必须全中;
252
+ - **阶段二启用后**的首个版本,必须实测"旧版客户端(0.6.35 或 0.6.37/0.6.38)能从新版正常更新"——
253
+ 若旧版拒绝,说明 `.sig` 已发布但旧版不在密钥环内,**立即撤下 `/updates/*.sig`**。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mingdao-harness",
3
- "version": "0.6.38",
3
+ "version": "0.6.39",
4
4
  "description": "MingDao Harness —— 开源智能体框架(Agent Harness)。零依赖、开箱即用,针对 DeepSeek-V4 系列优化,开放主流模型接入。",
5
5
  "type": "module",
6
6
  "bin": {
package/src/web/app.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { $, esc, highlight, renderMarkdown, scrollBottom, expandableBody, resultText, truncText, textBody, fmtDur, fmtTok, fmtT } from './util.js';
2
2
  import { MAX_ATTACHMENTS, MAX_IMAGE_BYTES, MAX_TEXT_BYTES } from './constants.js';
3
3
  import { sideNavBreakpoint, sideNavLayout, errCardParts, runStripView, permissionSourceLabel, toolStepView, toolTimelineSummary, toolTimelineVisibility, traceRoundView } from './viewmodel.js';
4
+ import { enqueueMessage, dequeueMessage, shouldDrainQueue, queueView, QUEUE_MAX } from './viewmodel.js';
4
5
 
5
6
  // 访问令牌(P1-3):地址带 ?token= 时记入 sessionStorage 并从地址栏移除(防截图/历史外泄),
6
7
  // 之后所有同源请求统一附加 X-MingDao-Token 头;无令牌时行为与旧版一致。
@@ -718,16 +719,72 @@ function renderAttachments(){
718
719
  }
719
720
  }
720
721
 
722
+ // —— 输入队列(v0.6.39)——
723
+ // 语义:生成中按 Enter 不再"被吞掉/只弹一句提示",而是**排队**;本轮结束后自动依次发出。
724
+ // 用户中止(■ 停止)时**不排空**——中止的意思是"停下",此时接着发下一条会违背用户意图。
725
+ // 队列本身只有一份(内存),页面刷新即丢:它是"这一刻的输入意图",不是持久任务。
726
+ let queued=[]; // [{id,text}]
727
+ let queueSeq=0;
728
+
729
+ function renderQueue(){
730
+ const strip=$('#queueStrip'); if(!strip) return;
731
+ const items=queueView(queued);
732
+ if(!items.length){ strip.style.display='none'; strip.innerHTML=''; return; }
733
+ strip.style.display='flex';
734
+ strip.innerHTML='';
735
+ const cap=document.createElement('span'); cap.className='qcap';
736
+ cap.textContent='排队中 '+items.length+' 条(本轮结束后自动发送)';
737
+ strip.appendChild(cap);
738
+ for(const it of items){
739
+ const chip=document.createElement('span'); chip.className='qchip';
740
+ const idx=document.createElement('span'); idx.className='qi'; idx.textContent=it.index+'.';
741
+ const txt=document.createElement('span'); txt.className='qt'; txt.textContent=it.label; txt.title=it.text;
742
+ // 点文本 → 放回输入框编辑(从队列移除,避免"编辑完发两次")
743
+ txt.onclick=()=>{ const r=dequeueMessage(queued,it.id); queued=r.queue; renderQueue(); input.value=r.removed?r.removed.text:input.value; input.focus(); autosize(); };
744
+ const x=document.createElement('span'); x.className='qx'; x.textContent='✕'; x.title='从队列移除';
745
+ x.onclick=()=>{ queued=dequeueMessage(queued,it.id).queue; renderQueue(); };
746
+ chip.appendChild(idx); chip.appendChild(txt); chip.appendChild(x);
747
+ strip.appendChild(chip);
748
+ }
749
+ }
750
+
751
+ /** 生成中把输入放进队列;返回是否入队成功(空文本/满员时给可读提示)。 */
752
+ function enqueueInput(text){
753
+ queueSeq+=1;
754
+ const r=enqueueMessage(queued,text,'q'+queueSeq);
755
+ queued=r.queue;
756
+ if(r.ok){ renderQueue(); return true; }
757
+ if(r.reason==='full'){
758
+ const d=document.createElement('div'); d.className='msg-sys';
759
+ d.innerHTML='⚠ 队列已满('+QUEUE_MAX+' 条),先发送或删掉几条再添加。';
760
+ chatEl.appendChild(d); scroll();
761
+ }
762
+ return false;
763
+ }
764
+
765
+ /** 本轮结束后排空队列(用 override 文本直接发下一条)。 */
766
+ function drainQueue(){
767
+ if(!shouldDrainQueue({queue:queued,generating,aborted:runState.aborted})) return;
768
+ const next=queued[0];
769
+ queued=queued.slice(1);
770
+ renderQueue();
771
+ input.value=next.text; autosize();
772
+ // 让 finally 里的收尾(按钮/状态栏/渲染)先跑完,再开下一轮
773
+ setTimeout(()=>{ try{ send(); }catch(e){ console.error('[MingDao] 队列续发失败',e); } },120);
774
+ }
775
+
721
776
  async function send(){
722
777
  const text=input.value.trim();
723
778
  console.log('[MingDao] send 点击:generating=' + generating + ' text=' + text.length + ' attachments=' + attachments.length);
724
779
  if(generating){
725
- // 审计(长任务静默硬伤):任务执行中追问「是否完成」→ 界面立即如实应答当前进度,
726
- // 不再静默吞掉输入;停止职责由「■ 停止」按钮承担(输入框内容保留不丢)。
727
- const secs=curWorkT0?Math.round((Date.now()-curWorkT0)/1000):0;
728
- const d=document.createElement('div'); d.className='msg-sys';
729
- d.innerHTML='⏳ 上一个任务仍在执行(第 '+curSteps+' 步 · 已 '+Math.floor(secs/60)+' 分 '+Math.round(secs%60)+' 秒),<b>尚未完成</b>。完成后总结与交付物会自动出现在本轮消息底部;如需立即中断请点「■ 停止」。';
730
- chatEl.appendChild(d); scroll();
780
+ // v0.6.39:生成中继续输入 → **入队**(本轮结束后自动发送),不再只弹一句"仍在执行"。
781
+ // 队列条在输入框上方:点文本回填编辑、点 ✕ 删除;用户中止时不排空(见 drainQueue/aborted)。
782
+ if(enqueueInput(text)){
783
+ input.value=''; input.style.height='44px';
784
+ const secs=curWorkT0?Math.round((Date.now()-curWorkT0)/1000):0;
785
+ const ht=$('#hintText');
786
+ if(ht) ht.textContent='已加入队列(共 '+queued.length+' 条),本轮结束后自动发送 · 当前第 '+curSteps+' 步 · 已 '+Math.floor(secs/60)+' 分 '+Math.round(secs%60)+' 秒 · 要立即中断请点「■ 停止」';
787
+ }
731
788
  return;
732
789
  }
733
790
  if(!text && !attachments.length) return;
@@ -905,7 +962,7 @@ async function send(){
905
962
  // 具体错误形态已进日志(fetch failed 等不再绕开续跑提示)。
906
963
  d.textContent=(e&&e.name==='AbortError')?(killedByWatchdog?'响应超时已中断(120 秒无任何响应),请重试':'已中断'):('连接中断,本轮未完成。已执行的工作已保存检查点——直接发送「继续」即可从断点续跑(' + Math.round((Date.now() - workT0) / 1000) + 's · ' + stepsCount + ' 步)。');
907
964
  msg.appendChild(d); scroll(); }
908
- finally{ clearInterval(hintTimer); disarm(); clearTimeout(stopFallbackTimer); stopFallbackTimer=null; stopping=false; turnAbortCtrl=null; generating=false; setBtn(); curTaskId=null; curPhase='模型推理中'; console.log('[MingDao] 回合收尾:generating=false,按钮恢复发送'); hintEl.classList.remove('working'); if(hintTextEl) hintTextEl.textContent=defaultHint; pending=false; msg._endedAt=Date.now(); finalizePendingSubagent(msg); content.innerHTML=renderMarkdown(raw); attachTrajMeta(msg); renderTracePanels(); activeAiMsg=null; curWorkT0=0; if(!runState.aborted){ runState.rawPhase='空闲'; runState.phase='空闲'; } else { runState.rawPhase='已中止'; runState.phase='已中止'; } renderWorkStatus(); scroll(); updateTasksPanel(); }
965
+ finally{ clearInterval(hintTimer); disarm(); clearTimeout(stopFallbackTimer); stopFallbackTimer=null; stopping=false; turnAbortCtrl=null; generating=false; setBtn(); curTaskId=null; curPhase='模型推理中'; console.log('[MingDao] 回合收尾:generating=false,按钮恢复发送'); hintEl.classList.remove('working'); if(hintTextEl) hintTextEl.textContent=defaultHint; pending=false; msg._endedAt=Date.now(); finalizePendingSubagent(msg); content.innerHTML=renderMarkdown(raw); attachTrajMeta(msg); renderTracePanels(); activeAiMsg=null; curWorkT0=0; if(!runState.aborted){ runState.rawPhase='空闲'; runState.phase='空闲'; } else { runState.rawPhase='已中止'; runState.phase='已中止'; } renderWorkStatus(); scroll(); updateTasksPanel(); drainQueue(); }
909
966
  }
910
967
 
911
968
  let curTaskId=null; // 本轮 SSE 任务 id(质检 L1:主停止按钮只断本任务,不再误伤其他 tab)
@@ -218,6 +218,13 @@ main{flex:1;overflow-y:auto;padding:18px 0 12px;min-width:0}
218
218
  #composer{display:flex;gap:8px;align-items:flex-end}
219
219
  #attachBtn{padding:10px 12px;background:var(--bg3);border:1px solid var(--border);border-radius:10px;cursor:pointer;font-size:15px}
220
220
  #attachList{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:6px}
221
+ #queueStrip{max-width:860px;margin:0 auto 6px;display:flex;gap:6px;flex-wrap:wrap;align-items:center}
222
+ #queueStrip .qcap{font-size:12px;color:var(--dim)}
223
+ #queueStrip .qchip{display:inline-flex;align-items:center;gap:6px;max-width:360px;background:rgba(90,160,255,.12);border:1px solid rgba(90,160,255,.38);border-radius:14px;padding:3px 10px;font-size:12.5px;color:var(--fg)}
224
+ #queueStrip .qchip .qi{color:var(--dim)}
225
+ #queueStrip .qchip .qt{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:text}
226
+ #queueStrip .qchip .qx{cursor:pointer;color:var(--dim)}
227
+ #queueStrip .qchip .qx:hover{color:#ff6b6b}
221
228
  .deliver{border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:10px;background:var(--bg2);padding:8px 12px;margin:4px 0;font-size:12.5px}
222
229
  .deliver .t{color:var(--accent2);font-weight:600;margin-bottom:2px}
223
230
  .deliver .i{color:var(--dim);padding:1px 0;font-family:ui-monospace,Consolas,monospace}
@@ -861,6 +868,8 @@ body.side-collapsed #sideNavSessions .sn-item.on,body.side-mid #sideNavSessions
861
868
  </div>
862
869
  <input type="file" id="fileInput" accept="image/png,image/jpeg,image/gif,image/webp,.txt,.md,.json,.js,.py,.log,.csv,.html,.css" multiple style="display:none">
863
870
  <div id="attachList" style="display:none"></div>
871
+ <!-- v0.6.39 输入队列:生成中继续输入会排队,本轮结束后自动依次发出(不打断当前任务) -->
872
+ <div id="queueStrip" style="display:none"></div>
864
873
  <div id="composer">
865
874
  <button id="attachBtn" title="上传图片或文本文件(图片需支持视觉的模型)">📎</button>
866
875
  <textarea id="input" rows="1" placeholder="输入任务…(Enter 发送,Shift+Enter 换行)"></textarea>
@@ -445,3 +445,77 @@ export function traceRoundView(round) {
445
445
  summary: parts.join(' · '),
446
446
  };
447
447
  }
448
+
449
+ // ---------------------------------------------------------------------------
450
+ // 输入队列(v0.6.39):生成中继续输入 → 排队,本轮结束后自动发出
451
+ //
452
+ // 为什么放在这里而不是直接写在 app.js:队列的**语义**(去重、上限、截断、何时排空)是纯逻辑,
453
+ // 必须能被单测钉住;DOM 渲染只是把它画出来。DSH 等 harness 的做法也是"输入先入队、在回合边界消费"。
454
+ // ---------------------------------------------------------------------------
455
+
456
+ /** 队列上限:防止用户连点把内存撑爆(也避免"一次发 50 条"把模型打爆)。 */
457
+ export const QUEUE_MAX = 20;
458
+ /** chip 上显示的最大字符数。 */
459
+ export const QUEUE_LABEL_MAX = 42;
460
+
461
+ /**
462
+ * 把一条输入加入队列。
463
+ * 空文本直接拒绝(不要让空 chip 出现在界面上);满员拒绝并给出可读原因。
464
+ * @param {Array<{id:string,text:string}>} queue
465
+ * @param {string} text
466
+ * @param {string} id
467
+ * @returns {{ok:boolean, queue:Array<{id:string,text:string}>, reason?:string}}
468
+ */
469
+ export function enqueueMessage(queue, text, id) {
470
+ const list = Array.isArray(queue) ? queue : [];
471
+ const t = String(text == null ? '' : text);
472
+ if (!t.trim()) return { ok: false, queue: list, reason: 'empty' };
473
+ if (list.length >= QUEUE_MAX) return { ok: false, queue: list, reason: 'full' };
474
+ return { ok: true, queue: [...list, { id: String(id || ''), text: t }] };
475
+ }
476
+
477
+ /**
478
+ * 从队列里移除指定 id。
479
+ * @param {Array<{id:string,text:string}>} queue
480
+ * @param {string} id
481
+ * @returns {{queue:Array<{id:string,text:string}>, removed:({id:string,text:string}|null)}}
482
+ */
483
+ export function dequeueMessage(queue, id) {
484
+ const list = Array.isArray(queue) ? queue : [];
485
+ const idx = list.findIndex((q) => String(q?.id) === String(id));
486
+ if (idx < 0) return { queue: list, removed: null };
487
+ return { queue: [...list.slice(0, idx), ...list.slice(idx + 1)], removed: list[idx] };
488
+ }
489
+
490
+ /**
491
+ * chip 上的单行显示文本(换行压成空格,超长截断加省略号)。
492
+ * @param {{text?:string}} item
493
+ * @returns {string}
494
+ */
495
+ export function queueLabel(item) {
496
+ const t = String(item?.text ?? '').replace(/\s+/g, ' ').trim();
497
+ if (!t) return '';
498
+ return t.length > QUEUE_LABEL_MAX ? t.slice(0, QUEUE_LABEL_MAX - 1) + '…' : t;
499
+ }
500
+
501
+ /**
502
+ * 本轮是否可以排空队列。
503
+ * 关键:**用户主动中止(aborted)时不排空**——中止的语义是"停下",此时接着发下一条会违背用户意图;
504
+ * 而"任务完成""出错"都应当继续把用户已经写好的下一条发出去(用户已经表达了意图)。
505
+ * @param {{queue?:Array<any>, generating?:boolean, aborted?:boolean}} st
506
+ * @returns {boolean}
507
+ */
508
+ export function shouldDrainQueue(st) {
509
+ const q = Array.isArray(st?.queue) ? st.queue : [];
510
+ return q.length > 0 && !st?.generating && !st?.aborted;
511
+ }
512
+
513
+ /**
514
+ * 队列的渲染模型(含序号、截断文本)。
515
+ * @param {Array<{id:string,text:string}>} queue
516
+ * @returns {Array<{id:string,index:number,label:string,text:string}>}
517
+ */
518
+ export function queueView(queue) {
519
+ const list = Array.isArray(queue) ? queue : [];
520
+ return list.map((q, i) => ({ id: String(q?.id || ''), index: i + 1, label: queueLabel(q), text: String(q?.text ?? '') }));
521
+ }