mingdao-harness 0.6.20 → 0.6.22
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/CONFIG.md +16 -0
- package/package.json +1 -1
- package/src/web/app.js +185 -51
- package/src/web/index.html +164 -59
- package/src/web/routes/domains/sessions.js +49 -3
- package/src/web/server.js +36 -3
package/docs/CONFIG.md
CHANGED
|
@@ -394,6 +394,22 @@ completion 计费,防止推理吃满上限时空轮白烧)、`compactTrigger
|
|
|
394
394
|
|
|
395
395
|
长期偏好仍走用户记忆(`AGENTS.md`,见上),不受此开关影响。
|
|
396
396
|
|
|
397
|
+
### WebUI 会话列表字段与消息时间(v0.6.22,§3.84)
|
|
398
|
+
|
|
399
|
+
`GET /api/sessions` 的每一项在既有 `file` / `mtime` / `label` 之外**只增**三个只读字段:
|
|
400
|
+
|
|
401
|
+
| 字段 | 含义 | 来源 |
|
|
402
|
+
| --- | --- | --- |
|
|
403
|
+
| `updatedAt` | 会话最后更新时间,ISO 字符串 | 会话文件 mtime |
|
|
404
|
+
| `messageCount` | 该会话的 JSONL 消息条数(坏行跳过) | 只读解析文件 |
|
|
405
|
+
| `title` | 会话标题(已有文件名即标题;自动标题/重命名都落在文件名上) | 文件名去 `.jsonl` |
|
|
406
|
+
|
|
407
|
+
`GET /api/session?file=` 的每条消息**只增** `ts`(ISO 字符串)。旧会话(v0.6.21 及以前写的
|
|
408
|
+
JSONL 没有 `ts`)返回 `"ts": null`,其余字段语义不变;WebUI 对 `ts` 为 `null` 的消息不渲染时间,
|
|
409
|
+
不会出现 `Invalid Date` / `undefined`。新消息在**服务端落盘时**写入 `ts`(压缩重写保留已有值),
|
|
410
|
+
前端不自行推算历史消息时间。分组(今天/昨天/本周/更早)与时间显示都在 `src/web/app.js`;
|
|
411
|
+
分组只在会话列表刷新时计算一次,不引入轮询。
|
|
412
|
+
|
|
397
413
|
## 模型名称以 API 为准(v0.6.19,§3.67)
|
|
398
414
|
|
|
399
415
|
**仓库不维护具体版本号,只维护「家族能力 / 定价模式」。** 官方改名、升版本(例如
|
package/package.json
CHANGED
package/src/web/app.js
CHANGED
|
@@ -224,16 +224,87 @@ chatEl.addEventListener('click', (e) => {
|
|
|
224
224
|
const done = () => { b.textContent = '已复制'; setTimeout(() => { b.textContent = '复制'; }, 1200); };
|
|
225
225
|
if (navigator.clipboard?.writeText) navigator.clipboard.writeText(t).then(done).catch(() => {});
|
|
226
226
|
});
|
|
227
|
+
// ==== v0.6.22 §3.84 会话分组 + 消息时间(纯函数,test/ui-sessions.js 可直接抽取执行)====
|
|
228
|
+
// 只做展示:分组顺序固定「今天 → 昨天 → 本周 → 更早」,组标题不可点击/不可折叠。
|
|
229
|
+
// 「现在」一律由调用方传入(默认 new Date()),测试用固定 now 避免跨午夜抖动。
|
|
230
|
+
const SESSION_GROUP_ORDER = ['today', 'yesterday', 'week', 'earlier'];
|
|
231
|
+
const SESSION_GROUP_TITLES = { today: '今天', yesterday: '昨天', week: '本周', earlier: '更早' };
|
|
232
|
+
|
|
233
|
+
/** 取本地时区某一天的 00:00(用日期构造器而非减 86400000,跨夏令时也不会偏一小时)。 */
|
|
234
|
+
function localDayStart(d) {
|
|
235
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* 单个会话落在哪个分组。时间不可解析/缺失 → earlier(绝不抛错)。
|
|
240
|
+
* 边界:今天 00:00 起算今天;昨天 00:00 起算昨天;本周从周一 00:00 起算。
|
|
241
|
+
* @param {string|number|Date|undefined|null} updatedAt
|
|
242
|
+
* @param {Date} [now]
|
|
243
|
+
* @returns {string}
|
|
244
|
+
*/
|
|
245
|
+
function sessionGroupKey(updatedAt, now) {
|
|
246
|
+
const t = new Date(updatedAt == null ? NaN : updatedAt).getTime();
|
|
247
|
+
const n = now instanceof Date ? now : new Date(now == null ? Date.now() : now);
|
|
248
|
+
if (!Number.isFinite(t) || Number.isNaN(n.getTime())) return 'earlier';
|
|
249
|
+
const today = localDayStart(n);
|
|
250
|
+
if (t >= today) return 'today';
|
|
251
|
+
const yesterday = new Date(n.getFullYear(), n.getMonth(), n.getDate() - 1).getTime();
|
|
252
|
+
if (t >= yesterday) return 'yesterday';
|
|
253
|
+
const dow = (n.getDay() + 6) % 7; // 周一 = 0
|
|
254
|
+
const weekStart = new Date(n.getFullYear(), n.getMonth(), n.getDate() - dow).getTime();
|
|
255
|
+
if (t >= weekStart) return 'week';
|
|
256
|
+
return 'earlier';
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* 一次性把会话列表分到四个桶(空桶不返回;顺序固定)。调用方只在数据刷新时调用一次,
|
|
261
|
+
* 渲染函数只读 `sessionsGroupedCache`——不得在每次渲染时重算。
|
|
262
|
+
* @param {any[]} sessions
|
|
263
|
+
* @param {Date} [now]
|
|
264
|
+
* @returns {{key:string,title:string,items:any[]}[]}
|
|
265
|
+
*/
|
|
266
|
+
function groupSessionsByUpdatedAt(sessions, now) {
|
|
267
|
+
/** @type {Record<string, any[]>} */
|
|
268
|
+
const buckets = { today: [], yesterday: [], week: [], earlier: [] };
|
|
269
|
+
for (const s of sessions || []) {
|
|
270
|
+
// updatedAt 是 v0.6.22 新增字段;mtime 是旧接口既有字段(毫秒),两者都能分组。
|
|
271
|
+
buckets[sessionGroupKey(s && (s.updatedAt ?? s.mtime), now)].push(s);
|
|
272
|
+
}
|
|
273
|
+
return SESSION_GROUP_ORDER
|
|
274
|
+
.filter((key) => buckets[key].length > 0)
|
|
275
|
+
.map((key) => ({ key, title: SESSION_GROUP_TITLES[key], items: buckets[key] }));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* 消息时间文案:同一天 `14:32`;跨天 `10-19 14:32`;缺失/非法 → ''(前端不渲染任何节点,
|
|
280
|
+
* 所以不会出现 "Invalid Date" / "undefined")。
|
|
281
|
+
* @param {any} ts
|
|
282
|
+
* @param {Date} [now]
|
|
283
|
+
* @returns {string}
|
|
284
|
+
*/
|
|
285
|
+
function msgTimeText(ts, now) {
|
|
286
|
+
if (typeof ts !== 'string' || !ts.trim()) return '';
|
|
287
|
+
const d = new Date(ts);
|
|
288
|
+
if (Number.isNaN(d.getTime())) return '';
|
|
289
|
+
const n = now instanceof Date ? now : new Date(now == null ? Date.now() : now);
|
|
290
|
+
if (Number.isNaN(n.getTime())) return '';
|
|
291
|
+
const hm = String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
|
|
292
|
+
const sameDay = d.getFullYear() === n.getFullYear() && d.getMonth() === n.getMonth() && d.getDate() === n.getDate();
|
|
293
|
+
if (sameDay) return hm;
|
|
294
|
+
return String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0') + ' ' + hm;
|
|
295
|
+
}
|
|
296
|
+
// ==== /v0.6.22 §3.84 ====
|
|
297
|
+
|
|
227
298
|
let generating = false; // 生成中:发送按钮复用为停止按钮
|
|
228
299
|
let currentSession = null, thinking = null;
|
|
229
300
|
|
|
230
301
|
|
|
231
302
|
|
|
232
|
-
function newAiMsg(){ const el=document.createElement('div'); el.className='msg-ai'; chatEl.appendChild(el); scrollBottom(); return el; }
|
|
303
|
+
function newAiMsg(ts){ const el=document.createElement('div'); el.className='msg-ai'; const t=msgTimeText(ts); if(t){ const m=document.createElement('div'); m.className='msg-time'; m.textContent=t; el.appendChild(m); } chatEl.appendChild(el); scrollBottom(); return el; }
|
|
233
304
|
function aiContent(msg){ const c=msg.querySelector('.content'); return c || (()=>{const d=document.createElement('div');d.className='content';msg.appendChild(d);return d;})(); }
|
|
234
305
|
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'); }
|
|
235
306
|
|
|
236
|
-
function addUser(text){ const el=document.createElement('div'); el.className='msg-user'; el.innerHTML='<div class="bubble">'+esc(text)+'</div>'; chatEl.appendChild(el); renderEmptyState(); scrollBottom(); }
|
|
307
|
+
function addUser(text, ts){ const el=document.createElement('div'); el.className='msg-user'; const t=msgTimeText(ts); el.innerHTML='<div class="bubble">'+esc(text)+(t?'<span class="msg-time">'+esc(t)+'</span>':'')+'</div>'; chatEl.appendChild(el); renderEmptyState(); scrollBottom(); }
|
|
237
308
|
|
|
238
309
|
const runningTools = new Map();
|
|
239
310
|
// P1-1 工具时间线(v0.6.19 §3.70)保留为轨迹详情的渲染原语(toolStepNode/paintToolStep/
|
|
@@ -484,8 +555,8 @@ async function send(){
|
|
|
484
555
|
if(attachments.length) payload.attachments=attachments;
|
|
485
556
|
const sentAttachments=attachments;
|
|
486
557
|
attachments=[]; renderAttachments();
|
|
487
|
-
addUser(text||(sentAttachments.map(a=>(a.type==='image'?'🖼 ':'📄 ')+a.name).join(' ')));
|
|
488
|
-
const msg=newAiMsg(); const content=aiContent(msg); let raw='';
|
|
558
|
+
addUser(text||(sentAttachments.map(a=>(a.type==='image'?'🖼 ':'📄 ')+a.name).join(' ')), new Date().toISOString());
|
|
559
|
+
const msg=newAiMsg(new Date().toISOString()); const content=aiContent(msg); let raw='';
|
|
489
560
|
activeAiMsg=msg; msg._traj=[]; msg._steps=0; msg._round=++turnSeq; msg._startedAt=Date.now(); msg._endedAt=null; pendingSubagent=null; curSteps=0; curWorkT0=Date.now();
|
|
490
561
|
// P0-1:本轮开始 → 运行状态条归零(上一轮的费用/首帧/工具数不许泄漏到本轮;上下文基数保留)
|
|
491
562
|
runState.rawPhase='发请求(等首帧)'; runState.phase='等首帧'; runState.aborted=false;
|
|
@@ -761,7 +832,7 @@ function renderWorkStatus(){
|
|
|
761
832
|
const running=bgTasks.filter((/** @type {any} */ t)=>t.status==='running');
|
|
762
833
|
const latest=running.length?running[running.length-1]:null;
|
|
763
834
|
bg.style.display=''; bg.textContent='🛠 '+bgRunning+' 个后台任务运行中'+(latest?' · '+String(latest.message||'').slice(0,22):'')+' — 点击查看详情';
|
|
764
|
-
bg.onclick=()=>{
|
|
835
|
+
bg.onclick=()=>{ activateRailTab('tasks'); }; // §3.82:后台任务 chip 打开右栏「任务」标签
|
|
765
836
|
} else { bg.style.display='none'; bg.onclick=null; }
|
|
766
837
|
}
|
|
767
838
|
// P1-4:右栏「本轮运行」与状态条**同源**——renderRunPanel() 内部只走 runPanelView()→runStripView(),
|
|
@@ -1159,6 +1230,8 @@ function layoutClassesFor(width) {
|
|
|
1159
1230
|
// 快捷键表 / 会话设置 / 空态 / 设置搜索 / 诊断。全部消费上面纯函数的结果。
|
|
1160
1231
|
// ============================================================================
|
|
1161
1232
|
let sessionsCache = [];
|
|
1233
|
+
// §3.84:分组只在 refreshSessions 里算一次;下拉/会话管理列表都只读这份缓存(不随渲染重算)。
|
|
1234
|
+
let sessionsGroupedCache = [];
|
|
1162
1235
|
let workspacesCache = [];
|
|
1163
1236
|
let currentWorkspaceName = '';
|
|
1164
1237
|
let modelsCache = [];
|
|
@@ -1166,6 +1239,44 @@ let homeDir = '';
|
|
|
1166
1239
|
let workingDirCache = '';
|
|
1167
1240
|
let compactAvailable = false; // Web 端当前没有 /api/compact;为 true 时才显示压缩入口
|
|
1168
1241
|
let runPanelOpen = false;
|
|
1242
|
+
// —— 右栏四标签(§3.82 第二步):轨迹 / 子Agent / 任务 / 仪表盘 ——
|
|
1243
|
+
// 标签切换**只控制 display**(railTabDisplay 纯函数),四个面板节点常驻 DOM、id 不变;
|
|
1244
|
+
// 不重写 updateTasksPanel / renderDashboard / openTraj / renderSubPanel 的数据逻辑。
|
|
1245
|
+
const RAIL_TABS = ['trace', 'sub', 'tasks', 'dash'];
|
|
1246
|
+
const RAIL_TAB_PANELS = { trace: '#tabTrace', sub: '#tabSub', tasks: '#tabTasks', dash: '#tabDash' };
|
|
1247
|
+
const RAIL_TAB_BUTTONS = { trace: '#rtTrace', sub: '#rtSub', tasks: '#rtTasks', dash: '#rtDash' };
|
|
1248
|
+
let activeRailTab = 'trace';
|
|
1249
|
+
function railTabDisplay(tab) {
|
|
1250
|
+
const active = RAIL_TABS.includes(tab) ? tab : 'trace';
|
|
1251
|
+
/** @type {Record<string, 'flex'|'none'>} */
|
|
1252
|
+
const out = {};
|
|
1253
|
+
for (const key of RAIL_TABS) out[key] = key === active ? 'flex' : 'none';
|
|
1254
|
+
return out;
|
|
1255
|
+
}
|
|
1256
|
+
function setRailTab(tab) {
|
|
1257
|
+
activeRailTab = RAIL_TABS.includes(tab) ? tab : 'trace';
|
|
1258
|
+
const disp = railTabDisplay(activeRailTab);
|
|
1259
|
+
for (const key of RAIL_TABS) {
|
|
1260
|
+
const panel = $(RAIL_TAB_PANELS[key]);
|
|
1261
|
+
if (panel) panel.style.display = disp[key];
|
|
1262
|
+
const btn = $(RAIL_TAB_BUTTONS[key]);
|
|
1263
|
+
if (btn) {
|
|
1264
|
+
btn.classList.toggle('on', key === activeRailTab);
|
|
1265
|
+
btn.setAttribute('aria-selected', key === activeRailTab ? 'true' : 'false');
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
// 搬进标签页的两个旧面板:display 继续跟着标签走(updateTasksPanel / refreshCostBadge 靠它判可见)。
|
|
1269
|
+
const tp = $('#tasksPanel'); if (tp) tp.style.display = disp.tasks;
|
|
1270
|
+
const dp = $('#dashPanel'); if (dp) dp.style.display = disp.dash;
|
|
1271
|
+
if (activeRailTab === 'tasks' && typeof updateTasksPanel === 'function') updateTasksPanel();
|
|
1272
|
+
if (activeRailTab === 'dash' && typeof refreshCostBadge === 'function') refreshCostBadge();
|
|
1273
|
+
return activeRailTab;
|
|
1274
|
+
}
|
|
1275
|
+
/** 点标签/既有入口的统一出口:先开右栏,再切标签。 */
|
|
1276
|
+
function activateRailTab(tab) {
|
|
1277
|
+
setRunPanel(true);
|
|
1278
|
+
return setRailTab(tab);
|
|
1279
|
+
}
|
|
1169
1280
|
let responsiveInitialized = false;
|
|
1170
1281
|
// —— 轨迹收纳状态(§3.73):两个折叠区 + 展开过的轮次/工具/子代理(重渲染后保持展开) ——
|
|
1171
1282
|
const traceFoldState = { trace: true, subs: true };
|
|
@@ -1214,8 +1325,7 @@ initTheme();
|
|
|
1214
1325
|
// —— 右栏「本轮运行」:<1180 收起为抽屉;宽屏默认展开;不做任何新轮询 ——
|
|
1215
1326
|
function syncRunPanel() {
|
|
1216
1327
|
const el = $('#runPanel'); if (!el) return;
|
|
1217
|
-
//
|
|
1218
|
-
// (syncPanelLayout 统一算,避免"两个面板各写一份互斥逻辑")。
|
|
1328
|
+
// §3.82:四个面板都在右栏标签页内,右边缘只剩右栏一个抽屉(显隐由 syncPanelLayout 统一算)。
|
|
1219
1329
|
syncPanelLayout();
|
|
1220
1330
|
const chip = $('#runChip'); if (chip) chip.setAttribute('aria-expanded', runPanelOpen ? 'true' : 'false');
|
|
1221
1331
|
const col = $('#rpCollapse'); if (col) col.textContent = '收起';
|
|
@@ -1242,6 +1352,12 @@ function initResponsive() {
|
|
|
1242
1352
|
initResponsive();
|
|
1243
1353
|
// §3.73:绑定两个折叠区 + 末尾区块总开关(渲染入口在下面的轨迹收纳块)。
|
|
1244
1354
|
initTraceUI();
|
|
1355
|
+
// §3.82:四个标签按钮(只切 display + 开右栏;默认轨迹)。
|
|
1356
|
+
for (const key of RAIL_TABS) {
|
|
1357
|
+
const btn = $(RAIL_TAB_BUTTONS[key]);
|
|
1358
|
+
if (btn) btn.onclick = () => activateRailTab(key);
|
|
1359
|
+
}
|
|
1360
|
+
setRailTab('trace');
|
|
1245
1361
|
|
|
1246
1362
|
// —— 工具时间线 DOM:一张卡 + 可复用行(开始/完成不换节点) ——
|
|
1247
1363
|
function toolStepNode(step) {
|
|
@@ -1678,6 +1794,7 @@ function focusTraceRound(round) {
|
|
|
1678
1794
|
const body = $('#tdBody'); if (body) body.hidden = false;
|
|
1679
1795
|
const toggle = $('#tdToggle'); if (toggle) toggle.setAttribute('aria-expanded', 'true');
|
|
1680
1796
|
} else {
|
|
1797
|
+
setRailTab('trace');
|
|
1681
1798
|
setRunPanel(true);
|
|
1682
1799
|
}
|
|
1683
1800
|
traceFoldState.trace = true;
|
|
@@ -1743,6 +1860,7 @@ function renderSessionSettings() {
|
|
|
1743
1860
|
? ((presetData.find((p) => p.name === psel.value) || {}).label || psel.value)
|
|
1744
1861
|
: '无预设';
|
|
1745
1862
|
runState.presetName = presetLabel;
|
|
1863
|
+
set('#ssPresetSrc', '当前:' + presetLabel + ' · 来源:你的选择 · 仅下一轮(工具白名单 / 参数)');
|
|
1746
1864
|
set('#ssEffective', sessionEffectiveLine({ model: md.name, modelLabel: md.label, permission: mode, sourceLabel: permissionSourceLabel(source), preset: presetLabel }));
|
|
1747
1865
|
set('#chipModel', md.label || md.name || '模型');
|
|
1748
1866
|
set('#chipPerm', permissionModeLabel(mode));
|
|
@@ -2070,38 +2188,29 @@ function openTraj(msg){
|
|
|
2070
2188
|
// 点击标题定位到聊天中的对应工具卡
|
|
2071
2189
|
list.appendChild(div);
|
|
2072
2190
|
}
|
|
2073
|
-
|
|
2074
|
-
$('#tasksPanel').style.display='none'; // 仅任务面板(右侧)与轨迹互斥;子代理面板可与轨迹同显
|
|
2191
|
+
// §3.82:轨迹入口不再单独开旧浮层——切到右栏「轨迹」标签(旧面板与其 #tjList 仍在 DOM 里)。
|
|
2075
2192
|
$('#trajRailBtn').classList.add('on');
|
|
2076
|
-
|
|
2193
|
+
activateRailTab('trace');
|
|
2077
2194
|
}
|
|
2078
|
-
//
|
|
2079
|
-
//
|
|
2195
|
+
// 面板布局同步:§3.82 起轨迹/子代理/任务/仪表盘都在右栏标签页内,
|
|
2196
|
+
// 右栏是否显示只由 runPanelOpen 决定;旧的四条 body 推开 class 不再使用(CSS 规则保留不动)。
|
|
2080
2197
|
function syncPanelLayout(){
|
|
2081
|
-
|
|
2082
|
-
document.body.classList.toggle('traj-open', open('trajPanel'));
|
|
2083
|
-
document.body.classList.toggle('sub-open', open('subPanel'));
|
|
2084
|
-
document.body.classList.toggle('tasks-open', open('tasksPanel'));
|
|
2085
|
-
document.body.classList.toggle('dash-open', open('dashPanel'));
|
|
2086
|
-
// P1-4:右边缘只留一个抽屉——既有右侧面板(子代理/任务/省钱仪表盘)打开时运行面板让位;
|
|
2087
|
-
// 它们关闭后 syncPanelLayout 再被调用,运行面板按 runPanelOpen 恢复。
|
|
2088
|
-
const otherRight=open('subPanel')||open('tasksPanel')||open('dashPanel');
|
|
2198
|
+
document.body.classList.remove('traj-open','sub-open','tasks-open','dash-open');
|
|
2089
2199
|
const narrow=layoutClassesFor(window.innerWidth||0).narrowRun;
|
|
2090
|
-
const showRun=Boolean(runPanelOpen)
|
|
2200
|
+
const showRun=Boolean(runPanelOpen);
|
|
2091
2201
|
const rp=$('#runPanel'); if(rp) rp.hidden=!showRun;
|
|
2092
2202
|
document.body.classList.toggle('run-open', showRun&&!narrow);
|
|
2093
2203
|
document.body.classList.toggle('run-drawer', showRun&&narrow);
|
|
2094
2204
|
// §3.73:窄屏右栏收起时,轨迹/子Agent 在对话流末尾显示;右栏(抽屉)一开就让位。
|
|
2095
2205
|
syncTraceDock();
|
|
2096
2206
|
}
|
|
2097
|
-
$('#tjClose').onclick=()=>{
|
|
2207
|
+
$('#tjClose').onclick=()=>{ setRunPanel(false); $('#trajRailBtn').classList.remove('on'); };
|
|
2098
2208
|
$('#trajRailBtn').onclick=()=>{
|
|
2099
|
-
|
|
2100
|
-
const
|
|
2101
|
-
if(
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
syncPanelLayout();
|
|
2209
|
+
// §3.82:轨迹入口 = 打开右栏并切到「轨迹」标签(没有轨迹时也切过去,显示空态)。
|
|
2210
|
+
const m=lastMsgWithTraj();
|
|
2211
|
+
if(m && m._traj && m._traj.length){ openTraj(m); return; }
|
|
2212
|
+
$('#trajRailBtn').classList.add('on');
|
|
2213
|
+
activateRailTab('trace');
|
|
2105
2214
|
};
|
|
2106
2215
|
function lastMsgWithTraj(){
|
|
2107
2216
|
const msgs=[...document.querySelectorAll('#chat .msg-ai')].reverse();
|
|
@@ -2128,15 +2237,13 @@ function renderSubPanel(){
|
|
|
2128
2237
|
if(!count) list.innerHTML='<div class="empty" style="color:var(--faint);font-size:12px;padding:10px">本会话还没有子 Agent(task 工具会派生子 Agent)</div>';
|
|
2129
2238
|
}
|
|
2130
2239
|
const toggleSubPanel=()=>{
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
$('#subRailBtn').classList.
|
|
2135
|
-
if(willShow){ $('#tasksPanel').style.display='none'; renderSubPanel(); } // 轨迹(左侧)可与子代理(右侧)同显
|
|
2136
|
-
syncPanelLayout();
|
|
2240
|
+
// §3.82:子Agent 入口 = 打开右栏并切到「子Agent」标签(不再单独开旧浮层)。
|
|
2241
|
+
renderSubPanel();
|
|
2242
|
+
activateRailTab('sub');
|
|
2243
|
+
$('#subRailBtn').classList.add('on');
|
|
2137
2244
|
};
|
|
2138
2245
|
$('#subRailBtn').onclick=toggleSubPanel;
|
|
2139
|
-
$('#sbClose').onclick=()=>{
|
|
2246
|
+
$('#sbClose').onclick=()=>{ setRunPanel(false); $('#subRailBtn').classList.remove('on'); };
|
|
2140
2247
|
// —— 任务面板(多会话并行) ——
|
|
2141
2248
|
function setBtn(){
|
|
2142
2249
|
// §138:停止点击后立刻有可见反馈(「停止中…」+ 禁用防连点),回合真正收尾后由 finally 复位。
|
|
@@ -2203,8 +2310,12 @@ async function refreshCostBadge(){
|
|
|
2203
2310
|
if($('#dashPanel').style.display==='flex') renderDashboard(j);
|
|
2204
2311
|
}
|
|
2205
2312
|
refreshCostBadge(); setInterval(refreshCostBadge, 15000);
|
|
2206
|
-
|
|
2207
|
-
$('#
|
|
2313
|
+
// §3.82:顶栏费用徽标 = 打开右栏「仪表盘」标签(原有 📊 内容仍在 #dashPanel 里;关闭用 ✕/收起)。
|
|
2314
|
+
$('#costBadge').onclick=()=>{
|
|
2315
|
+
$('#trajRailBtn').classList.remove('on'); $('#subRailBtn').classList.remove('on');
|
|
2316
|
+
activateRailTab('dash');
|
|
2317
|
+
};
|
|
2318
|
+
$('#dashClose').onclick=()=>{ setRunPanel(false); };
|
|
2208
2319
|
// 底部状态栏:轮次/步数/LLM 与工具时长/首 token 平均/吞吐/缓存命中/输入输出 tokens
|
|
2209
2320
|
async function refreshStatusBar(){
|
|
2210
2321
|
const r=await fetch('/api/cache-stats',{cache:'no-store'}).catch(()=>null); if(!r) return;
|
|
@@ -2216,17 +2327,35 @@ async function refreshStatusBar(){
|
|
|
2216
2327
|
$('#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';
|
|
2217
2328
|
}
|
|
2218
2329
|
refreshStatusBar(); setInterval(refreshStatusBar, 15000);
|
|
2219
|
-
|
|
2220
|
-
$('#
|
|
2330
|
+
// §3.82:顶栏「任务」= 打开右栏「任务」标签(右栏收起时也会展开;关闭用 ✕/收起)。
|
|
2331
|
+
$('#tasksBtn').onclick=()=>{
|
|
2332
|
+
$('#trajRailBtn').classList.remove('on'); $('#subRailBtn').classList.remove('on');
|
|
2333
|
+
activateRailTab('tasks');
|
|
2334
|
+
};
|
|
2335
|
+
$('#tpClose').onclick=()=>{ setRunPanel(false); };
|
|
2221
2336
|
|
|
2337
|
+
// v0.6.21(§3.81):顶栏「我在哪」——工作空间 › 当前会话。数据只取既有 currentWorkspaceName /
|
|
2338
|
+
// sessionsCache / currentSession,不新增后端字段。会话列表或工作空间刷新后同步更新。
|
|
2339
|
+
function renderCrumb(){
|
|
2340
|
+
const w=$('#crumbWs'); if(w) w.textContent=currentWorkspaceName||'工作空间';
|
|
2341
|
+
const cur=sessionsCache.find((s)=>s&&s.file===currentSession);
|
|
2342
|
+
const c=$('#crumbSession'); if(c) c.textContent=cur?(cur.label||cur.file):(currentSession?String(currentSession).replace(/\.jsonl$/,''):'新对话');
|
|
2343
|
+
}
|
|
2222
2344
|
async function refreshSessions(q){
|
|
2223
2345
|
const u=q?'/api/sessions?q='+encodeURIComponent(q):'/api/sessions';
|
|
2224
2346
|
const r=await fetch(u).catch(()=>null); if(!r)return;
|
|
2225
2347
|
const j=await r.json();
|
|
2226
2348
|
sessionsCache=Array.isArray(j.sessions)?j.sessions:[];
|
|
2349
|
+
sessionsGroupedCache=groupSessionsByUpdatedAt(sessionsCache); // 一次性分组(性能:不在渲染时重算)
|
|
2227
2350
|
const sel=$('#sessions'); sel.innerHTML='<option value="">历史会话</option>';
|
|
2228
|
-
for(const
|
|
2351
|
+
for(const g of sessionsGroupedCache){
|
|
2352
|
+
// 原生 optgroup 的组标题由系统渲染:不可点击、不可折叠——正好符合"保持简单"。
|
|
2353
|
+
const og=document.createElement('optgroup'); og.label=g.title;
|
|
2354
|
+
for(const s of g.items){ const o=document.createElement('option'); o.value=s.file; o.textContent=s.label; og.appendChild(o); }
|
|
2355
|
+
sel.appendChild(og);
|
|
2356
|
+
}
|
|
2229
2357
|
if(currentSession&&!q) sel.value=currentSession;
|
|
2358
|
+
renderCrumb();
|
|
2230
2359
|
}
|
|
2231
2360
|
let searchTimer=null;
|
|
2232
2361
|
$('#sessionSearch').addEventListener('input',e=>{ clearTimeout(searchTimer); searchTimer=setTimeout(()=>refreshSessions(e.target.value.trim()),300); });
|
|
@@ -2381,6 +2510,7 @@ async function refreshWsSel(){
|
|
|
2381
2510
|
sel.appendChild(add);
|
|
2382
2511
|
const mgr=document.createElement('option'); mgr.value='__manage__'; mgr.textContent='管理(重命名/删除/改目录)…';
|
|
2383
2512
|
sel.appendChild(mgr);
|
|
2513
|
+
renderCrumb();
|
|
2384
2514
|
}
|
|
2385
2515
|
/** 命令面板与顶栏下拉共用的工作空间切换(携带当前会话,语义与旧路径一致)。 */
|
|
2386
2516
|
async function applyWorkspaceSwitch(name){
|
|
@@ -2799,28 +2929,32 @@ function finalizeCurrentSession(){
|
|
|
2799
2929
|
if(!currentSession) return;
|
|
2800
2930
|
fetch('/api/session-finalize',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({file:currentSession})}).catch(()=>{});
|
|
2801
2931
|
}
|
|
2802
|
-
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(); }
|
|
2932
|
+
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, m.ts); else { const el=newAiMsg(m.ts); 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(); }
|
|
2803
2933
|
|
|
2804
2934
|
// §138:主按钮在生成态=停止,必须走**同一个**停止出口(stopTurnFromButton → stopTurn → POST /api/abort)
|
|
2805
2935
|
sendBtn.onclick=()=>{ if(generating){ stopTurnFromButton(); } else { send(); } };
|
|
2806
2936
|
input.addEventListener('keydown',e=>{ if(e.key==='Enter'&&!e.shiftKey){ e.preventDefault(); send(); } });
|
|
2807
2937
|
input.addEventListener('input',()=>{ input.style.height='44px'; input.style.height=Math.min(input.scrollHeight,200)+'px'; });
|
|
2808
|
-
$('#newChat').onclick=()=>{ finalizeCurrentSession(); currentSession=null; chatEl.innerHTML=''; resetTraceState(); renderEmptyState(); refreshSessions(); };
|
|
2938
|
+
$('#newChat').onclick=()=>{ finalizeCurrentSession(); currentSession=null; chatEl.innerHTML=''; resetTraceState(); renderEmptyState(); renderCrumb(); refreshSessions(); };
|
|
2809
2939
|
$('#sessMgrBtn').onclick=openSessModal;
|
|
2810
2940
|
$('#sessClose').onclick=()=>{ $('#sessModal').style.display='none'; };
|
|
2811
2941
|
async function openSessModal(){
|
|
2812
2942
|
$('#sessModal').style.display='flex';
|
|
2813
|
-
|
|
2943
|
+
await refreshSessions(); // 复用同一次分组缓存:打开管理面板不再单独算一遍
|
|
2814
2944
|
const list=$('#sessList'); list.innerHTML='';
|
|
2815
|
-
if(!
|
|
2816
|
-
for(const
|
|
2817
|
-
|
|
2818
|
-
div.
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2945
|
+
if(!sessionsCache.length){ list.innerHTML='<div style="color:var(--faint);font-size:12px;padding:6px">暂无会话</div>'; return; }
|
|
2946
|
+
for(const g of sessionsGroupedCache){
|
|
2947
|
+
// 小号次要文本、不可点击、不可折叠;只是分组标签。
|
|
2948
|
+
const gh=document.createElement('div'); gh.className='sess-group'; gh.textContent=g.title; list.appendChild(gh);
|
|
2949
|
+
for(const s of g.items){
|
|
2950
|
+
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';
|
|
2951
|
+
div.innerHTML='<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(s.label)+'</span>';
|
|
2952
|
+
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){ await refreshSessions(); openSessModal(); } else uiAlert(jj.error||'重命名失败'); };
|
|
2953
|
+
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(); } await refreshSessions(); openSessModal(); } else uiAlert(jj.error||'删除失败'); };
|
|
2954
|
+
div.appendChild(rn); div.appendChild(dl); list.appendChild(div);
|
|
2955
|
+
}
|
|
2822
2956
|
}
|
|
2823
2957
|
}
|
|
2824
|
-
$('#sessions').onchange=e=>{ if(e.target.value){ loadSession(e.target.value); } else { finalizeCurrentSession(); currentSession=null; chatEl.innerHTML=''; resetTraceState(); renderEmptyState(); } };
|
|
2958
|
+
$('#sessions').onchange=e=>{ if(e.target.value){ loadSession(e.target.value); } else { finalizeCurrentSession(); currentSession=null; chatEl.innerHTML=''; resetTraceState(); renderEmptyState(); renderCrumb(); } };
|
|
2825
2959
|
if('serviceWorker' in navigator){ navigator.serviceWorker.register('/sw.js').catch(()=>{}); }
|
|
2826
2960
|
init();
|
package/src/web/index.html
CHANGED
|
@@ -65,14 +65,35 @@ header{min-height:52px;display:flex;align-items:center;gap:10px;padding:8px 12px
|
|
|
65
65
|
header .logo{font-weight:700;font-size:16px;color:var(--accent);letter-spacing:.5px;flex:none}
|
|
66
66
|
header .logo span{color:var(--text)}
|
|
67
67
|
header .badge{font-size:12px;color:var(--dim);background:var(--bg3);border:1px solid var(--border);padding:2px 10px;border-radius:20px;flex:none;overflow:hidden;text-overflow:ellipsis;max-width:180px}
|
|
68
|
+
/* §3.83:crumb 是左半区唯一会随会话名变长的项;限到 120px,避免会话名一长就把右半区的
|
|
69
|
+
「历史会话」再次挤出 1000px 视口(完整标题仍在 title 里,内容不丢)。 */
|
|
70
|
+
#crumb{max-width:120px}
|
|
68
71
|
header button{white-space:nowrap;flex:none}
|
|
69
72
|
header select{flex:none}
|
|
73
|
+
/* §3.83 顶栏 1000px 不裁切:右半区控件(费用 / 搜索会话 / 历史会话 / 任务 / 会话管理 / 新对话)
|
|
74
|
+
收进一个可横向滚动的容器(min-width:0 才允许收缩);宽屏由容器内的 .spacer 把控件推到最右,
|
|
75
|
+
窄屏从「费用 + 搜索会话 + 历史会话」开始露出,其余控件在同一容器里横向滚动触达——
|
|
76
|
+
不再整条顶栏溢出到屏幕外。 */
|
|
77
|
+
header .hdr-right{display:flex;align-items:center;gap:8px;flex:1 1 auto;min-width:0;overflow-x:auto;overflow-y:hidden;scrollbar-width:thin;scrollbar-color:var(--border) transparent}
|
|
78
|
+
header .hdr-right::-webkit-scrollbar{height:4px}
|
|
79
|
+
header .hdr-right::-webkit-scrollbar-thumb{background:var(--border);border:0;border-radius:2px}
|
|
80
|
+
header .hdr-right::-webkit-scrollbar-track{background:transparent}
|
|
70
81
|
#sessionSearch{flex:1 1 auto;min-width:70px;max-width:220px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 12px;font-size:13px}
|
|
71
82
|
#modelSel{max-width:170px;min-width:0}
|
|
72
83
|
#presetSel{max-width:140px;min-width:0}
|
|
73
84
|
#permSel{max-width:150px;min-width:0}
|
|
74
85
|
#sessions{max-width:150px;min-width:0}
|
|
75
|
-
#wsSel{max-width:
|
|
86
|
+
#wsSel{max-width:110px;min-width:64px}
|
|
87
|
+
/* §3.83 1000px:诊断徽标(≤900 本就隐藏)让位给「费用 + 搜索 + 历史会话」;顶栏 gap 10→6,
|
|
88
|
+
右半区容器因此拿到约 322px(费用 122 + 搜索 70 + 历史会话 98),三者完整可见。 */
|
|
89
|
+
@media (max-width:1100px){
|
|
90
|
+
header{gap:6px;padding:8px}
|
|
91
|
+
header .hdr-right{gap:8px}
|
|
92
|
+
#envBadge{display:none!important}
|
|
93
|
+
#wsSel{max-width:120px}
|
|
94
|
+
#sessionSearch{min-width:70px;max-width:120px}
|
|
95
|
+
#sessions{max-width:110px}
|
|
96
|
+
}
|
|
76
97
|
@media (max-width:860px){main{padding:14px 0 8px}#chat{padding:0 10px}#envBadge{display:none!important}#sessionSearch{max-width:110px}#modelSel{max-width:110px}#permSel{max-width:120px}#sessions{max-width:110px}#wsSel{max-width:96px}header{gap:6px;padding:8px 8px}header .logo span{display:none}}
|
|
77
98
|
#sessionSearch:focus{outline:none;border-color:var(--accent2)}
|
|
78
99
|
#tasksPanel{position:fixed;right:0;top:52px;bottom:0;width:280px;background:var(--bg2);border-left:1px solid var(--border);z-index:40;display:flex;flex-direction:column}
|
|
@@ -152,6 +173,12 @@ main{flex:1;overflow-y:auto;padding:18px 0 12px;min-width:0}
|
|
|
152
173
|
.msg-user{display:flex;justify-content:flex-end}
|
|
153
174
|
.msg-user .bubble{background:var(--user);border:1px solid rgba(255,255,255,.08);border-radius:22px;padding:10px 16px;max-width:min(525px,82%);white-space:pre-wrap;word-break:break-word;font-size:16px;line-height:24px}
|
|
154
175
|
.msg-ai{display:flex;flex-direction:column;gap:10px;font-size:15px;color:var(--text)}
|
|
176
|
+
/* §3.84:消息时间——小号次要文本;缺失 ts 时前端根本不渲染此节点(不会出现 Invalid Date / undefined)。 */
|
|
177
|
+
.msg-time{font-size:11px;line-height:1.4;color:var(--dim);font-variant-numeric:tabular-nums}
|
|
178
|
+
.msg-user .bubble .msg-time{display:block;text-align:right;margin-top:4px}
|
|
179
|
+
.msg-ai>.msg-time{margin-bottom:-4px}
|
|
180
|
+
/* §3.84:会话分组标题(今天/昨天/本周/更早)——不可点击、不折叠。 */
|
|
181
|
+
.sess-group{font-size:11px;line-height:1.6;color:var(--dim);padding:7px 4px 2px;letter-spacing:.02em}
|
|
155
182
|
.msg-ai .content{white-space:normal;word-break:break-word;line-height:1.75}
|
|
156
183
|
.msg-ai .content h1,.msg-ai .content h2,.msg-ai .content h3,.msg-ai .content h4{color:var(--text);font-weight:700;line-height:1.4}
|
|
157
184
|
.msg-ai .content h1{font-size:20px;margin:18px 0 10px;padding-bottom:8px;border-bottom:1px solid var(--border)}
|
|
@@ -450,17 +477,19 @@ body.run-open #scrollBottomBtn{right:326px}
|
|
|
450
477
|
body.run-drawer #runPanel{display:flex;top:52px;width:300px;max-width:92vw;z-index:60;box-shadow:-12px 0 32px rgba(0,0,0,.4)}
|
|
451
478
|
}
|
|
452
479
|
|
|
453
|
-
/* —— 会话设置弹层(收编模型 / 预设 / 权限三件套)——
|
|
454
|
-
|
|
480
|
+
/* —— 会话设置弹层(收编模型 / 预设 / 权限三件套)——
|
|
481
|
+
§3.83:间距按设计稿 .popover 对齐(head/body 11px 13px、field label 5px、
|
|
482
|
+
effective 13px/11px + 8px 10px、foot 9px 13px);圆角保持设计稿 14px(sheet)/8px(effective)。 */
|
|
483
|
+
#sessSheet{position:fixed;right:18px;bottom:110px;width:min(400px,calc(100vw - 24px));max-height:min(72vh,560px);background:var(--panel);border:1px solid var(--border);border-radius:14px;box-shadow:0 16px 48px rgba(0,0,0,.5);z-index:70;display:flex;flex-direction:column;overflow:hidden}
|
|
455
484
|
body.run-open #sessSheet{right:318px}
|
|
456
485
|
@media (max-width:1180px){body.run-open #sessSheet{right:18px}}
|
|
457
|
-
.ss-head{display:flex;align-items:center;gap:8px;padding:
|
|
486
|
+
.ss-head{display:flex;align-items:center;gap:8px;padding:11px 13px;border-bottom:1px solid var(--border);font-size:13px;font-weight:600;color:var(--text);flex:none}
|
|
458
487
|
.ss-head .ss-sub{font-size:11px;color:var(--faint);font-weight:400}
|
|
459
488
|
.ss-head button{margin-left:auto;padding:1px 8px;font-size:11px}
|
|
460
|
-
.ss-body{padding:
|
|
489
|
+
.ss-body{padding:11px 13px;overflow-y:auto;min-height:0}
|
|
461
490
|
.ss-field{margin-bottom:12px}
|
|
462
491
|
.ss-field:last-child{margin-bottom:0}
|
|
463
|
-
.ss-field>label{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--dim);margin-bottom:
|
|
492
|
+
.ss-field>label{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--dim);margin-bottom:5px}
|
|
464
493
|
.ss-field .ss-scope{margin-left:auto;font-size:10px;color:var(--faint);border:1px solid var(--border);border-radius:999px;padding:0 6px;line-height:16px}
|
|
465
494
|
.ss-field select{width:100%;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 8px;font-size:13px}
|
|
466
495
|
.ss-src{font-size:11px;color:var(--faint);margin-top:4px;line-height:1.55;word-break:break-word}
|
|
@@ -469,9 +498,9 @@ body.run-open #sessSheet{right:318px}
|
|
|
469
498
|
.ss-seg{display:flex;gap:4px;background:var(--bg3);border:1px solid var(--border);border-radius:9px;padding:3px}
|
|
470
499
|
.ss-seg button{flex:1;height:26px;border:0;border-radius:6px;background:transparent;color:var(--dim);font-size:12px}
|
|
471
500
|
.ss-seg button.on{background:var(--panel);color:var(--text);font-weight:600;box-shadow:0 1px 3px rgba(0,0,0,.18)}
|
|
472
|
-
.ss-effective{margin:0
|
|
501
|
+
.ss-effective{margin:0 13px 11px;padding:8px 10px;border:1px dashed var(--border);border-radius:8px;font-size:11.5px;color:var(--dim);line-height:1.65}
|
|
473
502
|
.ss-effective b{color:var(--text)}
|
|
474
|
-
.ss-foot{display:flex;align-items:center;gap:8px;padding:
|
|
503
|
+
.ss-foot{display:flex;align-items:center;gap:8px;padding:9px 13px;border-top:1px solid var(--border)}
|
|
475
504
|
.ss-foot .grow{flex:1;font-size:11px;color:var(--faint)}
|
|
476
505
|
#sessSettingsBtn{max-width:230px;display:inline-flex;align-items:center;gap:6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
477
506
|
#sessSettingsBtn b{overflow:hidden;text-overflow:ellipsis}
|
|
@@ -562,63 +591,82 @@ body.run-open #sessSheet{right:318px}
|
|
|
562
591
|
.dr .p{color:var(--accent2);font-variant-numeric:tabular-nums}
|
|
563
592
|
.dr .c{color:var(--faint);font-variant-numeric:tabular-nums}
|
|
564
593
|
.badge-btn:hover{border-color:var(--accent2);color:var(--accent2)}
|
|
594
|
+
/* —— §3.82 右栏四标签(轨迹 / 子Agent / 任务 / 仪表盘)——
|
|
595
|
+
只新增标签栏/标签页容器样式;#runPanel 宽度、body.run-open 推开量、顶栏与网格均不动。
|
|
596
|
+
四个面板本体搬进标签页后不再 fixed(覆盖既有 #tasksPanel/#dashPanel 的定位,宽度跟随右栏)。 */
|
|
597
|
+
.rp-top{flex:none;padding:10px 12px 0}
|
|
598
|
+
.rp-top .rp-sec{margin-bottom:8px}
|
|
599
|
+
/* §3.83:四个标签统一成设计稿 `.seg` 分段控件观感(外框 bg3 + padding 3 / 圆角 9,
|
|
600
|
+
内按钮 26px 高 / 圆角 6;选中态 panel 底 + text 字 + 600 字重)。只改样式,切换逻辑不动。 */
|
|
601
|
+
.rp-tabs{display:flex;gap:4px;padding:3px;margin:0 12px 8px;flex:none;background:var(--bg3);border:1px solid var(--border);border-radius:9px}
|
|
602
|
+
.rp-tabs .seg{flex:1;min-width:0;height:26px;padding:0 4px;border:0;border-radius:6px;background:transparent;color:var(--dim);font-size:12px;white-space:nowrap;cursor:pointer}
|
|
603
|
+
.rp-tabs .seg:hover{color:var(--text)}
|
|
604
|
+
.rp-tabs .seg.on{background:var(--panel);color:var(--text);font-weight:600;box-shadow:0 1px 3px rgba(0,0,0,.18)}
|
|
605
|
+
.rp-tabbody{flex:1;min-height:0;display:flex;flex-direction:column;overflow:hidden}
|
|
606
|
+
.rp-tab{flex:1;min-height:0;display:flex;flex-direction:column;overflow-y:auto;padding:10px 12px}
|
|
607
|
+
.rp-tab.rp-tab-flush{padding:0;overflow:hidden}
|
|
608
|
+
#runPanel #tasksPanel,#runPanel #dashPanel{position:static;right:auto;top:auto;bottom:auto;width:auto;max-width:none;height:auto;border-left:0;z-index:auto;box-shadow:none;background:transparent;flex:1;min-height:0;overflow:hidden}
|
|
609
|
+
|
|
610
|
+
/* —— §3.83 对比度:承载信息的次要文本从 --faint 提到 --dim ——
|
|
611
|
+
只改下列选择器的 color,**不动** :root 里的 --faint/--dim 取值(避免全局漂移);
|
|
612
|
+
WCAG 对比度(暗色 #81858C→#ADB2B8):bg 4.92→8.54、bg2 4.64→8.06、
|
|
613
|
+
panel 3.76→6.53、bg3 3.26→5.67。覆盖:消息元信息/用量、提示条、任务与子Agent状态、
|
|
614
|
+
轨迹轮次与计数、工具耗时与输出计数、来源/作用域胶囊、rp-foot 说明、空态说明、底部累计状态。 */
|
|
615
|
+
.msg-meta,
|
|
616
|
+
.usage,
|
|
617
|
+
.banner:not(.banner-warn),
|
|
618
|
+
#hint,
|
|
619
|
+
#statusBar,
|
|
620
|
+
.sb-item .sb-meta,
|
|
621
|
+
.tp-item .tp-meta,
|
|
622
|
+
.tl-row .tl-dur,
|
|
623
|
+
.tl-out-meta,
|
|
624
|
+
.tl-more,
|
|
625
|
+
.tr-round-no,
|
|
626
|
+
.tr-sub-meta,
|
|
627
|
+
.tr-sub-stats,
|
|
628
|
+
.tr-sub-wait,
|
|
629
|
+
.tr-sub-state,
|
|
630
|
+
.rp-fold-meta,
|
|
631
|
+
.td-fold-meta,
|
|
632
|
+
.td-meta,
|
|
633
|
+
.rp-kv .rp-src,
|
|
634
|
+
.rp-foot .grow,
|
|
635
|
+
.ss-src,
|
|
636
|
+
.ss-field .ss-scope,
|
|
637
|
+
.dash-head .dash-sub,
|
|
638
|
+
.dr .c,
|
|
639
|
+
.tr-empty,
|
|
640
|
+
#emptyState .es-lead,
|
|
641
|
+
.empty-step .es-main span,
|
|
642
|
+
.empty-step .es-state{color:var(--dim)}
|
|
565
643
|
</style>
|
|
566
644
|
</head>
|
|
567
645
|
<body>
|
|
568
646
|
<header>
|
|
569
647
|
<div class="logo">MingDao <span>Harness</span></div>
|
|
648
|
+
<!-- v0.6.21 小步重排(§3.81):顶栏只保留「我在哪」的当前位置;模型/预设/权限只在 #sessSheet 里改。 -->
|
|
649
|
+
<span id="crumb" class="badge" title="当前工作空间 › 当前会话"><span id="crumbWs">工作空间</span><span aria-hidden="true"> › </span><b id="crumbSession">新对话</b></span>
|
|
570
650
|
<span id="cfgMsg" class="badge" style="display:none"></span>
|
|
571
651
|
<button id="paletteBtn" title="搜索或运行命令(⌘K / Ctrl+K)" aria-haspopup="dialog">⌘K <span class="palette-label">搜索或运行</span></button>
|
|
572
652
|
<button id="cfgBtn" title="设置">⚙</button>
|
|
573
653
|
<button id="runChip" title="本轮运行面板(窄屏以抽屉展开)" aria-expanded="false">运行</button>
|
|
574
654
|
<button id="themeBtn" title="切换明暗主题(跟随系统时由系统偏好决定)" aria-label="切换明暗主题">🌙</button>
|
|
575
655
|
<select id="wsSel" title="工作空间(切换 / 新建,目录自动创建)"></select>
|
|
576
|
-
<span class="badge" id="envBadge"></span
|
|
577
|
-
<div class="
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
656
|
+
<span class="badge" id="envBadge"></span>
|
|
657
|
+
<div class="hdr-right">
|
|
658
|
+
<div class="spacer"></div>
|
|
659
|
+
<span class="badge badge-btn" id="costBadge" title="省钱仪表盘(点击展开)" style="cursor:pointer">📊 —</span>
|
|
660
|
+
<input id="sessionSearch" placeholder="搜索会话…" title="关键词检索历史会话">
|
|
661
|
+
<select id="sessions" title="历史会话"><option value="">历史会话</option></select>
|
|
662
|
+
<button id="tasksBtn" title="任务面板">任务</button><button id="sessMgrBtn" title="管理会话(重命名/删除)">✎</button>
|
|
663
|
+
<button id="newChat">新对话</button>
|
|
664
|
+
</div>
|
|
582
665
|
</header>
|
|
583
666
|
<div id="trajRail">
|
|
584
667
|
<button class="rail-btn" id="trajRailBtn" title="轨迹(点击查看详情)">🧭</button>
|
|
585
668
|
<button class="rail-btn" id="subRailBtn" title="子 Agent">🤖<span class="rail-badge" id="subBadge" style="display:none"></span></button>
|
|
586
669
|
</div>
|
|
587
|
-
<aside id="subPanel" style="display:none">
|
|
588
|
-
<div class="sb-head">🤖 子 Agent<span id="sbCount" style="font-size:11.5px;color:var(--faint);font-weight:400"></span><button id="sbClose">关闭</button></div>
|
|
589
|
-
<div id="sbList"></div>
|
|
590
|
-
</aside>
|
|
591
|
-
<aside id="trajPanel" style="display:none">
|
|
592
|
-
<div class="tj-head">🧭 任务轨迹<span id="tjSummary" style="font-size:11.5px;color:var(--faint);font-weight:400"></span><button id="tjClose">关闭</button></div>
|
|
593
|
-
<div id="tjList"></div>
|
|
594
|
-
</aside>
|
|
595
|
-
<aside id="tasksPanel" style="display:none">
|
|
596
|
-
<div class="tp-head">任务面板 <span id="tpCount"></span><button id="tpClose" style="float:right">✕</button></div>
|
|
597
|
-
<div id="tpList"></div>
|
|
598
|
-
</aside>
|
|
599
|
-
<aside id="dashPanel" style="display:none">
|
|
600
|
-
<div class="dash-head">📊 省钱仪表盘 <span class="dash-sub">费用 · 命中 · 节省</span><button id="dashClose">✕</button></div>
|
|
601
|
-
<div class="dash-body">
|
|
602
|
-
<div class="dash-kpi">
|
|
603
|
-
<div class="kpi"><span class="kpi-l">今日费用</span><span class="kpi-v" id="kpiToday">—</span></div>
|
|
604
|
-
<div class="kpi"><span class="kpi-l">缓存命中</span><span class="kpi-v" id="kpiRate">—</span></div>
|
|
605
|
-
<div class="kpi"><span class="kpi-l">已节省</span><span class="kpi-v" style="color:var(--accent)" id="kpiSaved">—</span></div>
|
|
606
|
-
<div class="kpi"><span class="kpi-l">轮次/步数</span><span class="kpi-v" id="kpiTurns">—</span></div>
|
|
607
|
-
</div>
|
|
608
|
-
<div class="dash-gauge-wrap">
|
|
609
|
-
<svg id="rateGauge" viewBox="0 0 120 120" width="120" height="120"></svg>
|
|
610
|
-
<div class="gauge-label"><b id="rateGaugeNum">—</b><span>命中率</span></div>
|
|
611
|
-
</div>
|
|
612
|
-
<div class="dash-sec">近 14 天费用趋势(¥)</div>
|
|
613
|
-
<div id="trendChart" class="dash-chart"></div>
|
|
614
|
-
<div class="dash-sec">模型费用 Top</div>
|
|
615
|
-
<div id="dashModels" class="dash-bars"></div>
|
|
616
|
-
<div class="dash-sec">工具耗时 Top</div>
|
|
617
|
-
<div id="dashTools" class="dash-bars"></div>
|
|
618
|
-
<div class="dash-sec">最近缓存命中</div>
|
|
619
|
-
<div id="dashRecent" class="dash-recent"></div>
|
|
620
|
-
</div>
|
|
621
|
-
</aside>
|
|
622
670
|
<main>
|
|
623
671
|
<!-- P1-6 空态三步引导(v0.6.19 §3.70):无会话 / 无消息时显示;示例任务只填入输入框,不自动发送。 -->
|
|
624
672
|
<div id="emptyState" hidden>
|
|
@@ -690,7 +738,8 @@ body.run-open #sessSheet{right:318px}
|
|
|
690
738
|
≥1180 默认展开并推开正文;<1180 收起为顶栏「运行」chip,点开是覆盖式抽屉。无新增轮询。 -->
|
|
691
739
|
<aside id="runPanel" aria-label="本轮运行" hidden>
|
|
692
740
|
<div class="rp-head"><span class="rp-live" aria-hidden="true"></span><span>本轮运行</span><span class="rp-sub">指标口径:本轮(非累计)</span><button id="rpCollapse" type="button" title="收起右栏">收起</button></div>
|
|
693
|
-
|
|
741
|
+
<!-- §3.82:状态条(阶段/首帧/吞吐/工具数/上下文/费用)留在顶部,不随标签切换。 -->
|
|
742
|
+
<div class="rp-top">
|
|
694
743
|
<section class="rp-sec">
|
|
695
744
|
<div class="rp-sec-title">会话配置<span class="line"></span></div>
|
|
696
745
|
<dl class="rp-kv">
|
|
@@ -712,15 +761,71 @@ body.run-open #sessSheet{right:318px}
|
|
|
712
761
|
<div class="rp-metric wide"><span class="l">本轮费用(估算)</span><span class="v" data-rk="cost" id="rpCost">—</span></div>
|
|
713
762
|
</div>
|
|
714
763
|
</section>
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
764
|
+
</div>
|
|
765
|
+
<!-- §3.82 第二步:四个标签。只切 display(railTabDisplay),节点不销毁、id 不变。 -->
|
|
766
|
+
<div class="rp-tabs" role="tablist" aria-label="右栏标签">
|
|
767
|
+
<button id="rtTrace" type="button" role="tab" data-tab="trace" aria-selected="true" aria-controls="tabTrace" class="seg on">轨迹</button>
|
|
768
|
+
<button id="rtSub" type="button" role="tab" data-tab="sub" aria-selected="false" aria-controls="tabSub" class="seg">子Agent</button>
|
|
769
|
+
<button id="rtTasks" type="button" role="tab" data-tab="tasks" aria-selected="false" aria-controls="tabTasks" class="seg">任务</button>
|
|
770
|
+
<button id="rtDash" type="button" role="tab" data-tab="dash" aria-selected="false" aria-controls="tabDash" class="seg">仪表盘</button>
|
|
771
|
+
</div>
|
|
772
|
+
<div class="rp-tabbody">
|
|
773
|
+
<section id="tabTrace" class="rp-tab" role="tabpanel" aria-labelledby="rtTrace" data-tab="trace">
|
|
774
|
+
<!-- §3.73:轨迹折叠区(renderTracePanels 写入 #rpTraceList,不在这里重写)。 -->
|
|
775
|
+
<section class="rp-sec" id="rpTraceSec">
|
|
776
|
+
<button class="rp-sec-title rp-fold-head" type="button" data-fold="trace" aria-controls="rpTraceFoldBody" aria-expanded="true">轨迹<span class="rp-fold-meta" id="rpTraceMeta">—</span><span class="line"></span><span class="rp-fold-tri" aria-hidden="true"></span></button>
|
|
777
|
+
<div id="rpTraceFoldBody"><div id="rpTraceList" class="tr-list"></div></div>
|
|
778
|
+
</section>
|
|
779
|
+
<!-- 旧轨迹面板随标签搬入(id/子节点原样保留,openTraj 仍写 #tjList);默认收起,避免与上面的轮次列表重复。 -->
|
|
780
|
+
<aside id="trajPanel" style="display:none">
|
|
781
|
+
<div class="tj-head">🧭 任务轨迹<span id="tjSummary" style="font-size:11.5px;color:var(--faint);font-weight:400"></span><button id="tjClose">关闭</button></div>
|
|
782
|
+
<div id="tjList"></div>
|
|
783
|
+
</aside>
|
|
784
|
+
</section>
|
|
785
|
+
<section id="tabSub" class="rp-tab" role="tabpanel" aria-labelledby="rtSub" data-tab="sub" style="display:none">
|
|
786
|
+
<!-- §3.73:子Agent 折叠区(renderTracePanels 写入 #rpSubList)。 -->
|
|
787
|
+
<section class="rp-sec" id="rpSubSec">
|
|
788
|
+
<button class="rp-sec-title rp-fold-head" type="button" data-fold="subs" aria-controls="rpSubFoldBody" aria-expanded="true">子Agent<span class="rp-fold-meta" id="rpSubMeta">—</span><span class="line"></span><span class="rp-fold-tri" aria-hidden="true"></span></button>
|
|
789
|
+
<div id="rpSubFoldBody"><div id="rpSubList" class="tr-list"></div></div>
|
|
790
|
+
</section>
|
|
791
|
+
<!-- 旧子Agent 面板随标签搬入(id/子节点原样保留);默认收起。 -->
|
|
792
|
+
<aside id="subPanel" style="display:none">
|
|
793
|
+
<div class="sb-head">🤖 子 Agent<span id="sbCount" style="font-size:11.5px;color:var(--faint);font-weight:400"></span><button id="sbClose">关闭</button></div>
|
|
794
|
+
<div id="sbList"></div>
|
|
795
|
+
</aside>
|
|
796
|
+
</section>
|
|
797
|
+
<section id="tabTasks" class="rp-tab rp-tab-flush" role="tabpanel" aria-labelledby="rtTasks" data-tab="tasks" style="display:none">
|
|
798
|
+
<!-- 任务面板:原 <aside id="tasksPanel"> 整体搬入(#tpList/#tpCount 不动),宽度改为跟随右栏。 -->
|
|
799
|
+
<aside id="tasksPanel" style="display:none">
|
|
800
|
+
<div class="tp-head">任务面板 <span id="tpCount"></span><button id="tpClose" style="float:right">✕</button></div>
|
|
801
|
+
<div id="tpList"></div>
|
|
802
|
+
</aside>
|
|
720
803
|
</section>
|
|
721
|
-
<section class="rp-
|
|
722
|
-
|
|
723
|
-
<
|
|
804
|
+
<section id="tabDash" class="rp-tab rp-tab-flush" role="tabpanel" aria-labelledby="rtDash" data-tab="dash" style="display:none">
|
|
805
|
+
<!-- 省钱仪表盘:原 <aside id="dashPanel"> 整体搬入(KPI/趋势/Top 全部保留),宽度改为跟随右栏。 -->
|
|
806
|
+
<aside id="dashPanel" style="display:none">
|
|
807
|
+
<div class="dash-head">📊 省钱仪表盘 <span class="dash-sub">费用 · 命中 · 节省</span><button id="dashClose">✕</button></div>
|
|
808
|
+
<div class="dash-body">
|
|
809
|
+
<div class="dash-kpi">
|
|
810
|
+
<div class="kpi"><span class="kpi-l">今日费用</span><span class="kpi-v" id="kpiToday">—</span></div>
|
|
811
|
+
<div class="kpi"><span class="kpi-l">缓存命中</span><span class="kpi-v" id="kpiRate">—</span></div>
|
|
812
|
+
<div class="kpi"><span class="kpi-l">已节省</span><span class="kpi-v" style="color:var(--accent)" id="kpiSaved">—</span></div>
|
|
813
|
+
<div class="kpi"><span class="kpi-l">轮次/步数</span><span class="kpi-v" id="kpiTurns">—</span></div>
|
|
814
|
+
</div>
|
|
815
|
+
<div class="dash-gauge-wrap">
|
|
816
|
+
<svg id="rateGauge" viewBox="0 0 120 120" width="120" height="120"></svg>
|
|
817
|
+
<div class="gauge-label"><b id="rateGaugeNum">—</b><span>命中率</span></div>
|
|
818
|
+
</div>
|
|
819
|
+
<div class="dash-sec">近 14 天费用趋势(¥)</div>
|
|
820
|
+
<div id="trendChart" class="dash-chart"></div>
|
|
821
|
+
<div class="dash-sec">模型费用 Top</div>
|
|
822
|
+
<div id="dashModels" class="dash-bars"></div>
|
|
823
|
+
<div class="dash-sec">工具耗时 Top</div>
|
|
824
|
+
<div id="dashTools" class="dash-bars"></div>
|
|
825
|
+
<div class="dash-sec">最近缓存命中</div>
|
|
826
|
+
<div id="dashRecent" class="dash-recent"></div>
|
|
827
|
+
</div>
|
|
828
|
+
</aside>
|
|
724
829
|
</section>
|
|
725
830
|
</div>
|
|
726
831
|
<div class="rp-foot"><span class="grow" id="rpFootNote">停止与压缩:与状态条同一条停止出口</span><button id="rpCompact" type="button" hidden title="后端有 /compact 时才显示">压缩上下文</button><button id="rpStop" type="button" disabled>■ 停止</button></div>
|
|
@@ -739,7 +844,7 @@ body.run-open #sessSheet{right:318px}
|
|
|
739
844
|
<div class="ss-field">
|
|
740
845
|
<label for="presetSel">预设<span class="ss-scope">仅下一轮</span></label>
|
|
741
846
|
<select id="presetSel" title="智能体预设:一键切换工具白名单/权限/参数组合(选「无预设」恢复默认)"><option value="">无预设</option></select>
|
|
742
|
-
<div class="ss-src"
|
|
847
|
+
<div class="ss-src" id="ssPresetSrc">当前:无预设 · 来源:你的选择 · 仅下一轮(工具白名单 / 参数);不改变权限来源。</div>
|
|
743
848
|
</div>
|
|
744
849
|
<div class="ss-field">
|
|
745
850
|
<label>权限<span class="ss-scope">全局 + 本轮</span></label>
|
|
@@ -19,6 +19,35 @@ import {
|
|
|
19
19
|
removeSessionWorkspace,
|
|
20
20
|
} from '../../../workspace.js';
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* v0.6.22(§3.84):会话列表的**只增**元数据。
|
|
24
|
+
*
|
|
25
|
+
* 只读文件、绝不改写;坏行/不可读文件按 0 条处理,不得让整个列表接口 500——
|
|
26
|
+
* 真机上确实存在历史遗留的半截 JSONL(进程被 kill 时最后一行可能不完整)。
|
|
27
|
+
* 标题沿用既有事实来源:会话文件名(自动标题 / 手工重命名都会落到文件名)。
|
|
28
|
+
* @param {string} file 会话文件绝对路径
|
|
29
|
+
* @param {string} name 会话文件名
|
|
30
|
+
* @returns {{messageCount:number,title:string}}
|
|
31
|
+
*/
|
|
32
|
+
function sessionMeta(file, name) {
|
|
33
|
+
let messageCount = 0;
|
|
34
|
+
try {
|
|
35
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
36
|
+
for (const line of raw.split('\n')) {
|
|
37
|
+
if (!line.trim()) continue;
|
|
38
|
+
try {
|
|
39
|
+
const m = JSON.parse(line);
|
|
40
|
+
if (m && typeof m === 'object' && ['system', 'user', 'assistant', 'tool'].includes(m.role)) messageCount += 1;
|
|
41
|
+
} catch {
|
|
42
|
+
// 单行损坏只跳过该行:旧会话/半截写入都不能把整个列表打挂。
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
// 文件在 readdir 之后被删除/无权限:计数按 0,元数据仍可返回。
|
|
47
|
+
}
|
|
48
|
+
return { messageCount, title: name.replace(/\.jsonl$/, '') };
|
|
49
|
+
}
|
|
50
|
+
|
|
22
51
|
/**
|
|
23
52
|
* 会话域路由。命中返回 true,未命中返回 false。
|
|
24
53
|
* @param {{req:any,res:any,method:any,p:any,url:any}} ctx
|
|
@@ -33,9 +62,22 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
|
|
|
33
62
|
|
|
34
63
|
if (method === 'GET' && p === '/api/sessions') {
|
|
35
64
|
const q = url.searchParams.get('q');
|
|
65
|
+
// v0.6.22(§3.84):每项**只增** updatedAt / messageCount / title 三个字段;
|
|
66
|
+
// file / mtime / label 的既有语义与顺序不变(前端 crumb、命令面板、同步等仍按旧字段消费)。
|
|
67
|
+
const decorate = (/** @type {any} */ s, /** @type {string} */ label) => {
|
|
68
|
+
const meta = sessionMeta(path.join(home, 'sessions', path.basename(s.name)), s.name);
|
|
69
|
+
return {
|
|
70
|
+
file: s.name,
|
|
71
|
+
mtime: s.mtime,
|
|
72
|
+
label,
|
|
73
|
+
updatedAt: new Date(s.mtime).toISOString(),
|
|
74
|
+
messageCount: meta.messageCount,
|
|
75
|
+
title: meta.title,
|
|
76
|
+
};
|
|
77
|
+
};
|
|
36
78
|
const found = q
|
|
37
|
-
? searchSessions(home, q, { limit: 30 }).map((/** @type {any} */ s) => (
|
|
38
|
-
: listSessions(home).slice(0, 30).map((s) => (
|
|
79
|
+
? searchSessions(home, q, { limit: 30 }).map((/** @type {any} */ s) => decorate(s, `${relativeTime(s.mtime)} · ${s.snippet}`))
|
|
80
|
+
: listSessions(home).slice(0, 30).map((s) => decorate(s, `${relativeTime(s.mtime)} · ${sessionPreview(s.file)}`));
|
|
39
81
|
json(res, 200, { ok: true, sessions: found });
|
|
40
82
|
return true;
|
|
41
83
|
}
|
|
@@ -54,7 +96,11 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
|
|
|
54
96
|
}
|
|
55
97
|
json(res, 200, {
|
|
56
98
|
ok: true,
|
|
57
|
-
|
|
99
|
+
// v0.6.22(§3.84):只增 ts。旧会话消息没有 ts → **null**(前端据此不显示时间),
|
|
100
|
+
// 绝不在这里用文件 mtime 冒充消息时间,也绝不因缺字段报错。
|
|
101
|
+
messages: loaded.messages
|
|
102
|
+
.filter((m) => m.role === 'user' || m.role === 'assistant')
|
|
103
|
+
.map((m) => ({ role: m.role, content: m.content ?? '', ts: typeof m.ts === 'string' && m.ts ? m.ts : null })),
|
|
58
104
|
workspace: ws?.name || null,
|
|
59
105
|
// v0.3.0 P0-2:未完成检查点(供前端提示续跑)
|
|
60
106
|
taskState: loadTaskState(path.basename(file)),
|
package/src/web/server.js
CHANGED
|
@@ -77,6 +77,25 @@ import { syncStatus, syncLogin, syncLogout, syncPush, syncPull, syncRemoteList,
|
|
|
77
77
|
|
|
78
78
|
const INDEX_HTML = path.join(path.dirname(fileURLToPath(import.meta.url)), 'index.html');
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* v0.6.22(§3.84):落盘前给**新增**消息补 ISO 时间戳。
|
|
82
|
+
*
|
|
83
|
+
* 只补缺 ts 的消息(`{...m, ts}` 不改动原对象),已有 ts 的原样保留:
|
|
84
|
+
* 旧会话/旧消息不会被"补写"成另一个时间;压缩重写时也不会把历史时间冲掉。
|
|
85
|
+
* 时间只在服务端落盘这一刻取,前端渲染用同一字段——不引入第二套时间来源。
|
|
86
|
+
* @template T
|
|
87
|
+
* @param {T[]} messages
|
|
88
|
+
* @param {Date} [now]
|
|
89
|
+
* @returns {T[]}
|
|
90
|
+
*/
|
|
91
|
+
function stampMessageTs(messages, now = new Date()) {
|
|
92
|
+
const ts = now.toISOString();
|
|
93
|
+
return messages.map((/** @type {any} */ m) => {
|
|
94
|
+
if (!m || typeof m !== 'object' || m.ts) return m;
|
|
95
|
+
return { ...m, ts };
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
80
99
|
/** 合法权限模式(与 /api/config 的校验同一口径:非法值一律忽略,绝不 fail-open)。 */
|
|
81
100
|
const PERMISSION_MODES = new Set(['ask', 'auto', 'readonly']);
|
|
82
101
|
/** 各档位的"会发生什么"人话说明(可见说明与前端提示共用同一份文案)。 */
|
|
@@ -882,8 +901,15 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
|
|
|
882
901
|
}
|
|
883
902
|
}
|
|
884
903
|
messages.push({ role: 'user', content: built.content });
|
|
885
|
-
//
|
|
886
|
-
|
|
904
|
+
// 落盘用文本版(图文数组只发给模型,会话文件保持字符串可渲染)。
|
|
905
|
+
// v0.6.22(§3.84):同一次落盘的时间戳也写回内存里的这条消息——后续自动压缩整文件重写时
|
|
906
|
+
// 不会把这一轮刚写下的 ts 冲掉;历史消息(已有/缺失 ts)不在这里补。
|
|
907
|
+
const userTs = new Date().toISOString();
|
|
908
|
+
await withSessionLock(session.file, () => appendMessages(session.file, [{ role: 'user', content: built.persistText, ts: userTs }]));
|
|
909
|
+
{
|
|
910
|
+
const lastUserMsg = messages[messages.length - 1];
|
|
911
|
+
if (lastUserMsg && typeof lastUserMsg === 'object' && !lastUserMsg.ts) lastUserMsg.ts = userTs;
|
|
912
|
+
}
|
|
887
913
|
let persistedBefore = messages.length;
|
|
888
914
|
|
|
889
915
|
// 权限/选择类交互:发 ask 事件(带 taskId),等待 POST /api/permission 应答
|
|
@@ -1065,7 +1091,14 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
|
|
|
1065
1091
|
// 会话级路由统计(Hermes C2 升级检测):累计工具步数与截断次数,粘滞 flash 会话复杂度上来后自动升 planner
|
|
1066
1092
|
const st = session.routeStats || { steps: 0, truncated: 0 };
|
|
1067
1093
|
session.routeStats = { steps: st.steps + (io.stats().toolCount || 0), truncated: st.truncated + (r.truncated ? 1 : 0) };
|
|
1068
|
-
|
|
1094
|
+
// v0.6.22(§3.84):本回合新增的 assistant/tool 消息落盘时补 ts,并同步回内存数组,
|
|
1095
|
+
// 让之后发生的自动压缩整文件重写保留这些时间戳(历史消息不动)。
|
|
1096
|
+
{
|
|
1097
|
+
const tail = messages.slice(persistedBefore);
|
|
1098
|
+
const stamped = stampMessageTs(tail);
|
|
1099
|
+
for (let i = 0; i < stamped.length; i += 1) messages[persistedBefore + i] = stamped[i];
|
|
1100
|
+
await withSessionLock(session.file, () => appendMessages(session.file, stamped));
|
|
1101
|
+
}
|
|
1069
1102
|
io.printUsageLine({ modelName: runModel, usage: r.usage, durationMs: r.durationMs });
|
|
1070
1103
|
// v0.4.4:补记最后一轮 + 兜底总结的剩余 usage(自动续跑轮已在 onUsage 逐轮入账,避免重复计)
|
|
1071
1104
|
const remaining = {
|