mingdao-harness 0.6.37 → 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.
- package/docs/CODE-SIGNING.md +28 -12
- package/package.json +1 -1
- package/src/web/app.js +64 -7
- package/src/web/index.html +9 -0
- package/src/web/viewmodel.js +74 -0
package/docs/CODE-SIGNING.md
CHANGED
|
@@ -199,7 +199,8 @@ node scripts/update-sign.mjs --verify desktop/dist/mingdao-setup-<ver>-x64.exe -
|
|
|
199
199
|
## 六、更新来源签名的**两版上线**规程(v0.6.37 起)
|
|
200
200
|
|
|
201
201
|
> 版本说明:v0.6.36 因 GitHub 退役 macOS 14 runner 未能发布(tag 保留、无 Release/npm/官网),
|
|
202
|
-
> 内容随 **v0.6.37**
|
|
202
|
+
> 内容随 **v0.6.37** 首发;因此密钥环在 v0.6.37 到达用户。v0.6.38 是 macOS CI 测试判据的修复版
|
|
203
|
+
> (无产品行为变化),**签发继续顺延**——从 **v0.6.39** 开始。
|
|
203
204
|
|
|
204
205
|
### 6.1 为什么不能一次上线
|
|
205
206
|
|
|
@@ -208,30 +209,45 @@ node scripts/update-sign.mjs --verify desktop/dist/mingdao-setup-<ver>-x64.exe -
|
|
|
208
209
|
下载 → 取到 `.sig` → 用旧公钥验 → 失败 → **永久拒绝更新**。
|
|
209
210
|
这比"没有签名"严重得多——用户会被卡死在旧版本,只能手动重装。
|
|
210
211
|
|
|
211
|
-
### 6.2
|
|
212
|
+
### 6.2 三个阶段
|
|
212
213
|
|
|
213
214
|
| 版本 | 代码 | 发布侧 |
|
|
214
215
|
| --- | --- | --- |
|
|
215
|
-
| **v0.6.
|
|
216
|
-
| **v0.6.
|
|
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`**(回滚只需删文件,不动代码)。
|
|
217
228
|
|
|
218
229
|
### 6.3 开关与密钥
|
|
219
230
|
|
|
220
231
|
- 私钥**不入库**:`~/.mingdao/update-signing-key.pem`(0600),CI 里放 `secrets.UPDATE_SIGNING_KEY`(PEM 全文);
|
|
221
|
-
- CI
|
|
232
|
+
- CI 开关:**`secrets.UPDATE_SIGNING_KEY` 存在**即执行签名与上传(v0.6.39 起;此前文档写的是仓库变量
|
|
233
|
+
`UPDATE_SIGNING_ENABLED`,实际未接线)。`secrets` **不能**直接用在 step 级 `if`(整份 workflow 会判无效),
|
|
234
|
+
所以在 job 级 `env` 折算成 `HAS_UPDATE_SIGNING_KEY` 再判;
|
|
222
235
|
- 生成/轮换:`node desktop/update-verify.js --keygen`(沿用既有实现,私钥不打印、拒绝入库),
|
|
223
236
|
然后把新公钥追加到 `UPDATE_PUBLIC_KEYS`(**旧钥必须保留至少一个大版本**,否则又会断掉存量客户端)。
|
|
224
237
|
|
|
225
238
|
### 6.4 发布时 `.sig` 放哪里
|
|
226
239
|
|
|
227
240
|
客户端按 **`https://harness.mingdao.ai/updates/<安装包文件名>.sig`** 取(`collectUpdateSignature` 的第三来源)。
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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/` 目录**(客户端真正读取的位置)。
|
|
231
246
|
|
|
232
247
|
### 6.5 验收
|
|
233
248
|
|
|
234
|
-
- `node test/update-signing.js
|
|
235
|
-
|
|
236
|
-
-
|
|
237
|
-
|
|
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
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
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
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)
|
package/src/web/index.html
CHANGED
|
@@ -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>
|
package/src/web/viewmodel.js
CHANGED
|
@@ -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
|
+
}
|