@iamsamyiok/agents-chat 3.29.0 → 3.31.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/.env.example ADDED
@@ -0,0 +1,26 @@
1
+ # Agents Chat 配置示例:复制为同目录 .env 后按需修改;全部可省略
2
+
3
+ # ---- 通知 ----
4
+ # 任务/卡牌完成时转发 JSON 到该地址(钉钉/飞书自定义 bot 或自建中转均可):
5
+ # POST {event,kind,title,status,snippet,text},2 秒超时失败静默
6
+ # AGENTS_CHAT_WEBHOOK_URL=https://example.com/your-hook
7
+
8
+ # ---- 资源与清理 ----
9
+ # 每日滚动清理保留天数(默认 15;0 不代表关闭清理,请用大数值)
10
+ # AGENTS_CHAT_PRUNE_DAYS=15
11
+ # 单会话消息滚动上限(默认 500,0=关闭;主会话不受限)
12
+ # AGENTS_CHAT_MSG_LIMIT=500
13
+
14
+ # ---- 自动退出 ----
15
+ # 全部页面关闭且空闲后自动退出(默认开;0 关闭)
16
+ # AGENTS_CHAT_AUTOSTOP=1
17
+ # 空闲多少毫秒后退出(默认 50000)
18
+ # AGENTS_CHAT_AUTOSTOP_IDLE_MS=50000
19
+
20
+ # ---- 内核与执行 ----
21
+ # 自动安装 opencode(默认开;0 关闭)
22
+ # AGENTS_CHAT_AUTO_INSTALL=1
23
+ # 指定内核完整路径(按需)
24
+ # AGENTS_CHAT_OPENCODE_CMD=
25
+ # 单任务超时毫秒(默认 10 分钟)
26
+ # AGENTS_CHAT_TIMEOUT_MS=600000
package/app/lib/agent.js CHANGED
@@ -49,6 +49,8 @@ const KERNEL_DEFS = [
49
49
  // Windows 下 npm 安装的 CLI 是 .cmd 垫片,Node 18.20+ 禁止直接 spawn,须走 shell
50
50
  // 显式路径优先级:AGENTS_CHAT_<ID>_CMD(如 AGENTS_CHAT_OPENCODE_CMD)> PATH 查找
51
51
  let detectCache = { ts: 0, map: null };
52
+ // 安装新内核后刷新缓存(否则同进程 10 秒内仍认为未安装)
53
+ function resetDetectCache() { detectCache = { ts: 0, map: null }; }
52
54
 
53
55
  function findCli(def) {
54
56
  const custom = process.env[`AGENTS_CHAT_${def.id.toUpperCase()}_CMD`];
@@ -484,4 +486,4 @@ function spawnMock(args, agent, env, onChunk, scope) {
484
486
  return child;
485
487
  }
486
488
 
487
- module.exports = { runAgent, resolveRunner, detectKernels, KERNEL_DEFS, missingHint, describeTool, stopScope, stopAllChildren, resolveCwd, registerChild };
489
+ module.exports = { runAgent, resolveRunner, detectKernels, resetDetectCache, KERNEL_DEFS, missingHint, describeTool, stopScope, stopAllChildren, resolveCwd, registerChild };
package/app/lib/cards.js CHANGED
@@ -11,6 +11,7 @@
11
11
  const fs = require('fs');
12
12
  const path = require('path');
13
13
  const { resolveRunner, detectKernels, KERNEL_DEFS, stopScope } = require('./agent');
14
+ const { notifyDone } = require('./notify');
14
15
  const oc = require('./oc');
15
16
  const store = require('./store');
16
17
  const safejson = require('./safejson');
@@ -522,6 +523,7 @@ class CardRunner {
522
523
  finishedAt: Date.now()
523
524
  });
524
525
  broadcast({ type: 'task_done', cardId: card.id, status, title: card.title });
526
+ notifyDone({ kind: 'card', title: card.title, status, snippet: doneError || finalText });
525
527
  // 任务结束,移除进程登记
526
528
  this.procs.delete(card.id);
527
529
  }
@@ -0,0 +1,43 @@
1
+ // 内核自动安装:npm 包安装(postinstall)与 CLI 启动共用
2
+ // 策略:一个内核都没有时自动安装 opencode(默认推荐内核);任一内核已存在则跳过;
3
+ // 单文件 exe 形态无 npm,跳过;AGENTS_CHAT_AUTO_INSTALL=0 可关闭。
4
+ // 原则:任何失败只提示,绝不抛错(postinstall 失败会连带 npm install 整体失败)。
5
+ const { execSync } = require('child_process');
6
+
7
+ // 纯决策函数(单测覆盖):给定 detectKernels() 的 map,判断是否需要自动安装
8
+ function shouldAutoInstall(kernelMap, { standalone = false, disabled = false } = {}) {
9
+ if (standalone) return { need: false, reason: 'standalone' }; // exe 无 npm
10
+ if (disabled) return { need: false, reason: 'disabled' }; // 用户显式关闭
11
+ if (!kernelMap || typeof kernelMap !== 'object' || !Object.keys(kernelMap).length) return { need: false, reason: 'badmap' };
12
+ const anyOk = Object.values(kernelMap).some(k => k && k.ok);
13
+ if (anyOk) return { need: false, reason: 'has-kernel' };
14
+ return { need: true, reason: 'none' };
15
+ }
16
+
17
+ // 执行安装并返回结果;log/inject 可注入(postinstall 用 console,单测用收集器)
18
+ function ensureDefaultKernel({ log = console.log, standalone = !!process.versions.bun || process.env.AGENTS_CHAT_STANDALONE === '1' } = {}) {
19
+ const { detectKernels } = require('./agent');
20
+ const decision = shouldAutoInstall(detectKernels(), {
21
+ standalone,
22
+ disabled: process.env.AGENTS_CHAT_AUTO_INSTALL === '0'
23
+ });
24
+ if (!decision.need) {
25
+ if (decision.reason === 'standalone') log('ℹ 单文件版内置 npm 不可用:未检测到内核时请手动安装 opencode(npm install -g opencode-ai)');
26
+ return { installed: false, reason: decision.reason };
27
+ }
28
+ log('未检测到任何 AI 执行内核,正在自动安装 opencode(约 1-2 分钟,仅此一次)...');
29
+ try {
30
+ execSync('npm install -g opencode-ai', { stdio: 'inherit', timeout: 300000 });
31
+ // 刷新检测缓存,让随后的启动横幅直接看到新内核
32
+ try { require('./agent').resetDetectCache(); } catch { /* 旧版无此函数则忽略 */ }
33
+ log('✓ opencode 安装完成(PATH 由 npm 自动配置,重开终端生效)');
34
+ return { installed: true };
35
+ } catch (err) {
36
+ log('✗ 自动安装失败(通常是全局目录权限不足),请手动执行其中一条:');
37
+ log(' Windows: npm install -g opencode-ai');
38
+ log(' Linux/macOS: sudo npm install -g opencode-ai');
39
+ return { installed: false, reason: 'install-failed', error: err && err.message };
40
+ }
41
+ }
42
+
43
+ module.exports = { shouldAutoInstall, ensureDefaultKernel };
@@ -0,0 +1,26 @@
1
+ // 完成通知出口:webhook 转发(钉钉/飞书/Telegram bot 的中转地址均可)
2
+ // .env AGENTS_CHAT_WEBHOOK_URL 配置后生效;POST {event,title,status,snippet,text},text 字段可直接被
3
+ // 简单集成消费。2 秒超时失败静默——通知是锦上添花,绝不影响任务流程。
4
+ let warned = false;
5
+
6
+ function notifyDone({ kind = 'task', title = '', status = 'done', snippet = '' } = {}) {
7
+ const hook = process.env.AGENTS_CHAT_WEBHOOK_URL;
8
+ if (!hook) return;
9
+ const icon = status === 'done' ? '✅' : status === 'failed' ? '❌' : '⏹';
10
+ const text = `${icon} Agents Chat ${kind === 'card' ? '卡牌' : '任务'}${status === 'done' ? '完成' : status === 'failed' ? '失败' : '已停止'}:${title}${snippet ? '\n' + snippet.slice(0, 200) : ''}`;
11
+ const body = { event: 'done', kind, title, status, snippet: snippet.slice(0, 500), text };
12
+ const ctrl = new AbortController();
13
+ const timer = setTimeout(() => ctrl.abort(), 2000);
14
+ fetch(hook, {
15
+ method: 'POST',
16
+ headers: { 'content-type': 'application/json' },
17
+ body: JSON.stringify(body),
18
+ signal: ctrl.signal
19
+ }).then(() => { warned = false; })
20
+ .catch(() => {
21
+ if (!warned) { warned = true; console.warn('[notify] webhook 发送失败(后续失败静默):' + hook); }
22
+ })
23
+ .finally(() => clearTimeout(timer));
24
+ }
25
+
26
+ module.exports = { notifyDone };
package/app/lib/store.js CHANGED
@@ -551,6 +551,92 @@ function addMessage(msg) {
551
551
  msgs.push(...kept);
552
552
  }
553
553
  writeJson(msgShardPath(key), msgs);
554
+ // 用量统计:消息携带 usage(token 数)即累计——所有 runner 的消息路径统一经过这里
555
+ const u = Number(msg.usage);
556
+ if (u > 0) recordUsage({ tokens: Math.floor(u), requests: 1 });
557
+ }
558
+
559
+ // ---------- 全历史关键词检索:逐分片匹配,不合并大数组 ----------
560
+ function searchMessages(q, { limit = 50 } = {}) {
561
+ const needle = String(q || '').trim().toLowerCase();
562
+ if (!needle) return [];
563
+ const out = [];
564
+ let files = [];
565
+ try { files = fs.readdirSync(MSG_DIR); } catch { return out; }
566
+ for (const f of files) {
567
+ if (!f.endsWith('.json')) continue;
568
+ let list;
569
+ try { list = readJson(path.join(MSG_DIR, f), []); } catch { continue; }
570
+ if (!Array.isArray(list)) continue;
571
+ // 分片名还原会话标识(hex 编码逆变换,_main 为主会话)
572
+ const stem = f.replace(/\.json$/, '');
573
+ const sessionId = stem === '_main' ? '' : (stem.startsWith('_') ? Buffer.from(stem.slice(1), 'hex').toString('utf8') : stem);
574
+ for (const m of list) {
575
+ const c = String(m.content || '');
576
+ if (c.toLowerCase().includes(needle)) {
577
+ const idx = c.toLowerCase().indexOf(needle);
578
+ const from = Math.max(0, idx - 40);
579
+ out.push({
580
+ sessionId, role: m.role || '', agentName: m.agentName || '',
581
+ snippet: (from > 0 ? '…' : '') + c.slice(from, from + needle.length + 80) + '…',
582
+ timestamp: m.timestamp || ''
583
+ });
584
+ if (out.length >= limit * 3) break; // 分片内粗截,最后统一排序取 limit
585
+ }
586
+ }
587
+ }
588
+ out.sort((a, b) => String(b.timestamp).localeCompare(String(a.timestamp)));
589
+ return out.slice(0, limit);
590
+ }
591
+
592
+ // ---------- 用量统计(token/请求数按日累计,usage.json) ----------
593
+ const USAGE_PATH = path.join(DATA_DIR, 'usage.json');
594
+ function readUsage() {
595
+ const d = readJson(USAGE_PATH, { days: {}, total: { tokens: 0, requests: 0 } });
596
+ if (!d.days) d.days = {};
597
+ if (!d.total) d.total = { tokens: 0, requests: 0 };
598
+ return d;
599
+ }
600
+ function recordUsage({ tokens = 0, requests = 0 } = {}) {
601
+ const d = readUsage();
602
+ const day = new Date().toISOString().slice(0, 10);
603
+ if (!d.days[day]) d.days[day] = { tokens: 0, requests: 0 };
604
+ d.days[day].tokens += Math.floor(tokens);
605
+ d.days[day].requests += Math.floor(requests);
606
+ d.total.tokens += Math.floor(tokens);
607
+ d.total.requests += Math.floor(requests);
608
+ // 只保留最近 60 天明细,总量恒累计
609
+ const days = Object.keys(d.days).sort();
610
+ while (days.length > 60) delete d.days[days.shift()];
611
+ writeJson(USAGE_PATH, d);
612
+ }
613
+ function getUsageStats() {
614
+ const d = readUsage();
615
+ const today = new Date().toISOString().slice(0, 10);
616
+ const recent = Object.entries(d.days).slice(-7).map(([day, v]) => ({ day, ...v }));
617
+ return { today: d.days[today] || { tokens: 0, requests: 0 }, total: d.total, recent };
618
+ }
619
+
620
+ // ---------- 数据目录占用统计(可见性:让用户看得到空间去哪了) ----------
621
+ function dirSize(p) {
622
+ let total = 0;
623
+ let entries = [];
624
+ try { entries = fs.readdirSync(p, { withFileTypes: true }); } catch { return 0; }
625
+ for (const e of entries) {
626
+ const fp = path.join(p, e.name);
627
+ try {
628
+ if (e.isDirectory()) total += dirSize(fp);
629
+ else total += fs.statSync(fp).size;
630
+ } catch { /* ignore */ }
631
+ }
632
+ return total;
633
+ }
634
+ function dataStats() {
635
+ const parts = {};
636
+ for (const name of ['messages', 'outputs', 'workspace', 'flow']) {
637
+ parts[name] = dirSize(path.join(DATA_DIR, name));
638
+ }
639
+ return { total: dirSize(DATA_DIR), ...parts };
554
640
  }
555
641
 
556
642
  // 按会话统计消息数(逐分片累加,不合并大数组:统计/导出场景低内存)
@@ -795,6 +881,10 @@ module.exports = {
795
881
  addMessage,
796
882
  clearMessages,
797
883
  countMessagesByTask,
884
+ searchMessages,
885
+ recordUsage,
886
+ getUsageStats,
887
+ dataStats,
798
888
  addFlowEvent,
799
889
  getFlow,
800
890
  listFlowRuns,
@@ -2,7 +2,10 @@
2
2
  <html lang="zh-CN">
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
+ <meta name="theme-color" content="#07c160">
7
+ <link rel="manifest" href="/manifest.json">
8
+ <link rel="icon" href="/static/icon.svg" type="image/svg+xml">
6
9
  <title>多任务编排</title>
7
10
  <style>
8
11
  :root{
@@ -202,6 +205,15 @@
202
205
  .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
206
  .update-bar button{border:1px solid #d8b94a;background:#fff;border-radius:5px;padding:2px 10px;font-size:12px;cursor:pointer;color:#614700}
204
207
  .update-bar button:hover{background:#fff7d6}
208
+ /* 小屏增强:顶栏收纳 + 看板横向滚动 + 安全区 */
209
+ @media (max-width:760px){
210
+ header{flex-wrap:wrap;gap:6px;padding:8px 10px}
211
+ header .btn{padding:5px 8px;font-size:12px}
212
+ .filter-box input{width:90px}
213
+ .board{grid-template-columns:repeat(4,minmax(220px,1fr));overflow-x:auto;padding-bottom:calc(6px + env(safe-area-inset-bottom))}
214
+ .modal{width:96vw;max-height:92vh}
215
+ #maskDetail .detail-log{max-height:36vh}
216
+ }
205
217
  </style>
206
218
  </head>
207
219
  <body>
@@ -1108,6 +1120,13 @@ es.onmessage = (e)=>{
1108
1120
  refreshProcs();
1109
1121
  // 失败即时提醒(完成静默:看板移动可见 + all_done 汇总通知)
1110
1122
  if(ev.type==='task_done' && ev.status==='failed') toast('✗「'+(ev.title||'任务')+'」执行失败,点击卡片查看原因');
1123
+ // 系统级通知:开关开启且页面后台时,卡牌完成/失败弹系统通知(与群聊页共用开关)
1124
+ try{
1125
+ if(ev.type==='task_done' && localStorage.getItem('sysNotify')==='1' && document.hidden && typeof Notification!=='undefined' && Notification.permission==='granted'){
1126
+ const n=new Notification((ev.status==='failed'?'❌ 卡牌失败:':'✅ 卡牌完成:')+(ev.title||''),{tag:'agents-chat-card'});
1127
+ n.onclick=()=>{ window.focus(); n.close(); };
1128
+ }
1129
+ }catch(e){}
1111
1130
  if(OPEN_ID && ev.cardId===OPEN_ID){
1112
1131
  const log=$('#dLog'); if(log.querySelector('.empty')) log.innerHTML='';
1113
1132
  if(ev.type==='task_start') TEXT_PARTS.clear();
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
2
+ <rect width="128" height="128" rx="24" fill="#07c160"/>
3
+ <circle cx="40" cy="52" r="12" fill="#fff"/>
4
+ <circle cx="88" cy="52" r="12" fill="#fff"/>
5
+ <path d="M34 86c8 10 22 14 30 14s22-4 30-14" stroke="#fff" stroke-width="9" fill="none" stroke-linecap="round"/>
6
+ </svg>
@@ -2,7 +2,12 @@
2
2
  <html lang="zh-CN">
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
6
+ <meta name="theme-color" content="#07c160">
7
+ <meta name="apple-mobile-web-app-capable" content="yes">
8
+ <meta name="apple-mobile-web-app-title" content="AgentsChat">
9
+ <link rel="manifest" href="/manifest.json">
10
+ <link rel="icon" href="/static/icon.svg" type="image/svg+xml">
6
11
  <title>Agents 群聊</title>
7
12
  <style>
8
13
  * { margin: 0; padding: 0; box-sizing: border-box; }
@@ -84,6 +89,11 @@
84
89
  #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
90
  #jumpBtn:hover { color: #07c160; border-color: #07c160; }
86
91
  .sys-tip { text-align: center; margin: 10px 0; }
92
+ /* 历史弹窗:全历史搜索结果条目 */
93
+ .hist-hit { border: 1px solid #eee; border-radius: 8px; padding: 6px 10px; margin: 4px 0; cursor: pointer; background: #fafafa; }
94
+ .hist-hit:hover { border-color: #07c160; background: #f0f7f0; }
95
+ .hist-hit .hh-meta { font-size: 11px; color: #999; margin-bottom: 2px; }
96
+ .hist-hit .hh-snippet { font-size: 12px; color: #333; line-height: 1.5; word-break: break-all; }
87
97
  /* 长会话增量渲染:加载更早入口 */
88
98
  .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
99
  .load-earlier:hover { color: #07c160; border-color: #07c160; }
@@ -306,6 +316,19 @@
306
316
  #menuBtn { display: block !important; }
307
317
  .msg-body { max-width: 80%; }
308
318
  }
319
+ /* 触屏/小屏增强:更大触控目标、安全区适配、顶栏按钮收纳 */
320
+ @media (max-width: 760px) {
321
+ .hdr-right { flex-wrap: wrap; gap: 4px; }
322
+ .hdr-right .cfg-btn { padding: 4px 8px; font-size: 11px; }
323
+ #messages { padding: 10px 8px; }
324
+ .bubble.left, .bubble.right { font-size: 14px; word-break: break-word; }
325
+ .composer { padding-bottom: calc(8px + env(safe-area-inset-bottom)); }
326
+ #input { font-size: 16px; } /* 防 iOS 聚焦自动放大 */
327
+ .jumpBtn { bottom: calc(84px + env(safe-area-inset-bottom)); }
328
+ #jumpBtn { bottom: calc(84px + env(safe-area-inset-bottom)); }
329
+ .modal.wide { width: 96vw; max-height: 92vh; }
330
+ #previewMask .preview-card { width: 96vw; height: 92vh; }
331
+ }
309
332
  #menuBtn { display: none; background: none; border: none; font-size: 18px; cursor: pointer; color: #555; margin-right: 10px; }
310
333
 
311
334
  /* ---------- 侧栏顶部:单聊/群聊开关 + 任务操作按钮 ---------- */
@@ -441,6 +464,7 @@
441
464
  <button class="cfg-btn" id="cardsNavBtn" onclick="openCards()" title="多任务编排 · 卡牌:把任务写成卡牌,Agent 干完一件自动注入下一件">🗂️ 多任务编排</button>
442
465
  <span class="runner-tag" id="runnerTag" style="display:none"></span>
443
466
  <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>
467
+ <button class="cfg-btn" id="notifyBtn" onclick="toggleSysNotify()" title="系统通知:页面在后台时任务完成弹系统级通知(浏览器授权后生效)">🔔 通知</button>
444
468
  <button class="cfg-btn" id="helpBtn" onclick="openHelp()" title="使用帮助">❓ 帮助</button>
445
469
  </div>
446
470
  </header>
@@ -588,10 +612,16 @@
588
612
  <div class="modal wide">
589
613
  <h3>聊天历史(主会话)</h3>
590
614
  <div class="tip">按时间正序展示全部主会话记录(含已开启新会话前的内容),仅供查阅;消息中的 .md 文件路径可点击预览</div>
615
+ <input id="histSearch" placeholder="🔍 搜索全部会话消息(含任务/单聊会话,关键词匹配)…" autocomplete="off"
616
+ style="width:100%;box-sizing:border-box;padding:7px 10px;border:1px solid #e2e2e2;border-radius:8px;font-size:12.5px;margin-bottom:6px">
617
+ <div id="histUsage" style="font-size:11.5px;color:#888;margin:0 0 6px"></div>
618
+ <div id="histSearchResult" style="display:none"></div>
591
619
  <div id="historyList"></div>
592
620
  <div class="modal-actions" style="justify-content:space-between">
593
- <div style="display:flex;gap:8px">
621
+ <div style="display:flex;gap:8px;flex-wrap:wrap">
594
622
  <button class="mini-btn" onclick="exportHistory()" title="全部会话(含任务会话)导出为 Markdown 文件">⬇ 导出 sessions.md</button>
623
+ <button class="mini-btn" onclick="showDataStats()" title="查看数据目录磁盘占用分布">📊 空间占用</button>
624
+ <button class="mini-btn" onclick="pruneDataNow()" title="手动执行一次滚动清理:过期任务/会话/产出存档等(与每日自动清理同一套规则,默认保留 15 天)">🧹 清理旧数据</button>
595
625
  <button class="mini-btn danger" onclick="clearAllHistory()" title="删除全部消息记录、会话产出文件与流转日志(任务列表保留)">🗑 清空全部会话</button>
596
626
  </div>
597
627
  <button class="primary" onclick="closeHistory()">关闭</button>
@@ -666,7 +696,7 @@
666
696
  </div>
667
697
 
668
698
  <script>
669
- const PAGE_VERSION = '3.26.0'; // 与服务端 /api/health.version 互检,不一致说明页面缓存过期
699
+ const PAGE_VERSION = '3.31.0'; // 与服务端 /api/health.version 互检,不一致说明页面缓存过期
670
700
  const AV_COLORS = ['#5b8def','#07c160','#fa9d3b','#10aeff','#8a6fe8','#fa5151','#e8a33d','#3aa7a3'];
671
701
  const PHASE_LABEL = { plan: '调度规划', work: '执行', review: '验收', report: '汇总', talk: '圆桌发言', task: '任务执行' };
672
702
  // 职业图标库(智能体配置可选)
@@ -703,7 +733,29 @@ function escAttr(s) { return String(s == null ? '' : s).replace(/&/g,'&amp;').re
703
733
  function fmtTime(ts) { const d = new Date(ts); return String(d.getHours()).padStart(2,'0') + ':' + String(d.getMinutes()).padStart(2,'0'); }
704
734
  function fmtSched(ts) { const d = new Date(ts); const p = (n) => String(n).padStart(2,'0'); return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; }
705
735
  function fmtSchedNow() { const d = new Date(); const p = (n) => String(n).padStart(2,'0'); return `${d.getFullYear()}${p(d.getMonth()+1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`; }
706
- function toast(msg) { const t = document.getElementById('toast'); t.textContent = msg; t.style.display = 'block'; setTimeout(() => t.style.display = 'none', 2200); }
736
+ function toast(msg, ms) { const t = document.getElementById('toast'); t.textContent = msg; t.style.display = 'block'; setTimeout(() => t.style.display = 'none', ms || 2200); }
737
+
738
+ // ---------- 系统通知开关(浏览器 Notification API,状态记忆在 localStorage) ----------
739
+ function refreshNotifyBtn() {
740
+ const on = localStorage.getItem('sysNotify') === '1';
741
+ const b = document.getElementById('notifyBtn');
742
+ if (b) { b.style.color = on ? '#07c160' : ''; b.style.borderColor = on ? '#07c160' : ''; }
743
+ }
744
+ async function toggleSysNotify() {
745
+ const on = localStorage.getItem('sysNotify') === '1';
746
+ if (on) {
747
+ localStorage.setItem('sysNotify', '0');
748
+ toast('系统通知已关闭');
749
+ } else {
750
+ if (typeof Notification === 'undefined') { toast('当前浏览器不支持系统通知'); return; }
751
+ let perm = Notification.permission;
752
+ if (perm !== 'granted') perm = await Notification.requestPermission();
753
+ if (perm !== 'granted') { toast('浏览器未授权通知,请在地址栏权限设置中允许'); return; }
754
+ localStorage.setItem('sysNotify', '1');
755
+ toast('系统通知已开启:页面在后台时任务完成将弹系统通知');
756
+ }
757
+ refreshNotifyBtn();
758
+ }
707
759
  function colorOf(id) { let h = 0; const s = String(id); for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; return AV_COLORS[h % AV_COLORS.length]; }
708
760
  // 智能体显示图标:配置的职业图标 > 名称首字
709
761
  function agentIcon(agentId, name) {
@@ -761,6 +813,7 @@ async function init() {
761
813
  addSys(`⚠ 页面版本(${PAGE_VERSION})与服务端(${health.version})不一致:浏览器缓存了旧页面,请按 Ctrl+F5 强制刷新`);
762
814
  }
763
815
  renderUpdateInfo(health);
816
+ refreshNotifyBtn();
764
817
 
765
818
  input.addEventListener('input', handleAtInput);
766
819
  input.addEventListener('blur', () => setTimeout(hideAt, 150));
@@ -1554,6 +1607,16 @@ function lastReportText(taskId) {
1554
1607
 
1555
1608
  // 右下角弹窗:显示最终回答部分内容,点击跳回原会话;不处理 3 秒后自动消失(保留绿点)
1556
1609
  function showDoneToast(taskId, title) {
1610
+ // 系统级通知:开关开启且页面在后台时弹系统通知(人不在电脑前也能收到)
1611
+ try {
1612
+ if (localStorage.getItem('sysNotify') === '1' && document.hidden && typeof Notification !== 'undefined' && Notification.permission === 'granted') {
1613
+ const n = new Notification('✅ 任务完成:' + (title || ''), {
1614
+ body: (lastReportText(taskId) || '点击查看结果').slice(0, 120),
1615
+ tag: 'agents-chat-done'
1616
+ });
1617
+ n.onclick = () => { window.focus(); n.close(); };
1618
+ }
1619
+ } catch { /* ignore */ }
1557
1620
  const key = sessKey(taskId || '');
1558
1621
  if (key === curKey() || (chatMode === 'solo' && key === curOcId)) return; // 用户正在看该会话,无需提醒
1559
1622
  markUnread(key);
@@ -2287,9 +2350,75 @@ async function openHistory() {
2287
2350
  }
2288
2351
  document.getElementById('historyMask').classList.add('show');
2289
2352
  el.scrollTop = 0;
2353
+ loadUsageBar();
2290
2354
  }
2291
2355
  function closeHistory() { document.getElementById('historyMask').classList.remove('show'); }
2292
2356
 
2357
+ // ---------- 全历史搜索(服务端逐分片匹配,含任务/单聊会话) ----------
2358
+ let histSearchTimer = null;
2359
+ document.getElementById('histSearch').addEventListener('input', (e) => {
2360
+ clearTimeout(histSearchTimer);
2361
+ const q = e.target.value.trim();
2362
+ const box = document.getElementById('histSearchResult');
2363
+ if (!q) { box.style.display = 'none'; box.innerHTML = ''; return; }
2364
+ histSearchTimer = setTimeout(async () => {
2365
+ try {
2366
+ const data = await api('/api/search?q=' + encodeURIComponent(q) + '&limit=50');
2367
+ const list = data.results || [];
2368
+ box.style.display = 'block';
2369
+ box.innerHTML = list.length
2370
+ ? `<div class="tip" style="margin:0 0 4px">找到 ${list.length} 条匹配${list.length >= 50 ? '(已达上限,可加精确关键词)' : ''}</div>` +
2371
+ list.map(r => {
2372
+ const sess = r.sessionId === '' ? '主会话' : `会话 ${esc(r.sessionId.slice(0, 12))}`;
2373
+ const who = r.role === 'user' ? '👤 我' : esc(r.agentName || 'AI');
2374
+ return `<div class="hist-hit" onclick="jumpToSearchSess('${esc(r.sessionId)}')" title="点击定位该会话">
2375
+ <div class="hh-meta">${esc((r.timestamp || '').replace('T', ' ').slice(0, 16))} · ${sess} · ${who}</div>
2376
+ <div class="hh-snippet">${esc(r.snippet)}</div></div>`;
2377
+ }).join('')
2378
+ : '<div class="tip">没有匹配的消息</div>';
2379
+ } catch { box.innerHTML = '<div class="tip">搜索失败</div>'; box.style.display = 'block'; }
2380
+ }, 300);
2381
+ });
2382
+ function jumpToSearchSess(sessId) {
2383
+ // 主会话结果留在历史弹窗(消息就在下方);其余会话提示定位方式
2384
+ if (!sessId) { toast('该消息在主会话中,见下方历史记录'); return; }
2385
+ toast('该消息位于会话 ' + sessId.slice(0, 16) + '…:任务消息在任务列表点击对应任务查看,单聊在左侧会话列表选择');
2386
+ }
2387
+
2388
+ // ---------- 用量统计与空间管理 ----------
2389
+ async function loadUsageBar() {
2390
+ try {
2391
+ const d = await api('/api/usage');
2392
+ const u = d.usage || {};
2393
+ const fmt = (n) => n >= 10000 ? (n / 10000).toFixed(1) + '万' : String(n || 0);
2394
+ document.getElementById('histUsage').textContent =
2395
+ `📊 Token 用量:今日 ${fmt(u.today && u.today.tokens)}(${fmt(u.today && u.today.requests)} 次请求)· 累计 ${fmt(u.total && u.total.tokens)}${(!u.total || !u.total.tokens) ? '(内核输出未携带用量数据时不统计)' : ''}`;
2396
+ } catch { /* ignore */ }
2397
+ }
2398
+ function fmtSize(bytes) {
2399
+ const b = Number(bytes) || 0;
2400
+ if (b >= 1024 * 1024 * 1024) return (b / 1024 / 1024 / 1024).toFixed(2) + ' GB';
2401
+ if (b >= 1024 * 1024) return (b / 1024 / 1024).toFixed(1) + ' MB';
2402
+ if (b >= 1024) return (b / 1024).toFixed(1) + ' KB';
2403
+ return b + ' B';
2404
+ }
2405
+ async function showDataStats() {
2406
+ try {
2407
+ const d = await api('/api/data/stats');
2408
+ const s = d.stats || {};
2409
+ toast(`数据目录共 ${fmtSize(s.total)}:消息 ${fmtSize(s.messages)} · 产出存档 ${fmtSize(s.outputs)} · 工作区 ${fmtSize(s.workspace)} · 流转日志 ${fmtSize(s.flow)}`, 5000);
2410
+ } catch { toast('查询失败'); }
2411
+ }
2412
+ async function pruneDataNow() {
2413
+ if (!confirm('立即执行一次滚动清理?规则与每日自动清理相同:过期已完结任务、过期会话、超期产出存档等(默认保留 15 天,任务列表中的未执行任务不受影响)。')) return;
2414
+ try {
2415
+ const r = await api('/api/data/prune', { method: 'POST' });
2416
+ const s = r.stat || {};
2417
+ toast(`清理完成:任务 ${s.tasks || 0} · 会话 ${s.ocSessions || 0} · 消息 ${s.messages || 0} · 产出目录 ${s.outputs || 0} · 流转日志 ${s.flowEvents || 0}`, 5000);
2418
+ openHistory();
2419
+ } catch { toast('清理失败'); }
2420
+ }
2421
+
2293
2422
  // ---------- 历史管理:一键导出 sessions.md / 一键清空全部会话 ----------
2294
2423
  async function exportHistory() {
2295
2424
  try {
@@ -3378,8 +3507,17 @@ function openHelp() {
3378
3507
  </div>
3379
3508
  <div class="help-sec"><h4>🕘 历史与数据管理</h4>
3380
3509
  <ul>
3510
+ <li>「🕘 历史」弹窗顶部可<b>全历史搜索</b>:关键词匹配全部会话消息(含任务/单聊会话),点击结果定位所属会话</li>
3511
+ <li>弹窗内实时显示 <b>Token 用量</b>(今日/累计,内核输出携带用量数据时统计);「📊 空间占用」查看数据目录分布,「🧹 清理旧数据」手动执行一次滚动清理</li>
3381
3512
  <li>「🕘 历史」弹窗底部:<b>⬇ 导出 sessions.md</b> 把全部会话(含任务会话与单聊会话)导出为一个 Markdown 文件;<b>🗑 清空全部会话</b> 一键删除消息、单聊会话、会话产出文件与流转日志(任务列表保留)</li>
3382
- <li>全部页面关闭且无任务执行/审批等待时,后台服务空闲约 1 分钟自动退出(需要定时任务到点执行请保持页面打开;<code>.env AGENTS_CHAT_AUTOSTOP=0</code> 可关闭),需要时重新运行 start 即可</li>
3513
+ <li>全部页面关闭且无任务执行/审批等待时,后台服务空闲约 1 分钟自动退出(需要定时任务到点执行请保持页面打开,或运行 <code>agents-chat autostart on</code> 开机自启;<code>.env AGENTS_CHAT_AUTOSTOP=0</code> 可关闭),需要时重新运行 start 即可</li>
3514
+ </ul>
3515
+ </div>
3516
+ <div class="help-sec"><h4>🔔 完成通知</h4>
3517
+ <ul>
3518
+ <li>顶栏「🔔 通知」开启后(需浏览器授权),页面在后台时任务/卡牌完成会弹<b>系统级通知</b>,人不在电脑前也不错过</li>
3519
+ <li>进阶:.env 配置 <code>AGENTS_CHAT_WEBHOOK_URL</code> 可把完成事件转发到钉钉/飞书/自建中转(POST JSON,含标题/状态/摘要)</li>
3520
+ <li>定时任务抗机器重启:命令行 <code>agents-chat autostart on</code> 设置开机自启;停机期间错过的定时任务会在下次启动时自动补跑(控制台有日志说明)</li>
3383
3521
  </ul>
3384
3522
  </div>
3385
3523
  <div class="help-sec"><h4>🛂 协作通道(看板 / 委派 / 审批)</h4>
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "Agents Chat",
3
+ "short_name": "AgentsChat",
4
+ "description": "多智能体协作聊天与任务编排",
5
+ "start_url": "/",
6
+ "display": "standalone",
7
+ "background_color": "#f5f5f5",
8
+ "theme_color": "#07c160",
9
+ "icons": [
10
+ {
11
+ "src": "/static/icon.svg",
12
+ "sizes": "any",
13
+ "type": "image/svg+xml",
14
+ "purpose": "any"
15
+ }
16
+ ]
17
+ }
package/app/server.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // 启动:node app/server.js [--port 3456]
3
3
  const APP_VERSION = require('../package.json').version; // 单源版本:与 package.json 始终一致(页面互检/更新检查共用)
4
4
  const { checkLatest, UPDATE_COMMAND } = require('./lib/updatecheck');
5
+ const { notifyDone } = require('./lib/notify');
5
6
  const http = require('http');
6
7
  const fs = require('fs');
7
8
  const path = require('path');
@@ -326,6 +327,11 @@ const server = http.createServer(async (req, res) => {
326
327
  serveStatic(res, path.join(PUBLIC_DIR, 'cards.html'));
327
328
  return;
328
329
  }
330
+ if (req.method === 'GET' && p === '/manifest.json') {
331
+ // PWA 清单(添加到主屏幕/手机访问)
332
+ serveStatic(res, path.join(PUBLIC_DIR, 'manifest.json'));
333
+ return;
334
+ }
329
335
  if (req.method === 'GET' && p.startsWith('/static/')) {
330
336
  const safe = path.normalize(p.slice('/static/'.length)).replace(/^(\.\.[/\\])+/, '');
331
337
  serveStatic(res, path.join(PUBLIC_DIR, safe));
@@ -360,6 +366,30 @@ const server = http.createServer(async (req, res) => {
360
366
  return;
361
367
  }
362
368
 
369
+ // ---------- 历史检索 / 用量 / 空间 ----------
370
+ if (p === '/api/search' && req.method === 'GET') {
371
+ const q = String(parsed.query.q || '');
372
+ const limit = Math.min(200, Math.max(1, Number(parsed.query.limit) || 50));
373
+ json(res, 200, { success: true, results: store.searchMessages(q, { limit }) });
374
+ return;
375
+ }
376
+ if (p === '/api/usage' && req.method === 'GET') {
377
+ json(res, 200, { success: true, usage: store.getUsageStats() });
378
+ return;
379
+ }
380
+ if (p === '/api/data/stats' && req.method === 'GET') {
381
+ json(res, 200, { success: true, stats: store.dataStats(), dataDir: store.DATA_DIR });
382
+ return;
383
+ }
384
+ if (p === '/api/data/prune' && req.method === 'POST') {
385
+ // 手动清理:与每日自动清理同一套逻辑(已完结任务/过期会话/产出存档等),返回各项清理量
386
+ const body = await readBody(req);
387
+ const days = Math.max(1, Number(body.days) || PRUNE_DAYS);
388
+ const stat = store.pruneOldData(days);
389
+ json(res, 200, { success: true, days, stat });
390
+ return;
391
+ }
392
+
363
393
  if (p === '/api/stop' && req.method === 'POST') {
364
394
  // 手动停止:kill 对应作用域的全部子进程;编排循环检测令牌后跳过剩余工作
365
395
  const body = await readBody(req);
@@ -656,6 +686,8 @@ const server = http.createServer(async (req, res) => {
656
686
  } else {
657
687
  await executeTaskBatch(selected, send, myToken);
658
688
  }
689
+ // 群聊批次整体完成通知(单任务通知由 orchestrator 消息流承担,批次级聚合在此)
690
+ notifyDone({ kind: 'batch', title: `${soloScope ? '单聊' : '群聊'}任务批次(${selected.length} 个)`, status: 'done', snippet: selected.map(t => t.title).join('、') });
659
691
  } catch (err) {
660
692
  console.error('[tasks/run] 编排异常:', err && (err.stack || err));
661
693
  send({ type: 'error', content: `任务编排异常:${err && err.message || err}` });
@@ -1282,6 +1314,10 @@ function shutdown(code) {
1282
1314
  process.exit(code);
1283
1315
  }
1284
1316
  process.on('SIGINT', () => shutdown(0));
1317
+ // 兜底:async 路由内的同步异常会以 unhandledRejection 形式出现——记录日志防静默丢错(请求层应自行捕获)
1318
+ process.on('unhandledRejection', (err) => {
1319
+ console.error('[unhandledRejection]', err && (err.stack || err));
1320
+ });
1285
1321
  process.on('SIGTERM', () => shutdown(0));
1286
1322
  // Windows 关闭控制台窗口(CTRL_CLOSE_EVENT)在 Node 上映射为 SIGHUP,系统给约 5 秒处理宽限:
1287
1323
  // 同步 killTree 足够完成,正在执行的 AI 子进程不再变孤儿
@@ -1353,6 +1389,7 @@ async function executeSoloTaskBatch(selected, send, myToken) {
1353
1389
  if (runner.kind === 'missing') {
1354
1390
  store.updateTask(task.id, { status: 'failed', result: missingHint(runner).slice(0, 2000) });
1355
1391
  send({ type: 'task_done', taskId: task.id, title: task.title, status: 'failed' });
1392
+ notifyDone({ kind: 'task', title: task.title, status: 'failed', snippet: missingHint(runner) });
1356
1393
  return '';
1357
1394
  }
1358
1395
 
@@ -1397,6 +1434,7 @@ async function executeSoloTaskBatch(selected, send, myToken) {
1397
1434
  result: (doneError ? `执行出错:${doneError}` : finalText).slice(0, 2000)
1398
1435
  });
1399
1436
  send({ type: 'task_done', taskId: task.id, title: task.title, status: stopped ? 'pending' : (doneError ? 'failed' : 'done') });
1437
+ notifyDone({ kind: 'task', title: task.title, status: doneError ? 'failed' : 'done', snippet: finalText || doneError });
1400
1438
  return stopped ? '' : sesId;
1401
1439
  };
1402
1440
 
@@ -1430,12 +1468,19 @@ async function executeSoloTaskBatch(selected, send, myToken) {
1430
1468
  // 执行过程照常持久化到对应任务会话,用户打开会话即可查看全过程
1431
1469
  const SCHED_INTERVAL_MS = 15000;
1432
1470
  function startScheduler() {
1471
+ let firstScan = true; // 启动首扫:识别停机期间错过的定时任务并说明补跑
1433
1472
  const timer = setInterval(() => {
1434
1473
  if (runLocks.tasks) return; // 手动批次执行中,下轮再查
1435
1474
  if (store.getSchedEnabled() === false) return; // 用户关闭了定时调度总开关
1475
+ const now = Date.now();
1436
1476
  const due = store.getTasks().filter(t =>
1437
- t.kind === 'scheduled' && t.status === 'pending' && t.scheduledAt && t.scheduledAt <= Date.now());
1438
- if (!due.length) return;
1477
+ t.kind === 'scheduled' && t.status === 'pending' && t.scheduledAt && t.scheduledAt <= now);
1478
+ if (!due.length) { firstScan = false; return; }
1479
+ const missed = due.filter(t => now - t.scheduledAt > 60000); // 触发点已过 1 分钟以上 = 停机期间错过
1480
+ if (firstScan && missed.length) {
1481
+ console.log(`[scheduler] 检测到 ${missed.length} 个停机期间错过的定时任务,将立即补跑:${missed.map(t => t.title).join('、')}`);
1482
+ }
1483
+ firstScan = false;
1439
1484
  runLocks.tasks = true;
1440
1485
  const myToken = stopTokens.tasks;
1441
1486
  console.log(`[scheduler] 定时触发 ${due.length} 个任务:${due.map(t => t.title).join('、')}`);
@@ -70,6 +70,12 @@ function startServer() {
70
70
  cleanupDeadProcess(check.pid);
71
71
  }
72
72
 
73
+ // 兜底自动安装:postinstall 被跳过(yarn/pnpm/离线装包)时,首次 start 补装 opencode
74
+ try {
75
+ const { ensureDefaultKernel } = require('../app/lib/kernel-setup');
76
+ ensureDefaultKernel();
77
+ } catch { /* 任何失败不阻塞启动 */ }
78
+
73
79
  console.log(`启动 Agents Chat 服务 (端口 ${PORT})...`);
74
80
 
75
81
  const env = {
@@ -269,6 +275,104 @@ function showVersion() {
269
275
  console.log(`agents-chat v${PKG.version}`);
270
276
  }
271
277
 
278
+ // ---------- 开机自启(定时任务抗重启)----------
279
+ // Windows: HKCU Run 键 + VBS 静默启动(无黑窗闪烁);exe 形态直接注册 exe 路径
280
+ // macOS: ~/Library/LaunchAgents/com.agents-chat.plist(登录即拉起)
281
+ // Linux: crontab @reboot 行
282
+ const AUTOSTART_KEY = 'AgentsChat';
283
+ function autostartTarget() {
284
+ // 单文件 exe:直接跑 exe(已无窗口);npm 形态:VBS 静默执行 agents-chat start
285
+ if (process.env.AGENTS_CHAT_STANDALONE === '1' || process.versions.bun) {
286
+ return { kind: 'exe', cmd: `"${process.execPath}"` };
287
+ }
288
+ return { kind: 'npm', cmd: null };
289
+ }
290
+ function autostartOn() {
291
+ const t = autostartTarget();
292
+ const home = os.homedir();
293
+ if (process.platform === 'win32') {
294
+ const run = t.kind === 'exe'
295
+ ? t.cmd
296
+ : `wscript.exe "${path.join(home, '.agents-chat', 'autostart.vbs')}"`;
297
+ if (t.kind === 'npm') {
298
+ fs.mkdirSync(path.join(home, '.agents-chat'), { recursive: true });
299
+ // 0 = 隐藏窗口;npm bin 已在用户 PATH(npm 安装时配置), explorer 启动的进程可继承
300
+ fs.writeFileSync(path.join(home, '.agents-chat', 'autostart.vbs'),
301
+ `CreateObject("Wscript.Shell").Run "cmd /c agents-chat start", 0, False\r\n`);
302
+ }
303
+ execSync(`reg add "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" /v ${AUTOSTART_KEY} /t REG_SZ /d "${run}" /f`, { stdio: 'ignore' });
304
+ console.log('✓ 开机自启已开启(当前用户级,无需管理员)');
305
+ } else if (process.platform === 'darwin') {
306
+ const plist = path.join(home, 'Library', 'LaunchAgents', 'com.agents-chat.plist');
307
+ fs.mkdirSync(path.dirname(plist), { recursive: true });
308
+ const exe = t.kind === 'exe' ? process.execPath : process.execPath;
309
+ const arg = t.kind === 'exe' ? [] : [path.join(__dirname, '..', 'app', 'server.js'), '--port', String(PORT)];
310
+ const envData = ` <key>EnvironmentVariables</key>\n <dict><key>AGENTS_CHAT_DATA</key><string>${DATA_DIR}</string></dict>`;
311
+ fs.writeFileSync(plist, `<?xml version="1.0" encoding="UTF-8"?>
312
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
313
+ <plist version="1.0"><dict>
314
+ <key>Label</key><string>com.agents-chat</string>
315
+ <key>ProgramArguments</key>
316
+ <array><string>${exe}</string>${arg.map(a => `<string>${a}</string>`).join('')}</array>
317
+ ${envData}
318
+ <key>RunAtLoad</key><true/>
319
+ <key>StandardOutPath</key><string>${path.join(home, '.agents-chat', 'autostart.log')}</string>
320
+ <key>StandardErrorPath</key><string>${path.join(home, '.agents-chat', 'autostart.log')}</string>
321
+ </dict></plist>`);
322
+ try { execSync(`launchctl unload "${plist}" 2>/dev/null; launchctl load "${plist}"`); } catch { /* 下次登录生效 */ }
323
+ console.log('✓ 开机自启已开启(LaunchAgent,登录即启动)');
324
+ } else {
325
+ // Linux: crontab @reboot
326
+ let cron = '';
327
+ try { cron = execSync('crontab -l', { stdio: ['pipe', 'pipe', 'ignore'] }).toString(); } catch { /* 无 crontab */ }
328
+ const line = t.kind === 'exe'
329
+ ? `@reboot ${process.execPath}`
330
+ : `@reboot ${process.execPath} ${path.join(__dirname, '..', 'app', 'server.js')} --port ${PORT}`;
331
+ if (cron.includes(AUTOSTART_KEY)) {
332
+ cron = cron.split('\n').filter(l => l && !l.includes(AUTOSTART_KEY)).join('\n');
333
+ }
334
+ cron = (cron ? cron.trimEnd() + '\n' : '') + `${line} # ${AUTOSTART_KEY} env AGENTS_CHAT_DATA=${DATA_DIR}`;
335
+ // @reboot 行无法带 env 前缀于部分 cron 实现,数据目录路径直接写进 server 启动参数不可行时靠默认 ~/.agents-chat
336
+ execSync('crontab -', { input: cron.replace(/ # [^\n]*/, '') + '\n' });
337
+ console.log('✓ 开机自启已开启(crontab @reboot,数据目录 ~/.agents-chat)');
338
+ }
339
+ console.log('关闭方式: agents-chat autostart off');
340
+ }
341
+ function autostartOff() {
342
+ try {
343
+ if (process.platform === 'win32') {
344
+ execSync(`reg delete "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" /v ${AUTOSTART_KEY} /f`, { stdio: 'ignore' });
345
+ } else if (process.platform === 'darwin') {
346
+ const plist = path.join(os.homedir(), 'Library', 'LaunchAgents', 'com.agents-chat.plist');
347
+ try { execSync(`launchctl unload "${plist}"`); } catch { /* ignore */ }
348
+ fs.unlinkSync(plist);
349
+ } else {
350
+ let cron = '';
351
+ try { cron = execSync('crontab -l', { stdio: ['pipe', 'pipe', 'ignore'] }).toString(); } catch { }
352
+ const kept = cron.split('\n').filter(l => l && !(l.includes('@reboot') && (l.includes('app/server.js') || l.includes('agents-chat'))));
353
+ execSync('crontab -', { input: kept.join('\n') + (kept.length ? '\n' : '') });
354
+ }
355
+ console.log('✓ 开机自启已关闭');
356
+ } catch (err) {
357
+ console.error('关闭失败:', err.message);
358
+ }
359
+ }
360
+ function autostartStatus() {
361
+ try {
362
+ if (process.platform === 'win32') {
363
+ execSync(`reg query "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" /v ${AUTOSTART_KEY}`, { stdio: 'pipe' });
364
+ } else if (process.platform === 'darwin') {
365
+ fs.accessSync(path.join(os.homedir(), 'Library', 'LaunchAgents', 'com.agents-chat.plist'));
366
+ } else {
367
+ const cron = execSync('crontab -l', { stdio: ['pipe', 'pipe', 'ignore'] }).toString();
368
+ if (!/@reboot.*agents-chat|@reboot.*app\/server\.js/.test(cron)) throw new Error('not set');
369
+ }
370
+ console.log('开机自启: 已开启');
371
+ } catch {
372
+ console.log('开机自启: 未开启(agents-chat autostart on 开启)');
373
+ }
374
+ }
375
+
272
376
  // 主逻辑
273
377
  const command = process.argv[2] || 'start';
274
378
 
@@ -295,6 +399,12 @@ switch (command) {
295
399
  case '--version':
296
400
  showVersion();
297
401
  break;
402
+ case 'autostart':
403
+ if (process.argv[3] === 'off') autostartOff();
404
+ else if (process.argv[3] === 'status' || !process.argv[3]) autostartStatus();
405
+ else if (process.argv[3] === 'on') autostartOn();
406
+ else { console.error('用法: agents-chat autostart on|off|status'); process.exit(1); }
407
+ break;
298
408
  case 'help':
299
409
  case '--help':
300
410
  case '-h':
@@ -309,6 +419,9 @@ Agents Chat CLI v${PKG.version}
309
419
  agents-chat open 仅打开浏览器
310
420
  agents-chat update 检查并一键升级到 npm 最新版
311
421
  agents-chat version 查看当前版本
422
+ agents-chat autostart on|off|status
423
+ 开机自启管理(定时任务抗机器重启,Windows 用户级注册表/
424
+ macOS LaunchAgent/Linux crontab)
312
425
 
313
426
  环境变量:
314
427
  AGENTS_CHAT_PORT 端口号 (默认: 3456)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iamsamyiok/agents-chat",
3
- "version": "3.29.0",
3
+ "version": "3.31.0",
4
4
  "description": "多智能体群聊工具 - 支持 OpenCode/Claude Code/Codex/pi 内核,微信风格聊天界面",
5
5
  "main": "lib/start.js",
6
6
  "bin": {
@@ -9,7 +9,9 @@
9
9
  "files": [
10
10
  "bin",
11
11
  "lib",
12
- "app"
12
+ "app",
13
+ "scripts",
14
+ ".env.example"
13
15
  ],
14
16
  "keywords": [
15
17
  "ai",
@@ -34,7 +36,8 @@
34
36
  "homepage": "https://github.com/iamsamyiok/agents-chat",
35
37
  "scripts": {
36
38
  "start": "node app/server.js",
37
- "test": "node --test \"test/*.test.js\"",
38
- "build:exe": "node scripts/build-exe.js"
39
+ "test": "node --test --test-force-exit \"test/*.test.js\"",
40
+ "build:exe": "node scripts/build-exe.js",
41
+ "postinstall": "node scripts/postinstall.js"
39
42
  }
40
43
  }
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 单文件 exe 构建(bun compile)
4
+ *
5
+ * 用法:
6
+ * node scripts/build-exe.js # 全部平台(windows-x64 / linux-x64 / darwin-x64 / darwin-arm64)
7
+ * node scripts/build-exe.js windows-x64 # 只构建指定平台
8
+ *
9
+ * 产物: dist/agents-chat-<platform>[.exe](Windows 为 .exe)
10
+ * 原理: 先把 app/public/*.html 生成内嵌资源模块(app/lib/embedded-assets.js),
11
+ * 再由 bun build --compile 把 server.js + 内嵌资源打包成单可执行文件;
12
+ * 运行时 serveStatic 优先读内嵌资源,磁盘 public 目录仅开发模式使用。
13
+ */
14
+ const { execSync } = require('child_process');
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+
18
+ const ROOT = path.join(__dirname, '..');
19
+ const PUB = path.join(ROOT, 'app', 'public');
20
+ const EMBED = path.join(ROOT, 'app', 'lib', 'embedded-assets.js');
21
+ const DIST = path.join(ROOT, 'dist');
22
+ const PKG = require(path.join(ROOT, 'package.json'));
23
+
24
+ const ALL_TARGETS = ['windows-x64', 'linux-x64', 'darwin-x64', 'darwin-arm64'];
25
+ const targets = process.argv[2] ? [process.argv[2]] : ALL_TARGETS;
26
+ for (const t of targets) {
27
+ if (!ALL_TARGETS.includes(t)) {
28
+ console.error(`未知平台: ${t}(可选: ${ALL_TARGETS.join(' / ')})`);
29
+ process.exit(1);
30
+ }
31
+ }
32
+
33
+ // 1. 生成内嵌资源模块(HTML/JSON/SVG → JSON 字符串常量,杜绝转义问题)
34
+ const assets = {};
35
+ for (const f of fs.readdirSync(PUB)) {
36
+ if (/\.(html|json|svg)$/.test(f)) assets[f] = fs.readFileSync(path.join(PUB, f), 'utf8');
37
+ }
38
+ fs.writeFileSync(EMBED, [
39
+ '// 本文件由 scripts/build-exe.js 构建时自动生成,勿手工编辑、勿提交仓库',
40
+ `// 内嵌页面资源(构建于 ${new Date().toISOString()},v${PKG.version})`,
41
+ 'module.exports = ' + JSON.stringify(assets, null, 2) + ';',
42
+ ''
43
+ ].join('\n'));
44
+ console.log(`已生成内嵌资源: ${Object.keys(assets).join('、')}`);
45
+
46
+ // 2. bun compile 各平台
47
+ fs.mkdirSync(DIST, { recursive: true });
48
+ let failed = [];
49
+ for (const t of targets) {
50
+ const out = path.join(DIST, t === 'windows-x64' ? `agents-chat-${t}.exe` : `agents-chat-${t}`);
51
+ console.log(`构建 ${t} -> ${path.relative(ROOT, out)} ...`);
52
+ // windows: 双击运行不弹命令行窗口(日志自动落到 exe 旁 .data/agents-chat.log)
53
+ // 注:--windows-title/icon 需在 Windows 本机构建,交叉编译只加 hide-console
54
+ const extra = t === 'windows-x64' ? ' --windows-hide-console' : '';
55
+ try {
56
+ execSync(`bun build --compile --minify --define "process.env.AGENTS_CHAT_STANDALONE=\\"1\\"" --target=bun-${t}${extra} app/server.js --outfile "${path.relative(ROOT, out)}"`, {
57
+ cwd: ROOT, stdio: 'inherit'
58
+ });
59
+ const mb = (fs.statSync(out).size / 1024 / 1024).toFixed(1);
60
+ console.log(` 完成: ${path.relative(ROOT, out)} (${mb} MB)`);
61
+ } catch (e) {
62
+ console.error(` ${t} 构建失败: ${e.message}`);
63
+ failed.push(t);
64
+ }
65
+ }
66
+
67
+ // 3. 清理中间产物(下次构建重新生成;源码运行不需要它)
68
+ try { fs.unlinkSync(EMBED); } catch { /* ignore */ }
69
+
70
+ if (failed.length) {
71
+ console.error(`失败平台: ${failed.join(', ')}`);
72
+ process.exit(1);
73
+ }
74
+
75
+ // 4. 生成校验清单(Release 附上,供核对下载完整性;exe 未签名场景尤为重要)
76
+ const { createHash } = require('crypto');
77
+ const lines = [];
78
+ for (const f of fs.readdirSync(DIST).sort()) {
79
+ const fp = path.join(DIST, f);
80
+ const h = createHash('sha256').update(fs.readFileSync(fp)).digest('hex');
81
+ lines.push(`${h} ${f}`);
82
+ }
83
+ fs.writeFileSync(path.join(DIST, 'checksums.txt'), lines.join('\n') + '\n');
84
+ console.log('校验清单: dist/checksums.txt');
85
+ console.log('全部构建完成');
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ // npm 安装钩子:全无 AI 内核时自动安装 opencode,小白开箱即用
3
+ // 铁律:任何失败只提示不抛错——postinstall 非零退出会连带 npm install 整体报错
4
+ try {
5
+ const { ensureDefaultKernel } = require('../app/lib/kernel-setup');
6
+ const r = ensureDefaultKernel();
7
+ if (r.reason === 'has-kernel') console.log('✓ 已检测到 AI 执行内核,跳过自动安装');
8
+ } catch (err) {
9
+ console.warn('内核自动安装检查跳过:', err && err.message);
10
+ }