mingdao-harness 0.2.7 → 0.3.0

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/web/app.js CHANGED
@@ -1,3 +1,6 @@
1
+ import { $, esc, highlight, renderMarkdown, scrollBottom, expandableBody, resultText, truncText, fmtDur, fmtTok, fmtT } from './util.js';
2
+ import { MAX_ATTACHMENTS, MAX_IMAGE_BYTES, MAX_TEXT_BYTES } from './constants.js';
3
+
1
4
  // 访问令牌(P1-3):地址带 ?token= 时记入 sessionStorage 并从地址栏移除(防截图/历史外泄),
2
5
  // 之后所有同源请求统一附加 X-MingDao-Token 头;无令牌时行为与旧版一致。
3
6
  const AUTH_TOKEN = (() => {
@@ -18,7 +21,52 @@ if (AUTH_TOKEN) {
18
21
  return rawFetch(input, init);
19
22
  };
20
23
  }
21
- const $ = (s) => document.querySelector(s);
24
+
25
+ // —— 自绘悬浮气泡 tooltip(替换原生 title:出现快、样式与暗色主题一致) ——
26
+ let tipEl = null;
27
+ function ensureTip() {
28
+ if (!tipEl) {
29
+ tipEl = document.createElement('div');
30
+ tipEl.className = 'mdtip';
31
+ document.body.appendChild(tipEl);
32
+ }
33
+ return tipEl;
34
+ }
35
+ function showTip(text, anchor) {
36
+ if (!text || !anchor) return;
37
+ const t = ensureTip();
38
+ t.textContent = text;
39
+ t.style.display = 'block';
40
+ const r = anchor.getBoundingClientRect();
41
+ const w = t.offsetWidth, h = t.offsetHeight;
42
+ let left = r.left + r.width / 2 - w / 2;
43
+ left = Math.max(8, Math.min(left, window.innerWidth - w - 8));
44
+ let top = r.top - h - 8;
45
+ if (top < 8) top = r.bottom + 8;
46
+ t.style.left = left + 'px';
47
+ t.style.top = top + 'px';
48
+ }
49
+ function hideTip() { if (tipEl) tipEl.style.display = 'none'; }
50
+ function attachTip(el, text) {
51
+ if (!el || !text) return;
52
+ el.removeAttribute('title'); // 去掉原生 title,避免双提示
53
+ const get = typeof text === 'function' ? text : () => text;
54
+ el.addEventListener('mouseenter', () => showTip(get(), el));
55
+ el.addEventListener('mouseleave', hideTip);
56
+ el.addEventListener('focus', () => showTip(get(), el));
57
+ el.addEventListener('blur', hideTip);
58
+ el.addEventListener('mousedown', hideTip); // 打开下拉/点击时隐藏,避免遮挡
59
+ }
60
+ window.addEventListener('scroll', hideTip, true);
61
+
62
+ // 输入区下拉 / 附件按钮:把原生 title 换成自绘气泡(文本取自原 title,一次性读取后移除)
63
+ function initTips() {
64
+ const perm = $('#permSel'); if (perm) attachTip(perm, perm.getAttribute('title'));
65
+ const reas = $('#reasoningSel'); if (reas) attachTip(reas, reas.getAttribute('title'));
66
+ const model = $('#modelSel'); if (model) attachTip(model, () => { const o = model.options[model.selectedIndex]; return (o && o.title) ? o.title : '切换模型'; });
67
+ const at = $('#attachBtn'); if (at) attachTip(at, at.getAttribute('title'));
68
+ }
69
+ initTips();
22
70
 
23
71
  // —— 弹窗三件套(Electron 不实现 window.prompt/confirm/alert:prompt 恒返回 null、confirm 恒 false、
24
72
  // alert 静默无反应——桌面版「⚙ 设置里点设Key 没反应」即由此而来)。统一替换为应用内模态框。
@@ -125,61 +173,7 @@ chatEl.addEventListener('click', (e) => {
125
173
  let generating = false; // 生成中:发送按钮复用为停止按钮
126
174
  let currentSession = null, thinking = null;
127
175
 
128
- function esc(s){return String(s??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;')}
129
176
 
130
- function highlight(code, lang){
131
- const kws = new Set('const let var function return if else for while import from export class new await async try catch throw null undefined true false def elif raise with as lambda None True False self echo exit if then else fi done local function'.split(' '));
132
- const re = /(\/\/[^\n]*|#[^\n]*|"(?:\\.|[^"\\])*"|'[^']*'|`[^`]*`|\b\d[\d._]*\b|[A-Za-z_$][\w$]*)/g;
133
- return esc(code).replace(re, (tok)=>{
134
- if(tok.startsWith('//')||tok.startsWith('#')) return '<span class="hl-c">'+tok+'</span>';
135
- if(/^["'`]/.test(tok)) return '<span class="hl-s">'+tok+'</span>';
136
- if(/^\d/.test(tok)) return '<span class="hl-n">'+tok+'</span>';
137
- if(kws.has(tok)) return '<span class="hl-kw">'+tok+'</span>';
138
- return tok;
139
- });
140
- }
141
- function renderMarkdown(text){
142
- // 块级解析(美化:加粗标题 / 段落 / 真列表 / 引用 / 分割线,参照 DeepSeek-Harness 会话排版)
143
- const lines = String(text).split('\n');
144
- let out = '', code = null, codeLang = '';
145
- const flushCode = () => { if (code !== null) { out += '<div class="codeblock"><div class="cb-banner"><span class="cb-lang"><span class="cb-dot"></span>' + (esc(codeLang) || 'code') + '</span><span class="cb-copy" style="cursor:pointer">复制</span></div><pre><code class="lang-' + esc(codeLang) + '">' + highlight(code.join('\n'), codeLang) + '</code></pre></div>'; code = null; } };
146
- let para = [];
147
- let list = null; // {type:'ul'|'ol', items:[]}
148
- const flushPara = () => { if (para.length) { out += '<p>' + para.join('<br>') + '</p>'; para = []; } };
149
- const flushList = () => { if (list) { out += '<' + list.type + '>' + list.items.map((i) => '<li>' + i + '</li>').join('') + '</' + list.type + '>'; list = null; } };
150
- // 行内元素(审计 P1-3:内容与 codeLang 均经 esc 转义防 XSS)
151
- const inline = (l) => {
152
- let s = esc(l);
153
- s = s.replace(/`([^`]+)`/g, '<code>$1</code>')
154
- .replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>')
155
- .replace(/\*([^*\s][^*]*)\*/g, '<i>$1</i>')
156
- .replace(/\[([^\]]+)\]\((https?:\/\/[^\s'"<>)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
157
- return s;
158
- };
159
- for (const line of lines) {
160
- const t = line.trim();
161
- if (t.startsWith('```')) {
162
- if (code === null) { flushList(); flushPara(); code = []; codeLang = (t.slice(3).trim().split(/\s+/)[0] || ''); }
163
- else flushCode();
164
- continue;
165
- }
166
- if (code !== null) { code.push(line); continue; }
167
- if (t === '') { flushList(); flushPara(); continue; }
168
- const h = t.match(/^(#{1,6})\s+(.*)$/);
169
- if (h) { flushList(); flushPara(); const level = Math.min(h[1].length, 4); out += '<h' + level + '>' + inline(h[2]) + '</h' + level + '>'; continue; }
170
- const ul = t.match(/^([-*+])\s+(.*)$/);
171
- if (ul) { flushPara(); if (!list || list.type !== 'ul') { flushList(); list = { type: 'ul', items: [] }; } list.items.push(inline(ul[2])); continue; }
172
- const ol = t.match(/^(\d+)[.)]\s+(.*)$/);
173
- if (ol) { flushPara(); if (!list || list.type !== 'ol') { flushList(); list = { type: 'ol', items: [] }; } list.items.push(inline(ol[2])); continue; }
174
- if (/^>\s?/.test(t)) { flushList(); flushPara(); out += '<blockquote>' + inline(t.replace(/^>\s?/, '')) + '</blockquote>'; continue; }
175
- if (/^(-{3,}|\*{3,})$/.test(t)) { flushList(); flushPara(); out += '<hr>'; continue; }
176
- flushList();
177
- para.push(inline(t));
178
- }
179
- flushCode(); flushList(); flushPara();
180
- return out;
181
- }
182
- function scrollBottom(){ const sc=document.querySelector('main'); if(sc) sc.scrollTop = sc.scrollHeight; }
183
177
 
184
178
  function newAiMsg(){ const el=document.createElement('div'); el.className='msg-ai'; chatEl.appendChild(el); scrollBottom(); return el; }
185
179
  function aiContent(msg){ const c=msg.querySelector('.content'); return c || (()=>{const d=document.createElement('div');d.className='content';msg.appendChild(d);return d;})(); }
@@ -187,15 +181,6 @@ function addReasoning(msg){ const d=document.createElement('details'); d.classNa
187
181
 
188
182
  function addUser(text){ const el=document.createElement('div'); el.className='msg-user'; el.innerHTML='<div class="bubble">'+esc(text)+'</div>'; chatEl.appendChild(el); scrollBottom(); }
189
183
 
190
- function expandableBody(previewHtml, fullHtml){
191
- const b=document.createElement('div'); b.className='body';
192
- const prev=document.createElement('div'); const full=document.createElement('div');
193
- prev.innerHTML=previewHtml; full.innerHTML=fullHtml; full.style.display='none';
194
- const btn=document.createElement('button'); btn.textContent='展开全文'; btn.style.cssText='padding:1px 8px;font-size:11px;margin-top:4px';
195
- btn.onclick=()=>{ const open=full.style.display!=='none'; full.style.display=open?'none':'block'; btn.textContent=open?'展开全文':'收起'; scrollBottom(); };
196
- b.appendChild(prev); b.appendChild(full); b.appendChild(btn);
197
- return b;
198
- }
199
184
  const runningTools = new Map();
200
185
  function renderToolStartEvent(ev){
201
186
  const card=document.createElement('div'); card.className='tool';
@@ -273,7 +258,7 @@ function renderTodos(todos,msg){
273
258
  for(const t of todos){ const li=document.createElement('li'); li.className=t.status==='completed'?'done':t.status==='in_progress'?'doing':''; li.innerHTML=(t.status==='completed'?'✓ ':'○ ')+esc(t.content); ul.appendChild(li); }
274
259
  msg.appendChild(ul);
275
260
  }
276
- function renderBanner(ev){ const d=document.createElement('div'); d.className='banner'; d.textContent=ev.text||ev.title||''; insertBeforeActiveMsg(d); scrollBottom(); }
261
+ function renderBanner(ev){ const d=document.createElement('div'); d.className='banner'+(ev.warn?' banner-warn':''); d.textContent=ev.text||ev.title||''; insertBeforeActiveMsg(d); scrollBottom(); }
277
262
  // 工具卡/横幅/代码块统一插到「本轮 AI 消息」之前:结论与交付物永远位于消息最底部
278
263
  function insertBeforeActiveMsg(el){ if(activeAiMsg && activeAiMsg.parentNode){ chatEl.insertBefore(el, activeAiMsg); } else { chatEl.appendChild(el); } }
279
264
 
@@ -302,13 +287,13 @@ let attachments=[];
302
287
  $('#attachBtn').onclick=()=>{ $('#fileInput').click(); };
303
288
  $('#fileInput').addEventListener('change', async e=>{
304
289
  for(const f of e.target.files||[]){
305
- if(attachments.length>=4){ uiAlert('最多 4 个附件'); break; }
290
+ if(attachments.length>=MAX_ATTACHMENTS){ uiAlert('最多 '+MAX_ATTACHMENTS+' 个附件'); break; }
306
291
  if(f.type.startsWith('image/')){
307
- if(f.size>5*1024*1024){ uiAlert(f.name+' 超过 5MB'); continue; }
292
+ if(f.size>MAX_IMAGE_BYTES){ uiAlert(f.name+' 超过 5MB'); continue; }
308
293
  const dataUrl=await new Promise(r=>{ const fr=new FileReader(); fr.onload=()=>r(fr.result); fr.readAsDataURL(f); });
309
294
  attachments.push({type:'image',name:f.name,dataUrl});
310
295
  }else if(f.type.startsWith('text/')||/\.(txt|md|json|js|py|log|csv|html|css)$/i.test(f.name)){
311
- if(f.size>200*1024){ uiAlert(f.name+' 超过 200KB'); continue; }
296
+ if(f.size>MAX_TEXT_BYTES){ uiAlert(f.name+' 超过 200KB'); continue; }
312
297
  const content=await new Promise(r=>{ const fr=new FileReader(); fr.onload=()=>r(fr.result); fr.readAsText(f); });
313
298
  attachments.push({type:'text',name:f.name,content});
314
299
  }else{
@@ -420,6 +405,7 @@ function renderWorkStatus(){
420
405
  renderLiveBar();
421
406
  const el=$('#workStatus'); if(!el) return;
422
407
  let html='';
408
+ let tip='';
423
409
  if(generating){
424
410
  const secs=curWorkT0?Math.round((Date.now()-curWorkT0)/1000):0;
425
411
  html='<span class="ws-busy"><span class="spinner"></span><span class="ws-phase">'+esc(curPhase)+'</span><span>⏳ 第 '+curSteps+' 步 · '+Math.floor(secs/60)+' 分 '+Math.round(secs%60)+' 秒 · '+curTools+' 工具步'+(curTasks>0?' · '+curTasks+' 个子代理':'')+'</span></span>';
@@ -427,12 +413,12 @@ function renderWorkStatus(){
427
413
  // 后台任务 chip(非顶部,位于输入框上方):计数 + 最新任务 + 悬浮详情 tooltip,点击打开详情面板
428
414
  const running=bgTasks.filter((/** @type {any} */ t)=>t.status==='running');
429
415
  const latest=running.length?running[running.length-1]:null;
430
- const tip=bgTasks.map((/** @type {any} */ t)=>(t.kind==='schedule'?'⏰':'🛠')+' '+String(t.message||t.id||'').slice(0,40)+' · '+t.status).join('\n');
431
- html='<span class="ws-bg" title="'+esc(String(tip)).replace(/"/g,'&quot;')+'">🛠 '+bgRunning+' 个后台任务运行中'+(latest?' · '+esc(String(latest.message||'').slice(0,22)):'')+' — 点击查看详情</span>';
416
+ tip=bgTasks.map((/** @type {any} */ t)=>(t.kind==='schedule'?'⏰':'🛠')+' '+String(t.message||t.id||'').slice(0,40)+' · '+t.status).join('\n');
417
+ html='<span class="ws-bg">🛠 '+bgRunning+' 个后台任务运行中'+(latest?' · '+esc(String(latest.message||'').slice(0,22)):'')+' — 点击查看详情</span>';
432
418
  }
433
419
  el.style.display=html?'flex':'none';
434
420
  el.innerHTML=html;
435
- const bg=el.querySelector('.ws-bg'); if(bg) bg.onclick=()=>{ const tp=$('#tasksPanel'); tp.style.display = tp.style.display==='none'?'flex':'none'; if(tp.style.display==='flex'){ $('#trajPanel').style.display='none'; $('#subPanel').style.display='none'; updateTasksPanel(); } syncPanelLayout(); };
421
+ const bg=el.querySelector('.ws-bg'); if(bg){ bg.onclick=()=>{ const tp=$('#tasksPanel'); tp.style.display = tp.style.display==='none'?'flex':'none'; if(tp.style.display==='flex'){ $('#trajPanel').style.display='none'; $('#subPanel').style.display='none'; updateTasksPanel(); } syncPanelLayout(); }; attachTip(bg, tip); }
436
422
  }
437
423
  // 顶部常驻活动条(静默完美解决层):生成期间始终可见,滚动不影响
438
424
  function renderLiveBar(){
@@ -498,14 +484,6 @@ function syncPanelLayout(){
498
484
  document.body.classList.toggle('sub-open', open('subPanel'));
499
485
  document.body.classList.toggle('tasks-open', open('tasksPanel'));
500
486
  }
501
- function resultText(r){
502
- if(r==null||r===undefined) return '';
503
- if(typeof r==='string') return r;
504
- if(r.output) return String(r.output);
505
- if(r.stdout||r.stderr) return String(r.stdout||'')+String(r.stderr||'');
506
- return JSON.stringify(r);
507
- }
508
- function truncText(t,n){ t=String(t||''); return t.length>n?t.slice(0,n)+'\n…(截断,共 '+t.length+' 字)':t; }
509
487
  $('#tjClose').onclick=()=>{ $('#trajPanel').style.display='none'; $('#trajRailBtn').classList.remove('on'); syncPanelLayout(); };
510
488
  $('#trajRailBtn').onclick=()=>{
511
489
  const p=$('#trajPanel');
@@ -602,8 +580,6 @@ async function refreshCostBadge(){
602
580
  }
603
581
  refreshCostBadge(); setInterval(refreshCostBadge, 15000);
604
582
  // 底部状态栏:轮次/步数/LLM 与工具时长/首 token 平均/吞吐/缓存命中/输入输出 tokens
605
- function fmtDur(ms){ if(!ms) return '0s'; const sec=Math.round(ms/1000); const m=Math.floor(sec/60); return m>0 ? m+'m'+String(sec%60).padStart(2,'0')+'s' : sec+'s'; }
606
- function fmtTok(n){ if(n>=1e9) return (n/1e9).toFixed(1)+'B'; if(n>=1e6) return (n/1e6).toFixed(0)+'M'; if(n>=1e3) return (n/1e3).toFixed(1)+'K'; return String(n); }
607
583
  async function refreshStatusBar(){
608
584
  const r=await fetch('/api/cache-stats',{cache:'no-store'}).catch(()=>null); if(!r) return;
609
585
  const j=await r.json().catch(()=>null); if(!j) return;
@@ -620,6 +596,18 @@ $('#tpClose').onclick=()=>{ $('#tasksPanel').style.display='none'; syncPanelLayo
620
596
  async function refreshSessions(q){ const u=q?'/api/sessions?q='+encodeURIComponent(q):'/api/sessions'; const r=await fetch(u).catch(()=>null); if(!r)return; const j=await r.json(); const sel=$('#sessions'); sel.innerHTML='<option value="">历史会话</option>'; for(const s of j.sessions){ const o=document.createElement('option'); o.value=s.file; o.textContent=s.label; sel.appendChild(o); } if(currentSession&&!q) sel.value=currentSession; }
621
597
  let searchTimer=null;
622
598
  $('#sessionSearch').addEventListener('input',e=>{ clearTimeout(searchTimer); searchTimer=setTimeout(()=>refreshSessions(e.target.value.trim()),300); });
599
+ // 思考模式 / 推理等级(v0.2.8):与模型选择并列在输入区,按当前模型独立。
600
+ // 仅 reasoning 模型显示该下拉(关/低/高/最高);切换模型时随 /api/state 的 reasoning 字段刷新。
601
+ function applyReasoningUI(reasoning){
602
+ const sel=$('#reasoningSel');
603
+ if(!sel) return;
604
+ const supported = Boolean(reasoning && reasoning.supported);
605
+ sel.style.display = supported ? '' : 'none';
606
+ if(!supported) return;
607
+ const effort = reasoning && reasoning.effort ? reasoning.effort : 'high';
608
+ sel.value = ['off','low','high','max'].includes(effort) ? effort : 'high';
609
+ }
610
+ $('#reasoningSel').onchange=()=>{ applyConfig({reasoningEffort:$('#reasoningSel').value}); };
623
611
  async function init(){
624
612
  try{
625
613
  const r=await fetch('/api/state',{cache:'no-store'}); const j=await r.json();
@@ -628,7 +616,7 @@ async function init(){
628
616
  for(const m of (j.models||[])){ const g=m.providerLabel||'其他'; if(!groups[g]){groups[g]=[];order.push(g);} groups[g].push(m); }
629
617
  for(const g of order){
630
618
  const og=document.createElement('optgroup'); og.label=g;
631
- for(const m of groups[g]){ const o=document.createElement('option'); o.value=m.name; o.textContent=m.label; if(m.name===j.model) o.selected=true; og.appendChild(o); }
619
+ for(const m of groups[g]){ const o=document.createElement('option'); o.value=m.name; o.textContent=m.name; o.title=m.label; if(m.name===j.model) o.selected=true; og.appendChild(o); }
632
620
  ms.appendChild(og);
633
621
  }
634
622
  if(!(j.models||[]).length){ const o=document.createElement('option'); o.value=j.model; o.textContent=j.model; ms.appendChild(o); }
@@ -639,6 +627,7 @@ async function init(){
639
627
  $('#budgetInput').value=j.contextBudget||128000;
640
628
  $('#autoStartChk').checked=Boolean(j.autostart);
641
629
  $('#notifyChk').checked=j.notify!==false;
630
+ applyReasoningUI(j.reasoning);
642
631
  const env = ('路由'+(j.routing?'开':'关')+' · 沙箱'+((j.sandbox&&j.sandbox!=='off')?(j.sandboxSupported?j.sandbox:'降级'):'off'));
643
632
  $('#envBadge').textContent=env; $('#envBadge').style.display='';
644
633
  // 首次使用引导:无 API Key 时明确提示去设置(桌面版自动初始化后必走这里)
@@ -779,7 +768,6 @@ $('#memDedupe').onclick=async ()=>{
779
768
  if(j.ok){ memFlash('✓ 去重完成,移除 '+j.removed+' 行', true); loadMemoryUI(); } else memFlash('✖ '+(j.error||'去重失败'), false);
780
769
  };
781
770
  // —— 缓存命中率仪表盘 ——
782
- function fmtT(n){ n=Number(n||0); return n>=1000?(n/1000).toFixed(1)+'k':String(n); }
783
771
  // 省钱 B3:费用二级分账面板(模型/工具 Top5 + 近 14 天折线,零依赖 SVG)
784
772
  function renderCostBreakdown(bd){
785
773
  const models=$('#costTopModels'); models.innerHTML='';
@@ -904,12 +892,13 @@ async function reloadModels(){
904
892
  for(const m of (j.models||[])){ const g=m.providerLabel||'其他'; if(!groups[g]){groups[g]=[];order.push(g);} groups[g].push(m); }
905
893
  for(const g of order){
906
894
  const og=document.createElement('optgroup'); og.label=g;
907
- for(const m of groups[g]){ const o=document.createElement('option'); o.value=m.name; o.textContent=m.label; if(m.name===j.model) o.selected=true; og.appendChild(o); }
895
+ for(const m of groups[g]){ const o=document.createElement('option'); o.value=m.name; o.textContent=m.name; o.title=m.label; if(m.name===j.model) o.selected=true; og.appendChild(o); }
908
896
  ms.appendChild(og);
909
897
  }
910
898
  if(!(j.models||[]).length){ const o=document.createElement('option'); o.value=j.model; o.textContent=j.model; ms.appendChild(o); }
911
899
  const env=('路由'+(j.routing?'开':'关')+' · 沙箱'+((j.sandbox&&j.sandbox!=='off')?(j.sandboxSupported?j.sandbox:'降级'):'off'));
912
900
  $('#envBadge').textContent=env; $('#envBadge').style.display='';
901
+ applyReasoningUI(j.reasoning);
913
902
  }catch(e){}
914
903
  }
915
904
  async function refreshModelsCfg(){
@@ -1060,12 +1049,17 @@ async function applyConfig(payload, revertTarget){
1060
1049
  }
1061
1050
  $('#modelSel').addEventListener('change', e=>{ applyConfig({model:e.target.value}, e.target); });
1062
1051
  $('#permSel').addEventListener('change', e=>{ applyConfig({permission:e.target.value}, e.target); });
1063
- async function loadSession(file){ currentSession=file; chatEl.innerHTML=''; const r=await fetch('/api/session?file='+encodeURIComponent(file)); const j=await r.json(); for(const m of j.messages||[]){ if(typeof m.content==='string'&&m.content.startsWith('(系统提示)')) continue; if(m.role==='user') addUser(m.content); else { const el=newAiMsg(); aiContent(el).innerHTML=renderMarkdown(m.content); } } if(j.workspace){ const sel=$('#wsSel'); const has=[...sel.options].some(o=>o.value===j.workspace); if(has){ sel.value=j.workspace; } else { refreshWsSel(); } renderBanner({text:'↩ 已回到该会话的工作空间:'+j.workspace}); } scrollBottom(); }
1052
+ // v0.3.0 P0-3:会话边界收尾(切换/新对话时提取项目记忆+日志,fire-and-forget 不阻塞 UI)
1053
+ function finalizeCurrentSession(){
1054
+ if(!currentSession) return;
1055
+ fetch('/api/session-finalize',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({file:currentSession})}).catch(()=>{});
1056
+ }
1057
+ async function loadSession(file){ if(currentSession && currentSession!==file) finalizeCurrentSession(); currentSession=file; chatEl.innerHTML=''; const r=await fetch('/api/session?file='+encodeURIComponent(file)); const j=await r.json(); for(const m of j.messages||[]){ if(typeof m.content==='string'&&m.content.startsWith('(系统提示)')) continue; if(m.role==='user') addUser(m.content); else { const el=newAiMsg(); aiContent(el).innerHTML=renderMarkdown(m.content); } } if(j.workspace){ const sel=$('#wsSel'); const has=[...sel.options].some(o=>o.value===j.workspace); if(has){ sel.value=j.workspace; } else { refreshWsSel(); } renderBanner({text:'↩ 已回到该会话的工作空间:'+j.workspace}); } if(j.taskState&&(j.taskState.status==='cap'||j.taskState.status==='interrupted')){ renderBanner({text:'⚠ 该会话有未完成任务(步数上限中断)——直接发送消息即可从断点续跑,已完成文件不会重复做。', warn:true}); } scrollBottom(); }
1064
1058
 
1065
1059
  sendBtn.onclick=()=>{ if(generating){ abort(); } else { send(); } };
1066
1060
  input.addEventListener('keydown',e=>{ if(e.key==='Enter'&&!e.shiftKey){ e.preventDefault(); send(); } });
1067
1061
  input.addEventListener('input',()=>{ input.style.height='44px'; input.style.height=Math.min(input.scrollHeight,200)+'px'; });
1068
- $('#newChat').onclick=()=>{ currentSession=null; chatEl.innerHTML=''; refreshSessions(); };
1062
+ $('#newChat').onclick=()=>{ finalizeCurrentSession(); currentSession=null; chatEl.innerHTML=''; refreshSessions(); };
1069
1063
  $('#sessMgrBtn').onclick=openSessModal;
1070
1064
  $('#sessClose').onclick=()=>{ $('#sessModal').style.display='none'; };
1071
1065
  async function openSessModal(){
@@ -1081,6 +1075,6 @@ async function openSessModal(){
1081
1075
  div.appendChild(rn); div.appendChild(dl); list.appendChild(div);
1082
1076
  }
1083
1077
  }
1084
- $('#sessions').onchange=e=>{ if(e.target.value){ loadSession(e.target.value); } else { currentSession=null; chatEl.innerHTML=''; } };
1078
+ $('#sessions').onchange=e=>{ if(e.target.value){ loadSession(e.target.value); } else { finalizeCurrentSession(); currentSession=null; chatEl.innerHTML=''; } };
1085
1079
  if('serviceWorker' in navigator){ navigator.serviceWorker.register('/sw.js').catch(()=>{}); }
1086
1080
  init();
@@ -4,12 +4,11 @@
4
4
  // - 文本文件:直接拼接进消息文本(≤200KB),并留文件名标注
5
5
  // - 返回 { content(模型消息内容:字符串或图文数组), persistText(落盘文本), error }
6
6
 
7
- const MAX_IMAGE_DATAURL = 7 * 1024 * 1024; // base64 膨胀 1.33 倍,对应约 5MB 原图
8
- const MAX_TEXT = 200 * 1024;
7
+ import { MAX_ATTACHMENTS, MAX_IMAGE_DATAURL, MAX_TEXT_BYTES } from './constants.js';
9
8
 
10
9
  export function buildUserContent(/** @type {any} */ message, /** @type {any} */ attachments, /** @type {any} */ visionSupported) {
11
10
  const text = String(message ?? '').trim();
12
- const list = Array.isArray(attachments) ? attachments.slice(0, 4) : [];
11
+ const list = Array.isArray(attachments) ? attachments.slice(0, MAX_ATTACHMENTS) : [];
13
12
  const imageParts = [];
14
13
  const persistParts = [];
15
14
  let finalText = text;
@@ -31,7 +30,7 @@ export function buildUserContent(/** @type {any} */ message, /** @type {any} */
31
30
  } else if (a.type === 'text') {
32
31
  const content = String(a.content ?? '');
33
32
  if (!content.trim()) continue;
34
- if (content.length > MAX_TEXT) {
33
+ if (content.length > MAX_TEXT_BYTES) {
35
34
  return { error: `文本文件过大:${a.name || '未命名'}(≤200KB)` };
36
35
  }
37
36
  finalText += `${finalText ? '\n\n' : ''}[文件 ${a.name || '未命名'}]\n${content}`;
@@ -0,0 +1,9 @@
1
+ // 前端 + 服务端共享常量(零依赖纯 ESM,浏览器与 Node 均可 import)。
2
+ // 单源化(v0.2.8 B2):附件上限 / 并发上限等数值只在此定义,
3
+ // 防 app.js(客户端预检)/ attachments.js(服务端校验)/ fs-tools.js(读取上限)漂移。
4
+ export const MAX_CONCURRENT = 8; // 并发任务上限(server 与任务面板共用)
5
+ export const MAX_ATTACHMENTS = 4; // 单次最多附件数
6
+ export const MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 单张图片原图上限(5MB)
7
+ export const MAX_IMAGE_DATAURL = 7 * 1024 * 1024; // base64 膨胀 1.33 倍后的 dataURL 上限(≈5MB 原图)
8
+ export const MAX_TEXT_BYTES = 200 * 1024; // 文本附件上限(200KB)
9
+ export const MAX_FILE_READ_BYTES = 5 * 1024 * 1024; // read/edit 工具单文件读取上限(独立语义,非附件上限)
@@ -94,6 +94,7 @@ header select{flex:none}
94
94
  #trajPanel{position:fixed;left:42px;top:52px;bottom:0;width:252px;background:var(--bg2);border-right:1px solid var(--border);z-index:40;display:flex;flex-direction:column;box-shadow:4px 0 24px rgba(0,0,0,.35)}
95
95
  .tj-head{padding:12px 14px;font-size:14px;font-weight:600;border-bottom:1px solid var(--border);color:var(--accent2);display:flex;align-items:center;gap:8px}
96
96
  .tj-head button{margin-left:auto}
97
+ #tjClose,#sbClose{font-size:12px;white-space:nowrap;padding:3px 10px}
97
98
  #tjList{flex:1;overflow-y:auto;padding:8px}
98
99
  .tj-turn{color:var(--accent2);font-size:12px;font-weight:600;margin:8px 2px 4px}
99
100
  .tj-item{padding:6px 10px;border:1px solid var(--border);border-radius:8px;margin-bottom:6px;font-size:12.5px;background:var(--bg3);cursor:pointer}
@@ -186,13 +187,18 @@ main{flex:1;overflow-y:auto;padding:18px 0 12px;min-width:0}
186
187
  .todo li.doing{color:var(--accent2)}
187
188
  .usage{font-size:12px;color:var(--faint);border-top:1px dashed var(--border);padding-top:6px}
188
189
  .banner{text-align:center;color:var(--faint);font-size:12.5px}
190
+ .banner-warn{background:rgba(245,158,11,.08);border:1px solid rgba(245,158,11,.35);color:var(--warn);border-radius:12px;padding:8px 14px;text-align:left}
189
191
  .errline{color:var(--err);background:rgba(229,83,75,.08);border:1px solid rgba(229,83,75,.3);border-radius:10px;padding:10px 14px;font-size:13.5px}
190
192
  footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);padding:12px 18px}
191
193
  #composer{max-width:860px;margin:0 auto;display:flex;gap:10px;align-items:flex-end}
192
- #input{flex:1;background:var(--bg3);border:1px solid var(--border);border-radius:12px;color:var(--text);padding:10px 14px;font:14px/1.6 inherit;resize:none;min-height:44px;max-height:200px}
194
+ #composer #permSel{max-width:96px;min-width:64px;flex:none;padding:8px 8px}
195
+ #composer #reasoningSel{max-width:64px;min-width:48px;flex:none;padding:8px 6px;font-size:12px}
196
+ #composer #modelSel{max-width:150px;min-width:88px;flex:none;padding:8px 8px;font-size:12px}
197
+ #input{flex:1;background:var(--bg3);border:1px solid var(--border);border-radius:12px;color:var(--text);padding:10px 14px;font:14px/1.6 inherit;resize:none;min-height:44px;max-height:200px;min-width:0}
193
198
  #input:focus{outline:none;border-color:var(--accent2)}
194
199
  #hint{font-size:11.5px;color:var(--faint);margin-top:6px;max-width:860px;margin-left:auto;margin-right:auto}
195
200
  #statusBar{font-size:11.5px;color:var(--faint);margin-top:2px;max-width:860px;margin-left:auto;margin-right:auto;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
201
+ @media (max-width:560px){footer{padding:10px 8px}#composer{gap:6px}#composer #permSel{min-width:52px;padding:8px 4px}#composer #reasoningSel{min-width:44px;padding:8px 4px}#composer #modelSel{min-width:72px;padding:8px 4px}#attachBtn{padding:10px 9px}}
196
202
  .modal-mask{position:fixed;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;z-index:50}
197
203
  .modal{background:var(--panel);border:1px solid var(--border);border-radius:14px;width:min(520px,92vw);padding:18px;max-height:88vh;overflow-y:auto}
198
204
  /* 设置多级菜单(审计:一拉到底改为左侧分组导航 + 右侧面板) */
@@ -221,17 +227,13 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
221
227
  .spinner{display:inline-block;width:14px;height:14px;border:2px solid var(--accent);border-top-color:transparent;border-radius:50%;animation:spin .8s linear infinite;vertical-align:-2px}
222
228
  #scrollBottomBtn{position:fixed;right:26px;bottom:118px;width:40px;height:40px;border-radius:50%;background:linear-gradient(135deg,var(--accent),var(--accent2));color:#06121a;font-size:20px;font-weight:800;border:none;cursor:pointer;box-shadow:0 4px 16px rgba(0,0,0,.45);z-index:30;display:none;align-items:center;justify-content:center}
223
229
  @keyframes spin{to{transform:rotate(360deg)}}
230
+ /* 自绘悬浮气泡 tooltip(替换原生 title):暗色悬浮层 + 阴影,跟随锚点居中,越界自动回弹 */
231
+ .mdtip{position:fixed;z-index:1000;max-width:320px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:7px 11px;font-size:12px;line-height:1.6;box-shadow:0 8px 24px rgba(0,0,0,.55);pointer-events:none;white-space:pre-line;word-break:break-word;display:none}
224
232
  </style>
225
233
  </head>
226
234
  <body>
227
235
  <header>
228
236
  <div class="logo">MingDao <span>Harness</span></div>
229
- <select id="modelSel" title="切换模型"></select>
230
- <select id="permSel" title="权限模式">
231
- <option value="ask">权限 ask</option>
232
- <option value="auto">权限 auto</option>
233
- <option value="readonly">权限 readonly</option>
234
- </select>
235
237
  <span id="cfgMsg" class="badge" style="display:none"></span>
236
238
  <button id="cfgBtn" title="设置">⚙</button>
237
239
  <select id="wsSel" title="工作空间(切换 / 新建,目录自动创建)"></select>
@@ -266,6 +268,18 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
266
268
  <div id="composer">
267
269
  <button id="attachBtn" title="上传图片或文本文件(图片需视觉模型,如 deepseek-v4-flash-vision-exp)">📎</button>
268
270
  <textarea id="input" rows="1" placeholder="输入任务…(Enter 发送,Shift+Enter 换行)"></textarea>
271
+ <select id="permSel" title="权限模式:询问=写文件/执行命令前先确认(推荐) · 自动=全部直接放行 · 只读=禁止修改与执行">
272
+ <option value="ask">询问</option>
273
+ <option value="auto">自动</option>
274
+ <option value="readonly">只读</option>
275
+ </select>
276
+ <select id="modelSel" title="切换模型"></select>
277
+ <select id="reasoningSel" title="思考模式(推理等级):关=不推理省 token · 低 · 高(默认)· 最高=最强推理" style="display:none">
278
+ <option value="off">关</option>
279
+ <option value="low">低</option>
280
+ <option value="high">高</option>
281
+ <option value="max">最高</option>
282
+ </select>
269
283
  <button id="sendBtn" class="primary">发送</button>
270
284
  </div>
271
285
  <div id="hint"><span id="hintText">工具执行与权限确认会实时展示;生成中可点「中断」停止。</span><label style="margin-left:12px;cursor:pointer;user-select:none"><input type="checkbox" id="journalChk" style="vertical-align:-2px;margin:0 4px 0 0">📌 带上文(最近会话日志,默认关=新会话全新开始)</label></div>
@@ -442,6 +456,6 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
442
456
  </div>
443
457
  </div>
444
458
  </div>
445
- <script src="/app.js"></script>
459
+ <script type="module" src="/app.js"></script>
446
460
  </body>
447
461
  </html>
@@ -31,7 +31,7 @@ export function createApiDispatch(deps) {
31
31
  // 访问控制(P1-3/P1-4):token 校验覆盖数据与操作接口;壳页面与 PWA 静态资源公开
32
32
  // (壳不含任何数据,SPA 需要先加载才能读取 ?token=);Host 白名单覆盖一切请求
33
33
  const isStaticAsset =
34
- p === '/' || p === '/index.html' || p === '/app.js' || p === '/favicon.ico' || p === '/icon.svg' || p === '/icon-192.png' || p === '/icon-512.png' || p === '/manifest.webmanifest' || p === '/sw.js';
34
+ p === '/' || p === '/index.html' || p === '/app.js' || p === '/util.js' || p === '/constants.js' || p === '/favicon.ico' || p === '/icon.svg' || p === '/icon-192.png' || p === '/icon-512.png' || p === '/manifest.webmanifest' || p === '/sw.js';
35
35
  if (authEnabled && !isStaticAsset && !tokenMatches(requestToken(req, url))) {
36
36
  return json(res, 401, { error: '未授权:缺少或无效的访问令牌(地址需带 ?token=…,或请求头携带 X-MingDao-Token)' });
37
37
  }
@@ -60,15 +60,17 @@ export function createApiDispatch(deps) {
60
60
  }
61
61
 
62
62
  // —— 静态壳资源(公开;不经过域分发) ——
63
- if (method === 'GET' && p === '/app.js') {
64
- // 质检 Q2/S5:SPA 外部 JS——从磁盘读取(与 index.html 同目录)
63
+ // 质检 Q2/S5:SPA 外部 JS——从磁盘读取(与 index.html 同目录)。
64
+ // v0.2.8 C2:app.js 拆分为 ES Modules(app.js / util.js / constants.js),统一从此处伺服。
65
+ const WEB_JS_FILES = new Set(['app.js', 'util.js', 'constants.js']);
66
+ if (method === 'GET' && WEB_JS_FILES.has(p.slice(1))) {
65
67
  try {
66
- const js = fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'app.js'), 'utf8');
68
+ const js = fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), '..', p.slice(1)), 'utf8');
67
69
  res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-cache' });
68
70
  res.end(js);
69
71
  } catch {
70
72
  res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
71
- res.end('app.js 加载失败');
73
+ res.end(p.slice(1) + ' 加载失败');
72
74
  }
73
75
  return;
74
76
  }
@@ -124,8 +126,8 @@ export function createApiDispatch(deps) {
124
126
  }
125
127
  if (method === 'GET' && p === '/sw.js') {
126
128
  res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' });
127
- // 缓存键去掉 query(?token= 不落缓存);mingdao-v4:随 token 认证版本升版本号,强制替换旧 SW 缓存
128
- res.end(`self.addEventListener('install',()=>self.skipWaiting());self.addEventListener('activate',e=>e.waitUntil(caches.keys().then(ks=>Promise.all(ks.filter(k=>k!=='mingdao-v4').map(k=>caches.delete(k)))).then(()=>clients.claim())));self.addEventListener('fetch',e=>{if(e.request.method==='GET'&&new URL(e.request.url).origin===location.origin&&!e.request.url.includes('/api/')){const u=new URL(e.request.url);u.search='';e.respondWith(fetch(e.request).then(r=>{const c=r.clone();caches.open('mingdao-v4').then(cache=>cache.put(u.toString(),c));return r;}).catch(()=>caches.match(u.toString()).then(m=>m||caches.match('/'))));}});`);
129
+ // 缓存键去掉 query(?token= 不落缓存);mingdao-v5:随 token 认证版本升版本号,强制替换旧 SW 缓存
130
+ res.end(`self.addEventListener('install',()=>self.skipWaiting());self.addEventListener('activate',e=>e.waitUntil(caches.keys().then(ks=>Promise.all(ks.filter(k=>k!=='mingdao-v5').map(k=>caches.delete(k)))).then(()=>clients.claim())));self.addEventListener('fetch',e=>{if(e.request.method==='GET'&&new URL(e.request.url).origin===location.origin&&!e.request.url.includes('/api/')){const u=new URL(e.request.url);u.search='';e.respondWith(fetch(e.request).then(r=>{const c=r.clone();caches.open('mingdao-v5').then(cache=>cache.put(u.toString(),c));return r;}).catch(()=>caches.match(u.toString()).then(m=>m||caches.match('/'))));}});`);
129
131
  return;
130
132
  }
131
133
 
@@ -6,7 +6,7 @@ import { loadConfig, saveConfig } from '../../../config.js';
6
6
  import { setStoredKey, removeStoredKey, getStoredKey, maskKey } from '../../../credentials.js';
7
7
  import { availableModels, fetchProviderModels, providerHasKey } from '../../../model-discovery.js';
8
8
  import { createProvider, resolveProviderConfig } from '../../../providers/index.js';
9
- import { MODELS, PROVIDERS } from '../../../models.js';
9
+ import { MODELS, PROVIDERS, modelPreset } from '../../../models.js';
10
10
  import { detectSandbox } from '../../../tools/bash.js';
11
11
  import { enableAutostart, disableAutostart, autostartStatus } from '../../../autostart.js';
12
12
  import { PRICE_DATA_AS_OF } from '../../../pricing.js';
@@ -29,6 +29,14 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
29
29
  .map((s) => ({ file: s.name, mtime: s.mtime, label: `${relativeTime(s.mtime)} · ${sessionPreview(s.file)}` }));
30
30
  // 模型列表:只列已设置 Key 的服务商,名称以 /models 接口线上名单为准(预设仅回退与补价)
31
31
  const models = await availableModels(cfg, state.modelName);
32
+ // 思考模式 / 推理等级(v0.2.8):当前模型是否支持 reasoning、当前档位与可选档位。
33
+ // 按模型独立:reasoningByModel[当前模型] 覆盖 > 全局 reasoningEffort(旧配置兼容)> 模型预设默认。
34
+ const rp = modelPreset(state.modelName);
35
+ const reasoning = {
36
+ supported: Boolean(rp?.supportsReasoning),
37
+ effort: cfg.reasoningByModel?.[state.modelName] ?? cfg.reasoningEffort ?? rp?.reasoningEffort?.default ?? (rp?.supportsReasoning ? 'high' : 'off'),
38
+ options: rp?.reasoningEffort?.options ?? ['low', 'high', 'max'],
39
+ };
32
40
  json(res, 200, {
33
41
  ok: true,
34
42
  model: state.modelName,
@@ -40,6 +48,7 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
40
48
  sandbox: cfg.sandbox || 'off',
41
49
  sandboxSupported: detectSandbox() !== 'none',
42
50
  routing: cfg.routing?.enabled ? cfg.routing : null,
51
+ reasoning,
43
52
  contextBudget: cfg.contextBudget || 128000,
44
53
  pricingAsOf: PRICE_DATA_AS_OF,
45
54
  autostart: autostartStatus(),
@@ -103,6 +112,15 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
103
112
  next.contextBudget = n;
104
113
  cfg.contextBudget = n;
105
114
  }
115
+ if (body.reasoningEffort !== undefined) {
116
+ const re = String(body.reasoningEffort);
117
+ if (!['off', 'low', 'high', 'max'].includes(re)) {
118
+ return json(res, 400, { error: '思考强度必须是 off / low / high / max' });
119
+ }
120
+ // 按模型独立:写入当前模型的覆盖档位(不污染其他模型)
121
+ cfg.reasoningByModel = cfg.reasoningByModel || {};
122
+ cfg.reasoningByModel[state.modelName] = re;
123
+ }
106
124
  let autostartChanged = false;
107
125
  if (body.autostart !== undefined) {
108
126
  autostartChanged = true;
@@ -121,7 +139,7 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
121
139
  cfg.model = next.model;
122
140
  cfg.permission = next.permission;
123
141
  saveConfig(cfg);
124
- json(res, 200, { ok: true, model: state.modelName, permission: cfg.permission, sandbox: cfg.sandbox, routing: cfg.routing?.enabled, contextBudget: cfg.contextBudget, autostart: autostartChanged ? autostartStatus() : undefined, notify: cfg.notify !== false });
142
+ json(res, 200, { ok: true, model: state.modelName, permission: cfg.permission, sandbox: cfg.sandbox, routing: cfg.routing?.enabled, contextBudget: cfg.contextBudget, reasoningEffort: cfg.reasoningByModel?.[state.modelName] ?? cfg.reasoningEffort, autostart: autostartChanged ? autostartStatus() : undefined, notify: cfg.notify !== false });
125
143
  return true;
126
144
  }
127
145
 
@@ -9,7 +9,8 @@ import {
9
9
  relativeTime,
10
10
  sessionPreview,
11
11
  } from '../../../session.js';
12
- import { sanitizeTitle, renameSessionFile } from '../../../titles.js';
12
+ import { sanitizeTitle, renameSessionFile, titleModel } from '../../../titles.js';
13
+ import { loadTaskState } from '../../../task-state.js';
13
14
  import {
14
15
  workspaceForDir,
15
16
  touchWorkspace,
@@ -53,6 +54,8 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
53
54
  ok: true,
54
55
  messages: loaded.messages.filter((m) => m.role === 'user' || m.role === 'assistant').map((m) => ({ role: m.role, content: m.content ?? '' })),
55
56
  workspace: ws?.name || null,
57
+ // v0.3.0 P0-2:未完成检查点(供前端提示续跑)
58
+ taskState: loadTaskState(path.basename(file)),
56
59
  });
57
60
  } catch {
58
61
  json(res, 404, { error: '会话不存在' });
@@ -84,6 +87,38 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
84
87
  return json(res, 400, { error: '未知操作:rename|delete' });
85
88
  }
86
89
 
90
+ // v0.3.0 P0-3:会话收尾(前端「新对话/切换会话」时调用)——在会话边界提取项目记忆+日志,
91
+ // 而非逐轮提取,避免项目记忆文件在会话中途变化破坏前缀缓存(省钱核心)。
92
+ if (method === 'POST' && p === '/api/session-finalize') {
93
+ const body = await readBody(req, MAX_API_BODY);
94
+ const file = path.basename(String(body.file || ''));
95
+ if (!file) return json(res, 400, { error: '缺少 file 参数' });
96
+ try {
97
+ const loaded = loadSession(path.join(home, 'sessions', file));
98
+ if (loaded.messages.length) {
99
+ const wsDir = getSessionWorkspace(file) || state.workingDir;
100
+ const { finalizeSession } = await import('../../../memory.js');
101
+ const lastAsst = [...loaded.messages].reverse().find((/** @type {any} */ m) => m.role === 'assistant');
102
+ await finalizeSession({
103
+ cfg: deps.cfg,
104
+ provider: deps.provider,
105
+ model: titleModel(deps.cfg, state.modelName),
106
+ home,
107
+ workingDir: wsDir,
108
+ messages: loaded.messages,
109
+ turns: loaded.messages.filter((/** @type {any} */ m) => m.role === 'user').length,
110
+ lastText: lastAsst?.content || '',
111
+ });
112
+ }
113
+ // 清掉该会话的项目记忆快照:下次重新打开会用最新记忆重建系统提示
114
+ if (deps.sessionMemoryCache) deps.sessionMemoryCache.delete(file);
115
+ json(res, 200, { ok: true });
116
+ } catch (/** @type {any} */ e) {
117
+ json(res, 500, { error: String(e?.message || e) });
118
+ }
119
+ return true;
120
+ }
121
+
87
122
  if (method === 'GET' && p === '/api/draft') {
88
123
  // 质检 A5:按会话维度读取(无 file 参数用全局槽,兼容旧客户端)
89
124
  const key = String(url.searchParams.get('file') || '');