@iamsamyiok/agents-chat 3.21.0 → 3.22.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/cards.js CHANGED
@@ -13,6 +13,7 @@ const path = require('path');
13
13
  const { resolveRunner, detectKernels, KERNEL_DEFS, stopScope } = require('./agent');
14
14
  const oc = require('./oc');
15
15
  const store = require('./store');
16
+ const safejson = require('./safejson');
16
17
 
17
18
  const ROOT = path.join(__dirname, '..', '..');
18
19
  const DATA_DIR = process.env.AGENTS_CHAT_DATA || path.join(ROOT, '.data');
@@ -22,12 +23,11 @@ const TRASH_PATH = path.join(DATA_DIR, 'cards_trash.json');
22
23
  const TRASH_TTL = 30 * 24 * 3600 * 1000; // 垃圾桶默认保留 30 天
23
24
 
24
25
  function ensureDir() { if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }); }
25
- // 数据损坏保护:解析失败(文件存在但 JSON 损坏)时备份原文件并置损坏标记;
26
- // 后续写入直接拒绝,防止「读到空列表 新增一条 覆盖写」把用户全部卡牌冲掉
27
- const corrupted = new Set(); // 已损坏的文件路径
26
+ // 数据损坏保护(safejson 公共层):解析失败 → 备份 .corrupt-* 并只读;写入一律原子替换
27
+ const corrupted = new Set(); // 已损坏的文件路径(本模块文件)
28
28
  function cachedReader(file, cacheBox) {
29
29
  return function read() {
30
- if (corrupted.has(file)) return [];
30
+ if (safejson.isCorrupted(file)) return [];
31
31
  try {
32
32
  const st = fs.statSync(file);
33
33
  if (cacheBox.list && st.mtimeMs === cacheBox.mtime) return cacheBox.list;
@@ -39,11 +39,10 @@ function cachedReader(file, cacheBox) {
39
39
  cacheBox.mtime = 0; cacheBox.list = null;
40
40
  return [];
41
41
  }
42
- // 文件存在但解析失败:备份损坏现场,进入只读保护
43
- try { fs.copyFileSync(file, `${file}.corrupt-${Date.now()}`); } catch { /* ignore */ }
42
+ // 文件存在但解析失败:经 safejson 备份损坏现场并登记,进入只读保护
43
+ safejson.readJson(file, []);
44
44
  corrupted.add(file);
45
45
  cacheBox.mtime = 0; cacheBox.list = null;
46
- console.error(`[cards] 数据文件损坏已备份并进入保护:${file}`);
47
46
  return [];
48
47
  }
49
48
  };
@@ -66,7 +65,8 @@ function invalidate(file) {
66
65
  }
67
66
  // 损坏保护下的写入守卫:拒绝覆盖写(保留备份供人工恢复)
68
67
  function guardWrite(file) {
69
- if (corrupted.has(file)) {
68
+ if (corrupted.has(file) || safejson.isCorrupted(file)) {
69
+ corrupted.add(file);
70
70
  throw new Error(`数据文件 ${path.basename(file)} 已损坏(原文件已备份为 .corrupt-*),为防数据丢失已停止写入,请人工检查 ${DATA_DIR} 后删除损坏标记文件`);
71
71
  }
72
72
  }
@@ -74,9 +74,7 @@ function writeCards(list) {
74
74
  ensureDir();
75
75
  guardWrite(CARDS_PATH);
76
76
  invalidate(CARDS_PATH);
77
- const tmp = CARDS_PATH + '.tmp';
78
- fs.writeFileSync(tmp, JSON.stringify(list, null, 2), 'utf8');
79
- fs.renameSync(tmp, CARDS_PATH);
77
+ safejson.writeJson(CARDS_PATH, list);
80
78
  }
81
79
  function readConfig() {
82
80
  const v = readConfigRaw();
@@ -86,16 +84,17 @@ function writeConfig(cfg) {
86
84
  ensureDir();
87
85
  guardWrite(CARDS_CFG_PATH);
88
86
  invalidate(CARDS_CFG_PATH);
89
- fs.writeFileSync(CARDS_CFG_PATH, JSON.stringify(cfg, null, 2), 'utf8');
87
+ safejson.writeJson(CARDS_CFG_PATH, cfg);
90
88
  }
91
89
  function readTrash() {
92
90
  const v = readTrashRaw();
93
91
  return Array.isArray(v) ? v.slice() : [];
94
92
  }
95
93
  function writeTrash(list) {
94
+ ensureDir();
96
95
  guardWrite(TRASH_PATH);
97
96
  invalidate(TRASH_PATH);
98
- fs.writeFileSync(TRASH_PATH, JSON.stringify(list, null, 2), 'utf8');
97
+ safejson.writeJson(TRASH_PATH, list);
99
98
  }
100
99
  // 启动时清理超过 30 天的垃圾桶快照,并连带清除其日志,避免占用磁盘
101
100
  (function purgeTrash() {
@@ -161,11 +160,15 @@ const CardStore = {
161
160
  // 拖拽重排:order 重编 + priority 按新顺序重映射(保值域、变归属),
162
161
  // 使调度顺序恒等于看板顺序(priority 仍是排序主键,但层内次序由拖拽决定)
163
162
  reorder(ids) {
163
+ // 全量重编:传入 ids 按新顺序排前,未涉及的卡保持原相对顺序排后,
164
+ // 保证全表 order/priority 唯一且连续(部分重排不再产生并列 order)
164
165
  const list = readCards();
165
- const map = new Map(list.map(c => [c.id, c]));
166
- const ordered = ids.map(id => map.get(id)).filter(Boolean);
167
- const prios = ordered.map(c => (c.priority === undefined ? 999 : c.priority)).sort((a, b) => a - b);
168
- ordered.forEach((c, i) => { c.order = i + 1; c.priority = prios[i]; });
166
+ const idSet = new Set(ids);
167
+ const ordered = ids.map(id => list.find(c => c.id === id)).filter(Boolean);
168
+ const rest = list.filter(c => !idSet.has(c.id));
169
+ const seq = [...ordered, ...rest];
170
+ const prios = seq.map(c => (c.priority === undefined ? 999 : c.priority)).sort((a, b) => a - b);
171
+ seq.forEach((c, i) => { c.order = i + 1; c.priority = prios[i]; });
169
172
  writeCards(list);
170
173
  return true;
171
174
  },
@@ -628,7 +631,7 @@ class CardRunner {
628
631
  }
629
632
 
630
633
  // 数据文件损坏状态(供 API 告警展示)
631
- function getCorruptedFiles() { return [...corrupted]; }
634
+ function getCorruptedFiles() { return [...new Set([...corrupted, ...safejson.corruptedFiles()])]; }
632
635
 
633
636
  function isValidDir(p) {
634
637
  try { return fs.existsSync(p) && fs.statSync(p).isDirectory(); } catch { return false; }
@@ -1173,5 +1173,7 @@ module.exports = {
1173
1173
  // 测试导出(单测用,业务代码请勿依赖)
1174
1174
  testBoardInit: boardInit, testBoardAppend: boardAppend, testBoardRead: boardRead,
1175
1175
  testExtractBoardNote: extractBoardNote, testParseHandoff: parseHandoff,
1176
- testApprovalGate: approvalGate
1176
+ testApprovalGate: approvalGate,
1177
+ testExtractPlanJSON: extractPlanJSON, testResolveAgentRef: resolveAgentRef,
1178
+ testSplitByDependency: splitByDependency, testNormalizePhases: normalizePhases
1177
1179
  };
@@ -0,0 +1,34 @@
1
+ // 数据文件损坏保护 + 原子写公共层(零依赖)
2
+ // 策略:解析失败(文件存在但 JSON 损坏)→ 备份现场为 .corrupt-<ts> 并登记;
3
+ // 之后该文件的写请求一律抛错,防止「读到空数据 → 全量覆盖写」冲掉用户数据。
4
+ // 正常写入走 tmp + rename 原子替换,进程中断不会留下半截文件。
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+
8
+ const corrupted = new Set(); // 已损坏的文件绝对路径
9
+
10
+ function isCorrupted(file) { return corrupted.has(file); }
11
+ function corruptedFiles() { return [...corrupted]; }
12
+
13
+ function readJson(file, fallback) {
14
+ try {
15
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
16
+ } catch (err) {
17
+ if (err && err.code === 'ENOENT') return fallback; // 文件不存在:正常初始状态
18
+ try { fs.copyFileSync(file, `${file}.corrupt-${Date.now()}`); } catch { /* 备份失败也要继续登记 */ }
19
+ corrupted.add(file);
20
+ console.error(`[safejson] 数据文件损坏,已备份并进入只读保护:${file}`);
21
+ return fallback;
22
+ }
23
+ }
24
+
25
+ function writeJson(file, data) {
26
+ if (corrupted.has(file)) {
27
+ throw new Error(`数据文件 ${path.basename(file)} 已损坏(原文件已备份为 .corrupt-*),为防数据丢失已停止写入,请人工检查 ${path.dirname(file)} 后处理备份文件`);
28
+ }
29
+ const tmp = file + '.tmp';
30
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8');
31
+ fs.renameSync(tmp, file);
32
+ }
33
+
34
+ module.exports = { readJson, writeJson, isCorrupted, corruptedFiles };
package/app/lib/store.js CHANGED
@@ -1,13 +1,16 @@
1
1
  // 数据存储:纯 JSON 文件,零依赖
2
2
  // 文件位于数据目录(默认 <root>/.data),任务与消息持久化
3
+ // 损坏保护与原子写由 safejson 公共层提供:损坏文件备份 .corrupt-* 后只读,防覆盖丢数据
3
4
  const fs = require('fs');
4
5
  const path = require('path');
6
+ const safejson = require('./safejson');
5
7
 
6
8
  const ROOT = path.join(__dirname, '..', '..');
7
9
  const DATA_DIR = process.env.AGENTS_CHAT_DATA || path.join(ROOT, '.data');
8
10
  const CONFIG_PATH = path.join(DATA_DIR, 'config.json');
9
11
  const TASKS_PATH = path.join(DATA_DIR, 'tasks.json');
10
- const MESSAGES_PATH = path.join(DATA_DIR, 'messages.json');
12
+ const MESSAGES_PATH = path.join(DATA_DIR, 'messages.json'); // 旧版单文件(启动时一次性迁移到 messages/ 分片)
13
+ const MSG_DIR = path.join(DATA_DIR, 'messages');
11
14
  const MEMORY_PATH = path.join(DATA_DIR, 'memory.json');
12
15
  const OC_SESSIONS_PATH = path.join(DATA_DIR, 'oc-sessions.json');
13
16
 
@@ -16,18 +19,12 @@ function ensureDir() {
16
19
  }
17
20
 
18
21
  function readJson(file, fallback) {
19
- try {
20
- return JSON.parse(fs.readFileSync(file, 'utf8'));
21
- } catch {
22
- return fallback;
23
- }
22
+ return safejson.readJson(file, fallback);
24
23
  }
25
24
 
26
25
  function writeJson(file, data) {
27
26
  ensureDir();
28
- const tmp = file + '.tmp';
29
- fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8');
30
- fs.renameSync(tmp, file);
27
+ safejson.writeJson(file, data);
31
28
  }
32
29
 
33
30
  // ---------- 内置管家智能体(不可修改、不可删除,始终置顶) ----------
@@ -87,7 +84,7 @@ function getConfig() {
87
84
  let cfg = readJson(CONFIG_PATH, null);
88
85
  if (!cfg || !Array.isArray(cfg.agents)) {
89
86
  cfg = defaultConfig();
90
- writeJson(CONFIG_PATH, cfg);
87
+ try { writeJson(CONFIG_PATH, cfg); } catch { /* config 处于损坏保护:本次运行用默认配置,文件保留待人工恢复 */ }
91
88
  }
92
89
  // 管家始终置顶且使用内置定义(保证内置人设更新后自动生效)
93
90
  cfg.agents = [BUTLER, ...cfg.agents.filter(a => a && a.id !== 'butler')];
@@ -442,16 +439,80 @@ function getTask(id) {
442
439
  return getTasks().find(x => x.id === id) || null;
443
440
  }
444
441
 
445
- // ---------- 消息 ----------
446
- // taskId 为空 = 主会话;否则属于对应任务会话(每个任务一个独立会话)
442
+ // ---------- 消息(分片存储:messages/<key>.json,每个会话一个文件) ----------
443
+ // taskId 为空 = 主会话;否则属于对应任务/单聊会话
444
+ // 旧版全部消息集中在单个 messages.json:每条消息都要全量读写整个文件,历史越长 IO 越大;
445
+ // 分片后单会话读写只涉及自己的文件;旧文件在首次访问时一次性迁移(原件保留为 .migrated 备份)
446
+ let msgMigrated = false;
447
+ function migrateLegacyMessages() {
448
+ if (msgMigrated) return;
449
+ msgMigrated = true;
450
+ let raw = null;
451
+ try {
452
+ raw = fs.readFileSync(MESSAGES_PATH, 'utf8');
453
+ } catch { return; } // 无旧文件
454
+ let all;
455
+ try {
456
+ all = JSON.parse(raw);
457
+ } catch {
458
+ // 旧文件损坏:备份现场后放弃迁移(各会话从空开始,原件可供人工恢复)
459
+ try { fs.copyFileSync(MESSAGES_PATH, `${MESSAGES_PATH}.corrupt-${Date.now()}`); } catch { /* ignore */ }
460
+ console.error(`[store] 旧版 messages.json 损坏,已备份并跳过迁移:${MESSAGES_PATH}`);
461
+ return;
462
+ }
463
+ if (!Array.isArray(all)) {
464
+ try { fs.renameSync(MESSAGES_PATH, MESSAGES_PATH + '.migrated'); } catch { /* ignore */ }
465
+ return;
466
+ }
467
+ const groups = new Map();
468
+ for (const m of all) {
469
+ const k = (m && m.taskId) || '';
470
+ if (!groups.has(k)) groups.set(k, []);
471
+ groups.get(k).push(m);
472
+ }
473
+ try {
474
+ fs.mkdirSync(MSG_DIR, { recursive: true });
475
+ for (const [k, list] of groups) writeJson(msgShardPath(k), list);
476
+ fs.renameSync(MESSAGES_PATH, MESSAGES_PATH + '.migrated'); // 保留备份供人工核对
477
+ } catch (err) {
478
+ console.error('[store] messages 迁移失败(下次启动重试):', err && err.message);
479
+ msgMigrated = false;
480
+ }
481
+ }
482
+
483
+ // 会话 key -> 分片文件名:常规 id 原样使用;其余(空/特殊字符)十六进制编码,避免文件名问题
484
+ function msgShardName(key) {
485
+ const k = key == null ? '' : String(key);
486
+ if (k === '') return '_main.json';
487
+ if (/^[A-Za-z0-9_-]{1,80}$/.test(k)) return k + '.json';
488
+ return '~' + Buffer.from(k).toString('hex').slice(0, 160) + '.json';
489
+ }
490
+ function msgShardPath(key) { return path.join(MSG_DIR, msgShardName(key)); }
491
+
447
492
  function getMessages(taskId) {
448
- const msgs = readJson(MESSAGES_PATH, []);
449
- if (taskId === undefined) return msgs;
450
- return msgs.filter(m => (m.taskId || '') === taskId);
493
+ migrateLegacyMessages();
494
+ if (taskId === undefined) {
495
+ // 全量视图:合并所有分片,按时间排序还原全局顺序
496
+ let files = [];
497
+ try { files = fs.readdirSync(MSG_DIR); } catch { return []; }
498
+ const all = [];
499
+ for (const name of files) {
500
+ if (!name.endsWith('.json') || name.startsWith('.')) continue;
501
+ const list = readJson(path.join(MSG_DIR, name), []);
502
+ if (Array.isArray(list)) all.push(...list);
503
+ }
504
+ all.sort((a, b) => ((a && a.timestamp) || '') < ((b && b.timestamp) || '') ? -1 : 1);
505
+ return all;
506
+ }
507
+ const list = readJson(msgShardPath(String(taskId)), []);
508
+ return Array.isArray(list) ? list : [];
451
509
  }
452
510
 
453
511
  function addMessage(msg) {
454
- const msgs = readJson(MESSAGES_PATH, []);
512
+ migrateLegacyMessages();
513
+ fs.mkdirSync(MSG_DIR, { recursive: true });
514
+ const key = msg.taskId || '';
515
+ const msgs = readJson(msgShardPath(key), []);
455
516
  // 主会话消息记录所属 epoch:新会话开启后,旧 epoch 消息不再传入上下文
456
517
  const rec = {
457
518
  id: msg.id || `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
@@ -468,11 +529,19 @@ function addMessage(msg) {
468
529
  timestamp: msg.timestamp || new Date().toISOString()
469
530
  };
470
531
  msgs.push(rec);
471
- writeJson(MESSAGES_PATH, msgs);
532
+ writeJson(msgShardPath(key), msgs);
472
533
  }
473
534
 
474
535
  function clearMessages() {
475
- writeJson(MESSAGES_PATH, []);
536
+ migrateLegacyMessages();
537
+ // 清空全部会话消息:删除所有分片,主会话分片重置为空数组
538
+ try {
539
+ for (const name of fs.readdirSync(MSG_DIR)) {
540
+ if (!name.endsWith('.json') || name.startsWith('.')) continue;
541
+ try { fs.unlinkSync(path.join(MSG_DIR, name)); } catch { /* ignore */ }
542
+ }
543
+ } catch { /* 目录不存在 */ }
544
+ writeJson(msgShardPath(''), []);
476
545
  }
477
546
 
478
547
  // ---------- 流转日志(智能体之间的派发/交接/返工/验收事件,append-only) ----------
@@ -566,7 +635,7 @@ function getOcSession(id) {
566
635
 
567
636
  function deleteOcSession(id) {
568
637
  saveOcSessions(getOcSessions().filter(s => s.id !== id));
569
- writeJson(MESSAGES_PATH, readJson(MESSAGES_PATH, []).filter(m => (m.taskId || '') !== id));
638
+ try { fs.unlinkSync(msgShardPath(id)); } catch { /* 分片不存在 */ }
570
639
  }
571
640
 
572
641
  // ---------- 管家长期记忆(跨会话偏好与教训,读写由 memory.js 负责) ----------
@@ -614,16 +683,27 @@ function pruneOldData(days) {
614
683
  // 其消息由下方第 3 步统一按孤儿清理并计数(避免重复统计)
615
684
  }
616
685
 
617
- // 3. 消息:孤儿任务消息(任务已删)+ 过期的主会话/单聊会话消息(timestamp 超 cutoff)
686
+ // 3. 消息(分片):孤儿会话分片整文件删除;主会话分片按 timestamp 过滤
618
687
  const validIds = new Set([...keptTasks.map(t => t.id), ...keptSess.map(s => s.id)]);
619
- const keptMsgs = readJson(MESSAGES_PATH, []).filter(m => {
620
- const tid = m.taskId || '';
621
- if (tid && !validIds.has(tid)) { stat.messages++; return false; } // 孤儿消息
622
- if (!tid && tsOf(m) && tsOf(m) < cutoff) { stat.messages++; return false; } // 过期主会话
623
- return true;
624
- });
625
- const allMsgs = readJson(MESSAGES_PATH, []);
626
- if (keptMsgs.length !== allMsgs.length) writeJson(MESSAGES_PATH, keptMsgs);
688
+ try {
689
+ for (const name of fs.readdirSync(MSG_DIR)) {
690
+ if (!name.endsWith('.json') || name.startsWith('.')) continue;
691
+ const fp = path.join(MSG_DIR, name);
692
+ let list;
693
+ try { list = JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { continue; } // 损坏分片留给损坏保护处理
694
+ if (!Array.isArray(list)) continue;
695
+ const tid = list.length ? (list[0].taskId || '') : '';
696
+ if (tid && !validIds.has(tid)) {
697
+ // 孤儿会话(任务/单聊已删或超期):整分片删除
698
+ stat.messages += list.length;
699
+ try { fs.unlinkSync(fp); } catch { /* ignore */ }
700
+ } else if (!tid) {
701
+ // 主会话:按时间过滤
702
+ const kept = list.filter(m => !tsOf(m) || tsOf(m) >= cutoff);
703
+ if (kept.length !== list.length) { stat.messages += list.length - kept.length; writeJson(fp, kept); }
704
+ }
705
+ }
706
+ } catch { /* 无目录 */ }
627
707
 
628
708
  // 4. 流转日志:超期事件行过滤重写
629
709
  try {
@@ -682,5 +762,6 @@ module.exports = {
682
762
  upsertOcSession,
683
763
  getOcSession,
684
764
  deleteOcSession,
685
- pruneOldData
765
+ pruneOldData,
766
+ getCorruptedFiles: () => safejson.corruptedFiles()
686
767
  };
@@ -29,6 +29,17 @@
29
29
  .btn.danger{border-color:var(--err);color:var(--err)}
30
30
  .btn:disabled{opacity:.45;cursor:not-allowed}
31
31
  .status-pill{font-size:12px;color:var(--dim)}
32
+ .filter-box{display:flex;align-items:center;gap:4px;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:5px 8px}
33
+ .filter-box input{border:none;outline:none;font-size:12px;width:130px;background:transparent;color:inherit}
34
+ /* 详情复制按钮 */
35
+ .copy-btn{border:1px solid var(--line);background:transparent;color:var(--dim);font-size:11px;border-radius:4px;padding:1px 8px;cursor:pointer;margin-left:6px}
36
+ .copy-btn:hover{color:var(--acc2);border-color:var(--acc2)}
37
+ .copy-btn.done{color:#2f9e44;border-color:#2f9e44}
38
+ /* 看板统计条:总览 + 完成率进度 */
39
+ #boardStats{display:flex;align-items:center;gap:10px;padding:8px 18px;font-size:12px;color:var(--dim);border-bottom:1px solid var(--line);background:var(--panel)}
40
+ #boardStats b{color:inherit}
41
+ .done-bar{flex:1;height:6px;border-radius:3px;background:var(--line);overflow:hidden;max-width:360px}
42
+ .done-bar i{display:block;height:100%;background:linear-gradient(90deg,var(--acc2),#2f9e44);border-radius:3px;transition:width .4s}
32
43
  .ws-box{display:flex;align-items:center;gap:6px;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:5px 8px}
33
44
  .ws-box input{border:none;outline:none;font-size:12px;width:200px;background:transparent}
34
45
  .ws-box .ws-save{font-size:12px;color:var(--acc);cursor:pointer;white-space:nowrap}
@@ -175,6 +186,9 @@
175
186
  <h1>🗂️ 多任务编排</h1>
176
187
  <span class="status-pill" id="statusPill">空闲</span>
177
188
  <span class="kernel-chip" id="kernelChip" style="display:none"></span>
189
+ <div class="filter-box" title="按标题或内容即时过滤看板">
190
+ 🔍 <input id="cardFilter" placeholder="搜索任务…" autocomplete="off" />
191
+ </div>
178
192
  <span class="sp"></span>
179
193
  <div class="ws-box" title="工作区:选定后 Agent 在该目录读写相关文件(可选)">
180
194
  📁 <input id="wsInput" placeholder="工作文件夹(点右侧选择)" />
@@ -196,6 +210,13 @@
196
210
 
197
211
  <div class="procbar" id="procBar"><span class="ptitle">进程:</span><span class="proc-empty">无运行中的 opencode 进程</span></div>
198
212
 
213
+ <div id="boardStats">
214
+ <b id="stTotal">0</b> 项任务
215
+ <span id="stDoneRate">完成率 0%</span>
216
+ <div class="done-bar"><i id="stDoneBar" style="width:0%"></i></div>
217
+ <span id="stFailed" style="color:#c92a2a"></span>
218
+ </div>
219
+
199
220
  <main>
200
221
  <!-- 左侧:依赖图面板(约 1/4 宽,可收起) -->
201
222
  <aside id="graphPanel">
@@ -264,7 +285,7 @@
264
285
  <div class="detail-err" id="dError" style="display:none"></div>
265
286
  <label>执行过程(实时)</label>
266
287
  <div class="detail-log" id="dLog"><span class="empty">暂无过程记录</span></div>
267
- <label>最终结果</label>
288
+ <label>最终结果 <button class="copy-btn" id="dResultCopy" title="复制完整结果文本">复制</button></label>
268
289
  <div class="detail-result" id="dResult"><span class="empty">尚未产生结果</span></div>
269
290
  <div class="followup-box" id="fuBox">
270
291
  <div class="fu-head">💬 追加聊天 <span class="fu-st" id="fuState"></span></div>
@@ -332,6 +353,23 @@ async function loadCards(){
332
353
  loadTrashCount();
333
354
  }
334
355
 
356
+ // 文本复制(优先剪贴板 API,旧环境回退 execCommand)
357
+ function copyText(text, btn){
358
+ const done=()=>{ if(!btn) return; const old=btn.textContent; btn.textContent='已复制'; btn.classList.add('done'); setTimeout(()=>{ btn.textContent=old; btn.classList.remove('done'); },1200); };
359
+ const s=String(text==null?'':text);
360
+ if(navigator.clipboard && navigator.clipboard.writeText){ navigator.clipboard.writeText(s).then(done).catch(()=>{ fallbackCopy(s); done(); }); }
361
+ else { fallbackCopy(s); done(); }
362
+ }
363
+ function fallbackCopy(s){
364
+ const ta=document.createElement('textarea');
365
+ ta.value=s; ta.style.cssText='position:fixed;opacity:0';
366
+ document.body.appendChild(ta); ta.select();
367
+ try{ document.execCommand('copy'); }catch(e){}
368
+ document.body.removeChild(ta);
369
+ }
370
+ // 搜索过滤:输入即时重渲染
371
+ $('#cardFilter').addEventListener('input', render);
372
+
335
373
  // 内核状态展示:正常=头部小徽标;缺失/演示模式/数据保护=醒目横幅
336
374
  function renderKernelBanner(){
337
375
  const chip=$('#kernelChip'), banner=$('#kernelBanner');
@@ -474,10 +512,20 @@ document.addEventListener('dragover', e=>{ window.__dropY = e.clientY; }, true);
474
512
  function render(){
475
513
  const cols = {pending:$('#colPending'),running:$('#colRunning'),done:$('#colDone'),failed:$('#colFailed')};
476
514
  for(const k in cols) cols[k].innerHTML='';
515
+ // 搜索过滤:按标题 + 内容即时匹配(空串 = 全部)
516
+ const q=(($('#cardFilter').value||'').trim().toLowerCase());
517
+ const shown = q ? CARDS.filter(c=>((c.title||'')+'\n'+(c.content||'')).toLowerCase().includes(q)) : CARDS;
477
518
  let counts={pending:0,running:0,done:0,failed:0};
478
- for(const c of CARDS){ counts[c.status]=(counts[c.status]||0)+1; (cols[c.status]||cols.pending).appendChild(cardEl(c)); }
519
+ for(const c of shown){ counts[c.status]=(counts[c.status]||0)+1; (cols[c.status]||cols.pending).appendChild(cardEl(c)); }
479
520
  $('#cntPending').textContent=counts.pending||''; $('#cntRunning').textContent=counts.running||'';
480
521
  $('#cntDone').textContent=counts.done||''; $('#cntFailed').textContent=counts.failed||'';
522
+ // 统计条:全部卡的总览(不受过滤影响)+ 完成率
523
+ const total=CARDS.length, doneN=CARDS.filter(c=>c.status==='done').length, failedN=CARDS.filter(c=>c.status==='failed').length;
524
+ $('#stTotal').textContent=total;
525
+ const pct= total? Math.round(doneN/total*100):0;
526
+ $('#stDoneRate').textContent=`完成率 ${pct}%(${doneN}/${total})`;
527
+ $('#stDoneBar').style.width=pct+'%';
528
+ $('#stFailed').textContent= failedN? `失败 ${failedN}` : '';
481
529
  // 空看板引导:一个任务都没有时给出上手入口(含示例)
482
530
  if(!CARDS.length){
483
531
  cols.pending.innerHTML=`<div class="board-guide">
@@ -490,6 +538,12 @@ function render(){
490
538
  for(const k of ['running','done','failed']) cols[k].innerHTML='<div class="empty">—</div>';
491
539
  return;
492
540
  }
541
+ // 搜索无匹配:给出明确反馈与一键清除
542
+ if(q && !shown.length){
543
+ cols.pending.innerHTML=`<div class="board-guide">没有匹配「${esc(q)}」的任务<div class="bg-actions"><button class="btn" onclick="document.getElementById('cardFilter').value='';render()">清除搜索</button></div></div>`;
544
+ for(const k of ['running','done','failed']) cols[k].innerHTML='<div class="empty">—</div>';
545
+ return;
546
+ }
493
547
  for(const k in cols){ if(!cols[k].children.length) cols[k].innerHTML='<div class="empty">—</div>'; }
494
548
  }
495
549
 
@@ -698,6 +752,8 @@ async function openDetail(id){
698
752
  log.scrollTop=log.scrollHeight;
699
753
  const res=$('#dResult');
700
754
  if(card.result) res.textContent=card.result; else res.innerHTML='<span class="empty">尚未产生结果</span>';
755
+ const rCopy=$('#dResultCopy');
756
+ if(rCopy){ rCopy.disabled=!card.result; rCopy.title=card.result?'复制完整结果文本':'尚无结果可复制'; rCopy.onclick=()=>copyText(card.result||'', rCopy); }
701
757
  $('#dRun').style.display = (card.status==='running')?'none':'inline-block';
702
758
  // 追加聊天:任务结束(非 pending/running)即可用;无会话时提示限制
703
759
  const fuOk = card.status!=='pending' && card.status!=='running';
@@ -66,6 +66,14 @@
66
66
  .hdr-right { display: flex; align-items: center; gap: 10px; }
67
67
 
68
68
  #messages { flex: 1; overflow-y: auto; padding: 16px 18px; }
69
+ /* 消息复制按钮:hover 消息行时浮现 */
70
+ .copy-btn { border: none; background: rgba(0,0,0,.05); color: #888; font-size: 11px; border-radius: 4px; padding: 2px 7px; cursor: pointer; opacity: 0; transition: opacity .15s; vertical-align: middle; }
71
+ .msg-row:hover .copy-btn, .copy-btn:focus { opacity: 1; }
72
+ .copy-btn:hover { background: rgba(0,0,0,.1); color: #333; }
73
+ .copy-btn.done { color: #07c160; opacity: 1; }
74
+ /* 回到最新:阅读历史时新消息不再强制拽底,悬浮按钮一键回底 */
75
+ #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); }
76
+ #jumpBtn:hover { color: #07c160; border-color: #07c160; }
69
77
  .sys-tip { text-align: center; margin: 10px 0; }
70
78
  .approval-card { max-width: 480px; margin: 10px auto; background: #fff8e6; border: 1px solid #e8a33d; border-radius: 10px; padding: 10px 14px; text-align: left; }
71
79
  .approval-card .ap-title { font-weight: 600; font-size: 13px; color: #8a5a00; margin-bottom: 4px; }
@@ -413,8 +421,9 @@
413
421
  <button class="cfg-btn" id="helpBtn" onclick="openHelp()" title="使用帮助">❓ 帮助</button>
414
422
  </div>
415
423
  </header>
416
- <div id="groupView" style="flex:1;min-height:0;display:flex;flex-direction:column">
424
+ <div id="groupView" style="flex:1;min-height:0;display:flex;flex-direction:column;position:relative">
417
425
  <div id="messages"></div>
426
+ <button id="jumpBtn" onclick="jumpBottom()" title="有新消息了,点击回到最新">↓ 新消息</button>
418
427
  <footer class="composer" id="groupComposer">
419
428
  <div class="input-row">
420
429
  <button id="rtBtn" onclick="toggleRoundtable()" title="圆桌讨论模式:所有被 @ 的智能体(未 @ 则全体)围绕主题轮流发言、互相反驳,管家当主持人控制轮数并总结共识与分歧">💬 圆桌</button>
@@ -666,7 +675,7 @@ let curOcModel = ''; // 当前选中模型(localStorage 记忆)
666
675
 
667
676
  const input = document.getElementById('input');
668
677
 
669
- function esc(s) { const d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML; }
678
+ function esc(s) { const d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;'); }
670
679
  function escAttr(s) { return String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;'); }
671
680
  function fmtTime(ts) { const d = new Date(ts); return String(d.getHours()).padStart(2,'0') + ':' + String(d.getMinutes()).padStart(2,'0'); }
672
681
  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())}`; }
@@ -731,6 +740,17 @@ async function init() {
731
740
 
732
741
  input.addEventListener('input', handleAtInput);
733
742
  input.addEventListener('blur', () => setTimeout(hideAt, 150));
743
+ // 消息区滚动:贴底时收起「新消息」悬浮按钮
744
+ document.getElementById('messages').addEventListener('scroll', () => {
745
+ if (atBottom()) { const j = document.getElementById('jumpBtn'); if (j) j.style.display = 'none'; }
746
+ });
747
+ // 输入历史回填:输入框为空时按 ↑ 调出最近发送的一条(再按 Enter 直接重发)
748
+ input.addEventListener('keydown', (e) => {
749
+ if (e.key === 'ArrowUp' && input.value === '' && lastSentInputs.length) {
750
+ e.preventDefault();
751
+ input.value = lastSentInputs[lastSentInputs.length - 1];
752
+ }
753
+ });
734
754
  input.addEventListener('keydown', (e) => {
735
755
  if (atState) {
736
756
  if (e.key === 'ArrowDown') { e.preventDefault(); atState.active = (atState.active + 1) % atState.items.length; renderAt(); return; }
@@ -897,7 +917,7 @@ function soloMsgNode(m) {
897
917
  row.className = 'msg-row self';
898
918
  row.innerHTML = `
899
919
  <div class="avatar me">我</div>
900
- <div class="msg-body"><div class="bubble right">${esc(m.content)}</div></div>`;
920
+ <div class="msg-body"><div class="msg-name" style="text-align:right"><button class="copy-btn" onclick="copyText(decodeURIComponent('${encodeURIComponent(m.content || '')}'), this)">复制</button></div><div class="bubble right">${esc(m.content)}</div></div>`;
901
921
  return row;
902
922
  }
903
923
  // assistant:Markdown 渲染(opencode 输出天然是 Markdown)
@@ -906,7 +926,7 @@ function soloMsgNode(m) {
906
926
  row.innerHTML = `
907
927
  <div class="avatar solo-av">🤖</div>
908
928
  <div class="msg-body">
909
- <div class="msg-name">${esc(m.agentName || 'OpenCode')}</div>
929
+ <div class="msg-name">${esc(m.agentName || 'OpenCode')} <button class="copy-btn" onclick="copyText(decodeURIComponent('${encodeURIComponent(m.content || '')}'), this)" title="复制原始文本(含 Markdown 源码)">复制</button></div>
910
930
  <div class="bubble left solo-bubble"><div class="solo-part-md">${renderMd(m.content || '')}</div></div>
911
931
  </div>`;
912
932
  return row;
@@ -1542,6 +1562,30 @@ function renderHeader() {
1542
1562
 
1543
1563
  // ---------- 消息渲染 ----------
1544
1564
  function scrollBottom() { const box = document.getElementById('messages'); box.scrollTop = box.scrollHeight; }
1565
+ // 贴底判定:滚离底部超过 80px 视为「正在阅读历史」,新消息不再强制拽底
1566
+ function atBottom() { const b = document.getElementById('messages'); return !b || (b.scrollHeight - b.scrollTop - b.clientHeight < 80); }
1567
+ // 跟随滚动:贴底时自动滚到最新;阅读历史时改亮「新消息」按钮
1568
+ function followScroll() {
1569
+ if (atBottom()) scrollBottom();
1570
+ else { const j = document.getElementById('jumpBtn'); if (j) j.style.display = 'block'; }
1571
+ }
1572
+ function jumpBottom() { scrollBottom(); const j = document.getElementById('jumpBtn'); if (j) j.style.display = 'none'; }
1573
+
1574
+ // 文本复制(优先剪贴板 API,旧环境回退 execCommand),按钮成功后短暂反馈
1575
+ function copyText(text, btn) {
1576
+ const done = () => { if (!btn) return; const old = btn.textContent; btn.textContent = '已复制'; btn.classList.add('done'); setTimeout(() => { btn.textContent = old; btn.classList.remove('done'); }, 1200); };
1577
+ const s = String(text == null ? '' : text);
1578
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1579
+ navigator.clipboard.writeText(s).then(done).catch(() => { fallbackCopy(s); done(); });
1580
+ } else { fallbackCopy(s); done(); }
1581
+ }
1582
+ function fallbackCopy(s) {
1583
+ const ta = document.createElement('textarea');
1584
+ ta.value = s; ta.style.cssText = 'position:fixed;opacity:0';
1585
+ document.body.appendChild(ta); ta.select();
1586
+ try { document.execCommand('copy'); } catch { /* ignore */ }
1587
+ document.body.removeChild(ta);
1588
+ }
1545
1589
 
1546
1590
  function planNode(m) {
1547
1591
  const row = document.createElement('div');
@@ -1559,7 +1603,7 @@ function planNode(m) {
1559
1603
  row.innerHTML = `
1560
1604
  <div class="avatar butler">🎩</div>
1561
1605
  <div class="msg-body">
1562
- <div class="msg-name">管家 · 调度规划</div>
1606
+ <div class="msg-name">管家 · 调度规划 <button class="copy-btn" onclick="copyText(decodeURIComponent('${encodeURIComponent((p && p.thought) || m.content || '')}'), this)" title="复制规划思路">复制</button></div>
1563
1607
  <div class="bubble left plan-card">${inner}</div>
1564
1608
  </div>`;
1565
1609
  return row;
@@ -1708,7 +1752,7 @@ function msgNode(m) {
1708
1752
  row.className = 'msg-row self';
1709
1753
  row.innerHTML = `
1710
1754
  <div class="avatar me">我</div>
1711
- <div class="msg-body"><div class="bubble right">${esc(m.content)}</div></div>`;
1755
+ <div class="msg-body"><div class="msg-name" style="text-align:right"><button class="copy-btn" onclick="copyText(decodeURIComponent('${encodeURIComponent(m.content || '')}'), this)">复制</button></div><div class="bubble right">${esc(m.content)}</div></div>`;
1712
1756
  return row;
1713
1757
  }
1714
1758
  if (m.phase === 'plan') return planNode(m);
@@ -1722,7 +1766,7 @@ function msgNode(m) {
1722
1766
  row.innerHTML = `
1723
1767
  <div class="avatar ${m.agentId === butlerId ? 'butler' : ''}" style="${m.agentId === butlerId ? '' : `background:${colorOf(m.agentId || 'x')}`}">${avIcon}</div>
1724
1768
  <div class="msg-body">
1725
- <div class="msg-name">${esc(m.agentName || m.agentId || 'AI')}${m.agentId === butlerId ? ' · 管家' : ''}</div>
1769
+ <div class="msg-name">${esc(m.agentName || m.agentId || 'AI')}${m.agentId === butlerId ? ' · 管家' : ''} <button class="copy-btn" onclick="copyText(decodeURIComponent('${encodeURIComponent(m.content || '')}'), this)" title="复制原始文本(含 Markdown 源码)">复制</button></div>
1726
1770
  ${bodyHtml}
1727
1771
  ${m.outputPath ? `<div class="msg-file" ${/\.md$/i.test(m.outputPath) ? `data-path="${escAttr(m.outputPath)}" title="点击预览该 Markdown 存档"` : `title="系统过程存档:该智能体本阶段完整输出(内部交接用)。用户最终要的成果文件在产出存档目录中,以管家交付清单里的完整路径为准"`}>📄 过程存档 ${fileNodeText(m.outputPath)}</div>` : ''}
1728
1772
  </div>`;
@@ -1742,17 +1786,18 @@ function renderMessages() {
1742
1786
  finalizeMsgLinks(m); // 定稿消息中的 .md 路径变成可点击预览链接
1743
1787
  }
1744
1788
  scrollBottom();
1789
+ const jb = document.getElementById('jumpBtn'); if (jb) jb.style.display = 'none';
1745
1790
  }
1746
1791
 
1747
1792
  // 追加或更新一条流式消息(仅当前会话操作 DOM)
1748
1793
  function ensureMsgDom(m) {
1749
1794
  const key = sessKey(m.taskId || curTaskId);
1750
1795
  if (key !== curKey()) return;
1751
- if (m._dom && document.contains(m._dom)) { if (m._tx) m._tx.textContent = m.content; scrollBottom(); return; }
1796
+ if (m._dom && document.contains(m._dom)) { if (m._tx) m._tx.textContent = m.content; followScroll(); return; }
1752
1797
  const node = msgNode(m);
1753
1798
  m._dom = node;
1754
1799
  document.getElementById('messages').appendChild(node);
1755
- scrollBottom();
1800
+ followScroll();
1756
1801
  }
1757
1802
 
1758
1803
  function pushMsg(m) {
@@ -1851,7 +1896,7 @@ function onSaved(ev) {
1851
1896
  }
1852
1897
  file.innerHTML = '📄 过程存档 ' + fileNodeText(ev.path);
1853
1898
  m._dom.querySelector('.msg-body').appendChild(file);
1854
- scrollBottom();
1899
+ followScroll();
1855
1900
  }
1856
1901
  finalizeMsgLinks(m); // 交付文本中的成果文件路径也变成可点击链接
1857
1902
  return;
@@ -1940,6 +1985,7 @@ async function stopRun(scope) {
1940
1985
  } catch { toast('停止请求失败,请重试'); }
1941
1986
  }
1942
1987
 
1988
+ let lastSentInputs = []; // 输入历史(空输入框按 ↑ 回填最近一条)
1943
1989
  async function send() {
1944
1990
  const text = input.value.trim();
1945
1991
  if (!text || chatBusy) return;
@@ -1950,6 +1996,10 @@ async function send() {
1950
1996
  try {
1951
1997
  input.value = '';
1952
1998
  hideAt();
1999
+ if (lastSentInputs[lastSentInputs.length - 1] !== text) {
2000
+ lastSentInputs.push(text);
2001
+ if (lastSentInputs.length > 20) lastSentInputs.shift();
2002
+ }
1953
2003
  pushUser(text);
1954
2004
  typing = addSys('智能体处理中…');
1955
2005
 
@@ -2131,7 +2181,7 @@ async function openHistory() {
2131
2181
  if (m.role === 'sys') {
2132
2182
  html += `<div class="sys-tip"><span>${esc(m.content)}</span></div>`;
2133
2183
  } else if (m.role === 'user') {
2134
- html += `<div class="msg-row self"><div class="avatar me">我</div><div class="msg-body"><div class="bubble right">${esc(m.content)}</div></div></div>`;
2184
+ html += `<div class="msg-row self"><div class="avatar me">我</div><div class="msg-body"><div class="msg-name" style="text-align:right"><button class="copy-btn" onclick="copyText(decodeURIComponent('${encodeURIComponent(m.content || '')}'), this)">复制</button></div><div class="bubble right">${esc(m.content)}</div></div></div>`;
2135
2185
  } else if (m.phase === 'plan' && m.plan) {
2136
2186
  html += `<div class="msg-row"><div class="avatar butler">🎩</div><div class="msg-body"><div class="msg-name">${esc(m.agentName || '管家')} · 调度规划</div><div class="bubble left plan-card"><div class="plan-thought">${esc(m.plan.thought || '')}</div>${(m.plan.phases || []).map((g, gi) => `<div class="plan-phase"><b>阶段 ${gi + 1}</b>(${g.length > 1 ? g.length + ' 项并行' : '单执行'})${g.map(s => `<div class="plan-step">▸ <b>${esc(s.agentName)}</b>:${esc(s.instruction)}</div>`).join('')}</div>`).join('')}</div></div></div>`;
2137
2187
  } else {
@@ -2140,7 +2190,7 @@ async function openHistory() {
2140
2190
  const contentHtml = isFoldPhase(m.phase)
2141
2191
  ? `<div class="bubble left"><div class="fold-wrap">${tag}${linkifyMd(esc(m.content))}</div><div class="fold-toggle" onclick="toggleFoldEl(this)">展开全文 ▾</div></div>`
2142
2192
  : `<div class="bubble left">${tag}${linkifyMd(esc(m.content))}</div>`;
2143
- html += `<div class="msg-row"><div class="avatar ${m.agentId === butlerId ? 'butler' : ''}" style="${m.agentId === butlerId ? '' : `background:${colorOf(m.agentId || 'x')}`}">${esc(agentIcon(m.agentId, m.agentName))}</div><div class="msg-body"><div class="msg-name">${esc(m.agentName || m.agentId || 'AI')}</div>${contentHtml}${fileTag}</div></div>`;
2193
+ html += `<div class="msg-row"><div class="avatar ${m.agentId === butlerId ? 'butler' : ''}" style="${m.agentId === butlerId ? '' : `background:${colorOf(m.agentId || 'x')}`}">${esc(agentIcon(m.agentId, m.agentName))}</div><div class="msg-body"><div class="msg-name">${esc(m.agentName || m.agentId || 'AI')} <button class="copy-btn" onclick="copyText(decodeURIComponent('${encodeURIComponent(m.content || '')}'), this)" title="复制原始文本(含 Markdown 源码)">复制</button></div>${contentHtml}${fileTag}</div></div>`;
2144
2194
  }
2145
2195
  }
2146
2196
  el.innerHTML = html;
package/app/server.js CHANGED
@@ -298,7 +298,9 @@ const server = http.createServer(async (req, res) => {
298
298
  configKernel: String(store.getConfig().kernel || 'auto'),
299
299
  model: process.env.AGENTS_CHAT_MODEL || '',
300
300
  autoApprove: process.env.AGENTS_CHAT_AUTO_APPROVE !== '0',
301
- port: PORT
301
+ port: PORT,
302
+ // 数据损坏保护:损坏数据文件清单(已备份 .corrupt-*,写入已冻结,需人工处理)
303
+ corruptedDataFiles: store.getCorruptedFiles()
302
304
  });
303
305
  return;
304
306
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iamsamyiok/agents-chat",
3
- "version": "3.21.0",
3
+ "version": "3.22.0",
4
4
  "description": "多智能体群聊工具 - 支持 OpenCode/Claude Code/Codex/pi 内核,微信风格聊天界面",
5
5
  "main": "lib/start.js",
6
6
  "bin": {
@@ -34,6 +34,6 @@
34
34
  "homepage": "https://github.com/iamsamyiok/agents-chat",
35
35
  "scripts": {
36
36
  "start": "node app/server.js",
37
- "test": "echo \"Error: no test specified\" && exit 1"
37
+ "test": "node --test \"test/*.test.js\""
38
38
  }
39
39
  }