mingdao-harness 0.6.19 → 0.6.21
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/DEVELOPER.md +12 -0
- package/package.json +1 -1
- package/src/web/app.js +116 -799
- package/src/web/index.html +293 -1099
package/src/web/app.js
CHANGED
|
@@ -1,16 +1,5 @@
|
|
|
1
1
|
import { $, esc, highlight, renderMarkdown, scrollBottom, expandableBody, resultText, truncText, textBody, fmtDur, fmtTok, fmtT } from './util.js';
|
|
2
2
|
import { MAX_ATTACHMENTS, MAX_IMAGE_BYTES, MAX_TEXT_BYTES } from './constants.js';
|
|
3
|
-
import { LANG_STORAGE_KEY, applyI18n, detectLang, getLang, initI18n, setLang, storedLang, t, translate } from './i18n.js';
|
|
4
|
-
|
|
5
|
-
// P2-4(v0.6.19 §3.76):界面文案默认跟随 navigator.language,localStorage(mingdao-lang)可覆盖。
|
|
6
|
-
// initI18n 只改 <html lang> 与 data-i18n* 节点;动态区域在各自渲染函数里用 t()。
|
|
7
|
-
function safeStorage(){
|
|
8
|
-
try { return window.localStorage; } catch { return null; }
|
|
9
|
-
}
|
|
10
|
-
function navLang(){
|
|
11
|
-
try { return navigator.language || navigator.userLanguage || ''; } catch { return ''; }
|
|
12
|
-
}
|
|
13
|
-
const INITIAL_LANG = initI18n({ storage: safeStorage(), root: document.documentElement, navLang: navLang() });
|
|
14
3
|
|
|
15
4
|
// 访问令牌(P1-3):地址带 ?token= 时记入 sessionStorage 并从地址栏移除(防截图/历史外泄),
|
|
16
5
|
// 之后所有同源请求统一附加 X-MingDao-Token 头;无令牌时行为与旧版一致。
|
|
@@ -240,13 +229,7 @@ let currentSession = null, thinking = null;
|
|
|
240
229
|
|
|
241
230
|
|
|
242
231
|
|
|
243
|
-
function newAiMsg(){
|
|
244
|
-
const el=document.createElement('div'); el.className='msg-ai turn';
|
|
245
|
-
const head=document.createElement('div'); head.className='turn-head';
|
|
246
|
-
head.innerHTML='<span class="ai-dot" aria-hidden="true">M</span><b>助手</b><span class="chip mono">'+esc(runState.turnModel||runState.modelName||'')+'</span>';
|
|
247
|
-
el.appendChild(head);
|
|
248
|
-
chatEl.appendChild(el); scrollBottom(); return el;
|
|
249
|
-
}
|
|
232
|
+
function newAiMsg(){ const el=document.createElement('div'); el.className='msg-ai'; chatEl.appendChild(el); scrollBottom(); return el; }
|
|
250
233
|
function aiContent(msg){ const c=msg.querySelector('.content'); return c || (()=>{const d=document.createElement('div');d.className='content';msg.appendChild(d);return d;})(); }
|
|
251
234
|
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'); }
|
|
252
235
|
|
|
@@ -339,11 +322,11 @@ function askCardHtml(ev){
|
|
|
339
322
|
const q=String((ev&&ev.question)||'');
|
|
340
323
|
const opts=Array.isArray(ev&&ev.options)&&ev.options.length?ev.options:null;
|
|
341
324
|
const acts=opts
|
|
342
|
-
? opts.map((/** @type {any} */ o)=>'<button
|
|
343
|
-
: '<button class="
|
|
344
|
-
return '<div class="perm-head
|
|
345
|
-
+'<div class="perm-q
|
|
346
|
-
+'<div class="perm-acts">'+acts+'<button class="
|
|
325
|
+
? opts.map((/** @type {any} */ o)=>'<button data-a="'+esc(o.value)+'">'+esc(o.label||o.value)+'</button>').join('')+'<button class="danger" data-a="deny">拒绝</button>'
|
|
326
|
+
: '<button class="primary" data-a="allow">本次放行</button><button class="danger" data-a="deny">拒绝</button><button class="perm-always" data-a="always">始终允许本会话</button>';
|
|
327
|
+
return '<div class="perm-head">🔐 需要你确认<span class="perm-scope">本会话</span></div>'
|
|
328
|
+
+'<div class="perm-q">'+esc(q)+'</div>'
|
|
329
|
+
+'<div class="perm-acts">'+acts+'<button class="perm-stop" data-stop="1" title="停止本轮(与输入区的「■ 停止」同一条出口)">■ 停止本轮</button></div>'
|
|
347
330
|
+'<div class="perm-note">「始终允许本会话」只对<b>本会话</b>内相同确认(同一工具与参数)自动放行,不写入 config.json;刷新页面或新会话后失效。键盘:y = 本次放行,n = 拒绝,Esc = 拒绝。</div>'
|
|
348
331
|
+'<div class="perm-count" data-count>等待 02:00 后按「拒绝」处理</div>';
|
|
349
332
|
}
|
|
@@ -373,7 +356,6 @@ function mountNextAsk(){
|
|
|
373
356
|
while(askQueue.length){ const e=askQueue.shift(); if(!e.settled){ activeAsk=e; break; } }
|
|
374
357
|
const entry=activeAsk; if(!entry) return;
|
|
375
358
|
const card=document.createElement('div'); card.className='perm-card';
|
|
376
|
-
card.classList.add('permcard');
|
|
377
359
|
card.setAttribute('role','group'); card.setAttribute('aria-label','权限确认(不遮挡输入区)');
|
|
378
360
|
card.innerHTML=askCardHtml(entry.ev);
|
|
379
361
|
entry.card=card;
|
|
@@ -484,7 +466,7 @@ async function send(){
|
|
|
484
466
|
stopTurn(curTaskId).catch(()=>{});
|
|
485
467
|
},120000); };
|
|
486
468
|
arm();
|
|
487
|
-
input.value=''; input.style.height='44px';
|
|
469
|
+
input.value=''; input.style.height='44px';
|
|
488
470
|
const payload={message:text,file:currentSession};
|
|
489
471
|
// v0.4.0 Agent Preset:选择器选中的预设随本轮发送(服务端按会话应用一次)
|
|
490
472
|
const presetVal=$('#presetSel')?.value;
|
|
@@ -764,14 +746,6 @@ function renderWorkStatus(){
|
|
|
764
746
|
set('#rsCtx', view.ctx);
|
|
765
747
|
set('#rsTools', view.tools);
|
|
766
748
|
set('#rsCost', view.cost);
|
|
767
|
-
const ctxBar=$('#rsCtxBar');
|
|
768
|
-
if(ctxBar){
|
|
769
|
-
const usedCtx=Math.max(0, Number(runState.contextUsed)||0);
|
|
770
|
-
const totalCtx=Number(runState.contextTotal)||0;
|
|
771
|
-
ctxBar.style.width=(totalCtx?Math.min(100, Math.round((usedCtx/totalCtx)*100)):0)+'%';
|
|
772
|
-
}
|
|
773
|
-
const rsDot=$('#rsDot');
|
|
774
|
-
if(rsDot) rsDot.className='pdot rs-dot '+(view.phase==='已中止'?'stop':(generating?'acting':'idle'));
|
|
775
749
|
el.classList.toggle('rs-busy', generating && view.phase!=='已中止');
|
|
776
750
|
el.classList.toggle('rs-acting', view.phase==='执行工具');
|
|
777
751
|
el.classList.toggle('rs-aborted', view.phase==='已中止');
|
|
@@ -787,7 +761,7 @@ function renderWorkStatus(){
|
|
|
787
761
|
const running=bgTasks.filter((/** @type {any} */ t)=>t.status==='running');
|
|
788
762
|
const latest=running.length?running[running.length-1]:null;
|
|
789
763
|
bg.style.display=''; bg.textContent='🛠 '+bgRunning+' 个后台任务运行中'+(latest?' · '+String(latest.message||'').slice(0,22):'')+' — 点击查看详情';
|
|
790
|
-
bg.onclick=()=>{
|
|
764
|
+
bg.onclick=()=>{ activateRailTab('tasks'); }; // §3.82:后台任务 chip 打开右栏「任务」标签
|
|
791
765
|
} else { bg.style.display='none'; bg.onclick=null; }
|
|
792
766
|
}
|
|
793
767
|
// P1-4:右栏「本轮运行」与状态条**同源**——renderRunPanel() 内部只走 runPanelView()→runStripView(),
|
|
@@ -1180,369 +1154,6 @@ function layoutClassesFor(width) {
|
|
|
1180
1154
|
return { narrow: w < 900, narrowRun: w < 1180 };
|
|
1181
1155
|
}
|
|
1182
1156
|
|
|
1183
|
-
// ============================================================================
|
|
1184
|
-
// P2 纯函数(v0.6.19 §3.76)
|
|
1185
|
-
// 设置三列(默认 / 当前 / 影响)、六类危险操作的二次确认闸门、对话框无障碍
|
|
1186
|
-
// (role/aria-modal/焦点陷阱/Esc/焦点归还)、aria-live 状态节流、对比度自检。
|
|
1187
|
-
// test/ui-p2.js 直接 eval 这一整段做行为断言;DOM 接线在下面的 P2 DOM 段。
|
|
1188
|
-
// 注意:本段**不得**在顶层调用 t()/translate()/document(slice 后要能独立编译)。
|
|
1189
|
-
// ============================================================================
|
|
1190
|
-
|
|
1191
|
-
// —— 设置项三列:key → 一句「影响」(前端映射表;英文文案在 i18n.js 的 impact.*) ——
|
|
1192
|
-
const SETTING_KEYS = ['permission', 'sandbox', 'routing', 'contextBudget', 'timeout', 'autostart', 'notify', 'theme', 'language'];
|
|
1193
|
-
const SETTING_IMPACT = {
|
|
1194
|
-
permission: '切到 auto 会跳过逐次确认,工具直接执行;切到 readonly 会拒绝所有写操作。',
|
|
1195
|
-
sandbox: '关掉沙箱后 bash 直接在本机执行;readonly/safe 会限制只读与断网,但需要 Linux + bubblewrap。',
|
|
1196
|
-
routing: '开启后规划类任务走 pro、执行类走 flash;关闭则始终用当前模型,成本与延迟更可预测。',
|
|
1197
|
-
contextBudget: '改小 contextBudget 会减少可容纳历史,长任务更易触发压缩;改大受模型窗口与实际引擎上限约束。',
|
|
1198
|
-
timeout: '首 token / 流式空闲 / 总量调小会更早判定超时;留空表示按本地/云端模型自适应。',
|
|
1199
|
-
autostart: '开启后登录系统即自动启动本地服务;关闭后需要手动运行 mingdao web。',
|
|
1200
|
-
notify: '关闭后后台任务完成或失败不再发桌面通知,只能回到界面查看。',
|
|
1201
|
-
theme: '只改变界面明暗,不影响模型与成本;选「跟随系统」时随系统偏好切换。',
|
|
1202
|
-
language: '只改变界面文案语言,不改变日志、错误原文与会话内容。',
|
|
1203
|
-
memory: '长期记忆会在每次会话中注入;清空后偏好与约定全部丢失,且不可恢复。',
|
|
1204
|
-
};
|
|
1205
|
-
// 与 /api/state.defaults 同值的兜底:服务端字段读不到时仍能诚实显示「默认」列(来源写在设置页)。
|
|
1206
|
-
const SETTING_DEFAULTS_FALLBACK = {
|
|
1207
|
-
permission: 'ask',
|
|
1208
|
-
sandbox: 'off',
|
|
1209
|
-
routing: false,
|
|
1210
|
-
contextBudget: 128000,
|
|
1211
|
-
timeout: { firstTokenMs: null, streamIdleMs: null, totalMs: null },
|
|
1212
|
-
autostart: false,
|
|
1213
|
-
notify: true,
|
|
1214
|
-
reasoningEffort: 'high',
|
|
1215
|
-
theme: 'system',
|
|
1216
|
-
language: 'auto',
|
|
1217
|
-
};
|
|
1218
|
-
/** 默认值表:优先用 /api/state.defaults(只读透出),缺字段按同一份兜底补。 */
|
|
1219
|
-
function settingDefaultsFromState(state) {
|
|
1220
|
-
const src = state && state.defaults && typeof state.defaults === 'object' ? state.defaults : {};
|
|
1221
|
-
const out = Object.assign({}, SETTING_DEFAULTS_FALLBACK);
|
|
1222
|
-
for (const k of SETTING_KEYS) {
|
|
1223
|
-
if (Object.prototype.hasOwnProperty.call(src, k) && src[k] !== undefined) out[k] = src[k];
|
|
1224
|
-
}
|
|
1225
|
-
out.timeout = Object.assign({}, SETTING_DEFAULTS_FALLBACK.timeout, src.timeout || {});
|
|
1226
|
-
if (src.reasoningEffort !== undefined) out.reasoningEffort = src.reasoningEffort;
|
|
1227
|
-
return out;
|
|
1228
|
-
}
|
|
1229
|
-
/**
|
|
1230
|
-
* 设置项的展示值(不编造:未知值原样显示)。
|
|
1231
|
-
* @param {string} key
|
|
1232
|
-
* @param {any} value
|
|
1233
|
-
* @param {{lang?: string, translate?: Function}} [opts]
|
|
1234
|
-
* @returns {string}
|
|
1235
|
-
*/
|
|
1236
|
-
function settingDisplayValue(key, value, opts) {
|
|
1237
|
-
const o = opts || {};
|
|
1238
|
-
const zh = o.lang !== 'en';
|
|
1239
|
-
const tx = (/** @type {string} */ k, /** @type {string} */ fallback) => {
|
|
1240
|
-
try {
|
|
1241
|
-
const s = typeof o.translate === 'function' ? o.translate(zh ? 'zh' : 'en', k) : '';
|
|
1242
|
-
return s || fallback;
|
|
1243
|
-
} catch { return fallback; }
|
|
1244
|
-
};
|
|
1245
|
-
if (key === 'timeout') {
|
|
1246
|
-
const t0 = value && typeof value === 'object' ? value : {};
|
|
1247
|
-
const n = (/** @type {any} */ v) => (v == null || !Number.isFinite(Number(v)) || Number(v) <= 0 ? null : Number(v));
|
|
1248
|
-
const a = n(t0.firstTokenMs), b = n(t0.streamIdleMs), c = n(t0.totalMs);
|
|
1249
|
-
if (a == null && b == null && c == null) return tx('settings.auto', zh ? '自适应(留空)' : 'Auto (blank)');
|
|
1250
|
-
return [a == null ? null : '首帧 ' + Math.round(a / 1000) + 's', b == null ? null : '空闲 ' + Math.round(b / 1000) + 's', c == null ? null : '总量 ' + Math.round(c / 1000) + 's']
|
|
1251
|
-
.filter(Boolean).join(' · ');
|
|
1252
|
-
}
|
|
1253
|
-
if (typeof value === 'boolean') return value ? tx('settings.on', zh ? '开' : 'On') : tx('settings.off', zh ? '关' : 'Off');
|
|
1254
|
-
if (value == null || value === '') return '—';
|
|
1255
|
-
return String(value);
|
|
1256
|
-
}
|
|
1257
|
-
/**
|
|
1258
|
-
* 单个设置项的三列视图(默认 / 当前 / 影响)。
|
|
1259
|
-
* @param {string} key
|
|
1260
|
-
* @param {{defaults?: any, values?: any, lang?: string, translate?: Function}} [opts]
|
|
1261
|
-
* @returns {{key: string, defaultText: string, currentText: string, impact: string, changed: boolean}}
|
|
1262
|
-
*/
|
|
1263
|
-
function settingValueView(key, opts) {
|
|
1264
|
-
const o = opts || {};
|
|
1265
|
-
const defaults = o.defaults && typeof o.defaults === 'object' ? o.defaults : SETTING_DEFAULTS_FALLBACK;
|
|
1266
|
-
const values = o.values && typeof o.values === 'object' ? o.values : defaults;
|
|
1267
|
-
const hasCurrent = Object.prototype.hasOwnProperty.call(values, key);
|
|
1268
|
-
const defaultValue = Object.prototype.hasOwnProperty.call(defaults, key) ? defaults[key] : undefined;
|
|
1269
|
-
const currentValue = hasCurrent ? values[key] : defaultValue;
|
|
1270
|
-
const lang = o.lang === 'en' ? 'en' : 'zh';
|
|
1271
|
-
let impact = '';
|
|
1272
|
-
try {
|
|
1273
|
-
impact = (typeof o.translate === 'function' ? o.translate(lang, 'impact.' + key) : '') || SETTING_IMPACT[key] || '';
|
|
1274
|
-
} catch {
|
|
1275
|
-
impact = SETTING_IMPACT[key] || '';
|
|
1276
|
-
}
|
|
1277
|
-
return {
|
|
1278
|
-
key,
|
|
1279
|
-
defaultText: settingDisplayValue(key, defaultValue, o),
|
|
1280
|
-
currentText: settingDisplayValue(key, currentValue, o),
|
|
1281
|
-
impact,
|
|
1282
|
-
changed: settingDisplayValue(key, defaultValue, o) !== settingDisplayValue(key, currentValue, o),
|
|
1283
|
-
};
|
|
1284
|
-
}
|
|
1285
|
-
/**
|
|
1286
|
-
* 全部设置项的三列视图(HTML 里有几个 data-setting,这里就应覆盖几个)。
|
|
1287
|
-
* @param {{keys?: string[], defaults?: any, values?: any, lang?: string, translate?: Function}} [opts]
|
|
1288
|
-
* @returns {ReturnType<typeof settingValueView>[]}
|
|
1289
|
-
*/
|
|
1290
|
-
function settingColumnsView(opts) {
|
|
1291
|
-
const o = opts || {};
|
|
1292
|
-
const keys = Array.isArray(o.keys) && o.keys.length ? o.keys : SETTING_KEYS;
|
|
1293
|
-
return keys.map((k) => settingValueView(k, o));
|
|
1294
|
-
}
|
|
1295
|
-
|
|
1296
|
-
// —— 六类危险操作:先确认、后执行(取消 → 绝不执行) ——
|
|
1297
|
-
const DANGER_ACTIONS = {
|
|
1298
|
-
removeWorkspace: {
|
|
1299
|
-
titleKey: 'danger.removeWorkspace.title',
|
|
1300
|
-
consequenceKey: 'danger.removeWorkspace.consequence',
|
|
1301
|
-
titleFallback: '删除工作空间',
|
|
1302
|
-
consequenceFallback: '将从工作空间列表中移除该登记项;目录本身与其中的文件不会被删除,但依赖该空间的历史会话会失去一键切换入口。',
|
|
1303
|
-
},
|
|
1304
|
-
deleteSession: {
|
|
1305
|
-
titleKey: 'danger.deleteSession.title',
|
|
1306
|
-
consequenceKey: 'danger.deleteSession.consequence',
|
|
1307
|
-
titleFallback: '删除会话',
|
|
1308
|
-
consequenceFallback: '会话记录文件将被永久删除,无法恢复;若删的是当前会话,界面会回到空态。',
|
|
1309
|
-
},
|
|
1310
|
-
clearMemory: {
|
|
1311
|
-
titleKey: 'danger.clearMemory.title',
|
|
1312
|
-
consequenceKey: 'danger.clearMemory.consequence',
|
|
1313
|
-
titleFallback: '清空记忆',
|
|
1314
|
-
consequenceFallback: '长期记忆文件将被清空,注入每次会话的偏好与约定全部丢失,且不可恢复。',
|
|
1315
|
-
},
|
|
1316
|
-
resetConfig: {
|
|
1317
|
-
titleKey: 'danger.resetConfig.title',
|
|
1318
|
-
consequenceKey: 'danger.resetConfig.consequence',
|
|
1319
|
-
titleFallback: '恢复默认配置',
|
|
1320
|
-
consequenceFallback: '权限、沙箱、路由、上下文预算、超时、开机自启、通知将回到内置默认值;模型与 API Key 不受影响。',
|
|
1321
|
-
},
|
|
1322
|
-
permissionAuto: {
|
|
1323
|
-
titleKey: 'danger.permissionAuto.title',
|
|
1324
|
-
consequenceKey: 'danger.permissionAuto.consequence',
|
|
1325
|
-
titleFallback: '切换到「自动」权限',
|
|
1326
|
-
consequenceFallback: '之后写文件与执行命令不再逐次确认;模型可能在你未看清内容时修改文件或运行命令。',
|
|
1327
|
-
},
|
|
1328
|
-
sandboxOff: {
|
|
1329
|
-
titleKey: 'danger.sandboxOff.title',
|
|
1330
|
-
consequenceKey: 'danger.sandboxOff.consequence',
|
|
1331
|
-
titleFallback: '关闭沙箱',
|
|
1332
|
-
consequenceFallback: 'bash 将不再受只读/断网限制,直接在本机执行;沙箱提供的最外层隔离同时失效。',
|
|
1333
|
-
},
|
|
1334
|
-
};
|
|
1335
|
-
const DANGER_KEYS = ['removeWorkspace', 'deleteSession', 'clearMemory', 'resetConfig', 'permissionAuto', 'sandboxOff'];
|
|
1336
|
-
/**
|
|
1337
|
-
* 危险操作的标题 / 后果 / 按钮文案(未知 key 返回 null;绝不返回 undefined 文案)。
|
|
1338
|
-
* @param {string} key
|
|
1339
|
-
* @param {Function} [translateFn] 形如 translate(lang, key)
|
|
1340
|
-
* @param {string} [lang]
|
|
1341
|
-
* @returns {{key: string, title: string, consequence: string, confirmLabel: string, cancelLabel: string, note: string}|null}
|
|
1342
|
-
*/
|
|
1343
|
-
function dangerMeta(key, translateFn, lang) {
|
|
1344
|
-
const a = DANGER_ACTIONS[key];
|
|
1345
|
-
if (!a) return null;
|
|
1346
|
-
const l = lang === 'en' ? 'en' : 'zh';
|
|
1347
|
-
const tr = (/** @type {string} */ k, /** @type {string} */ fallback) => {
|
|
1348
|
-
try {
|
|
1349
|
-
const s = typeof translateFn === 'function' ? translateFn(l, k) : '';
|
|
1350
|
-
return s || fallback;
|
|
1351
|
-
} catch { return fallback; }
|
|
1352
|
-
};
|
|
1353
|
-
return {
|
|
1354
|
-
key,
|
|
1355
|
-
title: tr(a.titleKey, a.titleFallback),
|
|
1356
|
-
consequence: tr(a.consequenceKey, a.consequenceFallback),
|
|
1357
|
-
confirmLabel: tr('danger.confirm', '确认'),
|
|
1358
|
-
cancelLabel: tr('danger.cancel', '取消'),
|
|
1359
|
-
note: tr('danger.note', '需要点击「确认」才会执行;按回车不会确认,Esc 取消。'),
|
|
1360
|
-
};
|
|
1361
|
-
}
|
|
1362
|
-
/** 这两个档位切换是否属于危险操作(同值切换不算)。 */
|
|
1363
|
-
function dangerGateNeeded(key, from, to) {
|
|
1364
|
-
if (key === 'permissionAuto') return String(to) === 'auto' && String(from) !== 'auto';
|
|
1365
|
-
if (key === 'sandboxOff') return String(to) === 'off' && String(from) !== 'off';
|
|
1366
|
-
return false;
|
|
1367
|
-
}
|
|
1368
|
-
/**
|
|
1369
|
-
* 危险操作统一入口:**先** await confirm(meta),取消则直接返回且不执行;确认后才 execute。
|
|
1370
|
-
* @param {string} key
|
|
1371
|
-
* @param {{confirm: Function, execute: Function, translate?: Function, lang?: string}} deps
|
|
1372
|
-
* @returns {Promise<{ok: boolean, cancelled: boolean, result?: any}>}
|
|
1373
|
-
*/
|
|
1374
|
-
async function runDanger(key, deps) {
|
|
1375
|
-
const d = deps || {};
|
|
1376
|
-
const meta = dangerMeta(key, d.translate, d.lang);
|
|
1377
|
-
if (!meta) return { ok: false, cancelled: true };
|
|
1378
|
-
const answer = await d.confirm(meta);
|
|
1379
|
-
const confirmed = answer === true || Boolean(answer && answer.confirmed === true);
|
|
1380
|
-
if (!confirmed) return { ok: false, cancelled: true };
|
|
1381
|
-
const result = await d.execute(key, meta);
|
|
1382
|
-
return { ok: true, cancelled: false, result };
|
|
1383
|
-
}
|
|
1384
|
-
|
|
1385
|
-
// —— 对话框无障碍:role / aria-modal / 焦点陷阱 / Esc / 焦点归还 ——
|
|
1386
|
-
/** 对话框内可聚焦元素(按 DOM 顺序)。 */
|
|
1387
|
-
function dialogFocusables(el) {
|
|
1388
|
-
if (!el || typeof el.querySelectorAll !== 'function') return [];
|
|
1389
|
-
try {
|
|
1390
|
-
return Array.from(el.querySelectorAll('a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'))
|
|
1391
|
-
.filter((n) => n && !n.hidden);
|
|
1392
|
-
} catch { return []; }
|
|
1393
|
-
}
|
|
1394
|
-
/** Tab / Shift+Tab 的目标下标(纯函数;index<0 表示焦点在对话框外)。 */
|
|
1395
|
-
function trapTabTarget(shift, index, count) {
|
|
1396
|
-
const n = Number(count) || 0;
|
|
1397
|
-
if (n <= 0) return -1;
|
|
1398
|
-
const i = Number(index);
|
|
1399
|
-
if (!Number.isFinite(i) || i < 0) return shift ? n - 1 : 0;
|
|
1400
|
-
return shift ? (i - 1 + n) % n : (i + 1) % n;
|
|
1401
|
-
}
|
|
1402
|
-
/** 给元素补 dialog 语义(role=dialog + aria-modal=true + aria-label)。 */
|
|
1403
|
-
function applyDialogSemantics(el, label) {
|
|
1404
|
-
if (!el || typeof el.setAttribute !== 'function') return el;
|
|
1405
|
-
el.setAttribute('role', 'dialog');
|
|
1406
|
-
el.setAttribute('aria-modal', 'true');
|
|
1407
|
-
if (label) el.setAttribute('aria-label', String(label));
|
|
1408
|
-
return el;
|
|
1409
|
-
}
|
|
1410
|
-
/**
|
|
1411
|
-
* 安装对话框行为:焦点陷阱(Tab/Shift+Tab 循环)、Esc 关闭、关闭后焦点回到触发元素。
|
|
1412
|
-
* @param {any} el
|
|
1413
|
-
* @param {{document?: any, opener?: any, label?: string, initialFocus?: any, onClose?: Function}} [opts]
|
|
1414
|
-
* @returns {{close: Function, focusables: Function, element: any}}
|
|
1415
|
-
*/
|
|
1416
|
-
function installDialog(el, opts) {
|
|
1417
|
-
const o = opts || {};
|
|
1418
|
-
const doc = o.document || (typeof document !== 'undefined' ? document : null);
|
|
1419
|
-
const opener = o.opener || (doc && doc.activeElement) || null;
|
|
1420
|
-
applyDialogSemantics(el, o.label);
|
|
1421
|
-
let closed = false;
|
|
1422
|
-
const focusables = () => dialogFocusables(el);
|
|
1423
|
-
const onKey = (/** @type {any} */ e) => {
|
|
1424
|
-
if (closed || !e) return;
|
|
1425
|
-
if (e.key === 'Escape') {
|
|
1426
|
-
if (e.preventDefault) e.preventDefault();
|
|
1427
|
-
if (e.stopPropagation) e.stopPropagation();
|
|
1428
|
-
close(false);
|
|
1429
|
-
return;
|
|
1430
|
-
}
|
|
1431
|
-
if (e.key === 'Tab') {
|
|
1432
|
-
const list = focusables();
|
|
1433
|
-
if (!list.length) return;
|
|
1434
|
-
if (e.preventDefault) e.preventDefault();
|
|
1435
|
-
const active = doc && doc.activeElement;
|
|
1436
|
-
const idx = list.indexOf(active);
|
|
1437
|
-
const next = trapTabTarget(Boolean(e.shiftKey), idx, list.length);
|
|
1438
|
-
const target = list[next];
|
|
1439
|
-
if (target && typeof target.focus === 'function') {
|
|
1440
|
-
try { target.focus(); } catch { /* 桩 DOM */ }
|
|
1441
|
-
}
|
|
1442
|
-
}
|
|
1443
|
-
};
|
|
1444
|
-
if (doc && typeof doc.addEventListener === 'function') doc.addEventListener('keydown', onKey, true);
|
|
1445
|
-
const initial = o.initialFocus || focusables()[0] || null;
|
|
1446
|
-
if (initial && typeof initial.focus === 'function') {
|
|
1447
|
-
try { initial.focus(); } catch { /* 桩 DOM */ }
|
|
1448
|
-
}
|
|
1449
|
-
/** 关闭并归还焦点(onClose 只调用一次)。 */
|
|
1450
|
-
function close(result) {
|
|
1451
|
-
if (closed) return;
|
|
1452
|
-
closed = true;
|
|
1453
|
-
if (doc && typeof doc.removeEventListener === 'function') doc.removeEventListener('keydown', onKey, true);
|
|
1454
|
-
if (typeof o.onClose === 'function') {
|
|
1455
|
-
try { o.onClose(result); } catch { /* 关闭路径不再抛 */ }
|
|
1456
|
-
}
|
|
1457
|
-
if (opener && typeof opener.focus === 'function') {
|
|
1458
|
-
try { opener.focus(); } catch { /* 桩 DOM */ }
|
|
1459
|
-
}
|
|
1460
|
-
}
|
|
1461
|
-
return { close, focusables, element: el };
|
|
1462
|
-
}
|
|
1463
|
-
|
|
1464
|
-
// —— aria-live:只在状态变化时播报(同一状态重复渲染不打扰读屏) ——
|
|
1465
|
-
function liveSnapshot(view) {
|
|
1466
|
-
const v = view || {};
|
|
1467
|
-
return JSON.stringify({
|
|
1468
|
-
phase: String(v.phase || ''),
|
|
1469
|
-
tools: Number(v.toolCount) || 0,
|
|
1470
|
-
failed: Number(v.failed) || 0,
|
|
1471
|
-
subs: (v.subs || []).map((/** @type {any} */ s) => String((s && s.status) || '')).join(','),
|
|
1472
|
-
});
|
|
1473
|
-
}
|
|
1474
|
-
/**
|
|
1475
|
-
* @param {string} prevSnapshot
|
|
1476
|
-
* @param {any} view
|
|
1477
|
-
* @returns {{changed: boolean, snapshot: string, text: string}}
|
|
1478
|
-
*/
|
|
1479
|
-
function liveAnnouncement(prevSnapshot, view) {
|
|
1480
|
-
const snap = liveSnapshot(view);
|
|
1481
|
-
if (snap === prevSnapshot) return { changed: false, snapshot: snap, text: '' };
|
|
1482
|
-
const v = view || {};
|
|
1483
|
-
const parts = [String(v.phase || '空闲')];
|
|
1484
|
-
if (Number(v.toolCount)) parts.push(Number(v.toolCount) + ' 次工具调用');
|
|
1485
|
-
if (Number(v.failed)) parts.push(Number(v.failed) + ' 个失败');
|
|
1486
|
-
const subs = v.subs || [];
|
|
1487
|
-
const running = subs.filter((/** @type {any} */ s) => s && s.status === 'running').length;
|
|
1488
|
-
if (subs.length) parts.push(subs.length + ' 个子代理' + (running ? '(' + running + ' 运行中)' : ''));
|
|
1489
|
-
let prefix = '本轮状态:';
|
|
1490
|
-
try { prefix = (typeof translate === 'function' && translate(getLang(), 'a11y.livePrefix')) || prefix; } catch { /* 保持中文 */ }
|
|
1491
|
-
return { changed: true, snapshot: snap, text: prefix + parts.join(' · ') };
|
|
1492
|
-
}
|
|
1493
|
-
|
|
1494
|
-
// —— 对比度自检(WCAG 相对亮度;只给建议,**不改**主题令牌) ——
|
|
1495
|
-
const CONTRAST_TOKENS = {
|
|
1496
|
-
bg: '#151517', bg2: '#1B1B1C', bg3: '#353638', panel: '#2C2C2E',
|
|
1497
|
-
text: '#F9FAFB', dim: '#ADB2B8', faint: '#81858C',
|
|
1498
|
-
bgLight: '#FFFFFF', bg3Light: '#EFEFF1', panelLight: '#FFFFFF', textLight: '#18181B', dimLight: '#52525B', faintLight: '#71717A',
|
|
1499
|
-
};
|
|
1500
|
-
function hexToRgb(hex) {
|
|
1501
|
-
const s = String(hex || '').replace('#', '');
|
|
1502
|
-
const full = s.length === 3 ? s.split('').map((c) => c + c).join('') : s;
|
|
1503
|
-
return {
|
|
1504
|
-
r: parseInt(full.slice(0, 2), 16) || 0,
|
|
1505
|
-
g: parseInt(full.slice(2, 4), 16) || 0,
|
|
1506
|
-
b: parseInt(full.slice(4, 6), 16) || 0,
|
|
1507
|
-
};
|
|
1508
|
-
}
|
|
1509
|
-
function relativeLuminance(hex) {
|
|
1510
|
-
const { r, g, b } = hexToRgb(hex);
|
|
1511
|
-
const lin = [r, g, b].map((x) => {
|
|
1512
|
-
const c = x / 255;
|
|
1513
|
-
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
|
1514
|
-
});
|
|
1515
|
-
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
|
|
1516
|
-
}
|
|
1517
|
-
function contrastRatio(fg, bg) {
|
|
1518
|
-
const a = relativeLuminance(fg);
|
|
1519
|
-
const b = relativeLuminance(bg);
|
|
1520
|
-
const hi = Math.max(a, b);
|
|
1521
|
-
const lo = Math.min(a, b);
|
|
1522
|
-
return Math.round(((hi + 0.05) / (lo + 0.05)) * 100) / 100;
|
|
1523
|
-
}
|
|
1524
|
-
/**
|
|
1525
|
-
* 暗/亮两套 token 的正文 / 次要文本对比度。
|
|
1526
|
-
* @returns {{fg: string, bg: string, ratio: number, pass: boolean, advice: string}[]}
|
|
1527
|
-
*/
|
|
1528
|
-
function contrastAudit() {
|
|
1529
|
-
const t0 = CONTRAST_TOKENS;
|
|
1530
|
-
const pairs = [
|
|
1531
|
-
['text', 'bg'], ['text', 'panel'], ['text', 'bg3'],
|
|
1532
|
-
['dim', 'bg'], ['dim', 'bg2'], ['dim', 'bg3'], ['dim', 'panel'],
|
|
1533
|
-
['faint', 'bg'], ['faint', 'bg2'], ['faint', 'bg3'], ['faint', 'panel'],
|
|
1534
|
-
['textLight', 'bgLight'], ['dimLight', 'bgLight'], ['dimLight', 'bg3Light'], ['faintLight', 'bg3Light'],
|
|
1535
|
-
];
|
|
1536
|
-
return pairs.map(([fg, bg]) => {
|
|
1537
|
-
const ratio = contrastRatio(t0[fg], t0[bg]);
|
|
1538
|
-
const pass = ratio >= 4.5;
|
|
1539
|
-
return {
|
|
1540
|
-
fg, bg, ratio, pass,
|
|
1541
|
-
advice: pass ? '' : '比值为 ' + ratio + ':1,低于 4.5:1;建议该组合改用 var(--dim)(同色阶,正文/次要文本达标),或把 --faint 只用在 --bg/--bg2 上。',
|
|
1542
|
-
};
|
|
1543
|
-
});
|
|
1544
|
-
}
|
|
1545
|
-
|
|
1546
1157
|
// ============================================================================
|
|
1547
1158
|
// P1 DOM 接线(v0.6.19 §3.70):主题 / 断点 / 工具时间线 / 右栏 / 命令面板 /
|
|
1548
1159
|
// 快捷键表 / 会话设置 / 空态 / 设置搜索 / 诊断。全部消费上面纯函数的结果。
|
|
@@ -1555,13 +1166,45 @@ let homeDir = '';
|
|
|
1555
1166
|
let workingDirCache = '';
|
|
1556
1167
|
let compactAvailable = false; // Web 端当前没有 /api/compact;为 true 时才显示压缩入口
|
|
1557
1168
|
let runPanelOpen = false;
|
|
1169
|
+
// —— 右栏四标签(§3.82 第二步):轨迹 / 子Agent / 任务 / 仪表盘 ——
|
|
1170
|
+
// 标签切换**只控制 display**(railTabDisplay 纯函数),四个面板节点常驻 DOM、id 不变;
|
|
1171
|
+
// 不重写 updateTasksPanel / renderDashboard / openTraj / renderSubPanel 的数据逻辑。
|
|
1172
|
+
const RAIL_TABS = ['trace', 'sub', 'tasks', 'dash'];
|
|
1173
|
+
const RAIL_TAB_PANELS = { trace: '#tabTrace', sub: '#tabSub', tasks: '#tabTasks', dash: '#tabDash' };
|
|
1174
|
+
const RAIL_TAB_BUTTONS = { trace: '#rtTrace', sub: '#rtSub', tasks: '#rtTasks', dash: '#rtDash' };
|
|
1175
|
+
let activeRailTab = 'trace';
|
|
1176
|
+
function railTabDisplay(tab) {
|
|
1177
|
+
const active = RAIL_TABS.includes(tab) ? tab : 'trace';
|
|
1178
|
+
/** @type {Record<string, 'flex'|'none'>} */
|
|
1179
|
+
const out = {};
|
|
1180
|
+
for (const key of RAIL_TABS) out[key] = key === active ? 'flex' : 'none';
|
|
1181
|
+
return out;
|
|
1182
|
+
}
|
|
1183
|
+
function setRailTab(tab) {
|
|
1184
|
+
activeRailTab = RAIL_TABS.includes(tab) ? tab : 'trace';
|
|
1185
|
+
const disp = railTabDisplay(activeRailTab);
|
|
1186
|
+
for (const key of RAIL_TABS) {
|
|
1187
|
+
const panel = $(RAIL_TAB_PANELS[key]);
|
|
1188
|
+
if (panel) panel.style.display = disp[key];
|
|
1189
|
+
const btn = $(RAIL_TAB_BUTTONS[key]);
|
|
1190
|
+
if (btn) {
|
|
1191
|
+
btn.classList.toggle('on', key === activeRailTab);
|
|
1192
|
+
btn.setAttribute('aria-selected', key === activeRailTab ? 'true' : 'false');
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
// 搬进标签页的两个旧面板:display 继续跟着标签走(updateTasksPanel / refreshCostBadge 靠它判可见)。
|
|
1196
|
+
const tp = $('#tasksPanel'); if (tp) tp.style.display = disp.tasks;
|
|
1197
|
+
const dp = $('#dashPanel'); if (dp) dp.style.display = disp.dash;
|
|
1198
|
+
if (activeRailTab === 'tasks' && typeof updateTasksPanel === 'function') updateTasksPanel();
|
|
1199
|
+
if (activeRailTab === 'dash' && typeof refreshCostBadge === 'function') refreshCostBadge();
|
|
1200
|
+
return activeRailTab;
|
|
1201
|
+
}
|
|
1202
|
+
/** 点标签/既有入口的统一出口:先开右栏,再切标签。 */
|
|
1203
|
+
function activateRailTab(tab) {
|
|
1204
|
+
setRunPanel(true);
|
|
1205
|
+
return setRailTab(tab);
|
|
1206
|
+
}
|
|
1558
1207
|
let responsiveInitialized = false;
|
|
1559
|
-
// §3.78:右栏标签页(轨迹 / 子Agent / 任务 / 仪表盘)——原 5 个并列面板收编到同一栏。
|
|
1560
|
-
// RAIL_PANEL_IDS 映射到**原面板 id**(不重命名、不删节点),handler 仍按既有 id 查询。
|
|
1561
|
-
const RAIL_TABS = ['traj', 'sub', 'tasks', 'dash'];
|
|
1562
|
-
const RAIL_PANEL_IDS = { traj: 'trajPanel', sub: 'subPanel', tasks: 'tasksPanel', dash: 'dashPanel' };
|
|
1563
|
-
let railTab = 'traj';
|
|
1564
|
-
let lastNarrowRun = null;
|
|
1565
1208
|
// —— 轨迹收纳状态(§3.73):两个折叠区 + 展开过的轮次/工具/子代理(重渲染后保持展开) ——
|
|
1566
1209
|
const traceFoldState = { trace: true, subs: true };
|
|
1567
1210
|
const openTraceRounds = new Set();
|
|
@@ -1609,11 +1252,10 @@ initTheme();
|
|
|
1609
1252
|
// —— 右栏「本轮运行」:<1180 收起为抽屉;宽屏默认展开;不做任何新轮询 ——
|
|
1610
1253
|
function syncRunPanel() {
|
|
1611
1254
|
const el = $('#runPanel'); if (!el) return;
|
|
1612
|
-
//
|
|
1613
|
-
// (syncPanelLayout 统一算,避免"两个面板各写一份互斥逻辑")。
|
|
1255
|
+
// §3.82:四个面板都在右栏标签页内,右边缘只剩右栏一个抽屉(显隐由 syncPanelLayout 统一算)。
|
|
1614
1256
|
syncPanelLayout();
|
|
1615
1257
|
const chip = $('#runChip'); if (chip) chip.setAttribute('aria-expanded', runPanelOpen ? 'true' : 'false');
|
|
1616
|
-
const col = $('#rpCollapse'); if (col)
|
|
1258
|
+
const col = $('#rpCollapse'); if (col) col.textContent = '收起';
|
|
1617
1259
|
}
|
|
1618
1260
|
function setRunPanel(open) { runPanelOpen = Boolean(open); syncRunPanel(); }
|
|
1619
1261
|
function toggleRunPanel() { setRunPanel(!runPanelOpen); }
|
|
@@ -1622,11 +1264,6 @@ function applyResponsiveLayout(width) {
|
|
|
1622
1264
|
const cls = layoutClassesFor(w);
|
|
1623
1265
|
document.body.classList.toggle('narrow-side', cls.narrow);
|
|
1624
1266
|
document.body.classList.toggle('narrow-run', cls.narrowRun);
|
|
1625
|
-
// §3.78:<900 侧栏抽屉只在窄屏存在;宽屏恢复时自动关掉,避免盖住正文。
|
|
1626
|
-
if (!cls.narrow) document.body.classList.remove('side-drawer');
|
|
1627
|
-
// §3.78:宽 → 窄(跨过 1180)时右栏回到"收起 + 运行 chip",不默认盖在正文上。
|
|
1628
|
-
if (cls.narrowRun && lastNarrowRun === false) runPanelOpen = false;
|
|
1629
|
-
lastNarrowRun = cls.narrowRun;
|
|
1630
1267
|
syncRunPanel();
|
|
1631
1268
|
}
|
|
1632
1269
|
function initResponsive() {
|
|
@@ -1638,16 +1275,16 @@ function initResponsive() {
|
|
|
1638
1275
|
window.addEventListener('resize', () => applyResponsiveLayout());
|
|
1639
1276
|
const chip = $('#runChip'); if (chip) chip.onclick = toggleRunPanel;
|
|
1640
1277
|
const col = $('#rpCollapse'); if (col) col.onclick = () => setRunPanel(false);
|
|
1641
|
-
// §3.78:<900 侧栏抽屉开关(设计稿在 980 图标化;任务要求"侧栏变抽屉",见 §3.78 登记)
|
|
1642
|
-
const sideToggle = $('#sideToggle');
|
|
1643
|
-
if (sideToggle) sideToggle.onclick = () => {
|
|
1644
|
-
const on = document.body.classList.toggle('side-drawer');
|
|
1645
|
-
sideToggle.setAttribute('aria-expanded', on ? 'true' : 'false');
|
|
1646
|
-
};
|
|
1647
1278
|
}
|
|
1648
1279
|
initResponsive();
|
|
1649
1280
|
// §3.73:绑定两个折叠区 + 末尾区块总开关(渲染入口在下面的轨迹收纳块)。
|
|
1650
1281
|
initTraceUI();
|
|
1282
|
+
// §3.82:四个标签按钮(只切 display + 开右栏;默认轨迹)。
|
|
1283
|
+
for (const key of RAIL_TABS) {
|
|
1284
|
+
const btn = $(RAIL_TAB_BUTTONS[key]);
|
|
1285
|
+
if (btn) btn.onclick = () => activateRailTab(key);
|
|
1286
|
+
}
|
|
1287
|
+
setRailTab('trace');
|
|
1651
1288
|
|
|
1652
1289
|
// —— 工具时间线 DOM:一张卡 + 可复用行(开始/完成不换节点) ——
|
|
1653
1290
|
function toolStepNode(step) {
|
|
@@ -1655,7 +1292,6 @@ function toolStepNode(step) {
|
|
|
1655
1292
|
const row = document.createElement('button'); row.type = 'button'; row.className = 'tl-row'; row.setAttribute('aria-expanded', 'false');
|
|
1656
1293
|
const no = document.createElement('span'); no.className = 'tl-no'; no.textContent = '0';
|
|
1657
1294
|
const st = document.createElement('span'); st.className = 'tl-st ' + (step.stCls || ''); st.textContent = step.stIcon;
|
|
1658
|
-
st.setAttribute('aria-live', 'polite'); // P2-3:步骤状态变化时读屏播报(容器另有节流播报)
|
|
1659
1295
|
const tool = document.createElement('span'); tool.className = 'tl-tool'; tool.textContent = step.name;
|
|
1660
1296
|
const args = document.createElement('span'); args.className = 'tl-args'; args.textContent = step.args;
|
|
1661
1297
|
const dur = document.createElement('span'); dur.className = 'tl-dur'; dur.textContent = step.dur;
|
|
@@ -1793,9 +1429,6 @@ function renderRunPanel() {
|
|
|
1793
1429
|
set('#rpTtft', v.ttft);
|
|
1794
1430
|
set('#rpTps', v.tps);
|
|
1795
1431
|
set('#rpCtx', v.ctx);
|
|
1796
|
-
const ctxTotalN=Number(runState.contextTotal)||0;
|
|
1797
|
-
const ctxUsedN=Math.max(0, Number(runState.contextUsed)||0);
|
|
1798
|
-
set('#rpCtxTotal', ctxTotalN?(fmtTok(ctxUsedN)+' / '+fmtTok(ctxTotalN)):'');
|
|
1799
1432
|
set('#rpTools', v.tools);
|
|
1800
1433
|
set('#rpCost', v.cost);
|
|
1801
1434
|
set('#rpModel', v.modelLabel || v.model);
|
|
@@ -2029,7 +1662,6 @@ function buildTraceSubNode(s) {
|
|
|
2029
1662
|
const tools = document.createElement('span'); tools.className = 'tr-sub-meta'; tools.textContent = s.toolText;
|
|
2030
1663
|
const progress = document.createElement('span'); progress.className = 'tr-sub-meta'; progress.textContent = s.progress;
|
|
2031
1664
|
const state = document.createElement('span'); state.className = 'tr-sub-state ' + (s.statusCls || ''); state.textContent = s.statusText;
|
|
2032
|
-
state.setAttribute('aria-live', 'polite'); // P2-3:子代理状态变化时读屏播报(暂停/失败/完成)
|
|
2033
1665
|
[name, round, tools, progress, state].forEach((n) => head.appendChild(n));
|
|
2034
1666
|
const body = document.createElement('div'); body.className = 'tr-sub-body';
|
|
2035
1667
|
const q = document.createElement('div'); q.className = 'tr-sub-q'; q.textContent = '任务:' + (s.question || '—');
|
|
@@ -2056,7 +1688,6 @@ function renderTracePanels() {
|
|
|
2056
1688
|
const views = collectTraceViews();
|
|
2057
1689
|
const totalTools = views.reduce((a, v) => a + v.toolCount, 0);
|
|
2058
1690
|
const totalSubs = views.reduce((a, v) => a + v.subCount, 0);
|
|
2059
|
-
const totalFailed = views.reduce((a, v) => a + v.failed, 0);
|
|
2060
1691
|
const meta = views.length ? views.length + ' 轮 · ' + totalTools + ' 次工具 · ' + totalSubs + ' 个子代理' : '—';
|
|
2061
1692
|
['#rpTraceMeta', '#tdTraceMeta'].forEach((sel) => { const n = $(sel); if (n) n.textContent = meta; });
|
|
2062
1693
|
['#rpSubMeta', '#tdSubMeta'].forEach((sel) => { const n = $(sel); if (n) n.textContent = totalSubs ? totalSubs + ' 个' : '—'; });
|
|
@@ -2067,15 +1698,6 @@ function renderTracePanels() {
|
|
|
2067
1698
|
renderTraceSubList($('#rpSubList'), views);
|
|
2068
1699
|
renderTraceSubList($('#tdSubList'), views);
|
|
2069
1700
|
syncTraceFoldButtons();
|
|
2070
|
-
// P2-3:aria-live 只在**状态变化**时播报(同一状态重复渲染不打扰读屏)。
|
|
2071
|
-
try {
|
|
2072
|
-
updateLiveRegion({
|
|
2073
|
-
phase: runStripView(runState, generating).phase,
|
|
2074
|
-
toolCount: totalTools,
|
|
2075
|
-
failed: totalFailed,
|
|
2076
|
-
subs: views.reduce((a, v) => a.concat(v.subs), []),
|
|
2077
|
-
});
|
|
2078
|
-
} catch { /* 桩 DOM / 启动早期:无障碍播报不得影响主流程 */ }
|
|
2079
1701
|
}
|
|
2080
1702
|
/** 子代理运行中只刷新子代理两块(progress≈1s;不重建工具列表,少打扰)。 */
|
|
2081
1703
|
function renderSubagentSections() {
|
|
@@ -2099,6 +1721,7 @@ function focusTraceRound(round) {
|
|
|
2099
1721
|
const body = $('#tdBody'); if (body) body.hidden = false;
|
|
2100
1722
|
const toggle = $('#tdToggle'); if (toggle) toggle.setAttribute('aria-expanded', 'true');
|
|
2101
1723
|
} else {
|
|
1724
|
+
setRailTab('trace');
|
|
2102
1725
|
setRunPanel(true);
|
|
2103
1726
|
}
|
|
2104
1727
|
traceFoldState.trace = true;
|
|
@@ -2151,7 +1774,7 @@ function renderSessionSettings() {
|
|
|
2151
1774
|
const set = (id, text) => { const n = $(id); if (n) n.textContent = text; };
|
|
2152
1775
|
set('#ssModelLabel', md.label);
|
|
2153
1776
|
set('#ssModelId', md.subtitle);
|
|
2154
|
-
set('#ssModelSrc',
|
|
1777
|
+
set('#ssModelSrc', '来源:设置 › 模型与密钥 · 写入 config.json,下一轮生效');
|
|
2155
1778
|
const hint = $('#ssModelAliasHint');
|
|
2156
1779
|
if (hint) hint.hidden = !(md && md.label && md.label !== (md.id || md.name));
|
|
2157
1780
|
const permSel = $('#permSel');
|
|
@@ -2164,6 +1787,7 @@ function renderSessionSettings() {
|
|
|
2164
1787
|
? ((presetData.find((p) => p.name === psel.value) || {}).label || psel.value)
|
|
2165
1788
|
: '无预设';
|
|
2166
1789
|
runState.presetName = presetLabel;
|
|
1790
|
+
set('#ssPresetSrc', '当前:' + presetLabel + ' · 来源:你的选择 · 仅下一轮(工具白名单 / 参数)');
|
|
2167
1791
|
set('#ssEffective', sessionEffectiveLine({ model: md.name, modelLabel: md.label, permission: mode, sourceLabel: permissionSourceLabel(source), preset: presetLabel }));
|
|
2168
1792
|
set('#chipModel', md.label || md.name || '模型');
|
|
2169
1793
|
set('#chipPerm', permissionModeLabel(mode));
|
|
@@ -2171,31 +1795,13 @@ function renderSessionSettings() {
|
|
|
2171
1795
|
const reas = $('#reasoningSel');
|
|
2172
1796
|
if (field && reas) field.hidden = reas.style.display === 'none';
|
|
2173
1797
|
}
|
|
2174
|
-
let sessSheetDialog = null;
|
|
2175
1798
|
function openSessionSheet() {
|
|
2176
1799
|
renderSessionSettings();
|
|
2177
1800
|
const s = $('#sessSheet'); if (!s) return;
|
|
2178
1801
|
s.hidden = false;
|
|
2179
1802
|
const btn = $('#sessSettingsBtn'); if (btn) btn.setAttribute('aria-expanded', 'true');
|
|
2180
|
-
// P2-3:role=dialog + aria-modal + 焦点陷阱 + Esc 关闭 + 关闭后焦点回触发元素。
|
|
2181
|
-
sessSheetDialog = installDialog(s, {
|
|
2182
|
-
opener: (typeof document !== 'undefined' ? document.activeElement : null),
|
|
2183
|
-
label: t('sess.title'),
|
|
2184
|
-
initialFocus: $('#ssClose'),
|
|
2185
|
-
onClose: () => {
|
|
2186
|
-
sessSheetDialog = null;
|
|
2187
|
-
s.hidden = true;
|
|
2188
|
-
if (btn) btn.setAttribute('aria-expanded', 'false');
|
|
2189
|
-
},
|
|
2190
|
-
});
|
|
2191
1803
|
}
|
|
2192
1804
|
function closeSessionSheet() {
|
|
2193
|
-
if (sessSheetDialog) {
|
|
2194
|
-
const d = sessSheetDialog;
|
|
2195
|
-
sessSheetDialog = null;
|
|
2196
|
-
d.close();
|
|
2197
|
-
return;
|
|
2198
|
-
}
|
|
2199
1805
|
const s = $('#sessSheet'); if (!s) return;
|
|
2200
1806
|
s.hidden = true;
|
|
2201
1807
|
const btn = $('#sessSettingsBtn'); if (btn) btn.setAttribute('aria-expanded', 'false');
|
|
@@ -2207,7 +1813,6 @@ $('#sessSettingsBtn').onclick = () => {
|
|
|
2207
1813
|
};
|
|
2208
1814
|
$('#ssClose').onclick = closeSessionSheet;
|
|
2209
1815
|
$('#ssDone').onclick = closeSessionSheet;
|
|
2210
|
-
document.querySelectorAll('[data-open-settings]').forEach((b) => { b.onclick = () => openSessionSheet(); });
|
|
2211
1816
|
document.querySelectorAll('#ssPermSeg [data-perm]').forEach((b) => { b.onclick = () => setPermissionEverywhere(b.dataset.perm); });
|
|
2212
1817
|
|
|
2213
1818
|
async function setModelEverywhere(name) {
|
|
@@ -2222,16 +1827,6 @@ async function setModelEverywhere(name) {
|
|
|
2222
1827
|
renderSessionSettings();
|
|
2223
1828
|
}
|
|
2224
1829
|
async function setPermissionEverywhere(mode) {
|
|
2225
|
-
const prev = runState.permissionMode || (($('#permSel') && $('#permSel').value) || 'ask');
|
|
2226
|
-
// P2-2:切到 auto 属于危险操作——先说明后果,取消则保持原档(并复位分段控件)。
|
|
2227
|
-
if (dangerGateNeeded('permissionAuto', prev, mode)) {
|
|
2228
|
-
const gate = await confirmDanger('permissionAuto', async () => {});
|
|
2229
|
-
if (gate.cancelled) {
|
|
2230
|
-
if ($('#permSel')) $('#permSel').value = prev;
|
|
2231
|
-
renderSessionSettings();
|
|
2232
|
-
return false;
|
|
2233
|
-
}
|
|
2234
|
-
}
|
|
2235
1830
|
const sel = $('#permSel');
|
|
2236
1831
|
if (sel) sel.value = mode;
|
|
2237
1832
|
runState.permissionMode = mode;
|
|
@@ -2240,7 +1835,6 @@ async function setPermissionEverywhere(mode) {
|
|
|
2240
1835
|
renderWorkStatus();
|
|
2241
1836
|
await applyConfig({ permission: mode }, sel || undefined);
|
|
2242
1837
|
renderSessionSettings();
|
|
2243
|
-
return true;
|
|
2244
1838
|
}
|
|
2245
1839
|
function setPresetEverywhere(name) {
|
|
2246
1840
|
const sel = $('#presetSel');
|
|
@@ -2251,7 +1845,7 @@ function setPresetEverywhere(name) {
|
|
|
2251
1845
|
}
|
|
2252
1846
|
|
|
2253
1847
|
// —— 命令面板:数据全部来自既有 API 快照(打开时刷新一次,不是轮询) ——
|
|
2254
|
-
let paletteOpen = false, paletteSelected = 0, paletteItems = [], paletteButtons = [], paletteOpener = null
|
|
1848
|
+
let paletteOpen = false, paletteSelected = 0, paletteItems = [], paletteButtons = [], paletteOpener = null;
|
|
2255
1849
|
function paletteSources() {
|
|
2256
1850
|
return {
|
|
2257
1851
|
sessions: sessionsCache,
|
|
@@ -2329,33 +1923,9 @@ async function openPalette() {
|
|
|
2329
1923
|
renderPalette();
|
|
2330
1924
|
try { await refreshSessions(); } catch { /* 网络失败:仍显示缓存命令 */ }
|
|
2331
1925
|
try { await refreshWsSel(); } catch { /* 同上 */ }
|
|
2332
|
-
if (paletteOpen) {
|
|
2333
|
-
renderPalette();
|
|
2334
|
-
// P2-3:命令面板的 dialog 语义/焦点陷阱/Esc/焦点归还(HTML 已有 role/aria-modal,这里补齐行为)。
|
|
2335
|
-
const box = document.querySelector('#paletteMask .palette');
|
|
2336
|
-
if (box) {
|
|
2337
|
-
paletteDialog = installDialog(box, {
|
|
2338
|
-
opener: paletteOpener,
|
|
2339
|
-
label: t('palette.title'),
|
|
2340
|
-
initialFocus: $('#paletteInput'),
|
|
2341
|
-
onClose: () => {
|
|
2342
|
-
paletteDialog = null;
|
|
2343
|
-
const m = $('#paletteMask'); if (m) m.hidden = true;
|
|
2344
|
-
paletteOpen = false;
|
|
2345
|
-
},
|
|
2346
|
-
});
|
|
2347
|
-
}
|
|
2348
|
-
try { $('#paletteInput').focus(); } catch { /* ignore */ }
|
|
2349
|
-
}
|
|
1926
|
+
if (paletteOpen) { renderPalette(); try { $('#paletteInput').focus(); } catch { /* ignore */ } }
|
|
2350
1927
|
}
|
|
2351
1928
|
function closePalette() {
|
|
2352
|
-
if (paletteDialog) {
|
|
2353
|
-
const d = paletteDialog;
|
|
2354
|
-
paletteDialog = null;
|
|
2355
|
-
paletteOpen = false;
|
|
2356
|
-
d.close();
|
|
2357
|
-
return;
|
|
2358
|
-
}
|
|
2359
1929
|
const mask = $('#paletteMask'); if (mask) mask.hidden = true;
|
|
2360
1930
|
paletteOpen = false;
|
|
2361
1931
|
if (paletteOpener && paletteOpener.focus) { try { paletteOpener.focus(); } catch { /* ignore */ } }
|
|
@@ -2482,19 +2052,6 @@ function openDiagnostics() {
|
|
|
2482
2052
|
row.appendChild(kk); row.appendChild(vv); row.appendChild(cp);
|
|
2483
2053
|
box.appendChild(row);
|
|
2484
2054
|
}
|
|
2485
|
-
// P2-3:对比度自检(WCAG 相对亮度)。**只给建议,不改主题令牌**——主题是设计决策,不在运行时偷偷换色。
|
|
2486
|
-
const audit = contrastAudit();
|
|
2487
|
-
const failed = audit.filter((c) => !c.pass);
|
|
2488
|
-
const head = document.createElement('div'); head.className = 'diag-k'; head.style.cssText = 'margin-top:10px';
|
|
2489
|
-
head.textContent = failed.length ? '对比度未达标 ' + failed.length + ' 项(建议)' : '对比度:正文/次要文本全部 ≥4.5:1';
|
|
2490
|
-
box.appendChild(head);
|
|
2491
|
-
for (const c of failed) {
|
|
2492
|
-
const row = document.createElement('div'); row.className = 'diag-row';
|
|
2493
|
-
const kk = document.createElement('span'); kk.className = 'diag-k'; kk.textContent = c.fg + ' / ' + c.bg;
|
|
2494
|
-
const vv = document.createElement('span'); vv.className = 'diag-v'; vv.textContent = c.ratio + ':1 · ' + c.advice;
|
|
2495
|
-
row.appendChild(kk); row.appendChild(vv);
|
|
2496
|
-
box.appendChild(row);
|
|
2497
|
-
}
|
|
2498
2055
|
$('#diagModal').style.display = 'flex';
|
|
2499
2056
|
}
|
|
2500
2057
|
$('#diagClose').onclick = () => { $('#diagModal').style.display = 'none'; };
|
|
@@ -2514,7 +2071,6 @@ document.addEventListener('keydown', (e) => {
|
|
|
2514
2071
|
if ($('#diagModal').style.display === 'flex') { e.preventDefault(); $('#diagModal').style.display = 'none'; return; }
|
|
2515
2072
|
if ($('#cfgModal').style.display === 'flex') { e.preventDefault(); $('#cfgModal').style.display = 'none'; return; }
|
|
2516
2073
|
if ($('#dirModal').style.display === 'flex') { e.preventDefault(); $('#dirModal').style.display = 'none'; return; }
|
|
2517
|
-
if (document.body.classList.contains('side-drawer')) { e.preventDefault(); closeSideDrawer(); return; }
|
|
2518
2074
|
if (document.body.classList.contains('run-drawer')) { e.preventDefault(); setRunPanel(false); return; }
|
|
2519
2075
|
if (activeAsk) return; // 内联确认自己的 Esc 处理器负责(拒绝,不是停止)
|
|
2520
2076
|
if (generating) { e.preventDefault(); stopTurnFromButton(); return; }
|
|
@@ -2559,62 +2115,30 @@ function openTraj(msg){
|
|
|
2559
2115
|
// 点击标题定位到聊天中的对应工具卡
|
|
2560
2116
|
list.appendChild(div);
|
|
2561
2117
|
}
|
|
2562
|
-
|
|
2563
|
-
|
|
2118
|
+
// §3.82:轨迹入口不再单独开旧浮层——切到右栏「轨迹」标签(旧面板与其 #tjList 仍在 DOM 里)。
|
|
2119
|
+
$('#trajRailBtn').classList.add('on');
|
|
2120
|
+
activateRailTab('trace');
|
|
2564
2121
|
}
|
|
2565
|
-
//
|
|
2566
|
-
//
|
|
2567
|
-
/** 标签页显隐(纯函数;smoke §153 直接断言,不碰 DOM)。 */
|
|
2568
|
-
function railPanelDisplay(tab, panelTab, runOpen) {
|
|
2569
|
-
return runOpen && tab === panelTab ? 'flex' : 'none';
|
|
2570
|
-
}
|
|
2571
|
-
function showRailTab(name) {
|
|
2572
|
-
if (!RAIL_TABS.includes(name)) return;
|
|
2573
|
-
railTab = name;
|
|
2574
|
-
if (!runPanelOpen) setRunPanel(true); else syncPanelLayout();
|
|
2575
|
-
if (name === 'tasks') { try { updateTasksPanel(); } catch { /* 未初始化时忽略 */ } }
|
|
2576
|
-
if (name === 'dash') { try { refreshCostBadge(); } catch { /* 未初始化时忽略 */ } }
|
|
2577
|
-
if (name === 'sub') { try { renderSubPanel(); } catch { /* 未初始化时忽略 */ } }
|
|
2578
|
-
if (name === 'traj' || name === 'sub') { try { renderTracePanels(); } catch { /* 未初始化时忽略 */ } }
|
|
2579
|
-
}
|
|
2580
|
-
function initRailTabs() {
|
|
2581
|
-
document.querySelectorAll('#rpTabs [data-tab]').forEach((btn) => {
|
|
2582
|
-
btn.onclick = () => showRailTab(btn.dataset.tab);
|
|
2583
|
-
btn.addEventListener('keydown', (e) => {
|
|
2584
|
-
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
|
2585
|
-
e.preventDefault();
|
|
2586
|
-
const i = RAIL_TABS.indexOf(railTab);
|
|
2587
|
-
const n = (i + (e.key === 'ArrowRight' ? 1 : RAIL_TABS.length - 1)) % RAIL_TABS.length;
|
|
2588
|
-
showRailTab(RAIL_TABS[n]);
|
|
2589
|
-
const next = document.querySelector('#rpTabs [data-tab="' + RAIL_TABS[n] + '"]');
|
|
2590
|
-
if (next) next.focus();
|
|
2591
|
-
});
|
|
2592
|
-
});
|
|
2593
|
-
}
|
|
2594
|
-
function closeSideDrawer() { document.body.classList.remove('side-drawer'); }
|
|
2122
|
+
// 面板布局同步:§3.82 起轨迹/子代理/任务/仪表盘都在右栏标签页内,
|
|
2123
|
+
// 右栏是否显示只由 runPanelOpen 决定;旧的四条 body 推开 class 不再使用(CSS 规则保留不动)。
|
|
2595
2124
|
function syncPanelLayout(){
|
|
2596
|
-
|
|
2597
|
-
const
|
|
2598
|
-
const
|
|
2599
|
-
|
|
2600
|
-
document.body.classList.toggle('run-
|
|
2601
|
-
document.body.classList.toggle('
|
|
2602
|
-
for (const tab of RAIL_TABS) {
|
|
2603
|
-
const el = document.getElementById(RAIL_PANEL_IDS[tab]);
|
|
2604
|
-
if (el) el.style.display = railPanelDisplay(railTab, tab, open);
|
|
2605
|
-
const btn = document.querySelector('#rpTabs [data-tab="' + tab + '"]');
|
|
2606
|
-
if (btn) {
|
|
2607
|
-
const on = railTab === tab;
|
|
2608
|
-
btn.classList.toggle('on', on);
|
|
2609
|
-
btn.setAttribute('aria-selected', on ? 'true' : 'false');
|
|
2610
|
-
btn.tabIndex = on ? 0 : -1;
|
|
2611
|
-
}
|
|
2612
|
-
}
|
|
2125
|
+
document.body.classList.remove('traj-open','sub-open','tasks-open','dash-open');
|
|
2126
|
+
const narrow=layoutClassesFor(window.innerWidth||0).narrowRun;
|
|
2127
|
+
const showRun=Boolean(runPanelOpen);
|
|
2128
|
+
const rp=$('#runPanel'); if(rp) rp.hidden=!showRun;
|
|
2129
|
+
document.body.classList.toggle('run-open', showRun&&!narrow);
|
|
2130
|
+
document.body.classList.toggle('run-drawer', showRun&&narrow);
|
|
2613
2131
|
// §3.73:窄屏右栏收起时,轨迹/子Agent 在对话流末尾显示;右栏(抽屉)一开就让位。
|
|
2614
2132
|
syncTraceDock();
|
|
2615
2133
|
}
|
|
2616
|
-
$('#tjClose').onclick=()=>{ setRunPanel(false); };
|
|
2617
|
-
$('#trajRailBtn').onclick=()=>
|
|
2134
|
+
$('#tjClose').onclick=()=>{ setRunPanel(false); $('#trajRailBtn').classList.remove('on'); };
|
|
2135
|
+
$('#trajRailBtn').onclick=()=>{
|
|
2136
|
+
// §3.82:轨迹入口 = 打开右栏并切到「轨迹」标签(没有轨迹时也切过去,显示空态)。
|
|
2137
|
+
const m=lastMsgWithTraj();
|
|
2138
|
+
if(m && m._traj && m._traj.length){ openTraj(m); return; }
|
|
2139
|
+
$('#trajRailBtn').classList.add('on');
|
|
2140
|
+
activateRailTab('trace');
|
|
2141
|
+
};
|
|
2618
2142
|
function lastMsgWithTraj(){
|
|
2619
2143
|
const msgs=[...document.querySelectorAll('#chat .msg-ai')].reverse();
|
|
2620
2144
|
for(const m of msgs){ if(m._traj && m._traj.length) return m; }
|
|
@@ -2639,23 +2163,21 @@ function renderSubPanel(){
|
|
|
2639
2163
|
}
|
|
2640
2164
|
if(!count) list.innerHTML='<div class="empty" style="color:var(--faint);font-size:12px;padding:10px">本会话还没有子 Agent(task 工具会派生子 Agent)</div>';
|
|
2641
2165
|
}
|
|
2642
|
-
|
|
2643
|
-
|
|
2166
|
+
const toggleSubPanel=()=>{
|
|
2167
|
+
// §3.82:子Agent 入口 = 打开右栏并切到「子Agent」标签(不再单独开旧浮层)。
|
|
2168
|
+
renderSubPanel();
|
|
2169
|
+
activateRailTab('sub');
|
|
2170
|
+
$('#subRailBtn').classList.add('on');
|
|
2171
|
+
};
|
|
2172
|
+
$('#subRailBtn').onclick=toggleSubPanel;
|
|
2173
|
+
$('#sbClose').onclick=()=>{ setRunPanel(false); $('#subRailBtn').classList.remove('on'); };
|
|
2644
2174
|
// —— 任务面板(多会话并行) ——
|
|
2645
2175
|
function setBtn(){
|
|
2646
2176
|
// §138:停止点击后立刻有可见反馈(「停止中…」+ 禁用防连点),回合真正收尾后由 finally 复位。
|
|
2647
|
-
syncStripStop();
|
|
2648
2177
|
if(stopping){ sendBtn.textContent='⏹ 停止中…'; sendBtn.className='danger'; sendBtn.disabled=true; return; }
|
|
2649
2178
|
sendBtn.disabled=false;
|
|
2650
2179
|
sendBtn.textContent = generating ? '■ 停止' : '发送';
|
|
2651
|
-
sendBtn.className = generating ? '
|
|
2652
|
-
}
|
|
2653
|
-
/** §3.78:底部状态条里的 .stopbtn 与主按钮同一状态(同一条停止出口)。 */
|
|
2654
|
-
function syncStripStop(){
|
|
2655
|
-
const s=$('#stripStop'); if(!s) return;
|
|
2656
|
-
s.disabled=!generating||stopping;
|
|
2657
|
-
s.textContent=stopping?'■ 停止中…':'■ 停止';
|
|
2658
|
-
s.style.display=(generating||stopping)?'':'none';
|
|
2180
|
+
sendBtn.className = generating ? 'danger' : 'primary';
|
|
2659
2181
|
}
|
|
2660
2182
|
const lastBgStatus = new Map(); // 后台任务状态跟踪(完成/失败转换 → 聊天横幅反馈)
|
|
2661
2183
|
async function updateTasksPanel(){
|
|
@@ -2715,7 +2237,11 @@ async function refreshCostBadge(){
|
|
|
2715
2237
|
if($('#dashPanel').style.display==='flex') renderDashboard(j);
|
|
2716
2238
|
}
|
|
2717
2239
|
refreshCostBadge(); setInterval(refreshCostBadge, 15000);
|
|
2718
|
-
|
|
2240
|
+
// §3.82:顶栏费用徽标 = 打开右栏「仪表盘」标签(原有 📊 内容仍在 #dashPanel 里;关闭用 ✕/收起)。
|
|
2241
|
+
$('#costBadge').onclick=()=>{
|
|
2242
|
+
$('#trajRailBtn').classList.remove('on'); $('#subRailBtn').classList.remove('on');
|
|
2243
|
+
activateRailTab('dash');
|
|
2244
|
+
};
|
|
2719
2245
|
$('#dashClose').onclick=()=>{ setRunPanel(false); };
|
|
2720
2246
|
// 底部状态栏:轮次/步数/LLM 与工具时长/首 token 平均/吞吐/缓存命中/输入输出 tokens
|
|
2721
2247
|
async function refreshStatusBar(){
|
|
@@ -2728,9 +2254,20 @@ async function refreshStatusBar(){
|
|
|
2728
2254
|
$('#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';
|
|
2729
2255
|
}
|
|
2730
2256
|
refreshStatusBar(); setInterval(refreshStatusBar, 15000);
|
|
2731
|
-
|
|
2257
|
+
// §3.82:顶栏「任务」= 打开右栏「任务」标签(右栏收起时也会展开;关闭用 ✕/收起)。
|
|
2258
|
+
$('#tasksBtn').onclick=()=>{
|
|
2259
|
+
$('#trajRailBtn').classList.remove('on'); $('#subRailBtn').classList.remove('on');
|
|
2260
|
+
activateRailTab('tasks');
|
|
2261
|
+
};
|
|
2732
2262
|
$('#tpClose').onclick=()=>{ setRunPanel(false); };
|
|
2733
2263
|
|
|
2264
|
+
// v0.6.21(§3.81):顶栏「我在哪」——工作空间 › 当前会话。数据只取既有 currentWorkspaceName /
|
|
2265
|
+
// sessionsCache / currentSession,不新增后端字段。会话列表或工作空间刷新后同步更新。
|
|
2266
|
+
function renderCrumb(){
|
|
2267
|
+
const w=$('#crumbWs'); if(w) w.textContent=currentWorkspaceName||'工作空间';
|
|
2268
|
+
const cur=sessionsCache.find((s)=>s&&s.file===currentSession);
|
|
2269
|
+
const c=$('#crumbSession'); if(c) c.textContent=cur?(cur.label||cur.file):(currentSession?String(currentSession).replace(/\.jsonl$/,''):'新对话');
|
|
2270
|
+
}
|
|
2734
2271
|
async function refreshSessions(q){
|
|
2735
2272
|
const u=q?'/api/sessions?q='+encodeURIComponent(q):'/api/sessions';
|
|
2736
2273
|
const r=await fetch(u).catch(()=>null); if(!r)return;
|
|
@@ -2739,35 +2276,7 @@ async function refreshSessions(q){
|
|
|
2739
2276
|
const sel=$('#sessions'); sel.innerHTML='<option value="">历史会话</option>';
|
|
2740
2277
|
for(const s of sessionsCache){ const o=document.createElement('option'); o.value=s.file; o.textContent=s.label; sel.appendChild(o); }
|
|
2741
2278
|
if(currentSession&&!q) sel.value=currentSession;
|
|
2742
|
-
|
|
2743
|
-
updateCrumb();
|
|
2744
|
-
}
|
|
2745
|
-
/** §3.78 侧栏会话列表:消费既有 sessionsCache / #sessions change handler(不新增接口)。 */
|
|
2746
|
-
function renderSessionNav(){
|
|
2747
|
-
const nav=$('#sessionNav'); if(!nav) return;
|
|
2748
|
-
const list=Array.isArray(sessionsCache)?sessionsCache:[];
|
|
2749
|
-
nav.innerHTML='';
|
|
2750
|
-
if(!list.length){ nav.innerHTML='<div class="sess-empty">暂无会话(发一条消息后自动出现)</div>'; return; }
|
|
2751
|
-
for(const s of list){
|
|
2752
|
-
const btn=document.createElement('button');
|
|
2753
|
-
btn.type='button';
|
|
2754
|
-
btn.className='sess'+(currentSession===s.file?' active':'');
|
|
2755
|
-
btn.dataset.file=s.file;
|
|
2756
|
-
btn.title=s.label||s.file;
|
|
2757
|
-
if(currentSession===s.file) btn.setAttribute('aria-current','true');
|
|
2758
|
-
const t=document.createElement('span'); t.className='t'; t.textContent=s.label||s.file;
|
|
2759
|
-
btn.appendChild(t);
|
|
2760
|
-
btn.onclick=()=>{ const sel=$('#sessions'); if(sel){ sel.value=s.file; sel.dispatchEvent(new Event('change')); } closeSideDrawer(); };
|
|
2761
|
-
nav.appendChild(btn);
|
|
2762
|
-
}
|
|
2763
|
-
}
|
|
2764
|
-
/** §3.78 顶栏「工作空间 › 当前会话」:只读既有状态,不新增请求。 */
|
|
2765
|
-
function updateCrumb(){
|
|
2766
|
-
const ws=$('#wsSel');
|
|
2767
|
-
const opt=ws&&ws.selectedIndex>=0?ws.options[ws.selectedIndex]:null;
|
|
2768
|
-
const w=$('#crumbWs'); if(w) w.textContent=(opt?(opt.textContent||'').replace(/^●\s*/,''):'')||'工作空间';
|
|
2769
|
-
const cur=(sessionsCache||[]).find((x)=>x.file===currentSession);
|
|
2770
|
-
const c=$('#crumbSession'); if(c) c.textContent=cur?(cur.label||cur.file):'新对话';
|
|
2279
|
+
renderCrumb();
|
|
2771
2280
|
}
|
|
2772
2281
|
let searchTimer=null;
|
|
2773
2282
|
$('#sessionSearch').addEventListener('input',e=>{ clearTimeout(searchTimer); searchTimer=setTimeout(()=>refreshSessions(e.target.value.trim()),300); });
|
|
@@ -2840,8 +2349,6 @@ async function init(){
|
|
|
2840
2349
|
renderWorkStatus();
|
|
2841
2350
|
renderSessionSettings();
|
|
2842
2351
|
$('#sbxSel').value=j.sandbox||'off';
|
|
2843
|
-
settingDefaultsCache=settingDefaultsFromState(j);
|
|
2844
|
-
lastSandboxMode=j.sandbox||'off';
|
|
2845
2352
|
$('#sbxHint').textContent=j.sandboxSupported?'':'当前环境未检测到 bubblewrap,readonly/safe 将自动降级为 off';
|
|
2846
2353
|
$('#routeChk').checked=Boolean(j.routing);
|
|
2847
2354
|
$('#budgetInput').value=j.contextBudget||128000;
|
|
@@ -2871,12 +2378,11 @@ async function init(){
|
|
|
2871
2378
|
}catch(e){}
|
|
2872
2379
|
try{
|
|
2873
2380
|
const dr=await fetch('/api/draft?file='+encodeURIComponent(currentSession||''),{cache:'no-store'}); const dj=await dr.json();
|
|
2874
|
-
if(dj.text){ input.value=dj.text; input.style.height=Math.min(input.scrollHeight,200)+'px'; input.focus();
|
|
2381
|
+
if(dj.text){ input.value=dj.text; input.style.height=Math.min(input.scrollHeight,200)+'px'; input.focus(); }
|
|
2875
2382
|
}catch(e){}
|
|
2876
2383
|
renderSessionSettings();
|
|
2877
2384
|
renderEmptyState();
|
|
2878
2385
|
renderRunPanel();
|
|
2879
|
-
refreshSettingColumns();
|
|
2880
2386
|
renderTracePanels();
|
|
2881
2387
|
}
|
|
2882
2388
|
|
|
@@ -2903,7 +2409,6 @@ document.querySelectorAll('.cfg-nav-btn').forEach(b=>{ b.onclick=()=>showCfgPane
|
|
|
2903
2409
|
function openSettingsPanel(panel){
|
|
2904
2410
|
$('#cfgModal').style.display='flex';
|
|
2905
2411
|
showCfgPanel(panel||cfgPanel);
|
|
2906
|
-
refreshSettingColumns(); // P2-1:每次打开都按当前 config 刷新「默认 / 当前 / 影响」三列
|
|
2907
2412
|
}
|
|
2908
2413
|
$('#cfgBtn').onclick=()=>openSettingsPanel(cfgPanel);
|
|
2909
2414
|
$('#cfgCancel').onclick=()=>{ $('#cfgModal').style.display='none'; };
|
|
@@ -2926,7 +2431,7 @@ async function refreshWsSel(){
|
|
|
2926
2431
|
sel.appendChild(add);
|
|
2927
2432
|
const mgr=document.createElement('option'); mgr.value='__manage__'; mgr.textContent='管理(重命名/删除/改目录)…';
|
|
2928
2433
|
sel.appendChild(mgr);
|
|
2929
|
-
|
|
2434
|
+
renderCrumb();
|
|
2930
2435
|
}
|
|
2931
2436
|
/** 命令面板与顶栏下拉共用的工作空间切换(携带当前会话,语义与旧路径一致)。 */
|
|
2932
2437
|
async function applyWorkspaceSwitch(name){
|
|
@@ -2998,7 +2503,7 @@ async function refreshWorkspaces(){ const r=await fetch('/api/workspaces',{cach
|
|
|
2998
2503
|
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>';
|
|
2999
2504
|
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||'修改失败'); }); };
|
|
3000
2505
|
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||'重命名失败'); };
|
|
3001
|
-
const rm=document.createElement('button'); rm.textContent='删除'; rm.className='danger'; rm.style.cssText='padding:1px 8px;font-size:11px'; rm.onclick=async()=>{
|
|
2506
|
+
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(); };
|
|
3002
2507
|
div.appendChild(ed); div.appendChild(rn); div.appendChild(rm); list.appendChild(div);
|
|
3003
2508
|
}
|
|
3004
2509
|
}
|
|
@@ -3173,12 +2678,9 @@ async function reloadModels(){
|
|
|
3173
2678
|
$('#envBadge').textContent=env; $('#envBadge').style.display='';
|
|
3174
2679
|
if(j.contextBudget){ ctxLimit=Number(j.contextBudget)||ctxLimit; runState.contextTotal=ctxLimit; }
|
|
3175
2680
|
runState.permissionMode=String(j.permission||'ask'); runState.permissionSource=String(j.permissionSource||'');
|
|
3176
|
-
settingDefaultsCache=settingDefaultsFromState(j);
|
|
3177
|
-
lastSandboxMode=String(j.sandbox||lastSandboxMode||'off');
|
|
3178
2681
|
applyReasoningUI(j.reasoning);
|
|
3179
2682
|
renderWorkStatus();
|
|
3180
2683
|
renderSessionSettings();
|
|
3181
|
-
refreshSettingColumns();
|
|
3182
2684
|
}catch(e){}
|
|
3183
2685
|
}
|
|
3184
2686
|
async function refreshModelsCfg(){
|
|
@@ -3320,9 +2822,7 @@ async function refreshSyncConflicts(){
|
|
|
3320
2822
|
list.appendChild(div);
|
|
3321
2823
|
}
|
|
3322
2824
|
}
|
|
3323
|
-
$('#cfgSave').onclick=
|
|
3324
|
-
if(dangerGateNeeded('sandboxOff', lastSandboxMode, sbx)){ const gate=await confirmDanger('sandboxOff', async()=>{}); if(gate.cancelled){ $('#sbxSel').value=lastSandboxMode; return; } }
|
|
3325
|
-
lastSandboxMode=sbx; const payload={sandbox:sbx, routing:$('#routeChk').checked, contextBudget:Number($('#budgetInput').value), autostart:$('#autoStartChk').checked, notify:$('#notifyChk').checked}; if(Object.keys(to).length) payload.timeout=to; applyConfig(payload); refreshSettingColumns(); $('#cfgModal').style.display='none'; };
|
|
2825
|
+
$('#cfgSave').onclick=()=>{ const to={}; const ft=Number($('#toFirstToken').value), si=Number($('#toStreamIdle').value), tt=Number($('#toTotal').value); if(ft>0) to.firstTokenMs=Math.round(ft*1000); if(si>0) to.streamIdleMs=Math.round(si*1000); if(tt>0) to.totalMs=Math.round(tt*1000); const payload={sandbox:$('#sbxSel').value, routing:$('#routeChk').checked, contextBudget:Number($('#budgetInput').value), autostart:$('#autoStartChk').checked, notify:$('#notifyChk').checked}; if(Object.keys(to).length) payload.timeout=to; applyConfig(payload); $('#cfgModal').style.display='none'; };
|
|
3326
2826
|
async function applyConfig(payload, revertTarget){
|
|
3327
2827
|
const r=await fetch('/api/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
|
3328
2828
|
const j=await r.json().catch(()=>({error:'请求失败'}));
|
|
@@ -3344,202 +2844,19 @@ async function applyConfig(payload, revertTarget){
|
|
|
3344
2844
|
m.style.display=''; setTimeout(()=>{m.style.display='none';},4000);
|
|
3345
2845
|
}
|
|
3346
2846
|
$('#modelSel').addEventListener('change', e=>{ runState.modelName=e.target.value; runState.modelLabel=(modelsCache.find((m)=>m&&(m.id||m.name)===e.target.value)||{}).label||e.target.value; applyConfig({model:e.target.value}, e.target); renderSessionSettings(); renderRunPanel(); });
|
|
3347
|
-
$('#permSel').addEventListener('change', e=>{
|
|
2847
|
+
$('#permSel').addEventListener('change', e=>{ runState.permissionMode=e.target.value; runState.permissionSource='explicit'; applyConfig({permission:e.target.value}, e.target); renderSessionSettings(); renderWorkStatus(); });
|
|
3348
2848
|
// v0.3.0 P0-3:会话边界收尾(切换/新对话时提取项目记忆+日志,fire-and-forget 不阻塞 UI)
|
|
3349
2849
|
function finalizeCurrentSession(){
|
|
3350
2850
|
if(!currentSession) return;
|
|
3351
2851
|
fetch('/api/session-finalize',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({file:currentSession})}).catch(()=>{});
|
|
3352
2852
|
}
|
|
3353
|
-
async function loadSession(file){ if(currentSession && currentSession!==file) finalizeCurrentSession(); currentSession=file; chatEl.innerHTML=''; resetTraceState(); 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(); renderEmptyState();
|
|
3354
|
-
|
|
3355
|
-
// ============================================================================
|
|
3356
|
-
// P2 DOM 接线(v0.6.19 §3.76)
|
|
3357
|
-
// 设置三列 / 六类危险操作确认框 / 语言切换 / 对比度自检 / aria-live 播报。
|
|
3358
|
-
// ============================================================================
|
|
3359
|
-
let settingDefaultsCache = settingDefaultsFromState(null);
|
|
3360
|
-
let lastSandboxMode = 'off';
|
|
3361
|
-
|
|
3362
|
-
function secToMs(/** @type {any} */ v) {
|
|
3363
|
-
const n = Number(v);
|
|
3364
|
-
return Number.isFinite(n) && n > 0 ? Math.round(n * 1000) : null;
|
|
3365
|
-
}
|
|
3366
|
-
/** 当前设置值:全部从既有控件/状态读取(不另存一份配置)。 */
|
|
3367
|
-
function currentSettingValues() {
|
|
3368
|
-
return {
|
|
3369
|
-
permission: ($('#permSel') && $('#permSel').value) || runState.permissionMode || 'ask',
|
|
3370
|
-
sandbox: ($('#sbxSel') && $('#sbxSel').value) || lastSandboxMode || 'off',
|
|
3371
|
-
routing: Boolean($('#routeChk') && $('#routeChk').checked),
|
|
3372
|
-
contextBudget: Number($('#budgetInput') && $('#budgetInput').value) || settingDefaultsCache.contextBudget,
|
|
3373
|
-
timeout: {
|
|
3374
|
-
firstTokenMs: secToMs($('#toFirstToken') && $('#toFirstToken').value),
|
|
3375
|
-
streamIdleMs: secToMs($('#toStreamIdle') && $('#toStreamIdle').value),
|
|
3376
|
-
totalMs: secToMs($('#toTotal') && $('#toTotal').value),
|
|
3377
|
-
},
|
|
3378
|
-
autostart: Boolean($('#autoStartChk') && $('#autoStartChk').checked),
|
|
3379
|
-
notify: Boolean($('#notifyChk') && $('#notifyChk').checked),
|
|
3380
|
-
theme: storedThemePref(safeStorage()),
|
|
3381
|
-
language: storedLang(safeStorage()) || 'auto',
|
|
3382
|
-
};
|
|
3383
|
-
}
|
|
3384
|
-
/** 把三列写进设置面板(结构由 index.html 的 .cfg-cols 提供)。 */
|
|
3385
|
-
function refreshSettingColumns() {
|
|
3386
|
-
const values = currentSettingValues();
|
|
3387
|
-
const views = settingColumnsView({ defaults: settingDefaultsCache, values, lang: getLang(), translate });
|
|
3388
|
-
for (const v of views) {
|
|
3389
|
-
const box = document.querySelector('[data-cols="' + v.key + '"]');
|
|
3390
|
-
if (!box) continue;
|
|
3391
|
-
const put = (sel, text) => { const n = box.querySelector(sel); if (n) n.textContent = text; };
|
|
3392
|
-
put('.cfg-default', v.defaultText);
|
|
3393
|
-
put('.cfg-current', v.currentText);
|
|
3394
|
-
put('.cfg-impact', v.impact);
|
|
3395
|
-
if (box.classList) box.classList.toggle('changed', v.changed);
|
|
3396
|
-
if (box.setAttribute) box.setAttribute('data-changed', String(v.changed));
|
|
3397
|
-
}
|
|
3398
|
-
}
|
|
3399
|
-
// —— 危险操作确认框:说明后果 + 必须点「确认」(回车不确认) ——
|
|
3400
|
-
let dangerDialogHandle = null;
|
|
3401
|
-
function openDangerDialog(meta) {
|
|
3402
|
-
return new Promise((resolve) => {
|
|
3403
|
-
const mask = $('#dangerMask');
|
|
3404
|
-
const dialog = $('#dangerDialog');
|
|
3405
|
-
if (!mask || !dialog || !meta) { resolve(false); return; }
|
|
3406
|
-
const title = $('#dangerTitle'); if (title) title.textContent = meta.title;
|
|
3407
|
-
const body = $('#dangerBody'); if (body) body.textContent = meta.consequence;
|
|
3408
|
-
const note = $('#dangerNote'); if (note) note.textContent = meta.note;
|
|
3409
|
-
const ok = $('#dangerOk'); if (ok) ok.textContent = meta.confirmLabel;
|
|
3410
|
-
const cancel = $('#dangerCancel'); if (cancel) cancel.textContent = meta.cancelLabel;
|
|
3411
|
-
const opener = typeof document !== 'undefined' ? document.activeElement : null;
|
|
3412
|
-
mask.hidden = false;
|
|
3413
|
-
let settled = false;
|
|
3414
|
-
const finish = (/** @type {boolean} */ value) => {
|
|
3415
|
-
if (settled) return;
|
|
3416
|
-
settled = true;
|
|
3417
|
-
mask.hidden = true;
|
|
3418
|
-
if (dangerDialogHandle) { const h = dangerDialogHandle; dangerDialogHandle = null; h.close(true); }
|
|
3419
|
-
resolve(value);
|
|
3420
|
-
};
|
|
3421
|
-
if (ok) ok.onclick = () => finish(true);
|
|
3422
|
-
if (cancel) cancel.onclick = () => finish(false);
|
|
3423
|
-
dangerDialogHandle = installDialog(dialog, {
|
|
3424
|
-
opener,
|
|
3425
|
-
label: meta.title,
|
|
3426
|
-
initialFocus: cancel,
|
|
3427
|
-
onClose: () => {
|
|
3428
|
-
if (settled) return;
|
|
3429
|
-
settled = true;
|
|
3430
|
-
mask.hidden = true;
|
|
3431
|
-
resolve(false);
|
|
3432
|
-
},
|
|
3433
|
-
});
|
|
3434
|
-
});
|
|
3435
|
-
}
|
|
3436
|
-
/**
|
|
3437
|
-
* 确认 + 执行(取消 → execute 绝不调用)。所有危险操作共用这一条出口。
|
|
3438
|
-
* @param {string} key
|
|
3439
|
-
* @param {Function} execute
|
|
3440
|
-
* @returns {Promise<{ok: boolean, cancelled: boolean, result?: any}>}
|
|
3441
|
-
*/
|
|
3442
|
-
function confirmDanger(key, execute) {
|
|
3443
|
-
return runDanger(key, { confirm: openDangerDialog, execute, translate, lang: getLang() });
|
|
3444
|
-
}
|
|
3445
|
-
/** 设置项「恢复默认」的 payload(模型与密钥不在其中)。 */
|
|
3446
|
-
function resetConfigPayload() {
|
|
3447
|
-
const d = settingDefaultsCache;
|
|
3448
|
-
return {
|
|
3449
|
-
permission: d.permission || 'ask',
|
|
3450
|
-
sandbox: d.sandbox || 'off',
|
|
3451
|
-
routing: Boolean(d.routing),
|
|
3452
|
-
contextBudget: Number(d.contextBudget) || 128000,
|
|
3453
|
-
timeout: { firstTokenMs: null, streamIdleMs: null, totalMs: null },
|
|
3454
|
-
autostart: Boolean(d.autostart),
|
|
3455
|
-
notify: d.notify !== false,
|
|
3456
|
-
};
|
|
3457
|
-
}
|
|
3458
|
-
function applyResetConfigControls() {
|
|
3459
|
-
const d = settingDefaultsCache;
|
|
3460
|
-
if ($('#sbxSel')) $('#sbxSel').value = d.sandbox || 'off';
|
|
3461
|
-
if ($('#routeChk')) $('#routeChk').checked = Boolean(d.routing);
|
|
3462
|
-
if ($('#budgetInput')) $('#budgetInput').value = Number(d.contextBudget) || 128000;
|
|
3463
|
-
if ($('#toFirstToken')) $('#toFirstToken').value = '';
|
|
3464
|
-
if ($('#toStreamIdle')) $('#toStreamIdle').value = '';
|
|
3465
|
-
if ($('#toTotal')) $('#toTotal').value = '';
|
|
3466
|
-
if ($('#autoStartChk')) $('#autoStartChk').checked = Boolean(d.autostart);
|
|
3467
|
-
if ($('#notifyChk')) $('#notifyChk').checked = d.notify !== false;
|
|
3468
|
-
lastSandboxMode = d.sandbox || 'off';
|
|
3469
|
-
refreshSettingColumns();
|
|
3470
|
-
}
|
|
3471
|
-
|
|
3472
|
-
// —— aria-live:只在状态变化时写 #a11yLive(同一状态重复渲染直接返回 false) ——
|
|
3473
|
-
let lastLiveSnapshot = '';
|
|
3474
|
-
function updateLiveRegion(view) {
|
|
3475
|
-
const r = liveAnnouncement(lastLiveSnapshot, view);
|
|
3476
|
-
if (!r.changed) return false;
|
|
3477
|
-
lastLiveSnapshot = r.snapshot;
|
|
3478
|
-
const n = $('#a11yLive'); if (n) n.textContent = r.text;
|
|
3479
|
-
return true;
|
|
3480
|
-
}
|
|
3481
|
-
|
|
3482
|
-
// —— 语言切换:写 localStorage 并立即生效(默认跟随 navigator.language) ——
|
|
3483
|
-
function applyLanguageChoice(/** @type {string} */ pref) {
|
|
3484
|
-
const storage = safeStorage();
|
|
3485
|
-
let lang;
|
|
3486
|
-
if (pref === 'auto') {
|
|
3487
|
-
try { if (storage) storage.removeItem(LANG_STORAGE_KEY); } catch { /* 隐私模式 */ }
|
|
3488
|
-
lang = detectLang(navLang());
|
|
3489
|
-
setLang(lang, { root: document.documentElement });
|
|
3490
|
-
} else {
|
|
3491
|
-
lang = setLang(pref, { storage, root: document.documentElement, navLang: navLang() });
|
|
3492
|
-
}
|
|
3493
|
-
const sel = $('#langSel'); if (sel) sel.value = pref === 'auto' ? 'auto' : lang;
|
|
3494
|
-
refreshSettingColumns();
|
|
3495
|
-
renderSessionSettings();
|
|
3496
|
-
renderWorkStatus();
|
|
3497
|
-
renderRunPanel();
|
|
3498
|
-
return lang;
|
|
3499
|
-
}
|
|
3500
|
-
const langSel = $('#langSel');
|
|
3501
|
-
if (langSel) {
|
|
3502
|
-
langSel.value = storedLang(safeStorage()) || 'auto';
|
|
3503
|
-
langSel.onchange = () => applyLanguageChoice(langSel.value);
|
|
3504
|
-
}
|
|
3505
|
-
$('#memClear') && ($('#memClear').onclick = async () => {
|
|
3506
|
-
const gate = await confirmDanger('clearMemory', async () => {
|
|
3507
|
-
const r = await fetch('/api/memory', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: '' }) });
|
|
3508
|
-
return r.json().catch(() => ({}));
|
|
3509
|
-
});
|
|
3510
|
-
if (gate.cancelled) return;
|
|
3511
|
-
const j = gate.result || {};
|
|
3512
|
-
if (j.ok) { if ($('#memArea')) $('#memArea').value = ''; memFlash(t('settings.memCleared'), true); }
|
|
3513
|
-
else memFlash(t('settings.memClearFail') + (j.error || '失败'), false);
|
|
3514
|
-
});
|
|
3515
|
-
$('#cfgReset') && ($('#cfgReset').onclick = async () => {
|
|
3516
|
-
const gate = await confirmDanger('resetConfig', async () => applyConfig(resetConfigPayload()));
|
|
3517
|
-
if (gate.cancelled) return;
|
|
3518
|
-
applyResetConfigControls();
|
|
3519
|
-
const m = $('#cfgMsg');
|
|
3520
|
-
if (m) { m.textContent = t('settings.resetDone'); m.style.color = 'var(--accent)'; m.style.display = ''; setTimeout(() => { m.style.display = 'none'; }, 4000); }
|
|
3521
|
-
});
|
|
3522
|
-
// 沙箱:从非 off 切到 off 先确认;取消则回到上一个值(选择时拦截,保存时不再重复问)。
|
|
3523
|
-
const sbxEl = $('#sbxSel');
|
|
3524
|
-
if (sbxEl) {
|
|
3525
|
-
sbxEl.addEventListener('change', async () => {
|
|
3526
|
-
const next = sbxEl.value;
|
|
3527
|
-
if (dangerGateNeeded('sandboxOff', lastSandboxMode, next)) {
|
|
3528
|
-
const gate = await confirmDanger('sandboxOff', async () => {});
|
|
3529
|
-
if (gate.cancelled) { sbxEl.value = lastSandboxMode; return; }
|
|
3530
|
-
}
|
|
3531
|
-
lastSandboxMode = next;
|
|
3532
|
-
});
|
|
3533
|
-
}
|
|
3534
|
-
|
|
2853
|
+
async function loadSession(file){ if(currentSession && currentSession!==file) finalizeCurrentSession(); currentSession=file; chatEl.innerHTML=''; resetTraceState(); 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(); renderEmptyState(); renderCrumb(); }
|
|
3535
2854
|
|
|
2855
|
+
// §138:主按钮在生成态=停止,必须走**同一个**停止出口(stopTurnFromButton → stopTurn → POST /api/abort)
|
|
3536
2856
|
sendBtn.onclick=()=>{ if(generating){ stopTurnFromButton(); } else { send(); } };
|
|
3537
|
-
const stripStopEl=$('#stripStop'); if(stripStopEl) stripStopEl.onclick=()=>stopTurnFromButton();
|
|
3538
2857
|
input.addEventListener('keydown',e=>{ if(e.key==='Enter'&&!e.shiftKey){ e.preventDefault(); send(); } });
|
|
3539
|
-
input.addEventListener('input',()=>{ input.style.height='44px'; input.style.height=Math.min(input.scrollHeight,200)+'px';
|
|
3540
|
-
|
|
3541
|
-
function updateDraftBadge(){ const d=$('#draftBadge'); if(d) d.hidden=!String(input.value||'').trim(); }
|
|
3542
|
-
$('#newChat').onclick=()=>{ finalizeCurrentSession(); currentSession=null; chatEl.innerHTML=''; resetTraceState(); renderEmptyState(); updateCrumb(); renderSessionNav(); closeSideDrawer(); refreshSessions(); };
|
|
2858
|
+
input.addEventListener('input',()=>{ input.style.height='44px'; input.style.height=Math.min(input.scrollHeight,200)+'px'; });
|
|
2859
|
+
$('#newChat').onclick=()=>{ finalizeCurrentSession(); currentSession=null; chatEl.innerHTML=''; resetTraceState(); renderEmptyState(); renderCrumb(); refreshSessions(); };
|
|
3543
2860
|
$('#sessMgrBtn').onclick=openSessModal;
|
|
3544
2861
|
$('#sessClose').onclick=()=>{ $('#sessModal').style.display='none'; };
|
|
3545
2862
|
async function openSessModal(){
|
|
@@ -3551,10 +2868,10 @@ async function openSessModal(){
|
|
|
3551
2868
|
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';
|
|
3552
2869
|
div.innerHTML='<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(s.label)+'</span>';
|
|
3553
2870
|
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||'重命名失败'); };
|
|
3554
|
-
const dl=document.createElement('button'); dl.textContent='删除'; dl.className='danger'; dl.style.cssText='padding:1px 8px;font-size:11px'; dl.onclick=async()=>{
|
|
2871
|
+
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; chatEl.innerHTML=''; renderEmptyState(); } refreshSessions(); openSessModal(); } else uiAlert(jj.error||'删除失败'); };
|
|
3555
2872
|
div.appendChild(rn); div.appendChild(dl); list.appendChild(div);
|
|
3556
2873
|
}
|
|
3557
2874
|
}
|
|
3558
|
-
$('#sessions').onchange=e=>{ if(e.target.value){ loadSession(e.target.value); } else { finalizeCurrentSession(); currentSession=null; chatEl.innerHTML=''; resetTraceState(); renderEmptyState();
|
|
2875
|
+
$('#sessions').onchange=e=>{ if(e.target.value){ loadSession(e.target.value); } else { finalizeCurrentSession(); currentSession=null; chatEl.innerHTML=''; resetTraceState(); renderEmptyState(); renderCrumb(); } };
|
|
3559
2876
|
if('serviceWorker' in navigator){ navigator.serviceWorker.register('/sw.js').catch(()=>{}); }
|
|
3560
2877
|
init();
|