@iamsamyiok/agents-chat 3.24.0 → 3.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/app/lib/store.js CHANGED
@@ -508,6 +508,13 @@ function getMessages(taskId) {
508
508
  return Array.isArray(list) ? list : [];
509
509
  }
510
510
 
511
+ // 单会话消息上限:防止定时任务长跑数月将会话分片膨胀到读写不可承受
512
+ // 默认 500 条,AGENTS_CHAT_MSG_LIMIT 可调(0 = 不限制);主会话豁免(已有 epoch + 过期清理机制)
513
+ const MSG_LIMIT = (() => {
514
+ const n = Number(process.env.AGENTS_CHAT_MSG_LIMIT);
515
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 500;
516
+ })();
517
+
511
518
  function addMessage(msg) {
512
519
  migrateLegacyMessages();
513
520
  fs.mkdirSync(MSG_DIR, { recursive: true });
@@ -529,9 +536,38 @@ function addMessage(msg) {
529
536
  timestamp: msg.timestamp || new Date().toISOString()
530
537
  };
531
538
  msgs.push(rec);
539
+ // 滚动清理:超上限裁掉最旧消息,开头留一条系统标记说明去向(标记本身也会随时间被裁)
540
+ if (key !== '' && MSG_LIMIT > 0 && msgs.length > MSG_LIMIT) {
541
+ const kept = msgs.slice(msgs.length - MSG_LIMIT);
542
+ const first = kept[0];
543
+ if (!(first && first.role === 'sys' && /已按上限自动清理/.test(String(first.content || '')))) {
544
+ kept.unshift({ role: 'sys', content: `(已达单会话消息上限 ${MSG_LIMIT} 条,更早的过程消息已自动滚动清理,任务结果以最终结果区为准)`, taskId: key, timestamp: new Date().toISOString() });
545
+ }
546
+ msgs.length = 0;
547
+ msgs.push(...kept);
548
+ }
532
549
  writeJson(msgShardPath(key), msgs);
533
550
  }
534
551
 
552
+ // 按会话统计消息数(逐分片累加,不合并大数组:统计/导出场景低内存)
553
+ function countMessagesByTask() {
554
+ migrateLegacyMessages();
555
+ const counts = {};
556
+ let main = 0;
557
+ let files = [];
558
+ try { files = fs.readdirSync(MSG_DIR); } catch { return { counts, main }; }
559
+ for (const name of files) {
560
+ if (!name.endsWith('.json') || name.startsWith('.')) continue;
561
+ let list;
562
+ try { list = JSON.parse(fs.readFileSync(path.join(MSG_DIR, name), 'utf8')); } catch { continue; }
563
+ if (!Array.isArray(list) || !list.length) continue;
564
+ const tid = list[0].taskId || '';
565
+ if (tid) counts[tid] = (counts[tid] || 0) + list.length;
566
+ else main += list.length;
567
+ }
568
+ return { counts, main };
569
+ }
570
+
535
571
  function clearMessages() {
536
572
  migrateLegacyMessages();
537
573
  // 清空全部会话消息:删除所有分片,主会话分片重置为空数组
@@ -754,6 +790,7 @@ module.exports = {
754
790
  getMessages,
755
791
  addMessage,
756
792
  clearMessages,
793
+ countMessagesByTask,
757
794
  addFlowEvent,
758
795
  getFlow,
759
796
  listFlowRuns,
@@ -0,0 +1,43 @@
1
+ // npm 最新版本检查:供启动横幅、/api/health、CLI update/status 共用
2
+ // Node 18+ 自带全局 fetch;3 秒超时 + 1 小时结果缓存,失败静默(离线/内网不影响启动)
3
+
4
+ const PKG = require('../../package.json');
5
+ const REGISTRY_URL = `https://registry.npmjs.org/${PKG.name}/latest`;
6
+
7
+ let cached = { at: 0, latest: null }; // 缓存上次查询结果(1 小时内复用)
8
+
9
+ // 简化 semver 比较:a > b 返回 true(仅支持 x.y.z 数字段, prerelease 忽略比较)
10
+ function semverGt(a, b) {
11
+ const pa = String(a || '').split('-')[0].split('.').map(n => parseInt(n, 10) || 0);
12
+ const pb = String(b || '').split('-')[0].split('.').map(n => parseInt(n, 10) || 0);
13
+ for (let i = 0; i < 3; i++) {
14
+ if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) > (pb[i] || 0);
15
+ }
16
+ return false;
17
+ }
18
+
19
+ // 查询 npm registry 最新版本;force=true 跳过缓存(CLI update 用)
20
+ async function checkLatest({ force = false } = {}) {
21
+ const now = Date.now();
22
+ if (!force && cached.latest && now - cached.at < 3600 * 1000) {
23
+ return { latest: cached.latest, updateAvailable: semverGt(cached.latest, PKG.version) };
24
+ }
25
+ try {
26
+ const ctrl = new AbortController();
27
+ const timer = setTimeout(() => ctrl.abort(), 3000);
28
+ const res = await fetch(REGISTRY_URL, { signal: ctrl.signal, headers: { 'accept': 'application/json' } });
29
+ clearTimeout(timer);
30
+ if (!res.ok) return null;
31
+ const data = await res.json();
32
+ const latest = String(data.version || '');
33
+ if (!latest) return null;
34
+ cached = { at: now, latest };
35
+ return { latest, updateAvailable: semverGt(latest, PKG.version) };
36
+ } catch {
37
+ return null; // 网络不可达/超时:静默,不阻塞调用方
38
+ }
39
+ }
40
+
41
+ const UPDATE_COMMAND = `npm install -g ${PKG.name}@latest`;
42
+
43
+ module.exports = { semverGt, checkLatest, UPDATE_COMMAND, currentVersion: PKG.version };
@@ -190,6 +190,18 @@
190
190
  /* 空看板引导 */
191
191
  .board-guide{grid-column:1/-1;text-align:center;padding:40px 10px;color:var(--dim)}
192
192
  .board-guide .bg-actions{display:flex;gap:10px;justify-content:center;margin-top:14px}
193
+ /* 使用帮助 */
194
+ .help-sec{margin:0 0 14px}
195
+ .help-sec h4{margin:0 0 6px;font-size:13.5px;color:var(--acc)}
196
+ .help-sec ul,.help-sec ol{margin:0;padding-left:20px}
197
+ .help-sec li{margin:4px 0;font-size:12.5px;line-height:1.65;color:#333}
198
+ .help-sec code{background:var(--bg2);padding:1px 5px;border-radius:4px;font-size:11.5px;color:var(--acc)}
199
+ .help-sec p{font-size:12.5px;line-height:1.65;color:#333;margin:6px 0}
200
+ /* 版本徽标与更新提示条 */
201
+ .ver-chip{font-size:11px;color:var(--dim);border:1px solid var(--line);border-radius:10px;padding:2px 8px;white-space:nowrap}
202
+ .update-bar{position:fixed;top:0;left:0;right:0;z-index:60;background:#fffbe6;border-bottom:1px solid #ffe58f;color:#614700;font-size:12.5px;padding:7px 14px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
203
+ .update-bar button{border:1px solid #d8b94a;background:#fff;border-radius:5px;padding:2px 10px;font-size:12px;cursor:pointer;color:#614700}
204
+ .update-bar button:hover{background:#fff7d6}
193
205
  </style>
194
206
  </head>
195
207
  <body>
@@ -216,6 +228,8 @@
216
228
  <button class="btn primary" id="btnRun" title="按依赖与优先级调度全部待执行任务">▶ 开始编排</button>
217
229
  <button class="btn danger" id="btnStop" disabled title="停止编排:进行中的任务复位为待执行(不影响追加聊天),未开始的任务保留">⏹ 停止</button>
218
230
  <button class="btn" id="btnClear">清空</button>
231
+ <button class="btn" id="btnHelp" title="多任务编排使用帮助">❓ 帮助</button>
232
+ <span class="ver-chip" id="verChip" title="当前版本"></span>
219
233
  </header>
220
234
 
221
235
  <div class="kernel-banner" id="kernelBanner"></div>
@@ -316,6 +330,14 @@
316
330
  </div>
317
331
 
318
332
  <!-- 垃圾桶 -->
333
+ <!-- 使用帮助 -->
334
+ <div class="mask" id="helpMask">
335
+ <div class="modal" style="width:min(720px,94vw);max-height:86vh;overflow-y:auto">
336
+ <h3>❓ 多任务编排使用帮助</h3>
337
+ <div id="helpBody"></div>
338
+ <div class="foot"><button class="btn primary" id="helpClose">关闭</button></div>
339
+ </div>
340
+ </div>
319
341
  <div class="mask" id="trashMask">
320
342
  <div class="modal">
321
343
  <h3>🗑️ 垃圾桶 <span style="font-size:12px;color:var(--dim)">(保留 30 天内删除的任务快照)</span></h3>
@@ -360,6 +382,7 @@ async function loadCards(){
360
382
  if(data.config) $('#wsInput').value = data.config.workspace || '';
361
383
  if(data.maxParallel) $('#parallelSel').value = String(data.maxParallel);
362
384
  renderKernelBanner();
385
+ renderUpdateInfo(data);
363
386
  render();
364
387
  renderGraph();
365
388
  loadTrashCount();
@@ -753,7 +776,8 @@ async function openDetail(id){
753
776
  const log=$('#dLog'); log.innerHTML=''; TEXT_PARTS.clear();
754
777
  if(!msgs.length) log.innerHTML='<span class="empty">暂无过程记录</span>';
755
778
  for(const m of msgs){
756
- if(m.role==='user') appendLog(log,'👤 '+m.content,'msg-user');
779
+ if(m.role==='sys') appendLog(log,' '+m.content,'msg-tool');
780
+ else if(m.role==='user') appendLog(log,'👤 '+m.content,'msg-user');
757
781
  else if(m.phase==='archive') appendLog(log,'📦 '+m.content,'msg-tool');
758
782
  else if(m.phase==='system') appendLog(log,'⚠ '+m.content,'msg-err');
759
783
  else if(/工具|执行完成/.test(m.content||'')) appendLog(log,'🔧 '+m.content,'msg-tool');
@@ -929,6 +953,83 @@ async function openTrash(){
929
953
  }
930
954
  $('#trashFab').onclick=openTrash;
931
955
  $('#trashClose').onclick=()=>$('#trashMask').classList.remove('show');
956
+
957
+ // ---------- 使用帮助 ----------
958
+ function openHelp(){
959
+ $('#helpBody').innerHTML = `
960
+ <div class="help-sec"><h4>🗂 这是什么</h4>
961
+ <p>把要做的事写成一张张「任务卡牌」,程序按<b>依赖关系与优先级</b>自动调度 AI 执行:上游完成才启动下游,无依赖的任务并行跑。适合批量任务、流水线作业(翻译→润色→排版)与多分支并行调研。</p>
962
+ <p>与群聊的关系:群聊是「对话式」由管家现场拆解调度;本页是「清单式」由你预先定义好每张卡,执行过程可暂停、追问、重跑。</p>
963
+ </div>
964
+ <div class="help-sec"><h4>🚀 快速开始(三步)</h4>
965
+ <ol>
966
+ <li>「+ 新建任务」:写标题与任务内容(描述越具体越好,支持 Markdown)</li>
967
+ <li>(可选)在表单里选「依赖任务」:被依赖的卡完成后本卡才执行</li>
968
+ <li>「▶ 开始编排」:按依赖拓扑 + 优先级自动调度全部待执行卡牌</li>
969
+ </ol>
970
+ </div>
971
+ <div class="help-sec"><h4>⚙ 三种执行模式(新建任务表单)</h4>
972
+ <ul>
973
+ <li><b>新进程(new)</b>:独立会话执行,互不影响——默认</li>
974
+ <li><b>同会话续聊(continue)</b>:接续某张卡(续聊链首)的会话继续,AI 记得之前的成果,适合「初稿→修改→定稿」链</li>
975
+ <li><b>并行独立进程(parallel)</b>:与前后卡同时开跑,适合批量同类任务(同时查 5 个城市天气)</li>
976
+ </ul>
977
+ </div>
978
+ <div class="help-sec"><h4>🔀 依赖、优先级与并行度</h4>
979
+ <ul>
980
+ <li><b>依赖任务</b>可多选:全部完成后本卡才可执行(失败/跳过的依赖会阻断)</li>
981
+ <li><b>优先级</b>数字越小越先执行;拖拽看板卡到新位置会按新顺序自动重写优先级</li>
982
+ <li>顶栏 <b>⚡ 并行</b>(1-8):同时执行的最大任务数,改完即时生效</li>
983
+ <li>左下角「▶ 依赖图」可视化全部依赖关系,可放大查看</li>
984
+ </ul>
985
+ </div>
986
+ <div class="help-sec"><h4>📌 看板四列</h4>
987
+ <ul>
988
+ <li>待执行 → 执行中 → 完成 / 失败;卡牌角标显示该卡过程消息条数</li>
989
+ <li>失败列的「↻ 全部重跑」把失败卡重置为待执行并立即重新编排</li>
990
+ <li>顶栏搜索框按标题/内容即时过滤看板;统计条实时显示总数与完成率</li>
991
+ <li>删除的卡进入回收站,可随时恢复(清空需二次确认)</li>
992
+ </ul>
993
+ </div>
994
+ <div class="help-sec"><h4>💬 追问(完成后继续聊)</h4>
995
+ <ul>
996
+ <li>点开已完成的卡,详情页底部可输入追加消息:同一会话继续对话(让它改改措辞、补个章节都行)</li>
997
+ <li>追问期间不影响其他卡牌执行;追问结果计入该卡日志</li>
998
+ </ul>
999
+ </div>
1000
+ <div class="help-sec"><h4>📄 详情、日志与导出</h4>
1001
+ <ul>
1002
+ <li>点任意卡看执行过程(实时滚动,含工具调用)、最终结果与失败原因</li>
1003
+ <li>「⬇ 导出」把该卡导出为 Markdown(元信息 + 过程 + 结果),适合汇报存档</li>
1004
+ <li>「🗑 清空」只清看板卡牌,群聊/单聊数据不受影响</li>
1005
+ </ul>
1006
+ </div>
1007
+ <div class="help-sec"><h4>📁 工作区与内核</h4>
1008
+ <ul>
1009
+ <li>顶栏「工作文件夹」:选定后 Agent 在该目录读写相关文件(写代码、改文档直接落到你指定的目录),可选</li>
1010
+ <li>执行内核与群聊共用同一套(opencode / Claude Code / Codex / pi),在群聊页「⚙ 智能体配置」里切换</li>
1011
+ <li>头部徽标显示当前内核;未检测到内核时顶部横幅会给出安装指引</li>
1012
+ </ul>
1013
+ </div>`;
1014
+ $('#helpMask').classList.add('show');
1015
+ }
1016
+ $('#btnHelp').onclick=openHelp;
1017
+ $('#helpClose').onclick=()=>$('#helpMask').classList.remove('show');
1018
+ $('#helpMask').addEventListener('click',e=>{ if(e.target.id==='helpMask') e.target.classList.remove('show'); });
1019
+
1020
+ // ---------- 版本徽标与更新提示 ----------
1021
+ function renderUpdateInfo(data){
1022
+ if(data.version) $('#verChip').textContent='v'+data.version;
1023
+ if(!data.updateAvailable) return;
1024
+ if(document.getElementById('updateBar')) return;
1025
+ const bar=document.createElement('div');
1026
+ bar.id='updateBar'; bar.className='update-bar';
1027
+ bar.innerHTML='📦 发现新版本 <b>v'+esc(data.latestVersion)+'</b>(当前 v'+esc(data.version)+'):终端运行 <code>agents-chat update</code> 一键升级,或执行 <code>'+esc(data.updateCommand)+'</code>'
1028
+ +' <button id="updateCopy">复制升级命令</button>';
1029
+ document.body.prepend(bar);
1030
+ document.body.style.paddingTop='34px';
1031
+ $('#updateCopy').onclick=()=>copyText(data.updateCommand||('npm install -g @iamsamyiok/agents-chat@latest'));
1032
+ }
932
1033
  $('#trashEmpty').onclick=async()=>{ if(!confirm('确认清空垃圾桶?所有快照及其过程日志将被永久删除,不可恢复。'))return; const r=await post('/api/cards/trash/empty'); toast('已清空 '+r.cleared+' 项'); await loadCards(); };
933
1034
 
934
1035
  // ---- 进程状态条(PID + 工作状态,SSE 事件驱动 + 2s 轮询兜底) ----
@@ -84,6 +84,9 @@
84
84
  #jumpBtn { position: absolute; right: 18px; bottom: 100px; z-index: 5; display: none; border: 1px solid #dcdcdc; background: #fff; color: #333; border-radius: 16px; padding: 6px 14px; font-size: 12px; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,.15); animation: fade-in .2s ease; transition: color .15s, border-color .15s; }
85
85
  #jumpBtn:hover { color: #07c160; border-color: #07c160; }
86
86
  .sys-tip { text-align: center; margin: 10px 0; }
87
+ /* 长会话增量渲染:加载更早入口 */
88
+ .load-earlier { display: block; margin: 4px auto 16px; border: 1px solid #dcdcdc; background: #fff; color: #888; border-radius: 14px; padding: 5px 14px; font-size: 12px; cursor: pointer; transition: color .15s, border-color .15s; }
89
+ .load-earlier:hover { color: #07c160; border-color: #07c160; }
87
90
  .approval-card { max-width: 480px; margin: 10px auto; background: #fff8e6; border: 1px solid #e8a33d; border-radius: 10px; padding: 10px 14px; text-align: left; }
88
91
  .approval-card .ap-title { font-weight: 600; font-size: 13px; color: #8a5a00; margin-bottom: 4px; }
89
92
  .approval-card .ap-label { font-size: 12px; color: #555; margin-bottom: 8px; word-break: break-all; }
@@ -437,6 +440,7 @@
437
440
  <button class="cfg-btn" id="agentsCfgBtn" onclick="openAgents()">⚙ 智能体配置</button>
438
441
  <button class="cfg-btn" id="cardsNavBtn" onclick="openCards()" title="多任务编排 · 卡牌:把任务写成卡牌,Agent 干完一件自动注入下一件">🗂️ 多任务编排</button>
439
442
  <span class="runner-tag" id="runnerTag" style="display:none"></span>
443
+ <span class="ver-chip" id="verChip" title="当前版本" style="font-size:11px;color:#999;border:1px solid #e2e2e2;border-radius:10px;padding:2px 8px;white-space:nowrap"></span>
440
444
  <button class="cfg-btn" id="helpBtn" onclick="openHelp()" title="使用帮助">❓ 帮助</button>
441
445
  </div>
442
446
  </header>
@@ -662,7 +666,7 @@
662
666
  </div>
663
667
 
664
668
  <script>
665
- const PAGE_VERSION = '3.20.0'; // 与服务端 /api/health.version 互检,不一致说明页面缓存过期
669
+ const PAGE_VERSION = '3.26.0'; // 与服务端 /api/health.version 互检,不一致说明页面缓存过期
666
670
  const AV_COLORS = ['#5b8def','#07c160','#fa9d3b','#10aeff','#8a6fe8','#fa5151','#e8a33d','#3aa7a3'];
667
671
  const PHASE_LABEL = { plan: '调度规划', work: '执行', review: '验收', report: '汇总', talk: '圆桌发言', task: '任务执行' };
668
672
  // 职业图标库(智能体配置可选)
@@ -756,6 +760,7 @@ async function init() {
756
760
  if (health.version && health.version !== PAGE_VERSION) {
757
761
  addSys(`⚠ 页面版本(${PAGE_VERSION})与服务端(${health.version})不一致:浏览器缓存了旧页面,请按 Ctrl+F5 强制刷新`);
758
762
  }
763
+ renderUpdateInfo(health);
759
764
 
760
765
  input.addEventListener('input', handleAtInput);
761
766
  input.addEventListener('blur', () => setTimeout(hideAt, 150));
@@ -960,9 +965,36 @@ function soloRenderMessages() {
960
965
  return;
961
966
  }
962
967
  box.innerHTML = '';
963
- for (const m of getSess(curOcId)) box.appendChild(soloMsgNode(m));
968
+ const all = getSess(curOcId);
969
+ soloShownFrom = Math.max(0, all.length - RENDER_BATCH);
970
+ if (soloShownFrom > 0) box.appendChild(makeLoadEarlierBtn(loadEarlierSolo));
971
+ for (let i = soloShownFrom; i < all.length; i++) {
972
+ const n = soloMsgNode(all[i]);
973
+ all[i]._dom = n;
974
+ box.appendChild(n);
975
+ }
964
976
  soloScroll();
965
977
  }
978
+ function loadEarlierSolo() {
979
+ const box = document.getElementById('soloMessages');
980
+ const all = getSess(curOcId);
981
+ // 单聊消息节点构造器不同,这里专用批次回填
982
+ const fromIdx = soloShownFrom;
983
+ if (fromIdx <= 0) return;
984
+ const prevHeight = box.scrollHeight, prevTop = box.scrollTop;
985
+ const from = Math.max(0, fromIdx - RENDER_BATCH);
986
+ const frag = document.createDocumentFragment();
987
+ const oldBtn = box.querySelector('.load-earlier'); if (oldBtn) oldBtn.remove();
988
+ if (from > 0) frag.appendChild(makeLoadEarlierBtn(loadEarlierSolo));
989
+ for (let i = from; i < fromIdx; i++) {
990
+ const n = soloMsgNode(all[i]);
991
+ all[i]._dom = n;
992
+ frag.appendChild(n);
993
+ }
994
+ box.insertBefore(frag, box.firstChild);
995
+ box.scrollTop = prevTop + (box.scrollHeight - prevHeight);
996
+ soloShownFrom = from;
997
+ }
966
998
 
967
999
  function pushSoloMsg(key, m) {
968
1000
  getSess(key).push(m);
@@ -1795,15 +1827,52 @@ function msgNode(m) {
1795
1827
  }
1796
1828
 
1797
1829
  // 全量重绘当前会话(切换会话 / 载入历史时)
1830
+ // ---------- 长会话增量渲染:初始只渲染最近 RENDER_BATCH 条,更早的按需加载 ----------
1831
+ // 超长会话(数千条)全量渲染会拖慢浏览器;批次化后 DOM 数量恒定,加载更早时保持视口不跳动
1832
+ const RENDER_BATCH = 100;
1833
+ function makeLoadEarlierBtn(onClick) {
1834
+ const b = document.createElement('button');
1835
+ b.className = 'load-earlier';
1836
+ b.textContent = '加载更早的消息';
1837
+ b.onclick = onClick;
1838
+ return b;
1839
+ }
1840
+ function loadEarlierInto(box, all, fromIdx, onClick) {
1841
+ if (fromIdx <= 0) return 0;
1842
+ const prevHeight = box.scrollHeight, prevTop = box.scrollTop;
1843
+ const from = Math.max(0, fromIdx - RENDER_BATCH);
1844
+ const frag = document.createDocumentFragment();
1845
+ const oldBtn = box.querySelector('.load-earlier'); if (oldBtn) oldBtn.remove();
1846
+ if (from > 0) frag.appendChild(makeLoadEarlierBtn(onClick));
1847
+ for (let i = from; i < fromIdx; i++) {
1848
+ const node = msgNode(all[i]);
1849
+ all[i]._dom = node;
1850
+ frag.appendChild(node);
1851
+ finalizeMsgLinks(all[i]);
1852
+ }
1853
+ box.insertBefore(frag, box.firstChild);
1854
+ box.scrollTop = prevTop + (box.scrollHeight - prevHeight); // 视口停在原消息处
1855
+ return from;
1856
+ }
1857
+ let mainShownFrom = 0; // 主聊天区已渲染起始下标
1858
+ let soloShownFrom = 0; // 单聊区已渲染起始下标
1859
+ function loadEarlierMain() {
1860
+ const box = document.getElementById('messages');
1861
+ mainShownFrom = loadEarlierInto(box, getSess(curKey()), mainShownFrom, loadEarlierMain);
1862
+ }
1863
+
1798
1864
  function renderMessages() {
1799
1865
  const box = document.getElementById('messages');
1800
1866
  box.classList.add('bulk'); // 批量重建:抑制逐条入场动画
1801
1867
  box.innerHTML = '';
1802
- for (const m of getSess(curKey())) {
1803
- const node = msgNode(m);
1804
- m._dom = node;
1868
+ const all = getSess(curKey());
1869
+ mainShownFrom = Math.max(0, all.length - RENDER_BATCH);
1870
+ if (mainShownFrom > 0) box.appendChild(makeLoadEarlierBtn(loadEarlierMain));
1871
+ for (let i = mainShownFrom; i < all.length; i++) {
1872
+ const node = msgNode(all[i]);
1873
+ all[i]._dom = node;
1805
1874
  box.appendChild(node);
1806
- finalizeMsgLinks(m); // 定稿消息中的 .md 路径变成可点击预览链接
1875
+ finalizeMsgLinks(all[i]); // 定稿消息中的 .md 路径变成可点击预览链接
1807
1876
  }
1808
1877
  scrollBottom();
1809
1878
  requestAnimationFrame(() => requestAnimationFrame(() => box.classList.remove('bulk')));
@@ -3182,8 +3251,31 @@ function openCards() {
3182
3251
  window.open('/cards', '_blank');
3183
3252
  }
3184
3253
 
3254
+ // 版本徽标与更新提示条(health 下发;无新版静默)
3255
+ function renderUpdateInfo(health) {
3256
+ if (!health) return;
3257
+ const chip = document.getElementById('verChip');
3258
+ if (chip && health.version) chip.textContent = 'v' + health.version;
3259
+ if (!health.updateAvailable || document.getElementById('updateBar')) return;
3260
+ const bar = document.createElement('div');
3261
+ bar.id = 'updateBar';
3262
+ bar.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:60;background:#fffbe6;border-bottom:1px solid #ffe58f;color:#614700;font-size:12.5px;padding:7px 14px;display:flex;align-items:center;gap:8px;flex-wrap:wrap';
3263
+ bar.innerHTML = `📦 发现新版本 <b>v${esc(health.latestVersion)}</b>(当前 v${esc(health.version)}):终端运行 <code>agents-chat update</code> 一键升级,或执行 <code>${esc(health.updateCommand)}</code> <button id="updateCopy" style="border:1px solid #d8b94a;background:#fff;border-radius:5px;padding:2px 10px;font-size:12px;cursor:pointer;color:#614700">复制升级命令</button>`;
3264
+ document.body.prepend(bar);
3265
+ document.body.style.paddingTop = '34px';
3266
+ document.getElementById('updateCopy').onclick = () => copyText(health.updateCommand || 'npm install -g @iamsamyiok/agents-chat@latest');
3267
+ }
3268
+
3185
3269
  function openHelp() {
3186
3270
  document.getElementById('helpBody').innerHTML = kernelBanner() + `
3271
+ <div class="help-sec"><h4>🧭 三种使用模式(先选对界面)</h4>
3272
+ <ul>
3273
+ <li><b>👥 群聊</b>(本页默认):一句话发给整个团队——管家理解需求、拆解任务、调度子智能体协作完成;也可以 <code>@</code> 点名。适合不确定怎么拆、需要 AI 自己规划的事</li>
3274
+ <li><b>👤 单聊</b>(左上角开关切换):与一个 AI 一对一连续对话,像用普通 AI 助手;同一会话多轮续聊,顶部可选默认模型。适合问答、写作、改代码这类单人事务</li>
3275
+ <li><b>🗂️ 多任务编排</b>(顶栏按钮进入):把任务写成卡牌清单,按依赖与优先级自动批量执行。适合一次布置一批任务、流水线作业。编排在独立页面,有专属帮助(页面右上角 ❓)</li>
3276
+ </ul>
3277
+ <p>三者数据互通(同一数据目录、同一套内核),随时切换界面,互不干扰。</p>
3278
+ </div>
3187
3279
  <div class="help-sec"><h4>🚀 快速开始</h4>
3188
3280
  <ol>
3189
3281
  <li>本机安装任一执行内核并登录模型(模型与密钥由内核自己管理,本程序不做任何 API 配置):<br>
package/app/server.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // Agents Chat Portable - 零依赖 HTTP 服务
2
2
  // 启动:node app/server.js [--port 3456]
3
- const APP_VERSION = '3.20.0'; // 页面与服务端版本互检,不一致提示强刷
3
+ const APP_VERSION = require('../package.json').version; // 单源版本:与 package.json 始终一致(页面互检/更新检查共用)
4
+ const { checkLatest, UPDATE_COMMAND } = require('./lib/updatecheck');
4
5
  const http = require('http');
5
6
  const fs = require('fs');
6
7
  const path = require('path');
@@ -16,7 +17,14 @@ loadEnv(path.join(ROOT_DIR, '.env'));
16
17
  const LOG_DIR = process.env.AGENTS_CHAT_DATA || path.join(ROOT_DIR, '.data');
17
18
  try { fs.mkdirSync(LOG_DIR, { recursive: true }); } catch { /* ignore */ }
18
19
  const LOG_PATH = path.join(LOG_DIR, 'server.log');
19
- try { fs.writeFileSync(LOG_PATH, `=== Agents Chat started ${new Date().toISOString()} ===\n`); } catch { /* ignore */ }
20
+ const LOG_MAX_BYTES = 5 * 1024 * 1024; // 单文件 5MB 滚动:常驻/定时任务长跑不无限增长
21
+ function logRotateIfNeeded() {
22
+ try {
23
+ const st = fs.statSync(LOG_PATH);
24
+ if (st.size > LOG_MAX_BYTES) fs.renameSync(LOG_PATH, LOG_PATH + '.1'); // 保留一代,旧的 .1 被覆盖
25
+ } catch { /* 无文件 */ }
26
+ }
27
+ try { logRotateIfNeeded(); fs.writeFileSync(LOG_PATH, `=== Agents Chat started ${new Date().toISOString()} ===\n`); } catch { /* ignore */ }
20
28
  function teeWrite(args) {
21
29
  try { fs.appendFileSync(LOG_PATH, args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ') + '\n'); } catch { /* ignore */ }
22
30
  }
@@ -123,12 +131,19 @@ function sse(req, res) {
123
131
  Connection: 'keep-alive'
124
132
  });
125
133
  sseConns++;
134
+ // 背压保护:慢客户端的写缓冲积压超 1MB 时主动断开(防内存无限堆积),客户端重连后经 init 补拉状态
126
135
  const send = (obj) => {
127
- try { res.write(`data: ${JSON.stringify(obj)}\n\n`); } catch { /* closed */ }
136
+ try {
137
+ if (res.writableLength > 1024 * 1024) { try { res.destroy(); } catch { /* ignore */ } return; }
138
+ res.write(`data: ${JSON.stringify(obj)}\n\n`);
139
+ } catch { /* closed */ }
128
140
  };
129
141
  // 心跳防止代理断开
130
142
  const hb = setInterval(() => {
131
- try { res.write(': hb\n\n'); } catch { clearInterval(hb); }
143
+ try {
144
+ if (res.writableLength > 1024 * 1024) { res.destroy(); clearInterval(hb); return; }
145
+ res.write(': hb\n\n');
146
+ } catch { clearInterval(hb); }
132
147
  }, 15000);
133
148
  let closed = false;
134
149
  const onClose = () => {
@@ -288,9 +303,14 @@ const server = http.createServer(async (req, res) => {
288
303
  const { resolveRunner, detectKernels } = require('./lib/agent');
289
304
  const runner = resolveRunner();
290
305
  const kernels = detectKernels();
306
+ // 更新检查(1 小时缓存,失败静默返回 null 字段)
307
+ const upd = await checkLatest().catch(() => null);
291
308
  json(res, 200, {
292
309
  success: true,
293
310
  version: APP_VERSION,
311
+ latestVersion: upd ? upd.latest : '',
312
+ updateAvailable: upd ? upd.updateAvailable : false,
313
+ updateCommand: upd && upd.updateAvailable ? UPDATE_COMMAND : '',
294
314
  runner: runner.kind,
295
315
  kernelLabel: runner.kernel ? runner.kernel.label : '',
296
316
  kernelCmd: runner.cmd || '',
@@ -871,7 +891,8 @@ const server = http.createServer(async (req, res) => {
871
891
 
872
892
  // ---------- 历史管理:一键清空全部会话 / 导出 sessions.md ----------
873
893
  if (p === '/api/history/clear' && req.method === 'POST') {
874
- const msgCount = store.getMessages().length;
894
+ const msgStat = store.countMessagesByTask();
895
+ const msgCount = msgStat.main + Object.values(msgStat.counts).reduce((a, b) => a + b, 0);
875
896
  let outDirs = 0;
876
897
  store.clearMessages();
877
898
  // 单聊会话记录一并清空(消息已清,保留空会话列表无意义)
@@ -891,18 +912,20 @@ const server = http.createServer(async (req, res) => {
891
912
  }
892
913
 
893
914
  if (p === '/api/history/export' && req.method === 'GET') {
894
- const msgs = store.getMessages();
915
+ // 统计与头部:逐分片计数(低内存);正文按会话逐段拼接(导出为显式用户动作)
916
+ const stat = store.countMessagesByTask();
895
917
  const tasksAll = store.getTasks();
896
918
  const ocAll = store.getOcSessions();
897
919
  const fmtTs = (t) => { const d = new Date(t); const p2 = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())} ${p2(d.getHours())}:${p2(d.getMinutes())}`; };
898
920
  const roleOf = (m) => m.role === 'user' ? '👤 用户' : (m.agentName || '智能体') + (m.phase ? `(${m.phase})` : '');
921
+ const total = stat.main + Object.values(stat.counts).reduce((a, b) => a + b, 0);
899
922
  const lines = [
900
923
  '# Agents Chat 会话导出', '',
901
924
  `- 导出时间:${fmtTs(Date.now())}`,
902
- `- 会话数:${1 + tasksAll.filter(t => store.getMessages(t.id).length > 0).length + ocAll.filter(s => store.getMessages(s.id).length > 0).length}(主会话 + 任务会话 + 单聊会话)`,
903
- `- 消息总数:${msgs.length}`, ''
925
+ `- 会话数:${1 + tasksAll.filter(t => (stat.counts[t.id] || 0) > 0).length + ocAll.filter(s => (stat.counts[s.id] || 0) > 0).length}(主会话 + 任务会话 + 单聊会话)`,
926
+ `- 消息总数:${total}`, ''
904
927
  ];
905
- const main = msgs.filter(m => !m.taskId);
928
+ const main = store.getMessages('');
906
929
  lines.push('---', '', '## 主会话', '');
907
930
  for (const m of main) {
908
931
  lines.push(`### ${fmtTs(m.timestamp)} · ${roleOf(m)}`, '');
@@ -945,11 +968,9 @@ const server = http.createServer(async (req, res) => {
945
968
  // ---------- 事项管控(卡牌)API ----------
946
969
  if (p === '/api/cards' && req.method === 'GET') {
947
970
  const cards = CardStore.list();
948
- // 附带每个卡牌当前过程消息条数(供 UI 角标展示)
949
- const counts = {};
950
- try {
951
- for (const m of store.getMessages()) if (m.taskId) counts[m.taskId] = (counts[m.taskId] || 0) + 1;
952
- } catch { /* ignore */ }
971
+ // 附带每个卡牌当前过程消息条数(供 UI 角标展示):逐分片计数,不合并全量消息
972
+ let counts = {};
973
+ try { counts = store.countMessagesByTask().counts; } catch { /* ignore */ }
953
974
  // 执行内核状态(前端据此显示内核徽标/缺失警告)与追加聊天进行中的卡
954
975
  let runnerInfo = { kind: 'unknown', label: '' };
955
976
  try {
@@ -958,9 +979,13 @@ const server = http.createServer(async (req, res) => {
958
979
  runnerInfo = { kind: r.kind, label: r.kernel ? r.kernel.label : '' };
959
980
  } catch { /* ignore */ }
960
981
  const { getCorruptedFiles } = require('./lib/cards');
982
+ // 版本与更新信息(cards 页无 health 轮询,随主列表接口下发;1 小时缓存)
983
+ const upd = await checkLatest().catch(() => null);
961
984
  json(res, 200, {
962
985
  success: true, cards, running: cardRunner.isRunning(), maxParallel: cardRunner.maxP(), msgCounts: counts, config: CardStore.getConfig(),
963
- followupIds: cardRunner.getFollowupIds(), runner: runnerInfo, corrupted: getCorruptedFiles()
986
+ followupIds: cardRunner.getFollowupIds(), runner: runnerInfo, corrupted: getCorruptedFiles(),
987
+ version: APP_VERSION, latestVersion: upd ? upd.latest : '', updateAvailable: upd ? upd.updateAvailable : false,
988
+ updateCommand: upd && upd.updateAvailable ? UPDATE_COMMAND : ''
964
989
  });
965
990
  return;
966
991
  }
@@ -1204,20 +1229,35 @@ const server = http.createServer(async (req, res) => {
1204
1229
  });
1205
1230
 
1206
1231
  // 优雅退出:停掉全部子进程、把执行中任务复位为待执行,避免残留与假死状态
1232
+ // 幂等防重入:信号钩子与 exit 钩子可能先后触发,清理只执行一次
1233
+ let shutdownDone = false;
1207
1234
  function shutdown(code) {
1208
- try {
1209
- if (termProc) { try { termProc.kill(); } catch { /* ignore */ } termProc = null; }
1210
- const n = stopAllChildren();
1211
- const m = store.resetRunningTasks();
1212
- const mc = CardStore.resetRunning();
1213
- if (n || m || mc) console.log(`退出清理:终止 ${n} 个子进程,复位 ${m} 个执行中任务、${mc} 张卡牌`);
1214
- } catch (err) {
1215
- console.error('[shutdown] 清理失败:', err && (err.stack || err));
1235
+ if (!shutdownDone) {
1236
+ shutdownDone = true;
1237
+ try {
1238
+ if (termProc) { try { termProc.kill(); } catch { /* ignore */ } termProc = null; }
1239
+ const n = stopAllChildren();
1240
+ const m = store.resetRunningTasks();
1241
+ const mc = CardStore.resetRunning();
1242
+ if (n || m || mc) console.log(`退出清理:终止 ${n} 个子进程,复位 ${m} 个执行中任务、${mc} 张卡牌`);
1243
+ } catch (err) {
1244
+ console.error('[shutdown] 清理失败:', err && (err.stack || err));
1245
+ }
1216
1246
  }
1217
1247
  process.exit(code);
1218
1248
  }
1219
1249
  process.on('SIGINT', () => shutdown(0));
1220
1250
  process.on('SIGTERM', () => shutdown(0));
1251
+ // Windows 关闭控制台窗口(CTRL_CLOSE_EVENT)在 Node 上映射为 SIGHUP,系统给约 5 秒处理宽限:
1252
+ // 同步 killTree 足够完成,正在执行的 AI 子进程不再变孤儿
1253
+ process.on('SIGHUP', () => shutdown(0));
1254
+ // 兜底:显式 process.exit 路径(exit 钩子内仅允许同步操作,killTree 为同步调用)
1255
+ process.on('exit', () => {
1256
+ if (!shutdownDone) {
1257
+ shutdownDone = true;
1258
+ try { stopAllChildren(); } catch { /* ignore */ }
1259
+ }
1260
+ });
1221
1261
 
1222
1262
  // ---------- 任务批次执行(SSE 手动触发与定时调度共用) ----------
1223
1263
  // send: 事件推送(SSE 为真实推送,定时触发为 no-op,消息仍会持久化)
@@ -1426,8 +1466,27 @@ server.listen(PORT, () => {
1426
1466
  startScheduler();
1427
1467
  startAutoStop();
1428
1468
  startPruneTimer();
1429
- console.log(`Agents Chat 已启动: http://localhost:${PORT}`);
1469
+ console.log(`Agents Chat v${APP_VERSION} 已启动: http://localhost:${PORT}`);
1430
1470
  console.log(`运行内核: ${kindText}`);
1471
+ console.log('退出提示: 请用 Ctrl+C(或 agents-chat stop)退出,会自动清理执行中的 AI 子进程;直接关闭窗口可能残留正在执行的进程');
1431
1472
  console.log(`本机可用内核: ${avail}(配置页可切换)`);
1432
1473
  console.log(`数据目录: ${store.DATA_DIR}`);
1474
+ // 开发自检:页面内 PAGE_VERSION 落后于服务端 → 提示同步,避免互检误报强刷
1475
+ try {
1476
+ for (const f of ['index.html', 'cards.html']) {
1477
+ const html = fs.readFileSync(path.join(PUBLIC_DIR, f), 'utf8');
1478
+ const m = html.match(/PAGE_VERSION\s*=\s*'([^']+)'/);
1479
+ if (m && m[1] !== APP_VERSION) console.warn(`[版本自检] ${f} 的 PAGE_VERSION(${m[1]})与服务端(${APP_VERSION})不一致,发版前请同步`);
1480
+ }
1481
+ } catch { /* ignore */ }
1482
+ // 异步更新检查:不阻塞启动,有新版打印一键升级提示
1483
+ checkLatest().then((upd) => {
1484
+ if (upd && upd.updateAvailable) {
1485
+ console.log(`──────────────────────────────────────────────`);
1486
+ console.log(`📦 发现新版本 v${upd.latest}(当前 v${APP_VERSION})`);
1487
+ console.log(` 命令行一键升级: agents-chat update`);
1488
+ console.log(` 或手动执行: ${UPDATE_COMMAND}`);
1489
+ console.log(`──────────────────────────────────────────────`);
1490
+ }
1491
+ }).catch(() => { /* ignore */ });
1433
1492
  });
@@ -1,13 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * agents-chat CLI 入口
4
- *
4
+ *
5
5
  * 用法:
6
- * agents-chat # 启动服务(后台运行)
7
- * agents-chat start # 同上
8
- * agents-chat stop # 停止服务
9
- * agents-chat status # 查看服务状态
10
- * agents-chat open # 仅打开浏览器
6
+ * agents-chat # 启动服务(后台运行)
7
+ * agents-chat start # 同上
8
+ * agents-chat stop # 停止服务
9
+ * agents-chat status # 查看服务状态(含版本与更新检查)
10
+ * agents-chat open # 仅打开浏览器
11
+ * agents-chat update # 检查并一键升级到 npm 最新版
12
+ * agents-chat version # 查看当前版本
11
13
  */
12
14
 
13
15
  const { spawn, execSync } = require('child_process');
@@ -15,6 +17,9 @@ const fs = require('fs');
15
17
  const path = require('path');
16
18
  const os = require('os');
17
19
 
20
+ const PKG = require('../package.json');
21
+ const { checkLatest, UPDATE_COMMAND } = require('../app/lib/updatecheck');
22
+
18
23
  const PORT = parseInt(process.env.AGENTS_CHAT_PORT || '3456', 10);
19
24
  const PID_FILE = path.join(os.homedir(), '.agents-chat.pid');
20
25
  const LOG_FILE = path.join(os.homedir(), '.agents-chat.log');
@@ -216,6 +221,52 @@ function showStatus() {
216
221
  console.log('发现残留进程,已清理');
217
222
  }
218
223
  }
224
+ console.log(`当前版本: v${PKG.version}`);
225
+ checkLatest().then((upd) => {
226
+ if (!upd) { console.log('更新检查: 网络不可用,跳过'); return; }
227
+ if (upd.updateAvailable) {
228
+ console.log(`可用更新: v${upd.latest}(运行 agents-chat update 一键升级)`);
229
+ } else {
230
+ console.log(`版本状态: 已是最新(npm 最新 v${upd.latest})`);
231
+ }
232
+ });
233
+ }
234
+
235
+ // 一键升级:检测新版 → 停服务(避免文件占用/旧进程残留)→ npm 全局安装最新版
236
+ async function updateSelf() {
237
+ console.log(`当前版本: v${PKG.version}`);
238
+ console.log('正在检查 npm 最新版本...');
239
+ const upd = await checkLatest({ force: true });
240
+ if (!upd) {
241
+ console.error('无法访问 npm registry(网络超时或离线),请稍后重试或手动执行:');
242
+ console.log(` ${UPDATE_COMMAND}`);
243
+ process.exit(1);
244
+ }
245
+ if (!upd.updateAvailable) {
246
+ console.log(`已是最新版本 v${upd.latest},无需升级`);
247
+ return;
248
+ }
249
+ console.log(`发现新版本 v${upd.latest},开始升级...`);
250
+ const check = isRunning();
251
+ if (check.running) {
252
+ console.log('先停止运行中的服务(升级完成后需手动重新启动)...');
253
+ stopServer();
254
+ }
255
+ try {
256
+ execSync(UPDATE_COMMAND, { stdio: 'inherit' });
257
+ console.log('');
258
+ console.log(`升级完成: v${PKG.version} -> v${upd.latest}`);
259
+ console.log('运行 agents-chat 重新启动服务');
260
+ } catch (err) {
261
+ console.error('升级失败:', err.message);
262
+ console.log('可手动执行:');
263
+ console.log(` ${UPDATE_COMMAND}`);
264
+ process.exit(1);
265
+ }
266
+ }
267
+
268
+ function showVersion() {
269
+ console.log(`agents-chat v${PKG.version}`);
219
270
  }
220
271
 
221
272
  // 主逻辑
@@ -235,18 +286,29 @@ switch (command) {
235
286
  case 'open':
236
287
  openBrowser();
237
288
  break;
289
+ case 'update':
290
+ case 'upgrade':
291
+ updateSelf();
292
+ break;
293
+ case 'version':
294
+ case '-v':
295
+ case '--version':
296
+ showVersion();
297
+ break;
238
298
  case 'help':
239
299
  case '--help':
240
300
  case '-h':
241
301
  console.log(`
242
- Agents Chat CLI
302
+ Agents Chat CLI v${PKG.version}
243
303
 
244
304
  用法:
245
- agents-chat 启动服务(后台运行,自动打开浏览器)
246
- agents-chat start 同上
247
- agents-chat stop 停止服务
248
- agents-chat status 查看服务状态
249
- agents-chat open 仅打开浏览器
305
+ agents-chat 启动服务(后台运行,自动打开浏览器)
306
+ agents-chat start 同上
307
+ agents-chat stop 停止服务
308
+ agents-chat status 查看服务状态(含版本与更新检查)
309
+ agents-chat open 仅打开浏览器
310
+ agents-chat update 检查并一键升级到 npm 最新版
311
+ agents-chat version 查看当前版本
250
312
 
251
313
  环境变量:
252
314
  AGENTS_CHAT_PORT 端口号 (默认: 3456)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iamsamyiok/agents-chat",
3
- "version": "3.24.0",
3
+ "version": "3.26.0",
4
4
  "description": "多智能体群聊工具 - 支持 OpenCode/Claude Code/Codex/pi 内核,微信风格聊天界面",
5
5
  "main": "lib/start.js",
6
6
  "bin": {