mingdao-harness 0.2.8 → 0.3.1

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
@@ -258,7 +258,7 @@ function renderTodos(todos,msg){
258
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); }
259
259
  msg.appendChild(ul);
260
260
  }
261
- 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(); }
262
262
  // 工具卡/横幅/代码块统一插到「本轮 AI 消息」之前:结论与交付物永远位于消息最底部
263
263
  function insertBeforeActiveMsg(el){ if(activeAiMsg && activeAiMsg.parentNode){ chatEl.insertBefore(el, activeAiMsg); } else { chatEl.appendChild(el); } }
264
264
 
@@ -483,6 +483,7 @@ function syncPanelLayout(){
483
483
  document.body.classList.toggle('traj-open', open('trajPanel'));
484
484
  document.body.classList.toggle('sub-open', open('subPanel'));
485
485
  document.body.classList.toggle('tasks-open', open('tasksPanel'));
486
+ document.body.classList.toggle('dash-open', open('dashPanel'));
486
487
  }
487
488
  $('#tjClose').onclick=()=>{ $('#trajPanel').style.display='none'; $('#trajRailBtn').classList.remove('on'); syncPanelLayout(); };
488
489
  $('#trajRailBtn').onclick=()=>{
@@ -568,17 +569,20 @@ async function updateTasksPanel(){
568
569
  }
569
570
  }
570
571
  setInterval(updateTasksPanel, 2000);
571
- // 费用徽标:今日费用 / 缓存命中率 / 护栏(15s 刷新)
572
+ // 费用徽标:今日费用 / 缓存命中率 / 护栏(15s 刷新);点击展开省钱仪表盘
572
573
  async function refreshCostBadge(){
573
574
  const r=await fetch('/api/cache-stats',{cache:'no-store'}).catch(()=>null); if(!r) return;
574
575
  const j=await r.json().catch(()=>null); if(!j) return;
575
576
  const bd=j.breakdown||{}; const gd=j.guard||null;
576
- let t='今日 ≈¥'+(bd.today||0).toFixed(4);
577
+ let t='📊 今日 ≈¥'+(bd.today||0).toFixed(4);
577
578
  if(bd.rate!=null) t+=' · 命中 '+(bd.rate*100).toFixed(0)+'%';
578
579
  if(gd&&gd.limit>0&&gd.cost!=null) t+=' · 护栏 '+(gd.cost/gd.limit*100).toFixed(0)+'%';
579
580
  $('#costBadge').textContent=t;
581
+ if($('#dashPanel').style.display==='flex') renderDashboard(j);
580
582
  }
581
583
  refreshCostBadge(); setInterval(refreshCostBadge, 15000);
584
+ $('#costBadge').onclick=()=>{ const p=$('#dashPanel'); const show=p.style.display==='none'; p.style.display=show?'flex':'none'; if(show){ $('#subPanel').style.display='none'; $('#trajPanel').style.display='none'; $('#tasksPanel').style.display='none'; $('#subRailBtn').classList.remove('on'); $('#trajRailBtn').classList.remove('on'); refreshCostBadge(); } syncPanelLayout(); };
585
+ $('#dashClose').onclick=()=>{ $('#dashPanel').style.display='none'; syncPanelLayout(); };
582
586
  // 底部状态栏:轮次/步数/LLM 与工具时长/首 token 平均/吞吐/缓存命中/输入输出 tokens
583
587
  async function refreshStatusBar(){
584
588
  const r=await fetch('/api/cache-stats',{cache:'no-store'}).catch(()=>null); if(!r) return;
@@ -699,7 +703,7 @@ $('#wsSel').addEventListener('change', async e=>{
699
703
  refreshWsSel(); reloadModels();
700
704
  }
701
705
  });
702
- $('#cfgBtn').addEventListener('click', ()=>{ refreshModelsCfg(); refreshSyncUI(); refreshSyncShares(); refreshSyncConflicts(); refreshSchList(); refreshWorkspaces(); loadMemoryUI(); refreshCache(); refreshMcpPresets(); refreshSkillLib(''); });
706
+ $('#cfgBtn').addEventListener('click', ()=>{ refreshModelsCfg(); refreshSyncUI(); refreshSyncShares(); refreshSyncConflicts(); refreshSchList(); refreshWorkspaces(); loadMemoryUI(); refreshMcpPresets(); refreshSkillLib(''); });
703
707
  $('#schWhen').onchange=e=>{ const v=e.target.value; $('#schAtRow').style.display=v==='at'?'':'none'; $('#schEveryRow').style.display=v==='every'?'':'none'; $('#schAfterRow').style.display=v==='after'?'':'none'; $('#schChainRow').style.display=v==='chain'?'':'none'; };
704
708
  async function refreshSchList(){
705
709
  const r=await fetch('/api/schedule',{cache:'no-store'}).catch(()=>null); if(!r) return;
@@ -767,70 +771,61 @@ $('#memDedupe').onclick=async ()=>{
767
771
  const j=await r.json().catch(()=>({error:'请求失败'}));
768
772
  if(j.ok){ memFlash('✓ 去重完成,移除 '+j.removed+' 行', true); loadMemoryUI(); } else memFlash('✖ '+(j.error||'去重失败'), false);
769
773
  };
770
- // —— 缓存命中率仪表盘 ——
771
- // 省钱 B3:费用二级分账面板(模型/工具 Top5 + 近 14 天折线,零依赖 SVG)
772
- function renderCostBreakdown(bd){
773
- const models=$('#costTopModels'); models.innerHTML='';
774
- for(const m of ((bd.byModel||[]).slice(0,5))){
775
- const bar=document.createElement('div'); bar.style.cssText='height:3px;border-radius:2px;background:var(--bg3);margin:1px 0;overflow:hidden';
776
- const max=bd.byModel&&bd.byModel[0]?bd.byModel[0].cost:1;
777
- bar.innerHTML='<div style="height:100%;width:'+Math.max(2,Math.round((m.cost||0)/Math.max(max,1e-9)*100))+'%;background:var(--accent2)"></div>';
778
- const row=document.createElement('div'); row.style.cssText='display:flex;justify-content:space-between;font-size:11.5px;padding:1px 0';
779
- row.innerHTML='<span>'+esc(m.model||'—')+'</span><span>¥'+(m.cost||0).toFixed(4)+' · '+m.turns+' 轮</span>';
780
- const w=document.createElement('div'); w.appendChild(row); w.appendChild(bar); models.appendChild(w);
781
- }
782
- if(!(bd.byModel||[]).length) models.innerHTML='<div style="color:var(--faint);font-size:11.5px">暂无记录</div>';
783
- const tools=$('#costTopTools'); tools.innerHTML='';
784
- for(const t of ((bd.byTool||[]).slice(0,5))){
785
- const bar=document.createElement('div'); bar.style.cssText='height:3px;border-radius:2px;background:var(--bg3);margin:1px 0;overflow:hidden';
786
- const max=bd.byTool&&bd.byTool[0]?bd.byTool[0].ms:1;
787
- bar.innerHTML='<div style="height:100%;width:'+Math.max(2,Math.round((t.ms||0)/Math.max(max,1e-9)*100))+'%;background:var(--cyan)"></div>';
788
- const row=document.createElement('div'); row.style.cssText='display:flex;justify-content:space-between;font-size:11.5px;padding:1px 0';
789
- row.innerHTML='<span>'+esc(t.tool||'—')+'</span><span>'+t.calls+' 次 · '+fmtDur(t.ms)+'</span>';
790
- const w=document.createElement('div'); w.appendChild(row); w.appendChild(bar); tools.appendChild(w);
791
- }
792
- if(!(bd.byTool||[]).length) tools.innerHTML='<div style="color:var(--faint);font-size:11.5px">暂无记录</div>';
793
- const line=$('#costLine');
794
- const days=(bd.byDay||[]).slice(-14);
795
- if(!days.length){ line.innerHTML=''; return; }
796
- const W=320, H=56, P=4;
797
- const maxC=Math.max(...days.map(d=>d.cost), 1e-9);
798
- const pts=days.map((d,i)=>{
774
+ // —— 省钱仪表盘(v0.3.1:从设置移出,顶部费用徽标点击展开,真·仪表盘) ——
775
+ function renderRateGauge(rate){
776
+ const r=48, cx=60, cy=60, circ=2*Math.PI*r;
777
+ const p=Math.max(0,Math.min(1,Number(rate)||0));
778
+ const color = p>=0.8 ? 'var(--accent)' : p>=0.5 ? 'var(--accent2)' : 'var(--warn)';
779
+ $('#rateGauge').innerHTML =
780
+ '<circle cx="'+cx+'" cy="'+cy+'" r="'+r+'" fill="none" stroke="var(--bg3)" stroke-width="13"/>'+
781
+ '<circle cx="'+cx+'" cy="'+cy+'" r="'+r+'" fill="none" stroke="'+color+'" stroke-width="13" stroke-linecap="round" stroke-dasharray="'+(circ*p).toFixed(1)+' '+(circ*(1-p)).toFixed(1)+'" transform="rotate(-90 '+cx+' '+cy+')"/>';
782
+ $('#rateGaugeNum').textContent=(p*100).toFixed(1)+'%';
783
+ }
784
+ function trendChart(days){
785
+ if(!days||!days.length) return '<div style="color:var(--faint);font-size:11.5px;padding:6px">暂无费用记录</div>';
786
+ const W=360, H=88, P=8;
787
+ const max=Math.max(...days.map((/** @type {any} */ d)=>Number(d.cost)||0), 1e-9);
788
+ const pts=days.map((/** @type {any} */ d, /** @type {number} */ i)=>{
799
789
  const x=P+i*(W-2*P)/Math.max(days.length-1,1);
800
- const y=H-4-(d.cost/maxC)*(H-10);
801
- return [x.toFixed(1), y.toFixed(1)];
790
+ const y=H-P-(Number(d.cost)||0)/max*(H-2*P);
791
+ return [x,y];
802
792
  });
803
- const poly=pts.map(p=>p.join(',')).join(' ');
793
+ const line=pts.map((/** @type {any} */ p)=>p[0].toFixed(1)+','+p[1].toFixed(1)).join(' ');
794
+ const area='M'+pts[0][0].toFixed(1)+','+(H-P)+' L'+pts.map((/** @type {any} */ p)=>p[0].toFixed(1)+','+p[1].toFixed(1)).join(' L')+' L'+pts[pts.length-1][0].toFixed(1)+','+(H-P)+' Z';
804
795
  const last=pts[pts.length-1];
805
- line.innerHTML='<div style="font-size:11.5px;color:var(--faint);margin-bottom:2px">近 14 天费用折线(¥,最高 ¥'+maxC.toFixed(4)+')</div>'
806
- +'<svg viewBox="0 0 '+W+' '+H+'" style="width:100%;height:auto;background:var(--bg3);border-radius:8px">'
807
- +'<polyline points="'+poly+'" fill="none" style="stroke:var(--accent2);stroke-width:1.6;stroke-linejoin:round;stroke-linecap:round"/>'
808
- +'<circle cx="'+last[0]+'" cy="'+last[1]+'" r="2.6" style="fill:var(--accent)"/>'
809
- +'<text x="4" y="10" font-size="8" style="fill:var(--faint)">'+days[0].day+'</text>'
810
- +'<text x="'+(W-52)+'" y="10" font-size="8" style="fill:var(--faint)">'+days[days.length-1].day+'</text>'
796
+ return '<div style="font-size:10.5px;color:var(--faint);margin-bottom:4px">最高 ¥'+max.toFixed(4)+' · '+days[0].day+' → '+days[days.length-1].day+'</div>'
797
+ +'<svg viewBox="0 0 '+W+' '+H+'" style="width:100%;height:auto">'
798
+ +'<defs><linearGradient id="dg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" style="stop-color:var(--accent2);stop-opacity:.35"/><stop offset="1" style="stop-color:var(--accent2);stop-opacity:0"/></linearGradient></defs>'
799
+ +'<path d="'+area+'" fill="url(#dg)"/>'
800
+ +'<polyline points="'+line+'" fill="none" stroke="var(--accent2)" stroke-width="1.8" stroke-linejoin="round" stroke-linecap="round"/>'
801
+ +'<circle cx="'+last[0].toFixed(1)+'" cy="'+last[1].toFixed(1)+'" r="3" fill="var(--accent)"/>'
811
802
  +'</svg>';
812
803
  }
813
- async function refreshCache(){
814
- const r=await fetch('/api/cache-stats',{cache:'no-store'}).catch(()=>null); if(!r) return;
815
- const j=await r.json(); if(!j.ok) return;
816
- const s=j.summary||{};
817
- const rate=s.rate!=null?(s.rate*100).toFixed(0)+'%':'暂无缓存数据';
818
- $('#cacheSummary').innerHTML='轮次 '+s.turns+' · ↑'+fmtT(s.prompt)+' ↓'+fmtT(s.completion)+' tokens · 命中率 <b style="color:var(--accent)">'+rate+'</b> · 累计 ≈¥'+Number(s.cost||0).toFixed(5)+'(比全未命中省 ≈¥'+Number(s.saved||0).toFixed(5)+')';
819
- renderCostBreakdown(j.breakdown||{});
820
- const list=$('#cacheRecent'); list.innerHTML='';
821
- if(!(j.recent||[]).length){ list.innerHTML='<div style="color:var(--faint);font-size:12px;padding:6px 2px">暂无记录(每次对话后自动统计)</div>'; return; }
822
- for(const e of j.recent){
804
+ function barList(items, isCost){
805
+ if(!items||!items.length) return '<div style="color:var(--faint);font-size:11.5px;padding:4px">暂无记录</div>';
806
+ const max=Math.max(...items.map((/** @type {any} */ i)=>Number(i.val)||0), 1e-9);
807
+ return items.map((/** @type {any} */ i)=>{
808
+ const pct=Math.max(3,Math.round((Number(i.val)||0)/max*100));
809
+ const v=isCost ? '¥'+(Number(i.val)||0).toFixed(4) : fmtDur(i.val);
810
+ return '<div class="dbar"><div class="t"><span class="n" title="'+esc(i.name)+'">'+esc(String(i.name||'—').slice(0,26))+'</span><span class="v">'+v+(i.sub?' · '+i.sub:'')+'</span></div><div class="track"><div class="fill" style="width:'+pct+'%"></div></div></div>';
811
+ }).join('');
812
+ }
813
+ function renderDashboard(j){
814
+ const s=j.summary||{}; const bd=j.breakdown||{};
815
+ const rate=bd.rate!=null?bd.rate:(s.rate!=null?s.rate:0);
816
+ const saved=Number(s.saved||0);
817
+ $('#kpiToday').textContent='¥'+Number(bd.today||0).toFixed(4);
818
+ $('#kpiRate').textContent=(rate*100).toFixed(1)+'%';
819
+ $('#kpiSaved').textContent='¥'+saved.toFixed(4);
820
+ $('#kpiTurns').textContent=(s.turns||0)+' / '+(s.steps||0);
821
+ renderRateGauge(rate);
822
+ $('#trendChart').innerHTML=trendChart((bd.byDay||[]).slice(-14));
823
+ $('#dashModels').innerHTML=barList((bd.byModel||[]).slice(0,5).map((/** @type {any} */ m)=>({name:m.model, val:m.cost, sub:m.turns+' 轮'})), true);
824
+ $('#dashTools').innerHTML=barList((bd.byTool||[]).slice(0,5).map((/** @type {any} */ t)=>({name:t.tool, val:t.ms, sub:t.calls+' 次'})), false);
825
+ $('#dashRecent').innerHTML=(j.recent||[]).slice(0,6).map((/** @type {any} */ e)=>{
823
826
  const hit=e.hit!=null&&e.miss!=null&&(e.hit+e.miss)>0?e.hit/(e.hit+e.miss):null;
824
- const pct=hit==null?'—':(hit*100).toFixed(0)+'%';
825
- const bar=document.createElement('div'); bar.style.cssText='height:4px;border-radius:2px;background:var(--bg3);margin:2px 0;overflow:hidden';
826
- bar.innerHTML='<div style="height:100%;width:'+(hit==null?0:Math.round(hit*100))+'%;background:var(--accent)"></div>';
827
- const div=document.createElement('div'); div.style.cssText='display:flex;align-items:center;gap:8px;padding:4px 0;border-bottom:1px solid var(--border);font-size:11.5px;color:var(--dim)';
828
- const left=document.createElement('div'); left.style.cssText='flex:1';
829
- left.innerHTML='<div style="display:flex;justify-content:space-between"><span>'+esc(e.model||'')+'</span><span>'+pct+' · ¥'+Number(e.cost||0).toFixed(5)+'</span></div>';
830
- left.appendChild(bar);
831
- const when=document.createElement('span'); when.style.cssText='white-space:nowrap;color:var(--faint)'; when.textContent=new Date(e.at).toLocaleTimeString();
832
- div.appendChild(left); div.appendChild(when); list.appendChild(div);
833
- }
827
+ return '<div class="dr"><span class="m">'+esc(e.model||'')+'</span><span class="p">'+(hit==null?'—':(hit*100).toFixed(0)+'%')+'</span><span class="c">¥'+Number(e.cost||0).toFixed(4)+'</span></div>';
828
+ }).join('') || '<div style="color:var(--faint);font-size:11.5px">暂无记录</div>';
834
829
  }
835
830
  // —— MCP 生态预设 ——
836
831
  let mcpPresetData=[];
@@ -1049,12 +1044,17 @@ async function applyConfig(payload, revertTarget){
1049
1044
  }
1050
1045
  $('#modelSel').addEventListener('change', e=>{ applyConfig({model:e.target.value}, e.target); });
1051
1046
  $('#permSel').addEventListener('change', e=>{ applyConfig({permission:e.target.value}, e.target); });
1052
- 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(); }
1047
+ // v0.3.0 P0-3:会话边界收尾(切换/新对话时提取项目记忆+日志,fire-and-forget 不阻塞 UI)
1048
+ function finalizeCurrentSession(){
1049
+ if(!currentSession) return;
1050
+ fetch('/api/session-finalize',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({file:currentSession})}).catch(()=>{});
1051
+ }
1052
+ 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(); }
1053
1053
 
1054
1054
  sendBtn.onclick=()=>{ if(generating){ abort(); } else { send(); } };
1055
1055
  input.addEventListener('keydown',e=>{ if(e.key==='Enter'&&!e.shiftKey){ e.preventDefault(); send(); } });
1056
1056
  input.addEventListener('input',()=>{ input.style.height='44px'; input.style.height=Math.min(input.scrollHeight,200)+'px'; });
1057
- $('#newChat').onclick=()=>{ currentSession=null; chatEl.innerHTML=''; refreshSessions(); };
1057
+ $('#newChat').onclick=()=>{ finalizeCurrentSession(); currentSession=null; chatEl.innerHTML=''; refreshSessions(); };
1058
1058
  $('#sessMgrBtn').onclick=openSessModal;
1059
1059
  $('#sessClose').onclick=()=>{ $('#sessModal').style.display='none'; };
1060
1060
  async function openSessModal(){
@@ -1070,6 +1070,6 @@ async function openSessModal(){
1070
1070
  div.appendChild(rn); div.appendChild(dl); list.appendChild(div);
1071
1071
  }
1072
1072
  }
1073
- $('#sessions').onchange=e=>{ if(e.target.value){ loadSession(e.target.value); } else { currentSession=null; chatEl.innerHTML=''; } };
1073
+ $('#sessions').onchange=e=>{ if(e.target.value){ loadSession(e.target.value); } else { finalizeCurrentSession(); currentSession=null; chatEl.innerHTML=''; } };
1074
1074
  if('serviceWorker' in navigator){ navigator.serviceWorker.register('/sw.js').catch(()=>{}); }
1075
1075
  init();
@@ -31,20 +31,23 @@ body{margin:0;background:var(--bg);color:var(--text);font:15px/1.65 -apple-syste
31
31
  body.traj-open{padding-left:294px}
32
32
  body.sub-open{padding-right:320px}
33
33
  body.tasks-open{padding-right:280px}
34
+ body.dash-open{padding-right:400px}
34
35
  @media (max-width:1200px){
35
36
  #trajPanel{width:210px}body.traj-open{padding-left:252px}
36
37
  #subPanel{width:270px}body.sub-open{padding-right:270px}
37
38
  #tasksPanel{width:230px}body.tasks-open{padding-right:230px}
39
+ #dashPanel{width:360px}body.dash-open{padding-right:360px}
38
40
  }
39
41
  @media (max-width:860px){
40
42
  #trajPanel{width:160px}body.traj-open{padding-left:202px}
41
43
  #subPanel{width:190px}body.sub-open{padding-right:190px}
42
44
  #tasksPanel{width:190px}body.tasks-open{padding-right:190px}
45
+ #dashPanel{width:min(340px,94vw)}body.dash-open{padding-right:min(340px,94vw)}
43
46
  }
44
47
  @media (max-width:560px){
45
- body.traj-open,body.sub-open,body.tasks-open{padding:0}
48
+ body.traj-open,body.sub-open,body.tasks-open,body.dash-open{padding:0}
46
49
  #trajPanel{width:calc(100vw - 42px);left:42px}
47
- #subPanel,#tasksPanel{width:100vw}
50
+ #subPanel,#tasksPanel,#dashPanel{width:100vw}
48
51
  }
49
52
  header{min-height:52px;display:flex;align-items:center;gap:10px;padding:8px 12px;border-bottom:1px solid var(--border);background:var(--bg2);flex:none;flex-wrap:nowrap;white-space:nowrap;overflow-x:auto}
50
53
  header .logo{font-weight:700;font-size:16px;color:var(--accent);letter-spacing:.5px;flex:none}
@@ -187,6 +190,7 @@ main{flex:1;overflow-y:auto;padding:18px 0 12px;min-width:0}
187
190
  .todo li.doing{color:var(--accent2)}
188
191
  .usage{font-size:12px;color:var(--faint);border-top:1px dashed var(--border);padding-top:6px}
189
192
  .banner{text-align:center;color:var(--faint);font-size:12.5px}
193
+ .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}
190
194
  .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}
191
195
  footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);padding:12px 18px}
192
196
  #composer{max-width:860px;margin:0 auto;display:flex;gap:10px;align-items:flex-end}
@@ -228,6 +232,36 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
228
232
  @keyframes spin{to{transform:rotate(360deg)}}
229
233
  /* 自绘悬浮气泡 tooltip(替换原生 title):暗色悬浮层 + 阴影,跟随锚点居中,越界自动回弹 */
230
234
  .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}
235
+ /* —— 省钱仪表盘(v0.3.1 顶部入口,真·仪表盘) —— */
236
+ #dashPanel{position:fixed;right:0;top:52px;bottom:0;width:400px;max-width:94vw;background:var(--bg2);border-left:1px solid var(--border);z-index:45;display:flex;flex-direction:column;box-shadow:-8px 0 32px rgba(0,0,0,.4)}
237
+ .dash-head{padding:12px 14px;font-size:14px;font-weight:700;color:var(--accent);border-bottom:1px solid var(--border);display:flex;align-items:center;gap:8px}
238
+ .dash-head .dash-sub{font-size:11.5px;color:var(--faint);font-weight:400}
239
+ .dash-head button{margin-left:auto;padding:2px 8px;font-size:12px}
240
+ .dash-body{flex:1;overflow-y:auto;padding:14px}
241
+ .dash-kpi{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:14px}
242
+ .kpi{background:var(--bg3);border:1px solid var(--border);border-radius:10px;padding:10px 12px;display:flex;flex-direction:column;gap:2px}
243
+ .kpi-l{font-size:11px;color:var(--faint)}
244
+ .kpi-v{font-size:17px;font-weight:700;font-variant-numeric:tabular-nums}
245
+ .dash-gauge-wrap{position:relative;width:120px;margin:4px auto 14px;text-align:center}
246
+ .gauge-label{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:0}
247
+ .gauge-label b{font-size:20px;color:var(--accent2)}
248
+ .gauge-label span{font-size:10.5px;color:var(--faint)}
249
+ .dash-sec{font-size:11.5px;color:var(--faint);margin:12px 0 6px;border-top:1px dashed var(--border);padding-top:10px}
250
+ .dash-sec:first-of-type{border-top:none;padding-top:0}
251
+ .dash-chart{background:var(--bg3);border:1px solid var(--border);border-radius:10px;padding:8px}
252
+ .dash-bars{display:flex;flex-direction:column;gap:6px}
253
+ .dbar{background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:6px 10px}
254
+ .dbar .t{display:flex;justify-content:space-between;font-size:11.5px;margin-bottom:4px}
255
+ .dbar .t .n{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:60%}
256
+ .dbar .t .v{color:var(--dim);font-variant-numeric:tabular-nums}
257
+ .dbar .track{height:5px;border-radius:3px;background:var(--bg2);overflow:hidden}
258
+ .dbar .fill{height:100%;border-radius:3px;background:linear-gradient(90deg,var(--accent2),var(--accent))}
259
+ .dash-recent{display:flex;flex-direction:column;gap:5px;font-size:11.5px}
260
+ .dr{display:flex;justify-content:space-between;background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:5px 10px}
261
+ .dr .m{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:50%}
262
+ .dr .p{color:var(--accent2);font-variant-numeric:tabular-nums}
263
+ .dr .c{color:var(--faint);font-variant-numeric:tabular-nums}
264
+ .badge-btn:hover{border-color:var(--accent2);color:var(--accent2)}
231
265
  </style>
232
266
  </head>
233
267
  <body>
@@ -236,7 +270,7 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
236
270
  <span id="cfgMsg" class="badge" style="display:none"></span>
237
271
  <button id="cfgBtn" title="设置">⚙</button>
238
272
  <select id="wsSel" title="工作空间(切换 / 新建,目录自动创建)"></select>
239
- <span class="badge" id="envBadge"></span><span class="badge" id="costBadge" title="今日费用 / 缓存命中率(每 15s 刷新)"></span>
273
+ <span class="badge" id="envBadge"></span><span class="badge badge-btn" id="costBadge" title="省钱仪表盘(点击展开)" style="cursor:pointer">📊 —</span>
240
274
  <div class="spacer"></div>
241
275
  <input id="sessionSearch" placeholder="搜索会话…" title="关键词检索历史会话">
242
276
  <select id="sessions" title="历史会话"><option value="">历史会话</option></select>
@@ -259,6 +293,29 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
259
293
  <div class="tp-head">任务面板 <span id="tpCount"></span><button id="tpClose" style="float:right">✕</button></div>
260
294
  <div id="tpList"></div>
261
295
  </aside>
296
+ <aside id="dashPanel" style="display:none">
297
+ <div class="dash-head">📊 省钱仪表盘 <span class="dash-sub">费用 · 命中 · 节省</span><button id="dashClose">✕</button></div>
298
+ <div class="dash-body">
299
+ <div class="dash-kpi">
300
+ <div class="kpi"><span class="kpi-l">今日费用</span><span class="kpi-v" id="kpiToday">—</span></div>
301
+ <div class="kpi"><span class="kpi-l">缓存命中</span><span class="kpi-v" id="kpiRate">—</span></div>
302
+ <div class="kpi"><span class="kpi-l">已节省</span><span class="kpi-v" style="color:var(--accent)" id="kpiSaved">—</span></div>
303
+ <div class="kpi"><span class="kpi-l">轮次/步数</span><span class="kpi-v" id="kpiTurns">—</span></div>
304
+ </div>
305
+ <div class="dash-gauge-wrap">
306
+ <svg id="rateGauge" viewBox="0 0 120 120" width="120" height="120"></svg>
307
+ <div class="gauge-label"><b id="rateGaugeNum">—</b><span>命中率</span></div>
308
+ </div>
309
+ <div class="dash-sec">近 14 天费用趋势(¥)</div>
310
+ <div id="trendChart" class="dash-chart"></div>
311
+ <div class="dash-sec">模型费用 Top</div>
312
+ <div id="dashModels" class="dash-bars"></div>
313
+ <div class="dash-sec">工具耗时 Top</div>
314
+ <div id="dashTools" class="dash-bars"></div>
315
+ <div class="dash-sec">最近缓存命中</div>
316
+ <div id="dashRecent" class="dash-recent"></div>
317
+ </div>
318
+ </aside>
262
319
  <main><div id="chat"></div></main>
263
320
  <footer>
264
321
  <div id="workStatus" style="display:none"></div>
@@ -303,7 +360,7 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
303
360
  <button class="cfg-nav-btn" data-panel="schedule">⏰ 调度队列</button>
304
361
  <button class="cfg-nav-btn" data-panel="workspace">📁 工作空间</button>
305
362
  <button class="cfg-nav-btn" data-panel="memory">🧠 长期记忆</button>
306
- <button class="cfg-nav-btn" data-panel="cache">📊 缓存仪表盘</button>
363
+
307
364
  <button class="cfg-nav-btn" data-panel="mcp">🔌 MCP 生态</button>
308
365
  <button class="cfg-nav-btn" data-panel="skills">🧩 技能库</button>
309
366
  </nav>
@@ -401,24 +458,7 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
401
458
  <textarea id="memArea" rows="5" placeholder="例如:所有文档用中文;提交信息用约定式提交…" style="width:100%;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:12.5px;resize:vertical"></textarea>
402
459
  <div class="row" style="justify-content:flex-start"><button id="memSave">保存记忆</button><button id="memDedupe">去重合并</button><span id="memMsg" style="font-size:12px;color:var(--faint)"></span></div>
403
460
  </section>
404
- <section class="cfg-panel" data-panel="cache">
405
- <hr style="border:none;border-top:1px solid var(--border);margin:14px 0 8px">
406
- <h4 style="margin:4px 0;color:var(--accent2)">缓存命中率仪表盘(最近 10 次)</h4>
407
- <div id="cacheSummary" style="font-size:12.5px;color:var(--dim);padding:2px"></div>
408
- <div id="cacheRecent" style="max-height:130px;overflow-y:auto"></div>
409
- <h4 style="margin:10px 0 4px;color:var(--accent2)">费用二级分账(模型 / 工具 Top5 · 近 14 天)</h4>
410
- <div style="display:flex;gap:10px;flex-wrap:wrap">
411
- <div style="flex:1;min-width:170px">
412
- <div style="font-size:11.5px;color:var(--faint)">模型 Top5(费用)</div>
413
- <div id="costTopModels" style="font-size:12px;color:var(--dim)"></div>
414
- </div>
415
- <div style="flex:1;min-width:170px">
416
- <div style="font-size:11.5px;color:var(--faint)">工具 Top5(耗时)</div>
417
- <div id="costTopTools" style="font-size:12px;color:var(--dim)"></div>
418
- </div>
419
- </div>
420
- <div id="costLine" style="margin-top:8px"></div>
421
- </section>
461
+
422
462
  <section class="cfg-panel" data-panel="mcp">
423
463
  <hr style="border:none;border-top:1px solid var(--border);margin:14px 0 8px">
424
464
  <h4 style="margin:4px 0;color:var(--accent2)">MCP 生态预设(一键接入,重启生效)</h4>
@@ -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') || '');
package/src/web/server.js CHANGED
@@ -29,6 +29,8 @@ import { MAX_CONCURRENT } from './constants.js';
29
29
  import { createAgent } from '../agent.js';
30
30
  import { createPermission } from '../permissions.js';
31
31
  import { buildSystemPrompt } from '../prompts.js';
32
+ import { loadProjectMemory, loadProjectMemoryEntries, retrieveRelevant, extractAndAppendProjectMemory } from '../memory.js';
33
+ import { saveTaskStateMerge, clearTaskState, loadTaskState, resumePrompt } from '../task-state.js';
32
34
  import { createWebIO } from './web-io.js';
33
35
  import { startMcpServers } from '../mcp.js';
34
36
  import {
@@ -231,6 +233,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
231
233
  return next;
232
234
  }
233
235
  const draftTexts = new Map(); // 质检 A5:草稿按会话维度存储(此前单槽多客户端互相覆盖)
236
+ const sessionMemoryCache = new Map(); // v0.3.0 P0-3:项目记忆「会话内快照」——同一会话系统提示恒定(保前缀缓存)
234
237
 
235
238
  // —— 服务端诊断日志(第二问无反应排查 + 长期运维):<mingdao-home>/logs/web-server.log ——
236
239
  // 记录每次对话的关键阶段与耗时;2MB 滚动。桌面版与 WebUI 共用同一日志。
@@ -390,12 +393,34 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
390
393
  if (!sessionWsDir) {
391
394
  setSessionWorkspace(sessionName, taskDir, currentWorkspace(workingDir)?.name || null);
392
395
  }
393
- const systemPrompt = buildSystemPrompt({ workingDir: taskDir, withJournal: body.withJournal === true });
396
+ // v0.3.0 P0-3:项目记忆会话内快照——本会话全程用同一份快照(前缀稳定),
397
+ // 每轮结束提取的新条目只写文件、不回灌当前会话。
398
+ let projectMemorySnapshot = sessionMemoryCache.get(sessionName);
399
+ if (projectMemorySnapshot === undefined) {
400
+ // v0.3.1 语义检索:用本轮任务(新会话即首条消息)取「相关记忆条目」,而非全量截 4K——
401
+ // 换任务只注入相关记忆、省 token;快照保证同一会话前缀稳定。
402
+ const allEntries = loadProjectMemoryEntries(taskDir);
403
+ const query = String(built.persistText || '');
404
+ projectMemorySnapshot = query ? retrieveRelevant(allEntries, query, 6).join('\n') : loadProjectMemory(taskDir);
405
+ sessionMemoryCache.set(sessionName, projectMemorySnapshot);
406
+ if (sessionMemoryCache.size > 200) {
407
+ const oldest = sessionMemoryCache.keys().next().value;
408
+ sessionMemoryCache.delete(oldest);
409
+ }
410
+ }
411
+ const systemPrompt = buildSystemPrompt({ workingDir: taskDir, withJournal: body.withJournal === true, projectMemory: projectMemorySnapshot });
394
412
  let messages =
395
413
  session.messages?.length && session.messages[0]?.role === 'system'
396
414
  ? session.messages
397
415
  : [{ role: 'system', content: systemPrompt }, ...(session.messages || [])];
398
416
  messages[0] = { role: 'system', content: systemPrompt }; // 总是刷新 system(记忆/技能/AGENTS.md 最新)
417
+ // v0.3.0 P0-2:续跑——继续一个有未完成检查点的会话时,先注入进度摘要让模型从断点接着做
418
+ if (!isNew) {
419
+ const ts = loadTaskState(sessionName);
420
+ if (ts && (ts.status === 'cap' || ts.status === 'interrupted')) {
421
+ messages.push({ role: 'user', content: resumePrompt(ts) });
422
+ }
423
+ }
399
424
  messages.push({ role: 'user', content: built.content });
400
425
  // 落盘用文本版(图文数组只发给模型,会话文件保持字符串可渲染)
401
426
  await withSessionLock(session.file, () => appendMessages(session.file, [{ role: 'user', content: built.persistText }]));
@@ -483,6 +508,11 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
483
508
  await withSessionLock(session.file, () => appendMessages(session.file, messages.slice(persistedBefore)));
484
509
  io.printUsageLine({ modelName: runModel, usage: r.usage, durationMs: r.durationMs });
485
510
  recordUsage(r.perf?.usedModel || runModel, r.usage, /** @type {any} */ (r.perf));
511
+ // v0.3.0 P0-3:轮末提取项目记忆(有工具工作才提,fire-and-forget)。写文件供「未来会话」用,
512
+ // 当前会话用快照(上文已注入),故不会中途改系统提示、不破坏前缀缓存。
513
+ if (io.stats().toolCount > 0) {
514
+ extractAndAppendProjectMemory({ cfg, provider: providerNow, model: titleModel(cfg, runModel), messages, workingDir: taskDir }).catch(() => {});
515
+ }
486
516
  maybeAutoSync().catch(() => {});
487
517
  // 新会话自动标题(可配置关闭)
488
518
  srvlog('chat 标题生成前 ' + taskId);
@@ -498,6 +528,19 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
498
528
  }
499
529
  } catch {}
500
530
  }
531
+ // v0.3.0 P0-2:任务检查点——跑满步数(capHit)或中断(aborted)时落盘供续跑,正常完成清除
532
+ const finalSessionName = path.basename(session.file);
533
+ if (r.capHit || r.aborted) {
534
+ saveTaskStateMerge(finalSessionName, {
535
+ goal: built.persistText,
536
+ progress: r.text || '',
537
+ artifacts: io.stats().deliverables,
538
+ status: r.capHit ? 'cap' : 'interrupted',
539
+ updatedAt: new Date().toISOString(),
540
+ });
541
+ } else {
542
+ clearTaskState(finalSessionName);
543
+ }
501
544
  entry.status = r.aborted ? 'aborted' : 'done';
502
545
  entry.durationMs = Date.now() - entry.startedAt;
503
546
  srvlog('chat 发送 done ' + taskId + ' status=' + entry.status + ' 总耗时=' + entry.durationMs + 'ms');
@@ -544,7 +587,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
544
587
  providerCache, getProviderFor, tasks,
545
588
  MAX_CONCURRENT, pruneTasks, handleChat,
546
589
  mcpFacade, provider, validateRemoteUrl, isPrivateHost,
547
- authEnabled, tokenMatches, requestToken, trustedHost, INDEX_HTML, draftTexts, startupCwd,
590
+ authEnabled, tokenMatches, requestToken, trustedHost, INDEX_HTML, draftTexts, startupCwd, sessionMemoryCache,
548
591
  state: { get modelName() { return modelName; }, set modelName(v) { modelName = v; },
549
592
  get workingDir() { return workingDir; }, set workingDir(v) { workingDir = v; } },
550
593
  refs: { get inflight() { return inflight; }, set inflight(v) { inflight = v; } },