@iamsamyiok/agents-chat 3.24.0 → 3.25.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 +37 -0
- package/app/public/cards.html +2 -1
- package/app/public/index.html +72 -5
- package/app/server.js +52 -21
- package/package.json +1 -1
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,
|
package/app/public/cards.html
CHANGED
|
@@ -753,7 +753,8 @@ async function openDetail(id){
|
|
|
753
753
|
const log=$('#dLog'); log.innerHTML=''; TEXT_PARTS.clear();
|
|
754
754
|
if(!msgs.length) log.innerHTML='<span class="empty">暂无过程记录</span>';
|
|
755
755
|
for(const m of msgs){
|
|
756
|
-
if(m.role==='
|
|
756
|
+
if(m.role==='sys') appendLog(log,'ℹ '+m.content,'msg-tool');
|
|
757
|
+
else if(m.role==='user') appendLog(log,'👤 '+m.content,'msg-user');
|
|
757
758
|
else if(m.phase==='archive') appendLog(log,'📦 '+m.content,'msg-tool');
|
|
758
759
|
else if(m.phase==='system') appendLog(log,'⚠ '+m.content,'msg-err');
|
|
759
760
|
else if(/工具|执行完成/.test(m.content||'')) appendLog(log,'🔧 '+m.content,'msg-tool');
|
package/app/public/index.html
CHANGED
|
@@ -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; }
|
|
@@ -960,9 +963,36 @@ function soloRenderMessages() {
|
|
|
960
963
|
return;
|
|
961
964
|
}
|
|
962
965
|
box.innerHTML = '';
|
|
963
|
-
|
|
966
|
+
const all = getSess(curOcId);
|
|
967
|
+
soloShownFrom = Math.max(0, all.length - RENDER_BATCH);
|
|
968
|
+
if (soloShownFrom > 0) box.appendChild(makeLoadEarlierBtn(loadEarlierSolo));
|
|
969
|
+
for (let i = soloShownFrom; i < all.length; i++) {
|
|
970
|
+
const n = soloMsgNode(all[i]);
|
|
971
|
+
all[i]._dom = n;
|
|
972
|
+
box.appendChild(n);
|
|
973
|
+
}
|
|
964
974
|
soloScroll();
|
|
965
975
|
}
|
|
976
|
+
function loadEarlierSolo() {
|
|
977
|
+
const box = document.getElementById('soloMessages');
|
|
978
|
+
const all = getSess(curOcId);
|
|
979
|
+
// 单聊消息节点构造器不同,这里专用批次回填
|
|
980
|
+
const fromIdx = soloShownFrom;
|
|
981
|
+
if (fromIdx <= 0) return;
|
|
982
|
+
const prevHeight = box.scrollHeight, prevTop = box.scrollTop;
|
|
983
|
+
const from = Math.max(0, fromIdx - RENDER_BATCH);
|
|
984
|
+
const frag = document.createDocumentFragment();
|
|
985
|
+
const oldBtn = box.querySelector('.load-earlier'); if (oldBtn) oldBtn.remove();
|
|
986
|
+
if (from > 0) frag.appendChild(makeLoadEarlierBtn(loadEarlierSolo));
|
|
987
|
+
for (let i = from; i < fromIdx; i++) {
|
|
988
|
+
const n = soloMsgNode(all[i]);
|
|
989
|
+
all[i]._dom = n;
|
|
990
|
+
frag.appendChild(n);
|
|
991
|
+
}
|
|
992
|
+
box.insertBefore(frag, box.firstChild);
|
|
993
|
+
box.scrollTop = prevTop + (box.scrollHeight - prevHeight);
|
|
994
|
+
soloShownFrom = from;
|
|
995
|
+
}
|
|
966
996
|
|
|
967
997
|
function pushSoloMsg(key, m) {
|
|
968
998
|
getSess(key).push(m);
|
|
@@ -1795,15 +1825,52 @@ function msgNode(m) {
|
|
|
1795
1825
|
}
|
|
1796
1826
|
|
|
1797
1827
|
// 全量重绘当前会话(切换会话 / 载入历史时)
|
|
1828
|
+
// ---------- 长会话增量渲染:初始只渲染最近 RENDER_BATCH 条,更早的按需加载 ----------
|
|
1829
|
+
// 超长会话(数千条)全量渲染会拖慢浏览器;批次化后 DOM 数量恒定,加载更早时保持视口不跳动
|
|
1830
|
+
const RENDER_BATCH = 100;
|
|
1831
|
+
function makeLoadEarlierBtn(onClick) {
|
|
1832
|
+
const b = document.createElement('button');
|
|
1833
|
+
b.className = 'load-earlier';
|
|
1834
|
+
b.textContent = '加载更早的消息';
|
|
1835
|
+
b.onclick = onClick;
|
|
1836
|
+
return b;
|
|
1837
|
+
}
|
|
1838
|
+
function loadEarlierInto(box, all, fromIdx, onClick) {
|
|
1839
|
+
if (fromIdx <= 0) return 0;
|
|
1840
|
+
const prevHeight = box.scrollHeight, prevTop = box.scrollTop;
|
|
1841
|
+
const from = Math.max(0, fromIdx - RENDER_BATCH);
|
|
1842
|
+
const frag = document.createDocumentFragment();
|
|
1843
|
+
const oldBtn = box.querySelector('.load-earlier'); if (oldBtn) oldBtn.remove();
|
|
1844
|
+
if (from > 0) frag.appendChild(makeLoadEarlierBtn(onClick));
|
|
1845
|
+
for (let i = from; i < fromIdx; i++) {
|
|
1846
|
+
const node = msgNode(all[i]);
|
|
1847
|
+
all[i]._dom = node;
|
|
1848
|
+
frag.appendChild(node);
|
|
1849
|
+
finalizeMsgLinks(all[i]);
|
|
1850
|
+
}
|
|
1851
|
+
box.insertBefore(frag, box.firstChild);
|
|
1852
|
+
box.scrollTop = prevTop + (box.scrollHeight - prevHeight); // 视口停在原消息处
|
|
1853
|
+
return from;
|
|
1854
|
+
}
|
|
1855
|
+
let mainShownFrom = 0; // 主聊天区已渲染起始下标
|
|
1856
|
+
let soloShownFrom = 0; // 单聊区已渲染起始下标
|
|
1857
|
+
function loadEarlierMain() {
|
|
1858
|
+
const box = document.getElementById('messages');
|
|
1859
|
+
mainShownFrom = loadEarlierInto(box, getSess(curKey()), mainShownFrom, loadEarlierMain);
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1798
1862
|
function renderMessages() {
|
|
1799
1863
|
const box = document.getElementById('messages');
|
|
1800
1864
|
box.classList.add('bulk'); // 批量重建:抑制逐条入场动画
|
|
1801
1865
|
box.innerHTML = '';
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1866
|
+
const all = getSess(curKey());
|
|
1867
|
+
mainShownFrom = Math.max(0, all.length - RENDER_BATCH);
|
|
1868
|
+
if (mainShownFrom > 0) box.appendChild(makeLoadEarlierBtn(loadEarlierMain));
|
|
1869
|
+
for (let i = mainShownFrom; i < all.length; i++) {
|
|
1870
|
+
const node = msgNode(all[i]);
|
|
1871
|
+
all[i]._dom = node;
|
|
1805
1872
|
box.appendChild(node);
|
|
1806
|
-
finalizeMsgLinks(
|
|
1873
|
+
finalizeMsgLinks(all[i]); // 定稿消息中的 .md 路径变成可点击预览链接
|
|
1807
1874
|
}
|
|
1808
1875
|
scrollBottom();
|
|
1809
1876
|
requestAnimationFrame(() => requestAnimationFrame(() => box.classList.remove('bulk')));
|
package/app/server.js
CHANGED
|
@@ -16,7 +16,14 @@ loadEnv(path.join(ROOT_DIR, '.env'));
|
|
|
16
16
|
const LOG_DIR = process.env.AGENTS_CHAT_DATA || path.join(ROOT_DIR, '.data');
|
|
17
17
|
try { fs.mkdirSync(LOG_DIR, { recursive: true }); } catch { /* ignore */ }
|
|
18
18
|
const LOG_PATH = path.join(LOG_DIR, 'server.log');
|
|
19
|
-
|
|
19
|
+
const LOG_MAX_BYTES = 5 * 1024 * 1024; // 单文件 5MB 滚动:常驻/定时任务长跑不无限增长
|
|
20
|
+
function logRotateIfNeeded() {
|
|
21
|
+
try {
|
|
22
|
+
const st = fs.statSync(LOG_PATH);
|
|
23
|
+
if (st.size > LOG_MAX_BYTES) fs.renameSync(LOG_PATH, LOG_PATH + '.1'); // 保留一代,旧的 .1 被覆盖
|
|
24
|
+
} catch { /* 无文件 */ }
|
|
25
|
+
}
|
|
26
|
+
try { logRotateIfNeeded(); fs.writeFileSync(LOG_PATH, `=== Agents Chat started ${new Date().toISOString()} ===\n`); } catch { /* ignore */ }
|
|
20
27
|
function teeWrite(args) {
|
|
21
28
|
try { fs.appendFileSync(LOG_PATH, args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ') + '\n'); } catch { /* ignore */ }
|
|
22
29
|
}
|
|
@@ -123,12 +130,19 @@ function sse(req, res) {
|
|
|
123
130
|
Connection: 'keep-alive'
|
|
124
131
|
});
|
|
125
132
|
sseConns++;
|
|
133
|
+
// 背压保护:慢客户端的写缓冲积压超 1MB 时主动断开(防内存无限堆积),客户端重连后经 init 补拉状态
|
|
126
134
|
const send = (obj) => {
|
|
127
|
-
try {
|
|
135
|
+
try {
|
|
136
|
+
if (res.writableLength > 1024 * 1024) { try { res.destroy(); } catch { /* ignore */ } return; }
|
|
137
|
+
res.write(`data: ${JSON.stringify(obj)}\n\n`);
|
|
138
|
+
} catch { /* closed */ }
|
|
128
139
|
};
|
|
129
140
|
// 心跳防止代理断开
|
|
130
141
|
const hb = setInterval(() => {
|
|
131
|
-
try {
|
|
142
|
+
try {
|
|
143
|
+
if (res.writableLength > 1024 * 1024) { res.destroy(); clearInterval(hb); return; }
|
|
144
|
+
res.write(': hb\n\n');
|
|
145
|
+
} catch { clearInterval(hb); }
|
|
132
146
|
}, 15000);
|
|
133
147
|
let closed = false;
|
|
134
148
|
const onClose = () => {
|
|
@@ -871,7 +885,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
871
885
|
|
|
872
886
|
// ---------- 历史管理:一键清空全部会话 / 导出 sessions.md ----------
|
|
873
887
|
if (p === '/api/history/clear' && req.method === 'POST') {
|
|
874
|
-
const
|
|
888
|
+
const msgStat = store.countMessagesByTask();
|
|
889
|
+
const msgCount = msgStat.main + Object.values(msgStat.counts).reduce((a, b) => a + b, 0);
|
|
875
890
|
let outDirs = 0;
|
|
876
891
|
store.clearMessages();
|
|
877
892
|
// 单聊会话记录一并清空(消息已清,保留空会话列表无意义)
|
|
@@ -891,18 +906,20 @@ const server = http.createServer(async (req, res) => {
|
|
|
891
906
|
}
|
|
892
907
|
|
|
893
908
|
if (p === '/api/history/export' && req.method === 'GET') {
|
|
894
|
-
|
|
909
|
+
// 统计与头部:逐分片计数(低内存);正文按会话逐段拼接(导出为显式用户动作)
|
|
910
|
+
const stat = store.countMessagesByTask();
|
|
895
911
|
const tasksAll = store.getTasks();
|
|
896
912
|
const ocAll = store.getOcSessions();
|
|
897
913
|
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
914
|
const roleOf = (m) => m.role === 'user' ? '👤 用户' : (m.agentName || '智能体') + (m.phase ? `(${m.phase})` : '');
|
|
915
|
+
const total = stat.main + Object.values(stat.counts).reduce((a, b) => a + b, 0);
|
|
899
916
|
const lines = [
|
|
900
917
|
'# Agents Chat 会话导出', '',
|
|
901
918
|
`- 导出时间:${fmtTs(Date.now())}`,
|
|
902
|
-
`- 会话数:${1 + tasksAll.filter(t =>
|
|
903
|
-
`- 消息总数:${
|
|
919
|
+
`- 会话数:${1 + tasksAll.filter(t => (stat.counts[t.id] || 0) > 0).length + ocAll.filter(s => (stat.counts[s.id] || 0) > 0).length}(主会话 + 任务会话 + 单聊会话)`,
|
|
920
|
+
`- 消息总数:${total}`, ''
|
|
904
921
|
];
|
|
905
|
-
const main =
|
|
922
|
+
const main = store.getMessages('');
|
|
906
923
|
lines.push('---', '', '## 主会话', '');
|
|
907
924
|
for (const m of main) {
|
|
908
925
|
lines.push(`### ${fmtTs(m.timestamp)} · ${roleOf(m)}`, '');
|
|
@@ -945,11 +962,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
945
962
|
// ---------- 事项管控(卡牌)API ----------
|
|
946
963
|
if (p === '/api/cards' && req.method === 'GET') {
|
|
947
964
|
const cards = CardStore.list();
|
|
948
|
-
// 附带每个卡牌当前过程消息条数(供 UI
|
|
949
|
-
|
|
950
|
-
try {
|
|
951
|
-
for (const m of store.getMessages()) if (m.taskId) counts[m.taskId] = (counts[m.taskId] || 0) + 1;
|
|
952
|
-
} catch { /* ignore */ }
|
|
965
|
+
// 附带每个卡牌当前过程消息条数(供 UI 角标展示):逐分片计数,不合并全量消息
|
|
966
|
+
let counts = {};
|
|
967
|
+
try { counts = store.countMessagesByTask().counts; } catch { /* ignore */ }
|
|
953
968
|
// 执行内核状态(前端据此显示内核徽标/缺失警告)与追加聊天进行中的卡
|
|
954
969
|
let runnerInfo = { kind: 'unknown', label: '' };
|
|
955
970
|
try {
|
|
@@ -1204,20 +1219,35 @@ const server = http.createServer(async (req, res) => {
|
|
|
1204
1219
|
});
|
|
1205
1220
|
|
|
1206
1221
|
// 优雅退出:停掉全部子进程、把执行中任务复位为待执行,避免残留与假死状态
|
|
1222
|
+
// 幂等防重入:信号钩子与 exit 钩子可能先后触发,清理只执行一次
|
|
1223
|
+
let shutdownDone = false;
|
|
1207
1224
|
function shutdown(code) {
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1225
|
+
if (!shutdownDone) {
|
|
1226
|
+
shutdownDone = true;
|
|
1227
|
+
try {
|
|
1228
|
+
if (termProc) { try { termProc.kill(); } catch { /* ignore */ } termProc = null; }
|
|
1229
|
+
const n = stopAllChildren();
|
|
1230
|
+
const m = store.resetRunningTasks();
|
|
1231
|
+
const mc = CardStore.resetRunning();
|
|
1232
|
+
if (n || m || mc) console.log(`退出清理:终止 ${n} 个子进程,复位 ${m} 个执行中任务、${mc} 张卡牌`);
|
|
1233
|
+
} catch (err) {
|
|
1234
|
+
console.error('[shutdown] 清理失败:', err && (err.stack || err));
|
|
1235
|
+
}
|
|
1216
1236
|
}
|
|
1217
1237
|
process.exit(code);
|
|
1218
1238
|
}
|
|
1219
1239
|
process.on('SIGINT', () => shutdown(0));
|
|
1220
1240
|
process.on('SIGTERM', () => shutdown(0));
|
|
1241
|
+
// Windows 关闭控制台窗口(CTRL_CLOSE_EVENT)在 Node 上映射为 SIGHUP,系统给约 5 秒处理宽限:
|
|
1242
|
+
// 同步 killTree 足够完成,正在执行的 AI 子进程不再变孤儿
|
|
1243
|
+
process.on('SIGHUP', () => shutdown(0));
|
|
1244
|
+
// 兜底:显式 process.exit 路径(exit 钩子内仅允许同步操作,killTree 为同步调用)
|
|
1245
|
+
process.on('exit', () => {
|
|
1246
|
+
if (!shutdownDone) {
|
|
1247
|
+
shutdownDone = true;
|
|
1248
|
+
try { stopAllChildren(); } catch { /* ignore */ }
|
|
1249
|
+
}
|
|
1250
|
+
});
|
|
1221
1251
|
|
|
1222
1252
|
// ---------- 任务批次执行(SSE 手动触发与定时调度共用) ----------
|
|
1223
1253
|
// send: 事件推送(SSE 为真实推送,定时触发为 no-op,消息仍会持久化)
|
|
@@ -1428,6 +1458,7 @@ server.listen(PORT, () => {
|
|
|
1428
1458
|
startPruneTimer();
|
|
1429
1459
|
console.log(`Agents Chat 已启动: http://localhost:${PORT}`);
|
|
1430
1460
|
console.log(`运行内核: ${kindText}`);
|
|
1461
|
+
console.log('退出提示: 请用 Ctrl+C(或 agents-chat stop)退出,会自动清理执行中的 AI 子进程;直接关闭窗口可能残留正在执行的进程');
|
|
1431
1462
|
console.log(`本机可用内核: ${avail}(配置页可切换)`);
|
|
1432
1463
|
console.log(`数据目录: ${store.DATA_DIR}`);
|
|
1433
1464
|
});
|