mingdao-harness 0.6.21 → 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 +97 -14
- package/src/web/index.html +6 -0
- 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;
|
|
@@ -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 = [];
|
|
@@ -2273,8 +2346,14 @@ async function refreshSessions(q){
|
|
|
2273
2346
|
const r=await fetch(u).catch(()=>null); if(!r)return;
|
|
2274
2347
|
const j=await r.json();
|
|
2275
2348
|
sessionsCache=Array.isArray(j.sessions)?j.sessions:[];
|
|
2349
|
+
sessionsGroupedCache=groupSessionsByUpdatedAt(sessionsCache); // 一次性分组(性能:不在渲染时重算)
|
|
2276
2350
|
const sel=$('#sessions'); sel.innerHTML='<option value="">历史会话</option>';
|
|
2277
|
-
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
|
+
}
|
|
2278
2357
|
if(currentSession&&!q) sel.value=currentSession;
|
|
2279
2358
|
renderCrumb();
|
|
2280
2359
|
}
|
|
@@ -2850,7 +2929,7 @@ function finalizeCurrentSession(){
|
|
|
2850
2929
|
if(!currentSession) return;
|
|
2851
2930
|
fetch('/api/session-finalize',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({file:currentSession})}).catch(()=>{});
|
|
2852
2931
|
}
|
|
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(); }
|
|
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(); }
|
|
2854
2933
|
|
|
2855
2934
|
// §138:主按钮在生成态=停止,必须走**同一个**停止出口(stopTurnFromButton → stopTurn → POST /api/abort)
|
|
2856
2935
|
sendBtn.onclick=()=>{ if(generating){ stopTurnFromButton(); } else { send(); } };
|
|
@@ -2861,15 +2940,19 @@ $('#sessMgrBtn').onclick=openSessModal;
|
|
|
2861
2940
|
$('#sessClose').onclick=()=>{ $('#sessModal').style.display='none'; };
|
|
2862
2941
|
async function openSessModal(){
|
|
2863
2942
|
$('#sessModal').style.display='flex';
|
|
2864
|
-
|
|
2943
|
+
await refreshSessions(); // 复用同一次分组缓存:打开管理面板不再单独算一遍
|
|
2865
2944
|
const list=$('#sessList'); list.innerHTML='';
|
|
2866
|
-
if(!
|
|
2867
|
-
for(const
|
|
2868
|
-
|
|
2869
|
-
div.
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
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
|
+
}
|
|
2873
2956
|
}
|
|
2874
2957
|
}
|
|
2875
2958
|
$('#sessions').onchange=e=>{ if(e.target.value){ loadSession(e.target.value); } else { finalizeCurrentSession(); currentSession=null; chatEl.innerHTML=''; resetTraceState(); renderEmptyState(); renderCrumb(); } };
|
package/src/web/index.html
CHANGED
|
@@ -173,6 +173,12 @@ main{flex:1;overflow-y:auto;padding:18px 0 12px;min-width:0}
|
|
|
173
173
|
.msg-user{display:flex;justify-content:flex-end}
|
|
174
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}
|
|
175
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}
|
|
176
182
|
.msg-ai .content{white-space:normal;word-break:break-word;line-height:1.75}
|
|
177
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}
|
|
178
184
|
.msg-ai .content h1{font-size:20px;margin:18px 0 10px;padding-bottom:8px;border-bottom:1px solid var(--border)}
|
|
@@ -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 = {
|