mingdao-harness 0.1.69 → 0.2.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/package.json +1 -1
- package/src/cli.js +22 -13
- package/src/commands/skill.js +7 -1
- package/src/sync-server.js +25 -3
- package/src/web/app.js +1008 -0
- package/src/web/index.html +34 -960
- package/src/web/routes/api.js +832 -0
- package/src/web/server.js +81 -782
- package/src/web/web-io.js +3 -1
package/src/web/app.js
ADDED
|
@@ -0,0 +1,1008 @@
|
|
|
1
|
+
// 访问令牌(P1-3):地址带 ?token= 时记入 sessionStorage 并从地址栏移除(防截图/历史外泄),
|
|
2
|
+
// 之后所有同源请求统一附加 X-MingDao-Token 头;无令牌时行为与旧版一致。
|
|
3
|
+
const AUTH_TOKEN = (() => {
|
|
4
|
+
try {
|
|
5
|
+
const q = new URLSearchParams(location.search).get('token');
|
|
6
|
+
if (q) {
|
|
7
|
+
sessionStorage.setItem('mingdao-token', q);
|
|
8
|
+
history.replaceState(null, '', location.pathname);
|
|
9
|
+
return q;
|
|
10
|
+
}
|
|
11
|
+
return sessionStorage.getItem('mingdao-token') || null;
|
|
12
|
+
} catch { return null; }
|
|
13
|
+
})();
|
|
14
|
+
if (AUTH_TOKEN) {
|
|
15
|
+
const rawFetch = window.fetch.bind(window);
|
|
16
|
+
window.fetch = (input, init = {}) => {
|
|
17
|
+
init.headers = Object.assign({}, init.headers, { 'X-MingDao-Token': AUTH_TOKEN });
|
|
18
|
+
return rawFetch(input, init);
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
const $ = (s) => document.querySelector(s);
|
|
22
|
+
|
|
23
|
+
// —— 弹窗三件套(Electron 不实现 window.prompt/confirm/alert:prompt 恒返回 null、confirm 恒 false、
|
|
24
|
+
// alert 静默无反应——桌面版「⚙ 设置里点设Key 没反应」即由此而来)。统一替换为应用内模态框。
|
|
25
|
+
function modalBox(inner){
|
|
26
|
+
const mask=document.createElement('div'); mask.className='modal-mask';
|
|
27
|
+
const m=document.createElement('div'); m.className='modal'; m.innerHTML=inner;
|
|
28
|
+
mask.appendChild(m); document.body.appendChild(mask);
|
|
29
|
+
return {mask, m};
|
|
30
|
+
}
|
|
31
|
+
function uiPrompt(title, def, opts){
|
|
32
|
+
opts=opts||{};
|
|
33
|
+
return new Promise((resolve)=>{
|
|
34
|
+
const {mask, m}=modalBox('<h3>'+esc(String(title??''))+'</h3><input class="uiPromptInput" '+(opts.hidden?'type="password" ':'')+'style="width:100%;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:8px 10px;font-size:13.5px;margin-top:10px"><div class="row" style="margin-top:12px"><button class="danger" data-a="c">取消</button><button class="primary" data-a="y">确定</button></div>');
|
|
35
|
+
const inp=m.querySelector('input');
|
|
36
|
+
if(def!==undefined&&def!==null) inp.value=String(def);
|
|
37
|
+
const done=(v)=>{ mask.remove(); resolve(v); };
|
|
38
|
+
m.querySelector('[data-a=y]').onclick=()=>done(inp.value);
|
|
39
|
+
m.querySelector('[data-a=c]').onclick=()=>done(null);
|
|
40
|
+
inp.addEventListener('keydown',(e)=>{ if(e.key==='Enter') done(inp.value); if(e.key==='Escape') done(null); });
|
|
41
|
+
setTimeout(()=>inp.focus(),0);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
function uiConfirm(title){
|
|
45
|
+
return new Promise((resolve)=>{
|
|
46
|
+
const {mask, m}=modalBox('<h3>'+esc(String(title??''))+'</h3><div class="row" style="margin-top:12px"><button class="danger" data-a="c">取消</button><button class="primary" data-a="y">确定</button></div>');
|
|
47
|
+
m.querySelector('[data-a=y]').onclick=()=>{ mask.remove(); resolve(true); };
|
|
48
|
+
m.querySelector('[data-a=c]').onclick=()=>{ mask.remove(); resolve(false); };
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
function uiAlert(title){
|
|
52
|
+
const {mask, m}=modalBox('<h3>'+esc(String(title??''))+'</h3><div class="row" style="margin-top:12px"><button class="primary" data-a="y">确定</button></div>');
|
|
53
|
+
m.querySelector('[data-a=y]').onclick=()=>mask.remove();
|
|
54
|
+
}
|
|
55
|
+
// —— 目录选择器(新建工作空间 / 改目录):浏览服务器磁盘目录树 ——
|
|
56
|
+
let pickerCb = null, pickerDir = '/';
|
|
57
|
+
function loadDirList(){
|
|
58
|
+
$('#dirPath').textContent = pickerDir;
|
|
59
|
+
const box = $('#dirList');
|
|
60
|
+
fetch('/api/fs-browse?dir=' + encodeURIComponent(pickerDir), { cache: 'no-store' })
|
|
61
|
+
.then((r) => r.json())
|
|
62
|
+
.then((j) => {
|
|
63
|
+
box.innerHTML = '';
|
|
64
|
+
if (!j.ok) {
|
|
65
|
+
const e = document.createElement('div'); e.className = 'dir-entry'; e.style.color = 'var(--err)';
|
|
66
|
+
e.textContent = j.error || '无法读取该目录'; box.appendChild(e); return;
|
|
67
|
+
}
|
|
68
|
+
if (j.parent != null) {
|
|
69
|
+
const up = document.createElement('div'); up.className = 'dir-entry'; up.textContent = '⬆ …(上级目录)';
|
|
70
|
+
up.onclick = () => { pickerDir = j.parent; loadDirList(); };
|
|
71
|
+
box.appendChild(up);
|
|
72
|
+
}
|
|
73
|
+
if (!(j.entries || []).length) {
|
|
74
|
+
const e = document.createElement('div'); e.className = 'dir-entry'; e.style.color = 'var(--faint)';
|
|
75
|
+
e.textContent = '(无子目录)'; box.appendChild(e);
|
|
76
|
+
}
|
|
77
|
+
for (const en of j.entries || []) {
|
|
78
|
+
const d = document.createElement('div'); d.className = 'dir-entry'; d.textContent = '📁 ' + en.name;
|
|
79
|
+
d.onclick = () => { pickerDir = en.path; loadDirList(); };
|
|
80
|
+
box.appendChild(d);
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
.catch(() => {
|
|
84
|
+
box.innerHTML = '<div class="dir-entry" style="color:var(--err)">请求失败(请重试)</div>';
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
function openDirPicker(startDir, cb){
|
|
88
|
+
pickerCb = cb;
|
|
89
|
+
if (startDir) { pickerDir = startDir; loadDirList(); }
|
|
90
|
+
else {
|
|
91
|
+
// 无指定起点:从服务器当前工作目录开始浏览
|
|
92
|
+
fetch('/api/workspaces', { cache: 'no-store' }).then((r) => r.json()).then((j) => {
|
|
93
|
+
pickerDir = (j && j.cwd) || '/';
|
|
94
|
+
loadDirList();
|
|
95
|
+
}).catch(() => { pickerDir = '/'; loadDirList(); });
|
|
96
|
+
}
|
|
97
|
+
$('#dirModal').style.display = 'flex';
|
|
98
|
+
}
|
|
99
|
+
$('#dirPickOk').onclick = () => { const cb = pickerCb; pickerCb = null; $('#dirModal').style.display = 'none'; if (cb) cb(pickerDir); };
|
|
100
|
+
$('#dirPickNone').onclick = () => { const cb = pickerCb; pickerCb = null; $('#dirModal').style.display = 'none'; if (cb) cb(null); };
|
|
101
|
+
$('#dirPickCancel').onclick = () => { pickerCb = null; $('#dirModal').style.display = 'none'; };
|
|
102
|
+
const chatEl = $('#chat'), input = $('#input'), sendBtn = $('#sendBtn');
|
|
103
|
+
let activeAiMsg=null, bgRunning=0, curSteps=0, curWorkT0=0, curTools=0, curTasks=0; // 本轮进度(活动条/状态条/轨迹共用)
|
|
104
|
+
let curPhase='模型推理中'; // 阶段语义(服务端 progress 事件下发)
|
|
105
|
+
const sessionSubs=[]; // 本会话全部子代理(task 工具)条目:{seq, question, result, msg}
|
|
106
|
+
// 回到底部悬浮按钮:滚动容器为 main;上滚超过 300px 时出现,点击平滑回底
|
|
107
|
+
(function(){
|
|
108
|
+
const scroller = document.querySelector('main');
|
|
109
|
+
const btn = $('#scrollBottomBtn');
|
|
110
|
+
if (scroller && btn) {
|
|
111
|
+
const farFromBottom = () => scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight > 300;
|
|
112
|
+
scroller.addEventListener('scroll', () => { btn.style.display = farFromBottom() ? 'flex' : 'none'; });
|
|
113
|
+
btn.onclick = () => { scroller.scrollTo({ top: scroller.scrollHeight, behavior: 'smooth' }); };
|
|
114
|
+
}
|
|
115
|
+
})();
|
|
116
|
+
// 代码块「复制」按钮(事件委托:横幅随 markdown 渲染动态出现)
|
|
117
|
+
chatEl.addEventListener('click', (e) => {
|
|
118
|
+
const b = e.target.closest('.cb-copy'); if (!b) return;
|
|
119
|
+
const pre = b.closest('.codeblock')?.querySelector('pre');
|
|
120
|
+
const t = pre ? pre.textContent : '';
|
|
121
|
+
const done = () => { b.textContent = '已复制'; setTimeout(() => { b.textContent = '复制'; }, 1200); };
|
|
122
|
+
if (navigator.clipboard?.writeText) navigator.clipboard.writeText(t).then(done).catch(() => {});
|
|
123
|
+
});
|
|
124
|
+
let generating = false; // 生成中:发送按钮复用为停止按钮
|
|
125
|
+
let currentSession = null, thinking = null;
|
|
126
|
+
|
|
127
|
+
function esc(s){return String(s??'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''')}
|
|
128
|
+
|
|
129
|
+
function highlight(code, lang){
|
|
130
|
+
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(' '));
|
|
131
|
+
const re = /(\/\/[^\n]*|#[^\n]*|"(?:\\.|[^"\\])*"|'[^']*'|`[^`]*`|\b\d[\d._]*\b|[A-Za-z_$][\w$]*)/g;
|
|
132
|
+
return esc(code).replace(re, (tok)=>{
|
|
133
|
+
if(tok.startsWith('//')||tok.startsWith('#')) return '<span class="hl-c">'+tok+'</span>';
|
|
134
|
+
if(/^["'`]/.test(tok)) return '<span class="hl-s">'+tok+'</span>';
|
|
135
|
+
if(/^\d/.test(tok)) return '<span class="hl-n">'+tok+'</span>';
|
|
136
|
+
if(kws.has(tok)) return '<span class="hl-kw">'+tok+'</span>';
|
|
137
|
+
return tok;
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
function renderMarkdown(text){
|
|
141
|
+
// 块级解析(美化:加粗标题 / 段落 / 真列表 / 引用 / 分割线,参照 DeepSeek-Harness 会话排版)
|
|
142
|
+
const lines = String(text).split('\n');
|
|
143
|
+
let out = '', code = null, codeLang = '';
|
|
144
|
+
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; } };
|
|
145
|
+
let para = [];
|
|
146
|
+
let list = null; // {type:'ul'|'ol', items:[]}
|
|
147
|
+
const flushPara = () => { if (para.length) { out += '<p>' + para.join('<br>') + '</p>'; para = []; } };
|
|
148
|
+
const flushList = () => { if (list) { out += '<' + list.type + '>' + list.items.map((i) => '<li>' + i + '</li>').join('') + '</' + list.type + '>'; list = null; } };
|
|
149
|
+
// 行内元素(审计 P1-3:内容与 codeLang 均经 esc 转义防 XSS)
|
|
150
|
+
const inline = (l) => {
|
|
151
|
+
let s = esc(l);
|
|
152
|
+
s = s.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
153
|
+
.replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>')
|
|
154
|
+
.replace(/\*([^*\s][^*]*)\*/g, '<i>$1</i>')
|
|
155
|
+
.replace(/\[([^\]]+)\]\((https?:\/\/[^\s'"<>)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
|
156
|
+
return s;
|
|
157
|
+
};
|
|
158
|
+
for (const line of lines) {
|
|
159
|
+
const t = line.trim();
|
|
160
|
+
if (t.startsWith('```')) {
|
|
161
|
+
if (code === null) { flushList(); flushPara(); code = []; codeLang = (t.slice(3).trim().split(/\s+/)[0] || ''); }
|
|
162
|
+
else flushCode();
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (code !== null) { code.push(line); continue; }
|
|
166
|
+
if (t === '') { flushList(); flushPara(); continue; }
|
|
167
|
+
const h = t.match(/^(#{1,6})\s+(.*)$/);
|
|
168
|
+
if (h) { flushList(); flushPara(); const level = Math.min(h[1].length, 4); out += '<h' + level + '>' + inline(h[2]) + '</h' + level + '>'; continue; }
|
|
169
|
+
const ul = t.match(/^([-*+])\s+(.*)$/);
|
|
170
|
+
if (ul) { flushPara(); if (!list || list.type !== 'ul') { flushList(); list = { type: 'ul', items: [] }; } list.items.push(inline(ul[2])); continue; }
|
|
171
|
+
const ol = t.match(/^(\d+)[.)]\s+(.*)$/);
|
|
172
|
+
if (ol) { flushPara(); if (!list || list.type !== 'ol') { flushList(); list = { type: 'ol', items: [] }; } list.items.push(inline(ol[2])); continue; }
|
|
173
|
+
if (/^>\s?/.test(t)) { flushList(); flushPara(); out += '<blockquote>' + inline(t.replace(/^>\s?/, '')) + '</blockquote>'; continue; }
|
|
174
|
+
if (/^(-{3,}|\*{3,})$/.test(t)) { flushList(); flushPara(); out += '<hr>'; continue; }
|
|
175
|
+
flushList();
|
|
176
|
+
para.push(inline(t));
|
|
177
|
+
}
|
|
178
|
+
flushCode(); flushList(); flushPara();
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
function scrollBottom(){ const sc=document.querySelector('main'); if(sc) sc.scrollTop = sc.scrollHeight; }
|
|
182
|
+
|
|
183
|
+
function newAiMsg(){ const el=document.createElement('div'); el.className='msg-ai'; chatEl.appendChild(el); scrollBottom(); return el; }
|
|
184
|
+
function aiContent(msg){ const c=msg.querySelector('.content'); return c || (()=>{const d=document.createElement('div');d.className='content';msg.appendChild(d);return d;})(); }
|
|
185
|
+
function addReasoning(msg){ const d=document.createElement('details'); d.className='reasoning'; d.innerHTML='<summary>思考过程</summary><div class="body"></div>'; msg.appendChild(d); return d.querySelector('.body'); }
|
|
186
|
+
|
|
187
|
+
function addUser(text){ const el=document.createElement('div'); el.className='msg-user'; el.innerHTML='<div class="bubble">'+esc(text)+'</div>'; chatEl.appendChild(el); scrollBottom(); }
|
|
188
|
+
|
|
189
|
+
function expandableBody(previewHtml, fullHtml){
|
|
190
|
+
const b=document.createElement('div'); b.className='body';
|
|
191
|
+
const prev=document.createElement('div'); const full=document.createElement('div');
|
|
192
|
+
prev.innerHTML=previewHtml; full.innerHTML=fullHtml; full.style.display='none';
|
|
193
|
+
const btn=document.createElement('button'); btn.textContent='展开全文'; btn.style.cssText='padding:1px 8px;font-size:11px;margin-top:4px';
|
|
194
|
+
btn.onclick=()=>{ const open=full.style.display!=='none'; full.style.display=open?'none':'block'; btn.textContent=open?'展开全文':'收起'; scrollBottom(); };
|
|
195
|
+
b.appendChild(prev); b.appendChild(full); b.appendChild(btn);
|
|
196
|
+
return b;
|
|
197
|
+
}
|
|
198
|
+
const runningTools = new Map();
|
|
199
|
+
function renderToolStartEvent(ev){
|
|
200
|
+
const card=document.createElement('div'); card.className='tool';
|
|
201
|
+
const ico = {'read':'📄','write':'✏️','edit':'✎','bash':'⚙','grep':'🔎','glob':'🔎','ls':'📁','task':'👥','skill':'🧩','todo':'☑','undo':'↩'}[ev.name]||'🔌';
|
|
202
|
+
const args = ev.name==='bash'? (ev.args&&ev.args.command):(ev.args&&(ev.args.path||ev.args.pattern||ev.args.name||''))||'';
|
|
203
|
+
card.innerHTML='<div class="head"><span class="ico">'+ico+'</span><span class="name">'+esc(ev.name)+'</span><span class="args">'+esc(args)+'</span><span class="st" style="animation:blink 1.2s infinite">执行中…</span></div>';
|
|
204
|
+
insertBeforeActiveMsg(card);
|
|
205
|
+
runningTools.set(ev.seq, card);
|
|
206
|
+
scrollBottom();
|
|
207
|
+
}
|
|
208
|
+
function renderToolEvent(ev){
|
|
209
|
+
const card=document.createElement('div'); card.className='tool';
|
|
210
|
+
const ico = {'read':'📄','write':'✏️','edit':'✎','bash':'⚙','grep':'🔎','glob':'🔎','ls':'📁','task':'👥','skill':'🧩','todo':'☑','undo':'↩'}[ev.name]||'🔌';
|
|
211
|
+
const args = ev.name==='bash'? (ev.args&&ev.args.command):(ev.args&&(ev.args.path||ev.args.pattern||ev.args.name||''))||'';
|
|
212
|
+
const r = ev.result||{};
|
|
213
|
+
const ok = ev.result===undefined || r.ok!==false;
|
|
214
|
+
card.innerHTML='<div class="head"><span class="ico">'+ico+'</span><span class="name">'+esc(ev.name)+'</span><span class="args">'+esc(args)+'</span><span class="st '+(ok?'ok':'bad')+'">'+(ev.result===undefined?'…':(ok?'✓':'✖'))+(ev.durationMs!=null?' '+ev.durationMs+'ms':'')+'</span></div>';
|
|
215
|
+
insertBeforeActiveMsg(card);
|
|
216
|
+
const bodyWrap=(el)=>{ if(el) card.appendChild(el); };
|
|
217
|
+
if(ev.name==='skill'){
|
|
218
|
+
// 技能内容只供模型使用,不整篇倒给用户:一行摘要
|
|
219
|
+
const out=String(r.output||''); const desc=(out.match(/^description:\s*(.+)$/m)||[])[1]||'';
|
|
220
|
+
const b=document.createElement('div'); b.className='body';
|
|
221
|
+
b.innerHTML='<span style="color:var(--dim)">已加载技能 <b>'+esc(ev.args&&ev.args.name||'')+'</b>'+(desc?':'+esc(desc.slice(0,80)):'')+'(全文 '+out.length+' 字,供模型参考)</span>';
|
|
222
|
+
bodyWrap(b);
|
|
223
|
+
}else if(ev.name==='edit'&&r.diff){
|
|
224
|
+
const b=document.createElement('div'); b.className='body diff';
|
|
225
|
+
const a=(r.diff.before||'').split('\n'), bb=(r.diff.after||'').split('\n');
|
|
226
|
+
const n=Math.max(a.length,bb.length); const cap=40;
|
|
227
|
+
const line=(x)=>'<span class="l ctx"> '+esc(x??'')+'</span>';
|
|
228
|
+
const line2=(x)=>'<span class="l del">- '+esc(x)+'</span>';
|
|
229
|
+
const line3=(x)=>'<span class="l add">+ '+esc(x)+'</span>';
|
|
230
|
+
let prev='', full='';
|
|
231
|
+
for(let i=0;i<n;i++){
|
|
232
|
+
const html=(a[i]===bb[i])?line(a[i]??''):((a[i]!==undefined?line2(a[i]):'')+(bb[i]!==undefined?line3(bb[i]):''));
|
|
233
|
+
if(i<cap) prev+=html; full+=html;
|
|
234
|
+
}
|
|
235
|
+
if(n>cap){ b.appendChild(expandableBody(prev+'<div style="color:var(--faint)">…(共 '+n+' 行差异)</div>', full)); }
|
|
236
|
+
else b.innerHTML=prev;
|
|
237
|
+
bodyWrap(b);
|
|
238
|
+
}else if(ev.name==='write'&&r.ok){
|
|
239
|
+
const lines=String(ev.args&&ev.args.content||'').split('\n');
|
|
240
|
+
const b=document.createElement('div'); b.className='body';
|
|
241
|
+
b.innerHTML='<pre>'+esc(lines.slice(0,8).join('\n'))+(lines.length>8?'\n…(共 '+lines.length+' 行)':'')+'</pre>';
|
|
242
|
+
bodyWrap(b);
|
|
243
|
+
}else if(ev.name==='bash'&&r.ok){
|
|
244
|
+
const badge='<b style="color:'+(r.exitCode===0?'var(--accent)':'var(--err)')+'">'+(r.timedOut?'⏱ 超时':'exit '+r.exitCode)+'</b>';
|
|
245
|
+
const out=(r.stdout?'<pre>'+esc(String(r.stdout).slice(-1200))+'</pre>':'')+(r.stderr?'<pre style="color:#ff9d97">'+esc(String(r.stderr).slice(-600))+'</pre>':'');
|
|
246
|
+
const full=badge+out;
|
|
247
|
+
if((String(r.stdout||'').length+String(r.stderr||'').length)>1800) bodyWrap(expandableBody(badge+'<div style="color:var(--faint)">…(输出较长)</div>', full));
|
|
248
|
+
else { const b=document.createElement('div'); b.className='body'; b.innerHTML=full; bodyWrap(b); }
|
|
249
|
+
}else if(ev.name==='ls'&&r.ok){
|
|
250
|
+
const out=String(r.output||''); const lines=out.split('\n').filter((x)=>x.trim());
|
|
251
|
+
const names=lines.slice(0,3).map((x)=>x.trim()).join('、');
|
|
252
|
+
const prev='<span style="color:var(--dim)">列出 <b>'+lines.length+'</b> 项'+(names?':'+esc(names):'')+(lines.length>3?' 等':'')+'</span>';
|
|
253
|
+
if(lines.length>3) bodyWrap(expandableBody(prev, '<pre>'+esc(out)+'</pre>'));
|
|
254
|
+
else { const b=document.createElement('div'); b.className='body'; b.innerHTML=prev; bodyWrap(b); }
|
|
255
|
+
}else if(r.output){
|
|
256
|
+
const out=String(r.output);
|
|
257
|
+
const max=400;
|
|
258
|
+
if(out.length>max){
|
|
259
|
+
bodyWrap(expandableBody('<pre>'+esc(out.slice(0,max))+'…</pre>', '<pre>'+esc(out)+'</pre>'));
|
|
260
|
+
}else{
|
|
261
|
+
const b=document.createElement('div'); b.className='body'; b.innerHTML='<pre>'+esc(out)+'</pre>'; bodyWrap(b);
|
|
262
|
+
}
|
|
263
|
+
}else if(r.error){
|
|
264
|
+
const b=document.createElement('div'); b.className='body'; b.innerHTML='<span style="color:var(--err)">'+esc(r.error)+'</span>'; bodyWrap(b);
|
|
265
|
+
}
|
|
266
|
+
msg.appendChild(card);
|
|
267
|
+
if(ev.name==='todo'&&ev.result&&ev.result.todos) renderTodos(ev.result.todos, msg);
|
|
268
|
+
scrollBottom();
|
|
269
|
+
}
|
|
270
|
+
function renderTodos(todos,msg){
|
|
271
|
+
const ul=document.createElement('ul'); ul.className='todo';
|
|
272
|
+
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); }
|
|
273
|
+
msg.appendChild(ul);
|
|
274
|
+
}
|
|
275
|
+
function renderBanner(ev){ const d=document.createElement('div'); d.className='banner'; d.textContent=ev.text||ev.title||''; insertBeforeActiveMsg(d); scrollBottom(); }
|
|
276
|
+
// 工具卡/横幅/代码块统一插到「本轮 AI 消息」之前:结论与交付物永远位于消息最底部
|
|
277
|
+
function insertBeforeActiveMsg(el){ if(activeAiMsg && activeAiMsg.parentNode){ chatEl.insertBefore(el, activeAiMsg); } else { chatEl.appendChild(el); } }
|
|
278
|
+
|
|
279
|
+
function askModal(ev){
|
|
280
|
+
return new Promise(resolve=>{
|
|
281
|
+
const root=$('#modalRoot');
|
|
282
|
+
const mask=document.createElement('div'); mask.className='modal-mask';
|
|
283
|
+
const m=document.createElement('div'); m.className='modal';
|
|
284
|
+
m.innerHTML='<h3>权限确认</h3><div class="q">'+esc(ev.question)+'</div><div class="opts"></div><div class="row"><button class="danger" data-a="n">拒绝</button><button class="primary" data-a="y">允许</button></div>';
|
|
285
|
+
if(ev.options&&ev.options.length){ m.querySelector('.opts').style.display='flex'; ev.options.forEach(o=>{ const b=document.createElement('button'); b.dataset.a=o.value; b.textContent=o.label||o.value; b.onclick=()=>finish(o.value); m.querySelector('.opts').appendChild(b); }); m.querySelector('.row').style.display='none'; }
|
|
286
|
+
m.querySelectorAll('[data-a]').forEach(b=>{ if(!b.onclick) b.onclick=()=>finish(b.dataset.a); });
|
|
287
|
+
function finish(a){ mask.remove(); resolve(a); }
|
|
288
|
+
mask.appendChild(m); root.appendChild(mask);
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function handleEvents(stream, onEvent){
|
|
293
|
+
const reader=stream.getReader(); const dec=new TextDecoder(); let buf='';
|
|
294
|
+
for(;;){ const {done,value}=await reader.read(); if(done) break; buf+=dec.decode(value,{stream:true});
|
|
295
|
+
let nl; while((nl=buf.indexOf('\n'))>=0){ const line=buf.slice(0,nl).trim(); buf=buf.slice(nl+1); if(!line.startsWith('data:'))continue; try{ onEvent(JSON.parse(line.slice(5))); }catch{} }
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// —— 附件(图片 / 文本文件) ——
|
|
300
|
+
let attachments=[];
|
|
301
|
+
$('#attachBtn').onclick=()=>{ $('#fileInput').click(); };
|
|
302
|
+
$('#fileInput').addEventListener('change', async e=>{
|
|
303
|
+
for(const f of e.target.files||[]){
|
|
304
|
+
if(attachments.length>=4){ uiAlert('最多 4 个附件'); break; }
|
|
305
|
+
if(f.type.startsWith('image/')){
|
|
306
|
+
if(f.size>5*1024*1024){ uiAlert(f.name+' 超过 5MB'); continue; }
|
|
307
|
+
const dataUrl=await new Promise(r=>{ const fr=new FileReader(); fr.onload=()=>r(fr.result); fr.readAsDataURL(f); });
|
|
308
|
+
attachments.push({type:'image',name:f.name,dataUrl});
|
|
309
|
+
}else if(f.type.startsWith('text/')||/\.(txt|md|json|js|py|log|csv|html|css)$/i.test(f.name)){
|
|
310
|
+
if(f.size>200*1024){ uiAlert(f.name+' 超过 200KB'); continue; }
|
|
311
|
+
const content=await new Promise(r=>{ const fr=new FileReader(); fr.onload=()=>r(fr.result); fr.readAsText(f); });
|
|
312
|
+
attachments.push({type:'text',name:f.name,content});
|
|
313
|
+
}else{
|
|
314
|
+
uiAlert(f.name+':暂只支持图片与文本文件');
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
e.target.value='';
|
|
318
|
+
renderAttachments();
|
|
319
|
+
});
|
|
320
|
+
function renderAttachments(){
|
|
321
|
+
const list=$('#attachList'); list.innerHTML='';
|
|
322
|
+
if(!attachments.length){ list.style.display='none'; return; }
|
|
323
|
+
list.style.display='flex';
|
|
324
|
+
for(let i=0;i<attachments.length;i++){
|
|
325
|
+
const a=attachments[i];
|
|
326
|
+
const chip=document.createElement('span'); chip.className='atchip';
|
|
327
|
+
chip.textContent=(a.type==='image'?'🖼 ':'📄 ')+a.name+' ✕';
|
|
328
|
+
chip.title='点击移除';
|
|
329
|
+
chip.onclick=()=>{ attachments.splice(i,1); renderAttachments(); };
|
|
330
|
+
list.appendChild(chip);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function send(){
|
|
335
|
+
const text=input.value.trim();
|
|
336
|
+
console.log('[MingDao] send 点击:generating=' + generating + ' text=' + text.length + ' attachments=' + attachments.length);
|
|
337
|
+
if(generating){
|
|
338
|
+
// 审计(长任务静默硬伤):任务执行中追问「是否完成」→ 界面立即如实应答当前进度,
|
|
339
|
+
// 不再静默吞掉输入;停止职责由「■ 停止」按钮承担(输入框内容保留不丢)。
|
|
340
|
+
const secs=curWorkT0?Math.round((Date.now()-curWorkT0)/1000):0;
|
|
341
|
+
const d=document.createElement('div'); d.className='msg-sys';
|
|
342
|
+
d.innerHTML='⏳ 上一个任务仍在执行(第 '+curSteps+' 步 · 已 '+Math.floor(secs/60)+' 分 '+Math.round(secs%60)+' 秒),<b>尚未完成</b>。完成后总结与交付物会自动出现在本轮消息底部;如需立即中断请点「■ 停止」。';
|
|
343
|
+
chatEl.appendChild(d); scroll();
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if(!text && !attachments.length) return;
|
|
347
|
+
const turnCtrl=new AbortController();
|
|
348
|
+
// 回合看门狗(无活动超时,非总时长——审计:此前 120s 定时炸弹误杀长时间健康生成,
|
|
349
|
+
// 如模型持续输出大文件代码时到点被掐断:write 参数截断 + 「响应超时已中断」):
|
|
350
|
+
// 每收到一个 SSE 事件即重置;ask 权限等待期间暂停计时(用户思考不设限),
|
|
351
|
+
// 回复后的下一个事件自动重新武装。仅当 120 秒内真正没有任何事件(挂死)才强制中断。
|
|
352
|
+
let watchdog=null; let killedByWatchdog=false;
|
|
353
|
+
const disarm=()=>{ if(watchdog){ clearTimeout(watchdog); watchdog=null; } };
|
|
354
|
+
const arm=()=>{ disarm(); watchdog=setTimeout(()=>{
|
|
355
|
+
killedByWatchdog=true;
|
|
356
|
+
try{ turnCtrl.abort(); }catch{}
|
|
357
|
+
fetch('/api/abort',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}).catch(()=>{});
|
|
358
|
+
},120000); };
|
|
359
|
+
arm();
|
|
360
|
+
input.value=''; input.style.height='44px';
|
|
361
|
+
const payload={message:text,file:currentSession};
|
|
362
|
+
// 带上文开关:勾选后系统提示注入最近会话日志(默认不注入——新会话全新开始,避免串到历史会话上下文)
|
|
363
|
+
if($('#journalChk')?.checked) payload.withJournal=true;
|
|
364
|
+
if(attachments.length) payload.attachments=attachments;
|
|
365
|
+
const sentAttachments=attachments;
|
|
366
|
+
attachments=[]; renderAttachments();
|
|
367
|
+
addUser(text||(sentAttachments.map(a=>(a.type==='image'?'🖼 ':'📄 ')+a.name).join(' ')));
|
|
368
|
+
const msg=newAiMsg(); const content=aiContent(msg); let raw='';
|
|
369
|
+
activeAiMsg=msg; msg._traj=[]; msg._steps=0; curSteps=0; curWorkT0=Date.now(); renderWorkStatus();
|
|
370
|
+
let reason=null; let taskId=null;
|
|
371
|
+
// 等待输出时的可见状态:脉动「正在思考…」(每轮生成重新显示)
|
|
372
|
+
const think=document.createElement('div'); think.className='think'; think.textContent='💭 正在思考…'; msg.appendChild(think);
|
|
373
|
+
const showThink=()=>{ if(!think.parentNode) msg.appendChild(think); };
|
|
374
|
+
// 工作指示:提示栏实时显示已工作时长与执行步数,让用户时刻感受到智能体在干活
|
|
375
|
+
// 审计(第二问根因):文本只写 #hintText,绝不 textContent 覆盖 #hint——那会把其中的
|
|
376
|
+
// #journalChk 复选框从 DOM 抹掉,第二问读 .checked 抛异常 → 按钮卡红 → 全部发送静默
|
|
377
|
+
let stepsCount=0; const workT0=Date.now();
|
|
378
|
+
const hintEl=$('#hint'); const hintTextEl=$('#hintText'); const defaultHint=hintTextEl?hintTextEl.textContent:'';
|
|
379
|
+
hintEl.classList.add('working');
|
|
380
|
+
const hintTimer=setInterval(()=>{ if(hintTextEl) hintTextEl.textContent='⏳ 正在工作 '+Math.round((Date.now()-workT0)/1000)+'s · 已执行 '+stepsCount+' 步 — 可点「■ 停止」中断'; renderWorkStatus(); },1000);
|
|
381
|
+
generating=true; setBtn(); // 本地在途状态(前置 DOM 段完成后才置位,异常不影响按钮)
|
|
382
|
+
const scroller=document.querySelector('main');
|
|
383
|
+
const nearBottom=()=> scroller? (scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < 90) : true;
|
|
384
|
+
const scroll=()=>{ if(nearBottom()&&scroller) scroller.scrollTop = scroller.scrollHeight; };
|
|
385
|
+
let pending=false;
|
|
386
|
+
const update=()=>{ if(pending) return; pending=true; requestAnimationFrame(()=>{ pending=false; content.innerHTML=renderMarkdown(raw); scroll(); }); };
|
|
387
|
+
const onActivity=()=>{ if(think.parentNode) think.remove(); if(reason&&reason.parentElement){ reason.parentElement.open=false; reason.parentElement.querySelector('summary').classList.remove('live'); } };
|
|
388
|
+
updateTasksPanel();
|
|
389
|
+
try{
|
|
390
|
+
const resp=await fetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload),signal:turnCtrl.signal});
|
|
391
|
+
if(!resp.ok){ const j=await resp.json().catch(()=>({})); onActivity(); msg.innerHTML='<div class="errline">'+esc(j.error||('HTTP '+resp.status))+'</div>'; updateTasksPanel(); return; }
|
|
392
|
+
await handleEvents(resp.body, ev=>{
|
|
393
|
+
if(ev.taskId){ taskId=ev.taskId; curTaskId=taskId; } // 质检 L1:主停止按钮只断本任务
|
|
394
|
+
if(ev.type==='ask') disarm(); else arm(); // 有活动→重置;权限等待→暂停计时
|
|
395
|
+
if(ev.type==='progress'){ const p=ev.seconds||0; if(ev.phase) curPhase=String(ev.phase); curTools=Number(ev.steps)||0; curTasks=Number(ev.tasks)||0; showThink(); think.textContent='💭 正在工作 '+Math.floor(p/60)+' 分 '+Math.round(p%60)+' 秒 · 第 '+msg._steps+' 回合 · '+curPhase+' · 已执行 '+ev.steps+' 步…'; renderWorkStatus(); }
|
|
396
|
+
if(ev.type==='text'){ curPhase='模型输出中'; onActivity(); raw+=ev.delta; update(); }
|
|
397
|
+
else if(ev.type==='reasoning'){ if(!reason){ reason=addReasoning(msg); reason.dataset.full=''; } if(!reason.parentElement.open) reason.parentElement.open=true; reason.parentElement.querySelector('summary').classList.add('live'); reason.dataset.full+=ev.delta; const full=reason.dataset.full; reason.textContent=(full.length>9000?full.slice(-9000)+'\n…(思考内容较长,仅显示末尾;已 '+full.length+' 字符)':full); reason.scrollTop=reason.scrollHeight; scroll(); }
|
|
398
|
+
else if(ev.type==='turnStart'){ stepsCount+=1; curSteps+=1; msg._steps=curSteps; curPhase='模型推理中'; msg._traj.push({kind:'turn', t:Date.now()}); showThink(); think.textContent='💭 第 '+msg._steps+' 回合:模型推理中…'; renderWorkStatus(); scroll(); }
|
|
399
|
+
else if(ev.type==='turnEnd'){ curPhase='回合完成,进入下一回合'; showThink(); think.textContent='⏸ 第 '+msg._steps+' 回合完成(累计 '+(ev.toolSteps||0)+' 工具步)· 进入下一回合…'; renderWorkStatus(); }
|
|
400
|
+
else if(ev.type==='toolStart'){ stepsCount+=1; curSteps+=1; curPhase=ev.name==='task'?'子代理执行中':'执行工具中'; msg._steps=curSteps; msg._traj.push({kind:'tool', seq:ev.seq, name:ev.name, args:ev.args, t:Date.now(), done:false}); onActivity(); update(); renderToolStartEvent(ev); renderWorkStatus(); }
|
|
401
|
+
else if(ev.type==='code'){ onActivity(); const pre=document.createElement('pre'); pre.innerHTML='<code>'+highlight(ev.code,ev.lang)+'</code>'; insertBeforeActiveMsg(pre); scroll(); }
|
|
402
|
+
else if(ev.type==='tool'){ onActivity(); update(); const pending=runningTools.get(ev.seq); if(pending){ pending.remove(); runningTools.delete(ev.seq); } const tj=msg._traj.find(x=>x.kind==='tool'&&x.seq===ev.seq); if(tj){ tj.done=true; tj.result=ev.result; tj.durationMs=ev.durationMs; tj.card=pending||null; } if(ev.name==='task'){ sessionSubs.push({seq:ev.seq, question:String(ev.args?.question||ev.args?.prompt||''), result:ev.result, durationMs:ev.durationMs, msg}); renderSubPanel(); } renderToolEvent(ev); }
|
|
403
|
+
else if(ev.type==='toolDenied'){ onActivity(); const tj=msg._traj.find(x=>x.kind==='tool'&&x.seq===ev.seq); if(tj){ tj.done=true; tj.denied=ev.reason||'未授权'; } const d=document.createElement('div'); d.className='errline'; d.textContent='✖ '+(ev.reason==='未授权'||!ev.reason?'未授权':ev.reason)+':'+ev.name; msg.appendChild(d); scroll(); }
|
|
404
|
+
else if(ev.type==='banner'){ renderBanner(ev); }
|
|
405
|
+
else if(ev.type==='ask'){ update(); askModal(ev).then(a=>fetch('/api/permission',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:ev.id,answer:a,taskId})}).then(()=>arm()).catch(()=>{})); }
|
|
406
|
+
else if(ev.type==='usage'){ onActivity(); const u=document.createElement('div'); u.className='usage'; u.textContent=''+ev.modelName+' · ↑'+ev.usage.prompt_tokens+' ↓'+ev.usage.completion_tokens+' tokens · '+((ev.durationMs||0)/1000).toFixed(1)+'s'+ev.cost; msg.insertBefore(u, content); scroll(); }
|
|
407
|
+
else if(ev.type==='error'){ console.log('[MingDao] error 事件:' + ev.message); onActivity(); const d=document.createElement('div'); d.className='errline'; d.textContent=ev.message; msg.appendChild(d); scroll(); }
|
|
408
|
+
else if(ev.type==='done'){ console.log('[MingDao] done 事件:session=' + ev.session); onActivity(); if(ev.budget){ const b=ev.budget; if(hintTextEl) hintTextEl.textContent='预算 '+Math.round(b.used/1000)+'K/'+Math.round(b.total/1000)+'K('+Math.round(b.used/b.total*100)+'%)· 本轮完成 · 提示栏右侧为今日费用与命中率'; } refreshStatusBar(); if(ev.stats&&ev.stats.deliverables&&ev.stats.deliverables.length){ const card=document.createElement('div'); card.className='deliver'; card.innerHTML='<div class="t">📦 交付物('+ev.stats.deliverables.length+' 个文件)</div>'+ev.stats.deliverables.map(f=>'<div class="i">'+esc(f)+(f.toLowerCase().endsWith('.html')?' <span style="color:var(--accent2)">— 浏览器打开即可运行</span>':'')+'</div>').join(''); msg.appendChild(card); } if(ev.note){ const d=document.createElement('div'); d.className='errline'; d.style.color='var(--warn)'; d.textContent=ev.note; msg.appendChild(d); } currentSession=ev.session; update(); refreshSessions(); updateTasksPanel(); }
|
|
409
|
+
});
|
|
410
|
+
}catch(e){ onActivity(); const d=document.createElement('div'); d.className='errline'; d.textContent=(e&&e.name==='AbortError')?(killedByWatchdog?'响应超时已中断(120 秒无任何响应),请重试':'已中断'):(e&&e.message)||'网络错误'; msg.appendChild(d); scroll(); }
|
|
411
|
+
finally{ clearInterval(hintTimer); disarm(); generating=false; setBtn(); curTaskId=null; curPhase='模型推理中'; console.log('[MingDao] 回合收尾:generating=false,按钮恢复发送'); hintEl.classList.remove('working'); if(hintTextEl) hintTextEl.textContent=defaultHint; pending=false; content.innerHTML=renderMarkdown(raw); attachTrajMeta(msg); activeAiMsg=null; curWorkT0=0; renderWorkStatus(); scroll(); updateTasksPanel(); }
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
let curTaskId=null; // 本轮 SSE 任务 id(质检 L1:主停止按钮只断本任务,不再误伤其他 tab)
|
|
415
|
+
function abort(){ fetch('/api/abort',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(curTaskId?{taskId:curTaskId}:{})}).then(()=>updateTasksPanel()).catch(()=>{}); }
|
|
416
|
+
|
|
417
|
+
// —— 输入框上方工作状态条(审计:长任务静默硬伤 → 实时进度可见) ——
|
|
418
|
+
function renderWorkStatus(){
|
|
419
|
+
renderLiveBar();
|
|
420
|
+
const el=$('#workStatus'); if(!el) return;
|
|
421
|
+
let html='';
|
|
422
|
+
if(generating){
|
|
423
|
+
const secs=curWorkT0?Math.round((Date.now()-curWorkT0)/1000):0;
|
|
424
|
+
html='<span class="ws-busy"><span class="spinner"></span>⏳ '+curSteps+' 步 · '+Math.floor(secs/60)+' 分 '+Math.round(secs%60)+' 秒 · '+curTools+' 工具步</span>';
|
|
425
|
+
} else if(bgRunning>0){
|
|
426
|
+
html='<span class="ws-bg">🛠 '+bgRunning+' 个后台任务运行中 — 点击查看</span>';
|
|
427
|
+
}
|
|
428
|
+
el.style.display=html?'flex':'none';
|
|
429
|
+
el.innerHTML=html;
|
|
430
|
+
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(); } };
|
|
431
|
+
}
|
|
432
|
+
// 顶部常驻活动条(静默完美解决层):生成期间始终可见,滚动不影响
|
|
433
|
+
function renderLiveBar(){
|
|
434
|
+
const el=$('#liveBar'); if(!el) return;
|
|
435
|
+
if(!generating){ el.style.display='none'; el.innerHTML=''; return; }
|
|
436
|
+
const secs=curWorkT0?Math.round((Date.now()-curWorkT0)/1000):0;
|
|
437
|
+
el.style.display='flex';
|
|
438
|
+
el.innerHTML='<span class="spinner"></span><span class="lb-phase">正在执行:'+esc(curPhase)+'</span>'+
|
|
439
|
+
'<span class="lb-stat">第 '+curSteps+' 步 · 已 '+Math.floor(secs/60)+' 分 '+Math.round(secs%60)+' 秒 · '+curTools+' 工具步'+(curTasks>0?' · '+curTasks+' 个子代理':'')+'</span>';
|
|
440
|
+
}
|
|
441
|
+
// 轨迹元行:任务完成后附在本轮消息顶部(步数/子代理数,点击打开轨迹面板)
|
|
442
|
+
function attachTrajMeta(msg){
|
|
443
|
+
if(!msg || !msg._traj || !msg._traj.length) return;
|
|
444
|
+
const tools=msg._traj.filter(x=>x.kind==='tool');
|
|
445
|
+
const subs=tools.filter(x=>x.name==='task');
|
|
446
|
+
const meta=document.createElement('div'); meta.className='msg-meta';
|
|
447
|
+
const b=document.createElement('button'); b.className='trajBtn';
|
|
448
|
+
b.textContent='🧭 轨迹:'+(msg._steps||tools.length)+' 步 · '+subs.length+' 个子代理';
|
|
449
|
+
b.onclick=()=>openTraj(msg);
|
|
450
|
+
meta.appendChild(b);
|
|
451
|
+
msg.insertBefore(meta, msg.firstChild);
|
|
452
|
+
}
|
|
453
|
+
function openTraj(msg){
|
|
454
|
+
const list=$('#tjList'); list.innerHTML='';
|
|
455
|
+
const tools=msg._traj.filter(x=>x.kind==='tool');
|
|
456
|
+
const subs=tools.filter(x=>x.name==='task');
|
|
457
|
+
$('#tjSummary').textContent='('+(msg._steps||tools.length)+' 步 · '+subs.length+' 个子代理)';
|
|
458
|
+
let turnN=0;
|
|
459
|
+
for(const e of msg._traj){
|
|
460
|
+
if(e.kind==='turn'){ turnN+=1;
|
|
461
|
+
const t=document.createElement('div'); t.className='tj-turn'; t.textContent='第 '+turnN+' 回合'; list.appendChild(t);
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
const isSub=e.name==='task';
|
|
465
|
+
const ico={'read':'📄','write':'✏️','edit':'✎','bash':'⚙','grep':'🔎','glob':'🔎','ls':'📁','task':'🤖','skill':'🧩','todo':'☑','undo':'↩'}[e.name]||'🔌';
|
|
466
|
+
const args = e.name==='bash' ? (e.args&&e.args.command) : (e.args&&(e.args.path||e.args.pattern||e.args.name||e.args.question||''))||'';
|
|
467
|
+
const div=document.createElement('div'); div.className='tj-item'+(isSub?' tj-sub':'');
|
|
468
|
+
const subLabel=isSub?'子代理:':'';
|
|
469
|
+
div.innerHTML='<div class="tj-head"><span>'+ico+'</span><span style="white-space:nowrap">'+esc(subLabel+e.name)+'</span><span class="tj-args">'+esc(String(args).slice(0,80))+'</span><span style="margin-left:auto;color:var(--faint)">'+(e.done?(e.denied?'✖ '+esc(e.denied):'✓'+(e.durationMs!=null?' '+e.durationMs+'ms':'')):'执行中…')+'</span></div>';
|
|
470
|
+
const body=document.createElement('div'); body.className='tj-body';
|
|
471
|
+
if(isSub){
|
|
472
|
+
body.textContent='任务:'+(e.args&&(e.args.question||e.args.prompt))+'\n\n结果:'+truncText(resultText(e.result), 1500);
|
|
473
|
+
}else if(e.result!==undefined){
|
|
474
|
+
body.textContent='参数:'+truncText(JSON.stringify(e.args||{}), 500)+'\n\n结果:'+truncText(resultText(e.result), 1500);
|
|
475
|
+
}else{
|
|
476
|
+
body.textContent='参数:'+truncText(JSON.stringify(e.args||{}), 500);
|
|
477
|
+
}
|
|
478
|
+
div.appendChild(body);
|
|
479
|
+
div.onclick=(ev)=>{ if(ev.target.closest('.tj-body')) return; div.classList.toggle('open'); };
|
|
480
|
+
// 点击标题定位到聊天中的对应工具卡
|
|
481
|
+
list.appendChild(div);
|
|
482
|
+
}
|
|
483
|
+
$('#trajPanel').style.display='flex';
|
|
484
|
+
$('#tasksPanel').style.display='none';
|
|
485
|
+
$('#subPanel').style.display='none';
|
|
486
|
+
$('#trajRailBtn').classList.add('on');
|
|
487
|
+
$('#subRailBtn').classList.remove('on');
|
|
488
|
+
}
|
|
489
|
+
function resultText(r){
|
|
490
|
+
if(r==null||r===undefined) return '';
|
|
491
|
+
if(typeof r==='string') return r;
|
|
492
|
+
if(r.output) return String(r.output);
|
|
493
|
+
if(r.stdout||r.stderr) return String(r.stdout||'')+String(r.stderr||'');
|
|
494
|
+
return JSON.stringify(r);
|
|
495
|
+
}
|
|
496
|
+
function truncText(t,n){ t=String(t||''); return t.length>n?t.slice(0,n)+'\n…(截断,共 '+t.length+' 字)':t; }
|
|
497
|
+
$('#tjClose').onclick=()=>{ $('#trajPanel').style.display='none'; $('#trajRailBtn').classList.remove('on'); };
|
|
498
|
+
$('#trajRailBtn').onclick=()=>{
|
|
499
|
+
const p=$('#trajPanel');
|
|
500
|
+
const willShow=p.style.display==='none';
|
|
501
|
+
if(willShow){ const m=lastMsgWithTraj(); if(!m){ uiAlert('本轮还没有可展示的轨迹(先发起一个任务)'); return; } openTraj(m); }
|
|
502
|
+
else p.style.display='none';
|
|
503
|
+
$('#trajRailBtn').classList.toggle('on', willShow);
|
|
504
|
+
};
|
|
505
|
+
function lastMsgWithTraj(){
|
|
506
|
+
const msgs=[...document.querySelectorAll('#chat .msg-ai')].reverse();
|
|
507
|
+
for(const m of msgs){ if(m._traj && m._traj.length) return m; }
|
|
508
|
+
return msgs.length ? msgs[0] : null;
|
|
509
|
+
}
|
|
510
|
+
function renderSubPanel(){
|
|
511
|
+
const count=sessionSubs.length;
|
|
512
|
+
const badge=$('#subBadge'); if(badge){ badge.style.display=count>0?'':'none'; badge.textContent=count>99?'99+':String(count); }
|
|
513
|
+
$('#sbCount').textContent=count>0?('('+count+' 个)'):'';
|
|
514
|
+
if($('#subPanel').style.display!=='flex') return;
|
|
515
|
+
const list=$('#sbList'); list.innerHTML='';
|
|
516
|
+
for(const sub of [...sessionSubs].reverse()){
|
|
517
|
+
const div=document.createElement('div'); div.className='sb-item';
|
|
518
|
+
div.innerHTML='<div class="sb-q">🤖 '+esc(sub.question.slice(0,60))+'</div><div class="sb-meta">'+(sub.durationMs!=null?sub.durationMs+'ms · ':'')+((sub.result&&sub.result.ok===false)?'失败':'完成')+'</div>';
|
|
519
|
+
const body=document.createElement('div'); body.className='sb-body';
|
|
520
|
+
body.textContent='任务:'+sub.question+'\n\n结果:'+truncText(resultText(sub.result), 1500);
|
|
521
|
+
div.appendChild(body);
|
|
522
|
+
div.onclick=(e)=>{ if(e.target.closest('.sb-body')) return; div.classList.toggle('open'); };
|
|
523
|
+
list.appendChild(div);
|
|
524
|
+
}
|
|
525
|
+
if(!count) list.innerHTML='<div class="empty" style="color:var(--faint);font-size:12px;padding:10px">本会话还没有子代理(task 工具会派生子代理)</div>';
|
|
526
|
+
}
|
|
527
|
+
const toggleSubPanel=()=>{
|
|
528
|
+
const p=$('#subPanel');
|
|
529
|
+
const willShow=p.style.display==='none';
|
|
530
|
+
p.style.display=willShow?'flex':'none';
|
|
531
|
+
$('#subRailBtn').classList.toggle('on', willShow);
|
|
532
|
+
if(willShow){ $('#tasksPanel').style.display='none'; $('#trajPanel').style.display='none'; $('#trajRailBtn').classList.remove('on'); renderSubPanel(); }
|
|
533
|
+
};
|
|
534
|
+
$('#subRailBtn').onclick=toggleSubPanel;
|
|
535
|
+
$('#sbClose').onclick=()=>{ $('#subPanel').style.display='none'; $('#subRailBtn').classList.remove('on'); };
|
|
536
|
+
// —— 任务面板(多会话并行) ——
|
|
537
|
+
function setBtn(){
|
|
538
|
+
sendBtn.textContent = generating ? '■ 停止' : '发送';
|
|
539
|
+
sendBtn.className = generating ? 'danger' : 'primary';
|
|
540
|
+
}
|
|
541
|
+
async function updateTasksPanel(){
|
|
542
|
+
const r=await fetch('/api/tasks').catch(()=>null); if(!r) return;
|
|
543
|
+
const j=await r.json(); const list=$('#tpList'); list.innerHTML='';
|
|
544
|
+
bgRunning=Number(j.running)||0; renderWorkStatus();
|
|
545
|
+
$('#tpCount').textContent='('+j.running+' 运行中 / 上限 '+j.maxConcurrent+')';
|
|
546
|
+
// 审计(第二问无反应修复):generating 改为「本轮在途」本地状态,由 send/finally 维护,
|
|
547
|
+
// 不再由 /api/tasks 轮询推导——任务面板瞬时波动或一次轮询失败曾让按钮永久卡在停止态,
|
|
548
|
+
// 后续发送全部静默。面板仅展示,不参与按钮状态。
|
|
549
|
+
for(const t of j.tasks){
|
|
550
|
+
const div=document.createElement('div'); div.className='tp-item';
|
|
551
|
+
const dot = t.status==='running'?'tp-run':(t.status==='done'?'tp-done':'tp-bad');
|
|
552
|
+
const secs = t.status==='running'?'…':' '+(t.durationMs/1000).toFixed(1)+'s';
|
|
553
|
+
div.innerHTML='<div class="tp-title">'+esc(t.message||'任务')+'</div><div class="tp-meta"><span class="tp-dot '+dot+'"></span>'+t.status+secs+'</div>';
|
|
554
|
+
if(t.status==='running'){ const b=document.createElement('button'); b.textContent='中断'; b.className='danger'; b.style.cssText='margin-left:auto;padding:2px 8px;font-size:11px'; b.onclick=()=>fetch('/api/abort',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({taskId:t.id})}).catch(()=>{}); div.querySelector('.tp-meta').appendChild(b); }
|
|
555
|
+
list.appendChild(div);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
setInterval(updateTasksPanel, 2000);
|
|
559
|
+
// 费用徽标:今日费用 / 缓存命中率 / 护栏(15s 刷新)
|
|
560
|
+
async function refreshCostBadge(){
|
|
561
|
+
const r=await fetch('/api/cache-stats',{cache:'no-store'}).catch(()=>null); if(!r) return;
|
|
562
|
+
const j=await r.json().catch(()=>null); if(!j) return;
|
|
563
|
+
const bd=j.breakdown||{}; const gd=j.guard||null;
|
|
564
|
+
let t='今日 ≈¥'+(bd.today||0).toFixed(4);
|
|
565
|
+
if(bd.rate!=null) t+=' · 命中 '+(bd.rate*100).toFixed(0)+'%';
|
|
566
|
+
if(gd&&gd.limit>0) t+=' · 护栏 '+(gd.cost/gd.limit*100).toFixed(0)+'%';
|
|
567
|
+
$('#costBadge').textContent=t;
|
|
568
|
+
}
|
|
569
|
+
refreshCostBadge(); setInterval(refreshCostBadge, 15000);
|
|
570
|
+
// 底部状态栏:轮次/步数/LLM 与工具时长/首 token 平均/吞吐/缓存命中/输入输出 tokens
|
|
571
|
+
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'; }
|
|
572
|
+
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); }
|
|
573
|
+
async function refreshStatusBar(){
|
|
574
|
+
const r=await fetch('/api/cache-stats',{cache:'no-store'}).catch(()=>null); if(!r) return;
|
|
575
|
+
const j=await r.json().catch(()=>null); if(!j) return;
|
|
576
|
+
const s=j.summary||{};
|
|
577
|
+
const ft = s.firstTokenAvgMs!=null ? (s.firstTokenAvgMs/1000).toFixed(0)+'s' : '—';
|
|
578
|
+
const tps = s.tokensPerSec!=null ? Math.round(s.tokensPerSec)+' tok/s' : '—';
|
|
579
|
+
const rate = s.rate!=null ? (s.rate*100).toFixed(1)+'%' : '—';
|
|
580
|
+
$('#statusBar').textContent = s.turns+' 轮 · '+s.steps+' 步 | LLM '+fmtDur(s.llmMs)+' · 工具调用 '+fmtDur(s.toolMs)+' | 首 token 平均 '+ft+' · '+tps+' | 缓存命中 '+rate+' | 输入 '+fmtTok(s.prompt)+' tok · 输出 '+fmtTok(s.completion)+' tok';
|
|
581
|
+
}
|
|
582
|
+
refreshStatusBar(); setInterval(refreshStatusBar, 15000);
|
|
583
|
+
$('#tasksBtn').onclick=()=>{ const p=$('#tasksPanel'); p.style.display = p.style.display==='none'?'flex':'none'; if(p.style.display==='flex'){ $('#subPanel').style.display='none'; $('#trajPanel').style.display='none'; $('#trajRailBtn').classList.remove('on'); $('#subRailBtn').classList.remove('on'); updateTasksPanel(); } };
|
|
584
|
+
$('#tpClose').onclick=()=>{ $('#tasksPanel').style.display='none'; };
|
|
585
|
+
|
|
586
|
+
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; }
|
|
587
|
+
let searchTimer=null;
|
|
588
|
+
$('#sessionSearch').addEventListener('input',e=>{ clearTimeout(searchTimer); searchTimer=setTimeout(()=>refreshSessions(e.target.value.trim()),300); });
|
|
589
|
+
async function init(){
|
|
590
|
+
try{
|
|
591
|
+
const r=await fetch('/api/state',{cache:'no-store'}); const j=await r.json();
|
|
592
|
+
const ms=$('#modelSel'); ms.innerHTML='';
|
|
593
|
+
const groups={}; const order=[];
|
|
594
|
+
for(const m of (j.models||[])){ const g=m.providerLabel||'其他'; if(!groups[g]){groups[g]=[];order.push(g);} groups[g].push(m); }
|
|
595
|
+
for(const g of order){
|
|
596
|
+
const og=document.createElement('optgroup'); og.label=g;
|
|
597
|
+
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); }
|
|
598
|
+
ms.appendChild(og);
|
|
599
|
+
}
|
|
600
|
+
if(!(j.models||[]).length){ const o=document.createElement('option'); o.value=j.model; o.textContent=j.model; ms.appendChild(o); }
|
|
601
|
+
$('#permSel').value=j.permission||'ask';
|
|
602
|
+
$('#sbxSel').value=j.sandbox||'off';
|
|
603
|
+
$('#sbxHint').textContent=j.sandboxSupported?'':'当前环境未检测到 bubblewrap,readonly/safe 将自动降级为 off';
|
|
604
|
+
$('#routeChk').checked=Boolean(j.routing);
|
|
605
|
+
$('#budgetInput').value=j.contextBudget||128000;
|
|
606
|
+
$('#autoStartChk').checked=Boolean(j.autostart);
|
|
607
|
+
$('#notifyChk').checked=j.notify!==false;
|
|
608
|
+
const env = ('路由'+(j.routing?'开':'关')+' · 沙箱'+((j.sandbox&&j.sandbox!=='off')?(j.sandboxSupported?j.sandbox:'降级'):'off'));
|
|
609
|
+
$('#envBadge').textContent=env; $('#envBadge').style.display='';
|
|
610
|
+
// 首次使用引导:无 API Key 时明确提示去设置(桌面版自动初始化后必走这里)
|
|
611
|
+
if (j.keyReady === false) {
|
|
612
|
+
renderBanner({ text: '🔑 欢迎使用 MingDao Harness!还没有配置 API Key:请点击右上角 ⚙ 设置 →「模型与 API Key」选择模型并填入密钥(DeepSeek 平台申请)后即可对话。' });
|
|
613
|
+
}
|
|
614
|
+
}catch(e){
|
|
615
|
+
const ms=$('#modelSel'); if(!ms.options.length){ const o=document.createElement('option'); o.textContent='加载失败,刷新重试'; ms.appendChild(o); }
|
|
616
|
+
}
|
|
617
|
+
refreshSessions();
|
|
618
|
+
refreshWsSel();
|
|
619
|
+
try{
|
|
620
|
+
const dr=await fetch('/api/draft',{cache:'no-store'}); const dj=await dr.json();
|
|
621
|
+
if(dj.text){ input.value=dj.text; input.style.height=Math.min(input.scrollHeight,200)+'px'; input.focus(); }
|
|
622
|
+
}catch(e){}
|
|
623
|
+
}
|
|
624
|
+
// 设置多级菜单:左侧分组导航 ↔ 右侧面板切换(记住上次所在分组)
|
|
625
|
+
let cfgPanel='models';
|
|
626
|
+
function showCfgPanel(name){
|
|
627
|
+
cfgPanel=name;
|
|
628
|
+
document.querySelectorAll('.cfg-nav-btn').forEach(b=>b.classList.toggle('active', b.dataset.panel===name));
|
|
629
|
+
document.querySelectorAll('.cfg-panel').forEach(p=>p.classList.toggle('active', p.dataset.panel===name));
|
|
630
|
+
}
|
|
631
|
+
document.querySelectorAll('.cfg-nav-btn').forEach(b=>{ b.onclick=()=>showCfgPanel(b.dataset.panel); });
|
|
632
|
+
$('#cfgBtn').onclick=()=>{ $('#cfgModal').style.display='flex'; showCfgPanel(cfgPanel); };
|
|
633
|
+
$('#cfgCancel').onclick=()=>{ $('#cfgModal').style.display='none'; };
|
|
634
|
+
// —— 工作空间头部下拉(⚙ 右侧:切换 / 新建 / 管理) ——
|
|
635
|
+
async function refreshWsSel(){
|
|
636
|
+
const r=await fetch('/api/workspaces',{cache:'no-store'}).catch(()=>null); if(!r) return;
|
|
637
|
+
const j=await r.json();
|
|
638
|
+
const sel=$('#wsSel'); sel.innerHTML='';
|
|
639
|
+
const cur=document.createElement('option'); cur.value=''; cur.textContent='工作空间'+(j.current?':'+j.current:'');
|
|
640
|
+
sel.appendChild(cur);
|
|
641
|
+
for(const w of j.workspaces||[]){
|
|
642
|
+
const o=document.createElement('option'); o.value=w.name; o.textContent=(w.name===j.current?'● ':'')+w.name;
|
|
643
|
+
if(w.name===j.current) o.selected=true;
|
|
644
|
+
sel.appendChild(o);
|
|
645
|
+
}
|
|
646
|
+
const add=document.createElement('option'); add.value='__add__'; add.textContent='+ 新建工作空间…';
|
|
647
|
+
sel.appendChild(add);
|
|
648
|
+
const mgr=document.createElement('option'); mgr.value='__manage__'; mgr.textContent='管理(重命名/删除/改目录)…';
|
|
649
|
+
sel.appendChild(mgr);
|
|
650
|
+
}
|
|
651
|
+
$('#wsSel').addEventListener('change', async e=>{
|
|
652
|
+
const v=e.target.value;
|
|
653
|
+
if(v==='__add__'){
|
|
654
|
+
const name=await uiPrompt('新工作空间名称:'); if(!name){ refreshWsSel(); return; }
|
|
655
|
+
openDirPicker(null, (dir)=>{
|
|
656
|
+
fetch('/api/workspaces',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'add',name,dir:dir||undefined})})
|
|
657
|
+
.then((r)=>r.json()).then((j)=>{
|
|
658
|
+
if(j.ok) renderBanner({text:'✓ 已登记工作空间 '+j.name+' → '+j.dir+'(目录已自动创建)'}); else uiAlert(j.error||'创建失败');
|
|
659
|
+
refreshWsSel(); reloadModels();
|
|
660
|
+
});
|
|
661
|
+
});
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
if(v==='__manage__'){
|
|
665
|
+
$('#cfgModal').style.display='flex'; showCfgPanel('workspace'); refreshWorkspaces();
|
|
666
|
+
refreshWsSel();
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
if(v){
|
|
670
|
+
// 携带当前会话:显式切换时该会话的工作空间一并切过去(P3-4 会话级工作空间)
|
|
671
|
+
const payload={action:'set',name:v};
|
|
672
|
+
if(currentSession) payload.file=currentSession;
|
|
673
|
+
const r=await fetch('/api/workspaces',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
|
674
|
+
const j=await r.json().catch(()=>({error:'请求失败'}));
|
|
675
|
+
if(j.ok) renderBanner({text:'✓ 已切换到工作空间 '+j.name+'('+j.dir+'),后续工具在此目录执行'}); else { uiAlert(j.error||'切换失败'); }
|
|
676
|
+
refreshWsSel(); reloadModels();
|
|
677
|
+
}
|
|
678
|
+
});
|
|
679
|
+
$('#cfgBtn').addEventListener('click', ()=>{ refreshModelsCfg(); refreshSyncUI(); refreshSyncShares(); refreshSyncConflicts(); refreshSchList(); refreshWorkspaces(); loadMemoryUI(); refreshCache(); refreshMcpPresets(); refreshSkillLib(''); });
|
|
680
|
+
$('#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'; };
|
|
681
|
+
async function refreshSchList(){
|
|
682
|
+
const r=await fetch('/api/schedule',{cache:'no-store'}).catch(()=>null); if(!r) return;
|
|
683
|
+
const j=await r.json(); const list=$('#schList'); list.innerHTML='';
|
|
684
|
+
if(!j.jobs||!j.jobs.length){ list.innerHTML='<div style="color:var(--faint);font-size:12px;padding:6px 2px">暂无调度任务</div>'; return; }
|
|
685
|
+
for(const s of j.jobs){
|
|
686
|
+
const div=document.createElement('div'); div.style.cssText='display:flex;align-items:center;gap:6px;padding:5px 0;border-bottom:1px solid var(--border);font-size:12px';
|
|
687
|
+
const mark=s.status==='running'?'▶':s.status==='paused'?'⏸':s.status==='done'?'✓':s.status==='skipped'?'⊘':'⏳';
|
|
688
|
+
const when=s.kind==='every'?('每 '+(s.interval>=86400000?(s.interval/86400000)+'d':s.interval>=3600000?(s.interval/3600000)+'h':s.interval>=60000?(s.interval/60000)+'m':(s.interval/1000)+'s')):s.kind==='once'?(s.nextRunAt?new Date(s.nextRunAt).toLocaleString():''):('依赖 '+ (s.after||[]).join(','));
|
|
689
|
+
div.innerHTML='<span>'+mark+'</span><span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(s.question)+'</span><span style="color:var(--faint);white-space:nowrap">'+esc(when)+' · '+s.runs+'次</span>';
|
|
690
|
+
if((s.history||[]).length){ const hb=document.createElement('button'); hb.textContent='历史('+s.history.length+')'; hb.style.cssText='padding:1px 8px;font-size:11px'; const hist=document.createElement('div'); hist.style.cssText='display:none;padding:4px 0 4px 18px;font-size:11.5px;color:var(--dim)'; for(const h of s.history.slice(-8).reverse()){ hist.innerHTML+='<div>· '+new Date(h.at).toLocaleTimeString()+' '+(h.status==='done'?'✓':'✖')+' '+(h.durationMs!=null?(h.durationMs/1000).toFixed(1)+'s':'')+' '+esc((h.text||'').slice(0,60))+'</div>'; } hb.onclick=()=>{ hist.style.display=hist.style.display==='none'?'block':'none'; }; div.parentElement.insertBefore(hist, div.nextSibling); div.appendChild(hb); }
|
|
691
|
+
if(s.status!=='done'&&s.status!=='skipped'){ const pa=document.createElement('button'); pa.textContent=s.status==='paused'?'恢复':'暂停'; pa.style.cssText='padding:1px 8px;font-size:11px'; pa.onclick=()=>schAction(s.status==='paused'?'resume':'pause', s.id); div.appendChild(pa); }
|
|
692
|
+
const rm=document.createElement('button'); rm.textContent='删除'; rm.className='danger'; rm.style.cssText='padding:1px 8px;font-size:11px'; rm.onclick=()=>schAction('remove', s.id); div.appendChild(rm);
|
|
693
|
+
list.appendChild(div);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
async function schAction(action,id){ await fetch('/api/schedule',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action,id})}); refreshSchList(); }
|
|
697
|
+
$('#schAdd').onclick=async ()=>{
|
|
698
|
+
const q=$('#schQuestion').value.trim(); if(!q) return;
|
|
699
|
+
const kind=$('#schWhen').value;
|
|
700
|
+
const payload={action:'add',question:q};
|
|
701
|
+
if($('#schOffpeak').checked) payload.offpeak=true;
|
|
702
|
+
if(kind==='at'){ const v=$('#schAt').value; if(!v) return; payload.at=v.replace('T',' '); }
|
|
703
|
+
else if(kind==='every'){ payload.every=$('#schEvery').value; }
|
|
704
|
+
else if(kind==='chain'){ const v=$('#schChain').value.trim(); if(!v) return; const qs=v.split('\n').map(x=>x.trim()).filter(Boolean); if(qs.length<2){ uiAlert('链式需要至少两行任务'); return; } payload.action='chain'; payload.questions=qs; }
|
|
705
|
+
else { const v=$('#schAfter').value.trim(); if(!v) return; payload.after=[v]; }
|
|
706
|
+
const r=await fetch('/api/schedule',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
|
707
|
+
const j=await r.json().catch(()=>({error:'请求失败'}));
|
|
708
|
+
if(j.ok){ $('#schQuestion').value=''; refreshSchList(); } else { uiAlert(j.error||'添加失败'); }
|
|
709
|
+
};
|
|
710
|
+
// —— 工作空间 ——
|
|
711
|
+
async function refreshWorkspaces(){
|
|
712
|
+
const r=await fetch('/api/workspaces',{cache:'no-store'}).catch(()=>null); if(!r) return;
|
|
713
|
+
const j=await r.json(); const list=$('#wsList'); list.innerHTML='';
|
|
714
|
+
if(!j.workspaces||!j.workspaces.length){ list.innerHTML='<div style="color:var(--faint);font-size:12px;padding:6px 2px">暂无登记的工作空间</div>'; return; }
|
|
715
|
+
for(const w of j.workspaces){
|
|
716
|
+
const div=document.createElement('div'); div.style.cssText='display:flex;align-items:center;gap:6px;padding:5px 0;border-bottom:1px solid var(--border);font-size:12px';
|
|
717
|
+
div.innerHTML='<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(w.name)+(w.name===j.current?' <span style="color:var(--accent)">●当前</span>':'')+'</span><span style="flex:1.4;color:var(--faint);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;direction:rtl;text-align:left">'+esc(w.dir||'')+'</span>';
|
|
718
|
+
const ed=document.createElement('button'); ed.textContent='改目录'; ed.style.cssText='padding:1px 8px;font-size:11px'; ed.onclick=()=>{ openDirPicker(w.dir||null, async (d)=>{ if(d==null) return; const rr=await fetch('/api/workspaces',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'set',name:w.name,dir:d})}); const jj=await rr.json().catch(()=>({error:'失败'})); if(jj.ok){ refreshWorkspaces(); refreshWsSel(); reloadModels(); } else uiAlert(jj.error||'修改失败'); }); };
|
|
719
|
+
const rn=document.createElement('button'); rn.textContent='重命名'; rn.style.cssText='padding:1px 8px;font-size:11px'; rn.onclick=async()=>{ const t=await uiPrompt('新名称:', w.name); if(!t) return; const rr=await fetch('/api/workspaces',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'rename',name:w.name,newName:t})}); const jj=await rr.json().catch(()=>({error:'失败'})); if(jj.ok){ refreshWorkspaces(); refreshWsSel(); } else uiAlert(jj.error||'重命名失败'); };
|
|
720
|
+
const rm=document.createElement('button'); rm.textContent='删除'; rm.className='danger'; rm.style.cssText='padding:1px 8px;font-size:11px'; rm.onclick=async()=>{ if(!await uiConfirm('删除工作空间 '+w.name+'?')) return; await fetch('/api/workspaces',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'remove',name:w.name})}); refreshWorkspaces(); refreshWsSel(); reloadModels(); };
|
|
721
|
+
div.appendChild(ed); div.appendChild(rn); div.appendChild(rm); list.appendChild(div);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
$('#wsAdd').onclick=async ()=>{
|
|
725
|
+
const name=$('#wsName').value.trim(); if(!name) return;
|
|
726
|
+
const dir=$('#wsDir').value.trim();
|
|
727
|
+
const r=await fetch('/api/workspaces',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'add',name,dir})});
|
|
728
|
+
const j=await r.json().catch(()=>({error:'请求失败'}));
|
|
729
|
+
if(j.ok){ $('#wsName').value=''; $('#wsDir').value=''; refreshWorkspaces(); refreshWsSel(); reloadModels(); } else uiAlert(j.error||'添加失败');
|
|
730
|
+
};
|
|
731
|
+
// —— 长期记忆 ——
|
|
732
|
+
function memFlash(msg, good){ const m=$('#memMsg'); m.textContent=msg; m.style.color=good?'var(--accent)':'var(--err)'; setTimeout(()=>{m.textContent='';},4000); }
|
|
733
|
+
async function loadMemoryUI(){
|
|
734
|
+
const r=await fetch('/api/memory',{cache:'no-store'}).catch(()=>null); if(!r) return;
|
|
735
|
+
const j=await r.json(); if(j.ok) $('#memArea').value=j.content||'';
|
|
736
|
+
}
|
|
737
|
+
$('#memSave').onclick=async ()=>{
|
|
738
|
+
const r=await fetch('/api/memory',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({content:$('#memArea').value})});
|
|
739
|
+
const j=await r.json().catch(()=>({error:'请求失败'}));
|
|
740
|
+
memFlash(j.ok?'✓ 已保存':('✖ '+(j.error||'保存失败')), Boolean(j.ok));
|
|
741
|
+
};
|
|
742
|
+
$('#memDedupe').onclick=async ()=>{
|
|
743
|
+
const r=await fetch('/api/memory',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'dedupe'})});
|
|
744
|
+
const j=await r.json().catch(()=>({error:'请求失败'}));
|
|
745
|
+
if(j.ok){ memFlash('✓ 去重完成,移除 '+j.removed+' 行', true); loadMemoryUI(); } else memFlash('✖ '+(j.error||'去重失败'), false);
|
|
746
|
+
};
|
|
747
|
+
// —— 缓存命中率仪表盘 ——
|
|
748
|
+
function fmtT(n){ n=Number(n||0); return n>=1000?(n/1000).toFixed(1)+'k':String(n); }
|
|
749
|
+
async function refreshCache(){
|
|
750
|
+
const r=await fetch('/api/cache-stats',{cache:'no-store'}).catch(()=>null); if(!r) return;
|
|
751
|
+
const j=await r.json(); if(!j.ok) return;
|
|
752
|
+
const s=j.summary||{};
|
|
753
|
+
const rate=s.rate!=null?(s.rate*100).toFixed(0)+'%':'暂无缓存数据';
|
|
754
|
+
$('#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)+')';
|
|
755
|
+
const list=$('#cacheRecent'); list.innerHTML='';
|
|
756
|
+
if(!(j.recent||[]).length){ list.innerHTML='<div style="color:var(--faint);font-size:12px;padding:6px 2px">暂无记录(每次对话后自动统计)</div>'; return; }
|
|
757
|
+
for(const e of j.recent){
|
|
758
|
+
const hit=e.hit!=null&&e.miss!=null&&(e.hit+e.miss)>0?e.hit/(e.hit+e.miss):null;
|
|
759
|
+
const pct=hit==null?'—':(hit*100).toFixed(0)+'%';
|
|
760
|
+
const bar=document.createElement('div'); bar.style.cssText='height:4px;border-radius:2px;background:var(--bg3);margin:2px 0;overflow:hidden';
|
|
761
|
+
bar.innerHTML='<div style="height:100%;width:'+(hit==null?0:Math.round(hit*100))+'%;background:var(--accent)"></div>';
|
|
762
|
+
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)';
|
|
763
|
+
const left=document.createElement('div'); left.style.cssText='flex:1';
|
|
764
|
+
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>';
|
|
765
|
+
left.appendChild(bar);
|
|
766
|
+
const when=document.createElement('span'); when.style.cssText='white-space:nowrap;color:var(--faint)'; when.textContent=new Date(e.at).toLocaleTimeString();
|
|
767
|
+
div.appendChild(left); div.appendChild(when); list.appendChild(div);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
// —— MCP 生态预设 ——
|
|
771
|
+
let mcpPresetData=[];
|
|
772
|
+
async function refreshMcpPresets(){
|
|
773
|
+
const r=await fetch('/api/mcp-presets',{cache:'no-store'}).catch(()=>null); if(!r) return;
|
|
774
|
+
const j=await r.json(); if(!j.ok) return;
|
|
775
|
+
mcpPresetData=j.presets||[]; const sel=$('#mcpPresetSel'); sel.innerHTML='';
|
|
776
|
+
for(const p of mcpPresetData){ const o=document.createElement('option'); o.value=p.name; o.textContent=p.name+' — '+p.label; o.title=p.command+' '+p.args.join(' '); sel.appendChild(o); }
|
|
777
|
+
sel.onchange=()=>{ const p=mcpPresetData.find(x=>x.name===sel.value); $('#mcpArg').placeholder=p&&p.argLabel?('参数:'+p.argLabel):'本预设无需参数'; };
|
|
778
|
+
sel.onchange();
|
|
779
|
+
}
|
|
780
|
+
$('#mcpAdd').onclick=async ()=>{
|
|
781
|
+
const name=$('#mcpPresetSel').value; if(!name) return;
|
|
782
|
+
const arg=$('#mcpArg').value.trim();
|
|
783
|
+
const r=await fetch('/api/mcp-presets',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,arg})});
|
|
784
|
+
const j=await r.json().catch(()=>({error:'请求失败'}));
|
|
785
|
+
if(j.ok) uiAlert('✓ 已接入 '+name+'(重启 mingdao web 后生效,/mcp 查看状态)'); else uiAlert(j.error||'接入失败');
|
|
786
|
+
};
|
|
787
|
+
// —— 技能库 ——
|
|
788
|
+
let skillSearchTimer=null;
|
|
789
|
+
$('#skillSearch').addEventListener('input',e=>{ clearTimeout(skillSearchTimer); skillSearchTimer=setTimeout(()=>refreshSkillLib(e.target.value.trim()),300); });
|
|
790
|
+
async function refreshSkillLib(q, force){
|
|
791
|
+
let u=q?'/api/skill-library?q='+encodeURIComponent(q):'/api/skill-library';
|
|
792
|
+
if(force) u+=(q?'&':'?')+'refresh=1';
|
|
793
|
+
const r=await fetch(u,{cache:'no-store'}).catch(()=>null); if(!r) return;
|
|
794
|
+
const j=await r.json(); const list=$('#skillLibList'); list.innerHTML='';
|
|
795
|
+
if(j.registry&&j.registry.error){
|
|
796
|
+
const err=document.createElement('div'); err.style.cssText='color:var(--warn);font-size:11.5px;padding:4px 2px';
|
|
797
|
+
err.textContent='✗ 线上 registry 不可达(仅显示内置库):'+j.registry.error; list.appendChild(err);
|
|
798
|
+
}else if(j.registry){
|
|
799
|
+
const meta=document.createElement('div'); meta.style.cssText='color:var(--faint);font-size:11.5px;padding:4px 2px';
|
|
800
|
+
meta.textContent='线上索引 '+new Date(j.registry.updatedAt).toLocaleString()+(j.registry.stale?'(缓存,已过期)':'');
|
|
801
|
+
list.appendChild(meta);
|
|
802
|
+
}
|
|
803
|
+
if(!j.library||!j.library.length){ list.innerHTML='<div style="color:var(--faint);font-size:12px;padding:6px 2px">没有匹配的技能</div>'; return; }
|
|
804
|
+
for(const s of j.library){
|
|
805
|
+
const div=document.createElement('div'); div.style.cssText='display:flex;align-items:center;gap:6px;padding:5px 0;border-bottom:1px solid var(--border);font-size:12px';
|
|
806
|
+
const badge=s.source==='registry'?'<span style="color:var(--accent2);font-size:10px">线上</span>':'<span style="color:var(--faint);font-size:10px">内置库</span>';
|
|
807
|
+
div.innerHTML='<span style="flex:1;min-width:0"><b>'+esc(s.name)+'</b> '+badge+' <span style="color:var(--faint)">'+esc(s.description||'')+'</span></span>';
|
|
808
|
+
const b=document.createElement('button');
|
|
809
|
+
if(s.installed){ b.textContent='卸载'; b.className='danger'; } else { b.textContent='安装'; }
|
|
810
|
+
b.style.cssText='padding:1px 8px;font-size:11px';
|
|
811
|
+
b.onclick=async()=>{
|
|
812
|
+
const body=s.installed?{action:'uninstall',name:s.name}:{action:'install',name:s.name};
|
|
813
|
+
const rr=await fetch('/api/skills',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
|
814
|
+
const jj=await rr.json().catch(()=>({error:'请求失败'}));
|
|
815
|
+
if(jj.ok) refreshSkillLib($('#skillSearch').value.trim()); else uiAlert(jj.error||'操作失败');
|
|
816
|
+
};
|
|
817
|
+
div.appendChild(b); list.appendChild(div);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
$('#skillRegRefresh').onclick=()=>{ refreshSkillLib($('#skillSearch').value.trim(), true); };
|
|
821
|
+
// —— 模型与 API Key ——
|
|
822
|
+
async function reloadModels(){
|
|
823
|
+
try{
|
|
824
|
+
const r=await fetch('/api/state',{cache:'no-store'}); const j=await r.json();
|
|
825
|
+
const ms=$('#modelSel'); ms.innerHTML='';
|
|
826
|
+
const groups={}; const order=[];
|
|
827
|
+
for(const m of (j.models||[])){ const g=m.providerLabel||'其他'; if(!groups[g]){groups[g]=[];order.push(g);} groups[g].push(m); }
|
|
828
|
+
for(const g of order){
|
|
829
|
+
const og=document.createElement('optgroup'); og.label=g;
|
|
830
|
+
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); }
|
|
831
|
+
ms.appendChild(og);
|
|
832
|
+
}
|
|
833
|
+
if(!(j.models||[]).length){ const o=document.createElement('option'); o.value=j.model; o.textContent=j.model; ms.appendChild(o); }
|
|
834
|
+
const env=('路由'+(j.routing?'开':'关')+' · 沙箱'+((j.sandbox&&j.sandbox!=='off')?(j.sandboxSupported?j.sandbox:'降级'):'off'));
|
|
835
|
+
$('#envBadge').textContent=env; $('#envBadge').style.display='';
|
|
836
|
+
}catch(e){}
|
|
837
|
+
}
|
|
838
|
+
async function refreshModelsCfg(){
|
|
839
|
+
const r=await fetch('/api/models-config',{cache:'no-store'}).catch(()=>null); if(!r) return;
|
|
840
|
+
const j=await r.json(); if(!j.ok) return;
|
|
841
|
+
const pk=$('#pkList'); pk.innerHTML='';
|
|
842
|
+
for(const p of j.providers){
|
|
843
|
+
const div=document.createElement('div'); div.style.cssText='display:flex;align-items:center;gap:6px;padding:5px 0;border-bottom:1px solid var(--border);font-size:12px';
|
|
844
|
+
const state=p.keyState==='stored'?'<span style="color:var(--accent)">已存 '+esc(p.keyMasked||'')+'</span>':p.keyState==='env'?'<span style="color:var(--dim)">环境变量 '+esc(p.envKey||'')+'</span>':'<span style="color:var(--err)">未设置</span>';
|
|
845
|
+
div.innerHTML='<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"><b>'+esc(p.name)+'</b> '+esc(p.label)+'</span><span style="white-space:nowrap">'+state+'</span>';
|
|
846
|
+
const set=document.createElement('button'); set.textContent='设Key'; set.style.cssText='padding:1px 8px;font-size:11px';
|
|
847
|
+
set.onclick=async()=>{ const k=await uiPrompt('API Key('+p.name+'):', null, {hidden:true}); if(k===null) return; const rr=await fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'setProviderKey',provider:p.name,key:k})}); const jj=await rr.json().catch(()=>({error:'请求失败'})); if(jj.ok){ refreshModelsCfg(); reloadModels(); if(jj.modelsNote) uiAlert(jj.modelsNote); } else uiAlert(jj.error||'设置失败'); };
|
|
848
|
+
if(p.keyState==='stored'&&p.name!=='custom'){ const rf=document.createElement('button'); rf.textContent='刷新模型'; rf.style.cssText='padding:1px 8px;font-size:11px'; rf.onclick=async()=>{ const rr=await fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'refreshModels',provider:p.name})}); const jj=await rr.json().catch(()=>({error:'请求失败'})); if(jj.ok){ reloadModels(); uiAlert('✓ 已拉取 '+jj.models.length+' 个线上模型'+(jj.fromCache?'(缓存)':'')); } else uiAlert(jj.error||'刷新失败'); }; div.appendChild(rf); }
|
|
849
|
+
if(p.keyState==='stored'){ const rm=document.createElement('button'); rm.textContent='删除'; rm.className='danger'; rm.style.cssText='padding:1px 8px;font-size:11px'; rm.onclick=async()=>{ if(!await uiConfirm('删除 '+p.name+' 的 Key?')) return; await fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'removeProviderKey',provider:p.name})}); refreshModelsCfg(); reloadModels(); }; div.appendChild(rm); }
|
|
850
|
+
div.appendChild(set); pk.appendChild(div);
|
|
851
|
+
}
|
|
852
|
+
const cl=$('#cmList'); cl.innerHTML='';
|
|
853
|
+
if(!(j.customModels||[]).length) cl.innerHTML='<div style="color:var(--faint);font-size:12px;padding:6px 2px">暂无自定义模型</div>';
|
|
854
|
+
for(const c of j.customModels){
|
|
855
|
+
const div=document.createElement('div'); div.style.cssText='display:flex;align-items:center;gap:6px;padding:5px 0;border-bottom:1px solid var(--border);font-size:12px';
|
|
856
|
+
const kstate=c.keyState==='stored'?'<span style="color:var(--accent)">'+esc(c.keyMasked||'')+'</span>':'<span style="color:var(--err)">无Key</span>';
|
|
857
|
+
div.innerHTML='<span style="flex:1;min-width:0"><b>'+esc(c.name)+'</b> <span style="color:var(--faint)">'+esc(c.label)+' · '+esc(c.baseUrl)+'</span></span><span style="white-space:nowrap">'+kstate+'</span>';
|
|
858
|
+
const sk=document.createElement('button'); sk.textContent='设Key'; sk.style.cssText='padding:1px 8px;font-size:11px'; sk.onclick=async()=>{ const k=await uiPrompt('API Key('+c.name+'):', null, {hidden:true}); if(k===null) return; fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'setCustomKey',name:c.name,key:k})}).then(rr=>rr.json()).then(jj=>{ if(jj.ok) refreshModelsCfg(); else uiAlert(jj.error||'设置失败'); }); };
|
|
859
|
+
const ed=document.createElement('button'); ed.textContent='修改'; ed.style.cssText='padding:1px 8px;font-size:11px'; ed.onclick=async()=>{ const u=await uiPrompt('API 地址:', c.baseUrl); if(u===null) return; const l=await uiPrompt('标签:', c.label); if(l===null) return; fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'updateCustom',name:c.name,baseUrl:u,label:l})}).then(rr=>rr.json()).then(jj=>{ if(jj.ok){ refreshModelsCfg(); reloadModels(); } else uiAlert(jj.error||'修改失败'); }); };
|
|
860
|
+
const rm=document.createElement('button'); rm.textContent='删除'; rm.className='danger'; rm.style.cssText='padding:1px 8px;font-size:11px'; rm.onclick=async()=>{ if(!await uiConfirm('删除自定义模型 '+c.name+'?')) return; const rr=await fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'removeCustom',name:c.name})}); const jj=await rr.json().catch(()=>({error:'失败'})); if(jj.ok){ refreshModelsCfg(); reloadModels(); } else uiAlert(jj.error||'删除失败'); };
|
|
861
|
+
div.appendChild(sk); div.appendChild(ed); div.appendChild(rm); cl.appendChild(div);
|
|
862
|
+
}
|
|
863
|
+
$('#baseUrlOverride').value=j.baseUrlOverride||'';
|
|
864
|
+
}
|
|
865
|
+
$('#cmAdd').onclick=async ()=>{
|
|
866
|
+
const name=$('#cmName').value.trim();
|
|
867
|
+
const url=$('#cmUrl').value.trim();
|
|
868
|
+
if(!name||!url){ uiAlert('模型名与 API 地址必填'); return; }
|
|
869
|
+
const r=await fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'addCustom',name,label:$('#cmLabel').value.trim(),baseUrl:url,key:$('#cmKey').value})});
|
|
870
|
+
const j=await r.json().catch(()=>({error:'请求失败'}));
|
|
871
|
+
if(j.ok){ $('#cmName').value=''; $('#cmLabel').value=''; $('#cmUrl').value=''; $('#cmKey').value=''; refreshModelsCfg(); reloadModels(); } else uiAlert(j.error||'添加失败');
|
|
872
|
+
};
|
|
873
|
+
$('#baseUrlSave').onclick=async ()=>{
|
|
874
|
+
const r=await fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'setBaseUrl',baseUrl:$('#baseUrlOverride').value.trim()})});
|
|
875
|
+
const j=await r.json().catch(()=>({error:'请求失败'}));
|
|
876
|
+
if(j.ok) uiAlert('✓ API 地址已保存(立即生效)'); else uiAlert(j.error||'保存失败');
|
|
877
|
+
};
|
|
878
|
+
// —— 云同步 ——
|
|
879
|
+
async function refreshSyncUI(){
|
|
880
|
+
const r=await fetch('/api/sync',{cache:'no-store'}).catch(()=>null); if(!r) return;
|
|
881
|
+
const j=await r.json();
|
|
882
|
+
$('#syncUrl').value=j.url||'https://session.mingdao.ai/'; $('#syncUser').value=j.username||''; $('#syncDevice').value=j.deviceName||''; $('#syncAutoChk').checked=Boolean(j.auto);
|
|
883
|
+
const st=$('#syncStatus');
|
|
884
|
+
if(!j.configured) st.innerHTML='<span style="color:var(--faint)">未配置:填服务器地址并登录(服务器端运行 mingdao sync-server)</span>';
|
|
885
|
+
else if(!j.loggedIn) st.innerHTML='<span style="color:var(--warn)">已配置 '+esc(j.url)+' · 未登录(输入密码登录)</span>';
|
|
886
|
+
else {
|
|
887
|
+
let line='<span style="color:var(--accent)">✓ 已登录 '+esc(j.username)+'('+esc(j.deviceName)+')→ '+esc(j.url)+'</span>';
|
|
888
|
+
if(j.remote&&j.remote.sessions) line+=' · 远端 '+j.remote.sessions.length+' 个会话';
|
|
889
|
+
if(j.remote&&j.remote.error) line+=' · <span style="color:var(--err)">'+esc(j.remote.error)+'</span>';
|
|
890
|
+
st.innerHTML=line;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
$('#syncLogin').onclick=async ()=>{
|
|
894
|
+
const url=$('#syncUrl').value.trim(); const username=$('#syncUser').value.trim(); const password=$('#syncPass').value;
|
|
895
|
+
if(!url||!username||!password){ uiAlert('服务器地址、用户名、密码均必填'); return; }
|
|
896
|
+
const r=await fetch('/api/sync',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'login',url,username,password,deviceName:$('#syncDevice').value.trim()})});
|
|
897
|
+
const j=await r.json().catch(()=>({error:'请求失败'}));
|
|
898
|
+
if(j.ok){ $('#syncPass').value=''; refreshSyncUI(); } else uiAlert(j.error||'登录失败');
|
|
899
|
+
};
|
|
900
|
+
$('#syncPush').onclick=async ()=>{
|
|
901
|
+
const r=await fetch('/api/sync',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'push'})});
|
|
902
|
+
const j=await r.json().catch(()=>({error:'请求失败'}));
|
|
903
|
+
uiAlert(j.ok?('✓ 已推送 '+j.pushed+' 个会话'+(j.conflicts?','+j.conflicts+' 个远端版本已备份 .server-*':'')):j.error);
|
|
904
|
+
refreshSyncUI(); refreshSyncConflicts();
|
|
905
|
+
};
|
|
906
|
+
$('#syncLogout').onclick=async ()=>{ await fetch('/api/sync',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'logout'})}); refreshSyncUI(); };
|
|
907
|
+
$('#syncAutoChk').onchange=e=>{ applyConfig({syncAuto:e.target.checked}); };
|
|
908
|
+
// —— 分享与协作 / 冲突 ——
|
|
909
|
+
async function refreshSyncShares(){
|
|
910
|
+
const r=await fetch('/api/sync',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'shares'})}).catch(()=>null); if(!r) return;
|
|
911
|
+
const j=await r.json();
|
|
912
|
+
const ml=$('#shareList'); ml.innerHTML='';
|
|
913
|
+
if(j.ok&&j.mine&&j.mine.length){
|
|
914
|
+
for(const s of j.mine){
|
|
915
|
+
const div=document.createElement('div'); div.style.cssText='display:flex;align-items:center;gap:6px;padding:4px 0;border-bottom:1px solid var(--border);font-size:12px';
|
|
916
|
+
div.innerHTML='<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"><b>'+esc(s.shareId)+'</b> <span style="color:var(--faint)">'+esc(s.name)+' · 被接受 '+s.pulls+' 次</span></span>';
|
|
917
|
+
const b=document.createElement('button'); b.textContent='撤销'; b.className='danger'; b.style.cssText='padding:1px 8px;font-size:11px';
|
|
918
|
+
b.onclick=async()=>{ const rr=await fetch('/api/sync',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'unshare',shareId:s.shareId})}); const jj=await rr.json().catch(()=>({error:'失败'})); if(jj.ok) refreshSyncShares(); else uiAlert(jj.error||'撤销失败'); };
|
|
919
|
+
div.appendChild(b); ml.appendChild(div);
|
|
920
|
+
}
|
|
921
|
+
} else { ml.innerHTML='<div style="color:var(--faint);font-size:12px;padding:4px 2px">暂无分享(分享后此处显示分享码)</div>'; }
|
|
922
|
+
const al=$('#shareAccepted'); al.innerHTML='';
|
|
923
|
+
if(j.ok&&j.accepted&&j.accepted.length){
|
|
924
|
+
for(const s of j.accepted){
|
|
925
|
+
const div=document.createElement('div'); div.style.cssText='padding:4px 0;border-bottom:1px solid var(--border);font-size:12px;color:var(--dim)';
|
|
926
|
+
div.textContent='· '+s.owner+' 的 '+s.name+' → 本地 '+s.savedAs;
|
|
927
|
+
al.appendChild(div);
|
|
928
|
+
}
|
|
929
|
+
} else { al.innerHTML='<div style="color:var(--faint);font-size:12px;padding:4px 2px">未接受任何分享</div>'; }
|
|
930
|
+
}
|
|
931
|
+
$('#shareCreate').onclick=async ()=>{
|
|
932
|
+
const name=$('#shareName').value.trim(); if(!name){ uiAlert('输入会话文件名'); return; }
|
|
933
|
+
const r=await fetch('/api/sync',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'share',name})});
|
|
934
|
+
const j=await r.json().catch(()=>({error:'请求失败'}));
|
|
935
|
+
if(j.ok){ $('#shareName').value=''; refreshSyncShares(); } else uiAlert(j.error||'创建失败');
|
|
936
|
+
};
|
|
937
|
+
$('#shareAccept').onclick=async ()=>{
|
|
938
|
+
const code=$('#acceptCode').value.trim(); if(!code){ uiAlert('输入分享码'); return; }
|
|
939
|
+
const r=await fetch('/api/sync',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'accept',shareId:code})});
|
|
940
|
+
const j=await r.json().catch(()=>({error:'请求失败'}));
|
|
941
|
+
if(j.ok){ $('#acceptCode').value=''; refreshSyncShares(); refreshSessions(); } else uiAlert(j.error||'接受失败');
|
|
942
|
+
};
|
|
943
|
+
$('#passwdBtn').onclick=async ()=>{
|
|
944
|
+
const oldPass=$('#oldPass').value, newPass=$('#newPass').value;
|
|
945
|
+
if(!oldPass||!newPass){ uiAlert('旧密码与新密码均必填'); return; }
|
|
946
|
+
const r=await fetch('/api/sync',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'passwd',oldPassword:oldPass,newPassword:newPass})});
|
|
947
|
+
const j=await r.json().catch(()=>({error:'请求失败'}));
|
|
948
|
+
if(j.ok){ $('#oldPass').value=''; $('#newPass').value=''; uiAlert('✓ 密码已修改'); } else uiAlert(j.error||'修改失败');
|
|
949
|
+
};
|
|
950
|
+
async function refreshSyncConflicts(){
|
|
951
|
+
const r=await fetch('/api/sync-conflicts',{cache:'no-store'}).catch(()=>null); if(!r) return;
|
|
952
|
+
const j=await r.json(); const list=$('#conflictList'); list.innerHTML='';
|
|
953
|
+
if(!j.conflicts||!j.conflicts.length){ list.innerHTML='<div style="color:var(--faint);font-size:12px;padding:4px 2px">暂无冲突</div>'; return; }
|
|
954
|
+
for(const c of j.conflicts){
|
|
955
|
+
const div=document.createElement('div'); div.style.cssText='display:flex;align-items:center;gap:6px;padding:5px 0;border-bottom:1px solid var(--border);font-size:12px';
|
|
956
|
+
div.innerHTML='<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(c.base)+' <span style="color:var(--faint)">'+(c.localExists?'本地有':'本地无')+' · 备份 '+c.entries.length+'</span></span>';
|
|
957
|
+
const mk=(label,choice,cls)=>{ const b=document.createElement('button'); b.textContent=label; if(cls) b.className=cls; b.style.cssText='padding:1px 8px;font-size:11px'; b.onclick=async()=>{ const rr=await fetch('/api/sync',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'resolveConflict',base:c.base,choice})}); const jj=await rr.json().catch(()=>({error:'失败'})); if(jj.ok) refreshSyncConflicts(); else uiAlert(jj.error||'解决失败'); }; return b; };
|
|
958
|
+
div.appendChild(mk('保留本地','local')); div.appendChild(mk('采用远端','remote','danger')); div.appendChild(mk('都保留','both'));
|
|
959
|
+
list.appendChild(div);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
$('#cfgSave').onclick=()=>{ applyConfig({sandbox:$('#sbxSel').value, routing:$('#routeChk').checked, contextBudget:Number($('#budgetInput').value), autostart:$('#autoStartChk').checked, notify:$('#notifyChk').checked}); $('#cfgModal').style.display='none'; };
|
|
963
|
+
async function applyConfig(payload, revertTarget){
|
|
964
|
+
const r=await fetch('/api/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
|
965
|
+
const j=await r.json().catch(()=>({error:'请求失败'}));
|
|
966
|
+
const m=$('#cfgMsg');
|
|
967
|
+
if(j.ok){
|
|
968
|
+
if(payload.model!==undefined) m.textContent='✓ 已切换 '+j.model;
|
|
969
|
+
else if(payload.permission!==undefined) m.textContent='✓ 权限 '+j.permission;
|
|
970
|
+
else m.textContent='✓ 设置已保存';
|
|
971
|
+
m.style.color='var(--accent)';
|
|
972
|
+
reloadModels(); // 路由/沙箱等状态徽章实时刷新
|
|
973
|
+
}else{
|
|
974
|
+
m.textContent='✖ '+j.error; m.style.color='var(--err)';
|
|
975
|
+
if(revertTarget){
|
|
976
|
+
const st=await fetch('/api/state',{cache:'no-store'});
|
|
977
|
+
const sj=await st.json();
|
|
978
|
+
revertTarget.value=revertTarget.id==='modelSel'?sj.model:sj.permission;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
m.style.display=''; setTimeout(()=>{m.style.display='none';},4000);
|
|
982
|
+
}
|
|
983
|
+
$('#modelSel').addEventListener('change', e=>{ applyConfig({model:e.target.value}, e.target); });
|
|
984
|
+
$('#permSel').addEventListener('change', e=>{ applyConfig({permission:e.target.value}, e.target); });
|
|
985
|
+
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(); }
|
|
986
|
+
|
|
987
|
+
sendBtn.onclick=()=>{ if(generating){ abort(); } else { send(); } };
|
|
988
|
+
input.addEventListener('keydown',e=>{ if(e.key==='Enter'&&!e.shiftKey){ e.preventDefault(); send(); } });
|
|
989
|
+
input.addEventListener('input',()=>{ input.style.height='44px'; input.style.height=Math.min(input.scrollHeight,200)+'px'; });
|
|
990
|
+
$('#newChat').onclick=()=>{ currentSession=null; chatEl.innerHTML=''; refreshSessions(); };
|
|
991
|
+
$('#sessMgrBtn').onclick=openSessModal;
|
|
992
|
+
$('#sessClose').onclick=()=>{ $('#sessModal').style.display='none'; };
|
|
993
|
+
async function openSessModal(){
|
|
994
|
+
$('#sessModal').style.display='flex';
|
|
995
|
+
const r=await fetch('/api/sessions',{cache:'no-store'}); const j=await r.json();
|
|
996
|
+
const list=$('#sessList'); list.innerHTML='';
|
|
997
|
+
if(!j.sessions.length){ list.innerHTML='<div style="color:var(--faint);font-size:12px;padding:6px">暂无会话</div>'; return; }
|
|
998
|
+
for(const s of j.sessions){
|
|
999
|
+
const div=document.createElement('div'); div.style.cssText='display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--border);font-size:12.5px';
|
|
1000
|
+
div.innerHTML='<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(s.label)+'</span>';
|
|
1001
|
+
const rn=document.createElement('button'); rn.textContent='重命名'; rn.style.cssText='padding:1px 8px;font-size:11px'; rn.onclick=async()=>{ const t=await uiPrompt('新标题:', s.file.replace(/\.jsonl$/,'')); if(!t) return; const rr=await fetch('/api/session',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'rename',file:s.file,title:t})}); const jj=await rr.json().catch(()=>({error:'失败'})); if(jj.ok){ refreshSessions(); openSessModal(); } else uiAlert(jj.error||'重命名失败'); };
|
|
1002
|
+
const dl=document.createElement('button'); dl.textContent='删除'; dl.className='danger'; dl.style.cssText='padding:1px 8px;font-size:11px'; dl.onclick=async()=>{ if(!await uiConfirm('删除会话 '+s.file+'?此操作不可恢复。')) return; const rr=await fetch('/api/session',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'delete',file:s.file})}); const jj=await rr.json().catch(()=>({error:'失败'})); if(jj.ok){ if(currentSession===s.file) currentSession=null; refreshSessions(); openSessModal(); } else uiAlert(jj.error||'删除失败'); };
|
|
1003
|
+
div.appendChild(rn); div.appendChild(dl); list.appendChild(div);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
$('#sessions').onchange=e=>{ if(e.target.value){ loadSession(e.target.value); } else { currentSession=null; chatEl.innerHTML=''; } };
|
|
1007
|
+
if('serviceWorker' in navigator){ navigator.serviceWorker.register('/sw.js').catch(()=>{}); }
|
|
1008
|
+
init();
|