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/docs/PLAN-v0.3.0.md +109 -0
- package/docs/SAVINGS-BENCHMARK.md +42 -0
- package/docs/STRATEGY-0.3.md +121 -0
- package/package.json +2 -2
- package/src/agent.js +42 -11
- package/src/audit.js +3 -4
- package/src/cli.js +15 -0
- package/src/commands/diagnose.js +95 -0
- package/src/commands/repl.js +19 -0
- package/src/memory.js +161 -1
- package/src/prompts.js +9 -3
- package/src/redact.js +26 -0
- package/src/task-state.js +77 -0
- package/src/tools/fetch.js +56 -0
- package/src/tools/git.js +31 -0
- package/src/tools/index.js +39 -1
- package/src/web/app.js +65 -65
- package/src/web/index.html +62 -22
- package/src/web/routes/domains/sessions.js +36 -1
- package/src/web/server.js +45 -2
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();
|
|
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
|
-
|
|
772
|
-
|
|
773
|
-
const
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
const
|
|
784
|
-
|
|
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-
|
|
801
|
-
return [x
|
|
790
|
+
const y=H-P-(Number(d.cost)||0)/max*(H-2*P);
|
|
791
|
+
return [x,y];
|
|
802
792
|
});
|
|
803
|
-
const
|
|
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
|
-
|
|
806
|
-
+'<svg viewBox="0 0 '+W+' '+H+'" style="width:100%;height:auto
|
|
807
|
-
+'<
|
|
808
|
-
+'<
|
|
809
|
-
+'<
|
|
810
|
-
+'<
|
|
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
|
-
|
|
814
|
-
|
|
815
|
-
const
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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
|
-
|
|
825
|
-
|
|
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
|
-
|
|
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();
|
package/src/web/index.html
CHANGED
|
@@ -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="
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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; } },
|