@iamsamyiok/agents-chat 3.21.0 → 3.23.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
  };
@@ -14,10 +14,18 @@
14
14
  *{box-sizing:border-box}
15
15
  html,body{height:100%}
16
16
  body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;
17
- background:var(--bg);color:var(--txt);font-size:14px;display:flex;flex-direction:column;overflow:hidden}
17
+ background:var(--bg);color:var(--txt);font-size:14px;display:flex;flex-direction:column;overflow:hidden;
18
+ -webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
19
+ /* 细滚动条 + 键盘焦点环(与群聊页统一) */
20
+ ::-webkit-scrollbar{width:6px;height:6px}
21
+ ::-webkit-scrollbar-thumb{background:rgba(0,0,0,.16);border-radius:3px}
22
+ ::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.3)}
23
+ ::-webkit-scrollbar-track,::-webkit-scrollbar-corner{background:transparent}
24
+ html{scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.16) transparent}
25
+ button:focus-visible,a:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible{outline:2px solid rgba(16,174,255,.55);outline-offset:1px}
18
26
  header{display:flex;align-items:center;gap:12px;padding:12px 18px;background:var(--panel);border-bottom:1px solid var(--line);position:sticky;top:0;z-index:10}
19
27
  header h1{font-size:16px;margin:0;font-weight:600}
20
- .backlink{font-size:13px;color:var(--acc);text-decoration:none;border:1px solid var(--acc);border-radius:6px;padding:5px 10px}
28
+ .backlink{font-size:13px;color:var(--acc);text-decoration:none;border:1px solid var(--acc);border-radius:6px;padding:5px 10px;transition:background .15s}
21
29
  .backlink:hover{background:#f0faf4}
22
30
  .sp{flex:1}
23
31
  .btn{background:var(--panel);color:var(--txt);border:1px solid var(--line);border-radius:8px;padding:7px 12px;cursor:pointer;font-size:13px;transition:.15s}
@@ -29,7 +37,20 @@
29
37
  .btn.danger{border-color:var(--err);color:var(--err)}
30
38
  .btn:disabled{opacity:.45;cursor:not-allowed}
31
39
  .status-pill{font-size:12px;color:var(--dim)}
32
- .ws-box{display:flex;align-items:center;gap:6px;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:5px 8px}
40
+ .filter-box{display:flex;align-items:center;gap:4px;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:5px 8px;transition:border-color .15s}
41
+ .filter-box input{border:none;outline:none;font-size:12px;width:130px;background:transparent;color:inherit}
42
+ .filter-box:focus-within{border-color:var(--acc2)}
43
+ /* 详情复制按钮 */
44
+ .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}
45
+ .copy-btn:hover{color:var(--acc2);border-color:var(--acc2)}
46
+ .copy-btn.done{color:#2f9e44;border-color:#2f9e44}
47
+ /* 看板统计条:总览 + 完成率进度 */
48
+ #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)}
49
+ #boardStats b{color:inherit}
50
+ .done-bar{flex:1;height:6px;border-radius:3px;background:var(--line);overflow:hidden;max-width:360px}
51
+ .done-bar i{display:block;height:100%;background:linear-gradient(90deg,var(--acc2),#2f9e44);border-radius:3px;transition:width .4s}
52
+ .ws-box{display:flex;align-items:center;gap:6px;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:5px 8px;transition:border-color .15s}
53
+ .ws-box:focus-within{border-color:var(--acc)}
33
54
  .ws-box input{border:none;outline:none;font-size:12px;width:200px;background:transparent}
34
55
  .ws-box .ws-save{font-size:12px;color:var(--acc);cursor:pointer;white-space:nowrap}
35
56
  .ws-box .ws-pick{font-size:12px;color:var(--acc2);cursor:pointer;white-space:nowrap}
@@ -57,8 +78,8 @@
57
78
  .col .drop-zone{flex:1;overflow-y:auto;min-height:0}
58
79
  .col h2{font-size:13px;margin:0 0 10px;color:var(--dim);display:flex;align-items:center;gap:8px;text-transform:uppercase;letter-spacing:.5px}
59
80
  .dot{width:8px;height:8px;border-radius:50%}
60
- .card{background:#fafbfc;border:1px solid var(--line);border-radius:10px;padding:10px 12px;margin-bottom:10px;cursor:grab;transition:.12s;position:relative}
61
- .card:hover{border-color:var(--acc);box-shadow:0 2px 8px rgba(0,0,0,.08)}
81
+ .card{background:#fafbfc;border:1px solid var(--line);border-radius:10px;padding:10px 12px;margin-bottom:10px;cursor:grab;transition:.14s;position:relative}
82
+ .card:hover{border-color:var(--acc);box-shadow:0 3px 10px rgba(0,0,0,.09);transform:translateY(-1px)}
62
83
  .card.dragging{opacity:.4}
63
84
  .card.drag-over{border-color:var(--acc);border-style:dashed}
64
85
  .card .title{font-weight:600;margin-bottom:6px;line-height:1.4;word-break:break-word;padding-right:150px}
@@ -74,9 +95,11 @@
74
95
  .icon-btn{background:transparent;border:1px solid var(--line);color:var(--dim);border-radius:6px;width:24px;height:24px;cursor:pointer;font-size:12px;line-height:1}
75
96
  .icon-btn:hover{color:var(--acc);border-color:var(--acc)}
76
97
  .empty{color:var(--dim);font-size:12px;text-align:center;padding:18px 0}
77
- .mask{position:fixed;inset:0;background:rgba(0,0,0,.45);display:none;align-items:center;justify-content:center;z-index:50}
78
- .mask.show{display:flex}
79
- .modal{background:var(--panel);border:1px solid var(--line);border-radius:14px;width:min(680px,94vw);max-height:90vh;overflow:auto;padding:20px;box-shadow:var(--shadow)}
98
+ .mask{position:fixed;inset:0;background:rgba(0,0,0,.45);display:none;align-items:center;justify-content:center;z-index:50;backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px)}
99
+ .mask.show{display:flex;animation:fade-in .18s ease}
100
+ @keyframes fade-in{from{opacity:0}to{opacity:1}}
101
+ @keyframes modal-in{from{opacity:0;transform:translateY(14px) scale(.985)}to{opacity:1;transform:none}}
102
+ .modal{background:var(--panel);border:1px solid var(--line);border-radius:14px;width:min(680px,94vw);max-height:90vh;overflow:auto;padding:20px;box-shadow:var(--shadow);animation:modal-in .24s cubic-bezier(.2,.7,.3,1)}
80
103
  .modal h3{margin:0 0 14px;font-size:16px}
81
104
  label{display:block;font-size:12px;color:var(--dim);margin:10px 0 4px}
82
105
  input,textarea,select{width:100%;background:#fff;border:1px solid var(--line);color:var(--txt);border-radius:8px;padding:8px 10px;font-size:13px;font-family:inherit}
@@ -175,6 +198,9 @@
175
198
  <h1>🗂️ 多任务编排</h1>
176
199
  <span class="status-pill" id="statusPill">空闲</span>
177
200
  <span class="kernel-chip" id="kernelChip" style="display:none"></span>
201
+ <div class="filter-box" title="按标题或内容即时过滤看板">
202
+ 🔍 <input id="cardFilter" placeholder="搜索任务…" autocomplete="off" />
203
+ </div>
178
204
  <span class="sp"></span>
179
205
  <div class="ws-box" title="工作区:选定后 Agent 在该目录读写相关文件(可选)">
180
206
  📁 <input id="wsInput" placeholder="工作文件夹(点右侧选择)" />
@@ -196,6 +222,13 @@
196
222
 
197
223
  <div class="procbar" id="procBar"><span class="ptitle">进程:</span><span class="proc-empty">无运行中的 opencode 进程</span></div>
198
224
 
225
+ <div id="boardStats">
226
+ <b id="stTotal">0</b> 项任务
227
+ <span id="stDoneRate">完成率 0%</span>
228
+ <div class="done-bar"><i id="stDoneBar" style="width:0%"></i></div>
229
+ <span id="stFailed" style="color:#c92a2a"></span>
230
+ </div>
231
+
199
232
  <main>
200
233
  <!-- 左侧:依赖图面板(约 1/4 宽,可收起) -->
201
234
  <aside id="graphPanel">
@@ -264,7 +297,7 @@
264
297
  <div class="detail-err" id="dError" style="display:none"></div>
265
298
  <label>执行过程(实时)</label>
266
299
  <div class="detail-log" id="dLog"><span class="empty">暂无过程记录</span></div>
267
- <label>最终结果</label>
300
+ <label>最终结果 <button class="copy-btn" id="dResultCopy" title="复制完整结果文本">复制</button></label>
268
301
  <div class="detail-result" id="dResult"><span class="empty">尚未产生结果</span></div>
269
302
  <div class="followup-box" id="fuBox">
270
303
  <div class="fu-head">💬 追加聊天 <span class="fu-st" id="fuState"></span></div>
@@ -332,6 +365,23 @@ async function loadCards(){
332
365
  loadTrashCount();
333
366
  }
334
367
 
368
+ // 文本复制(优先剪贴板 API,旧环境回退 execCommand)
369
+ function copyText(text, btn){
370
+ const done=()=>{ if(!btn) return; const old=btn.textContent; btn.textContent='已复制'; btn.classList.add('done'); setTimeout(()=>{ btn.textContent=old; btn.classList.remove('done'); },1200); };
371
+ const s=String(text==null?'':text);
372
+ if(navigator.clipboard && navigator.clipboard.writeText){ navigator.clipboard.writeText(s).then(done).catch(()=>{ fallbackCopy(s); done(); }); }
373
+ else { fallbackCopy(s); done(); }
374
+ }
375
+ function fallbackCopy(s){
376
+ const ta=document.createElement('textarea');
377
+ ta.value=s; ta.style.cssText='position:fixed;opacity:0';
378
+ document.body.appendChild(ta); ta.select();
379
+ try{ document.execCommand('copy'); }catch(e){}
380
+ document.body.removeChild(ta);
381
+ }
382
+ // 搜索过滤:输入即时重渲染
383
+ $('#cardFilter').addEventListener('input', render);
384
+
335
385
  // 内核状态展示:正常=头部小徽标;缺失/演示模式/数据保护=醒目横幅
336
386
  function renderKernelBanner(){
337
387
  const chip=$('#kernelChip'), banner=$('#kernelBanner');
@@ -474,10 +524,20 @@ document.addEventListener('dragover', e=>{ window.__dropY = e.clientY; }, true);
474
524
  function render(){
475
525
  const cols = {pending:$('#colPending'),running:$('#colRunning'),done:$('#colDone'),failed:$('#colFailed')};
476
526
  for(const k in cols) cols[k].innerHTML='';
527
+ // 搜索过滤:按标题 + 内容即时匹配(空串 = 全部)
528
+ const q=(($('#cardFilter').value||'').trim().toLowerCase());
529
+ const shown = q ? CARDS.filter(c=>((c.title||'')+'\n'+(c.content||'')).toLowerCase().includes(q)) : CARDS;
477
530
  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)); }
531
+ for(const c of shown){ counts[c.status]=(counts[c.status]||0)+1; (cols[c.status]||cols.pending).appendChild(cardEl(c)); }
479
532
  $('#cntPending').textContent=counts.pending||''; $('#cntRunning').textContent=counts.running||'';
480
533
  $('#cntDone').textContent=counts.done||''; $('#cntFailed').textContent=counts.failed||'';
534
+ // 统计条:全部卡的总览(不受过滤影响)+ 完成率
535
+ const total=CARDS.length, doneN=CARDS.filter(c=>c.status==='done').length, failedN=CARDS.filter(c=>c.status==='failed').length;
536
+ $('#stTotal').textContent=total;
537
+ const pct= total? Math.round(doneN/total*100):0;
538
+ $('#stDoneRate').textContent=`完成率 ${pct}%(${doneN}/${total})`;
539
+ $('#stDoneBar').style.width=pct+'%';
540
+ $('#stFailed').textContent= failedN? `失败 ${failedN}` : '';
481
541
  // 空看板引导:一个任务都没有时给出上手入口(含示例)
482
542
  if(!CARDS.length){
483
543
  cols.pending.innerHTML=`<div class="board-guide">
@@ -490,6 +550,12 @@ function render(){
490
550
  for(const k of ['running','done','failed']) cols[k].innerHTML='<div class="empty">—</div>';
491
551
  return;
492
552
  }
553
+ // 搜索无匹配:给出明确反馈与一键清除
554
+ if(q && !shown.length){
555
+ 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>`;
556
+ for(const k of ['running','done','failed']) cols[k].innerHTML='<div class="empty">—</div>';
557
+ return;
558
+ }
493
559
  for(const k in cols){ if(!cols[k].children.length) cols[k].innerHTML='<div class="empty">—</div>'; }
494
560
  }
495
561
 
@@ -698,6 +764,8 @@ async function openDetail(id){
698
764
  log.scrollTop=log.scrollHeight;
699
765
  const res=$('#dResult');
700
766
  if(card.result) res.textContent=card.result; else res.innerHTML='<span class="empty">尚未产生结果</span>';
767
+ const rCopy=$('#dResultCopy');
768
+ if(rCopy){ rCopy.disabled=!card.result; rCopy.title=card.result?'复制完整结果文本':'尚无结果可复制'; rCopy.onclick=()=>copyText(card.result||'', rCopy); }
701
769
  $('#dRun').style.display = (card.status==='running')?'none':'inline-block';
702
770
  // 追加聊天:任务结束(非 pending/running)即可用;无会话时提示限制
703
771
  const fuOk = card.status!=='pending' && card.status!=='running';
@@ -10,7 +10,16 @@
10
10
  body {
11
11
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
12
12
  background: #f2f2f2; color: #191919; overflow: hidden;
13
- }
13
+ -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility;
14
+ }
15
+ /* 细滚动条:与界面精致度一致(Chrome/Safari + Firefox) */
16
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
17
+ ::-webkit-scrollbar-thumb { background: rgba(0,0,0,.16); border-radius: 3px; }
18
+ ::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,.3); }
19
+ ::-webkit-scrollbar-track, ::-webkit-scrollbar-corner { background: transparent; }
20
+ html { scrollbar-width: thin; scrollbar-color: rgba(0,0,0,.16) transparent; }
21
+ /* 键盘导航焦点环:统一品牌绿描边 */
22
+ button:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible { outline: 2px solid rgba(7,193,96,.55); outline-offset: 1px; }
14
23
  #app { display: flex; height: 100vh; max-width: 1240px; margin: 0 auto; background: #fff; box-shadow: 0 0 24px rgba(0,0,0,.08); }
15
24
 
16
25
  /* ---------- 左侧:会话列表(微信风格:主会话 + 任务会话) ---------- */
@@ -19,13 +28,13 @@
19
28
  .side-header h2 { font-size: 15px; font-weight: 600; display: flex; align-items: center; gap: 6px; }
20
29
  .badge { font-size: 11px; background: #fa5151; color: #fff; border-radius: 8px; padding: 0 6px; line-height: 16px; }
21
30
  .side-actions { display: flex; gap: 6px; }
22
- .mini-btn { font-size: 12px; color: #07c160; border: 1px solid #07c160; background: #fff; border-radius: 4px; padding: 3px 8px; cursor: pointer; }
31
+ .mini-btn { font-size: 12px; color: #07c160; border: 1px solid #07c160; background: #fff; border-radius: 6px; padding: 3px 8px; cursor: pointer; transition: background .15s, opacity .15s; }
23
32
  .mini-btn.gray { color: #888; border-color: #ddd; }
24
- .mini-btn:hover { opacity: .8; }
33
+ .mini-btn:hover { opacity: .85; background: #f0faf4; }
25
34
 
26
35
  .task-empty { color: #b0b0b0; font-size: 13px; text-align: center; padding: 30px 20px; line-height: 1.8; }
27
36
  .list-sep { font-size: 11px; color: #b2b2b2; padding: 10px 14px 4px; background: #fff; position: sticky; top: 0; z-index: 1; }
28
- .task-item { display: flex; gap: 10px; padding: 12px 14px; border-bottom: 1px solid #f5f5f5; cursor: pointer; align-items: center; position: relative; }
37
+ .task-item { display: flex; gap: 10px; padding: 12px 14px; border-bottom: 1px solid #f5f5f5; cursor: pointer; align-items: center; position: relative; transition: background .15s; }
29
38
  .task-item:hover { background: #f7f7f7; }
30
39
  .task-item.active { background: #eef7f0; }
31
40
  /* 未读绿点:会话有未查看的完成消息 */
@@ -61,11 +70,19 @@
61
70
  .runner-tag.opencode { color: #07c160; background: #eef7f0; }
62
71
  .runner-tag.demo { color: #c2821d; background: #faf3e3; }
63
72
  .runner-tag.missing { color: #d54941; background: #fbeceb; }
64
- .cfg-btn { font-size: 12px; color: #07c160; border: 1px solid #07c160; background: #fff; border-radius: 4px; padding: 4px 10px; cursor: pointer; white-space: nowrap; }
73
+ .cfg-btn { font-size: 12px; color: #07c160; border: 1px solid #07c160; background: #fff; border-radius: 6px; padding: 4px 10px; cursor: pointer; white-space: nowrap; transition: background .15s; }
65
74
  .cfg-btn:hover { background: #f0faf4; }
66
75
  .hdr-right { display: flex; align-items: center; gap: 10px; }
67
76
 
68
77
  #messages { flex: 1; overflow-y: auto; padding: 16px 18px; }
78
+ /* 消息复制按钮:hover 消息行时浮现 */
79
+ .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; }
80
+ .msg-row:hover .copy-btn, .copy-btn:focus { opacity: 1; }
81
+ .copy-btn:hover { background: rgba(0,0,0,.1); color: #333; }
82
+ .copy-btn.done { color: #07c160; opacity: 1; }
83
+ /* 回到最新:阅读历史时新消息不再强制拽底,悬浮按钮一键回底 */
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
+ #jumpBtn:hover { color: #07c160; border-color: #07c160; }
69
86
  .sys-tip { text-align: center; margin: 10px 0; }
70
87
  .approval-card { max-width: 480px; margin: 10px auto; background: #fff8e6; border: 1px solid #e8a33d; border-radius: 10px; padding: 10px 14px; text-align: left; }
71
88
  .approval-card .ap-title { font-weight: 600; font-size: 13px; color: #8a5a00; margin-bottom: 4px; }
@@ -75,8 +92,12 @@
75
92
  .approval-card .ap-ok { background: #07c160; color: #fff; }
76
93
  .approval-card .ap-no { background: #fa5151; color: #fff; }
77
94
  .approval-card .ap-done { font-size: 12px; color: #888; text-align: center; padding: 2px 0; }
78
- .sys-tip span { font-size: 12px; color: #9e9e9e; background: #e8e8e8; border-radius: 4px; padding: 3px 10px; display: inline-block; max-width: 80%; }
79
- .msg-row { display: flex; gap: 10px; margin: 14px 0; align-items: flex-start; }
95
+ .sys-tip span { font-size: 12px; color: #9e9e9e; background: #e8e8e8; border-radius: 8px; padding: 3px 10px; display: inline-block; max-width: 80%; animation: msg-in .18s ease; }
96
+ #messages.bulk .sys-tip span { animation: none; }
97
+ .msg-row { display: flex; gap: 10px; margin: 14px 0; align-items: flex-start; animation: msg-in .18s ease; }
98
+ /* 批量重建(切换会话/打开历史)时抑制逐条动画,避免满屏闪烁 */
99
+ #messages.bulk .msg-row, #messages.bulk .sys-tip { animation: none; }
100
+ @keyframes msg-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
80
101
  .msg-row.self { flex-direction: row-reverse; }
81
102
  .avatar { width: 38px; height: 38px; border-radius: 6px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 14px; }
82
103
  .avatar.me { background: #57be6a; }
@@ -108,8 +129,8 @@
108
129
 
109
130
  .composer { background: #fff; border-top: 1px solid #e7e7e7; padding: 12px 14px; position: relative; }
110
131
  .input-row { display: flex; gap: 10px; }
111
- #input, #soloInput { flex: 1; border: 1px solid #e2e2e2; border-radius: 6px; padding: 10px 12px; font-size: 14px; outline: none; }
112
- #input:focus, #soloInput:focus { border-color: #07c160; }
132
+ #input, #soloInput { flex: 1; border: 1px solid #e2e2e2; border-radius: 6px; padding: 10px 12px; font-size: 14px; outline: none; transition: border-color .15s, box-shadow .15s; background: #fff; }
133
+ #input:focus, #soloInput:focus { border-color: #07c160; box-shadow: 0 0 0 3px rgba(7,193,96,.12); }
113
134
  #sendBtn, #soloSendBtn { background: #07c160; color: #fff; border: none; border-radius: 6px; padding: 0 22px; font-size: 14px; cursor: pointer; }
114
135
  #rtBtn { background: #f2f3f5; color: #555; border: none; border-radius: 6px; padding: 0 14px; font-size: 13px; cursor: pointer; white-space: nowrap; }
115
136
  #rtBtn:hover { background: #e8eaee; }
@@ -182,6 +203,12 @@
182
203
 
183
204
  /* ---------- 弹窗 ---------- */
184
205
  #modalMask, #batchMask, #agentsMask, #historyMask, #helpMask, #flowMask, #cardMask, #teamsMask, #memMask { position: fixed; inset: 0; background: rgba(0,0,0,.4); display: none; align-items: center; justify-content: center; z-index: 51; }
206
+ /* 弹窗出现动画:遮罩淡入(含毛玻璃)+ 内容上浮,全站弹窗统一节奏 */
207
+ div[id$="Mask"] { backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px); }
208
+ div[id$="Mask"].show { animation: fade-in .18s ease; }
209
+ .modal, .preview-card { animation: modal-in .24s cubic-bezier(.2,.7,.3,1); }
210
+ @keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
211
+ @keyframes modal-in { from { opacity: 0; transform: translateY(14px) scale(.985); } to { opacity: 1; transform: none; } }
185
212
  #modalMask.show, #batchMask.show, #agentsMask.show, #historyMask.show, #helpMask.show, #flowMask.show, #cardMask.show, #teamsMask.show, #memMask.show { display: flex; }
186
213
  /* 团队仓库卡片 */
187
214
  .team-card { border: 1px solid #e5e7eb; border-radius: 10px; padding: 14px 16px; margin-bottom: 12px; }
@@ -413,8 +440,9 @@
413
440
  <button class="cfg-btn" id="helpBtn" onclick="openHelp()" title="使用帮助">❓ 帮助</button>
414
441
  </div>
415
442
  </header>
416
- <div id="groupView" style="flex:1;min-height:0;display:flex;flex-direction:column">
443
+ <div id="groupView" style="flex:1;min-height:0;display:flex;flex-direction:column;position:relative">
417
444
  <div id="messages"></div>
445
+ <button id="jumpBtn" onclick="jumpBottom()" title="有新消息了,点击回到最新">↓ 新消息</button>
418
446
  <footer class="composer" id="groupComposer">
419
447
  <div class="input-row">
420
448
  <button id="rtBtn" onclick="toggleRoundtable()" title="圆桌讨论模式:所有被 @ 的智能体(未 @ 则全体)围绕主题轮流发言、互相反驳,管家当主持人控制轮数并总结共识与分歧">💬 圆桌</button>
@@ -666,7 +694,7 @@ let curOcModel = ''; // 当前选中模型(localStorage 记忆)
666
694
 
667
695
  const input = document.getElementById('input');
668
696
 
669
- function esc(s) { const d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML; }
697
+ function esc(s) { const d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;'); }
670
698
  function escAttr(s) { return String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;'); }
671
699
  function fmtTime(ts) { const d = new Date(ts); return String(d.getHours()).padStart(2,'0') + ':' + String(d.getMinutes()).padStart(2,'0'); }
672
700
  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 +759,17 @@ async function init() {
731
759
 
732
760
  input.addEventListener('input', handleAtInput);
733
761
  input.addEventListener('blur', () => setTimeout(hideAt, 150));
762
+ // 消息区滚动:贴底时收起「新消息」悬浮按钮
763
+ document.getElementById('messages').addEventListener('scroll', () => {
764
+ if (atBottom()) { const j = document.getElementById('jumpBtn'); if (j) j.style.display = 'none'; }
765
+ });
766
+ // 输入历史回填:输入框为空时按 ↑ 调出最近发送的一条(再按 Enter 直接重发)
767
+ input.addEventListener('keydown', (e) => {
768
+ if (e.key === 'ArrowUp' && input.value === '' && lastSentInputs.length) {
769
+ e.preventDefault();
770
+ input.value = lastSentInputs[lastSentInputs.length - 1];
771
+ }
772
+ });
734
773
  input.addEventListener('keydown', (e) => {
735
774
  if (atState) {
736
775
  if (e.key === 'ArrowDown') { e.preventDefault(); atState.active = (atState.active + 1) % atState.items.length; renderAt(); return; }
@@ -897,7 +936,7 @@ function soloMsgNode(m) {
897
936
  row.className = 'msg-row self';
898
937
  row.innerHTML = `
899
938
  <div class="avatar me">我</div>
900
- <div class="msg-body"><div class="bubble right">${esc(m.content)}</div></div>`;
939
+ <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
940
  return row;
902
941
  }
903
942
  // assistant:Markdown 渲染(opencode 输出天然是 Markdown)
@@ -906,7 +945,7 @@ function soloMsgNode(m) {
906
945
  row.innerHTML = `
907
946
  <div class="avatar solo-av">🤖</div>
908
947
  <div class="msg-body">
909
- <div class="msg-name">${esc(m.agentName || 'OpenCode')}</div>
948
+ <div class="msg-name">${esc(m.agentName || 'OpenCode')} <button class="copy-btn" onclick="copyText(decodeURIComponent('${encodeURIComponent(m.content || '')}'), this)" title="复制原始文本(含 Markdown 源码)">复制</button></div>
910
949
  <div class="bubble left solo-bubble"><div class="solo-part-md">${renderMd(m.content || '')}</div></div>
911
950
  </div>`;
912
951
  return row;
@@ -1542,6 +1581,30 @@ function renderHeader() {
1542
1581
 
1543
1582
  // ---------- 消息渲染 ----------
1544
1583
  function scrollBottom() { const box = document.getElementById('messages'); box.scrollTop = box.scrollHeight; }
1584
+ // 贴底判定:滚离底部超过 80px 视为「正在阅读历史」,新消息不再强制拽底
1585
+ function atBottom() { const b = document.getElementById('messages'); return !b || (b.scrollHeight - b.scrollTop - b.clientHeight < 80); }
1586
+ // 跟随滚动:贴底时自动滚到最新;阅读历史时改亮「新消息」按钮
1587
+ function followScroll() {
1588
+ if (atBottom()) scrollBottom();
1589
+ else { const j = document.getElementById('jumpBtn'); if (j) j.style.display = 'block'; }
1590
+ }
1591
+ function jumpBottom() { scrollBottom(); const j = document.getElementById('jumpBtn'); if (j) j.style.display = 'none'; }
1592
+
1593
+ // 文本复制(优先剪贴板 API,旧环境回退 execCommand),按钮成功后短暂反馈
1594
+ function copyText(text, btn) {
1595
+ const done = () => { if (!btn) return; const old = btn.textContent; btn.textContent = '已复制'; btn.classList.add('done'); setTimeout(() => { btn.textContent = old; btn.classList.remove('done'); }, 1200); };
1596
+ const s = String(text == null ? '' : text);
1597
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1598
+ navigator.clipboard.writeText(s).then(done).catch(() => { fallbackCopy(s); done(); });
1599
+ } else { fallbackCopy(s); done(); }
1600
+ }
1601
+ function fallbackCopy(s) {
1602
+ const ta = document.createElement('textarea');
1603
+ ta.value = s; ta.style.cssText = 'position:fixed;opacity:0';
1604
+ document.body.appendChild(ta); ta.select();
1605
+ try { document.execCommand('copy'); } catch { /* ignore */ }
1606
+ document.body.removeChild(ta);
1607
+ }
1545
1608
 
1546
1609
  function planNode(m) {
1547
1610
  const row = document.createElement('div');
@@ -1559,7 +1622,7 @@ function planNode(m) {
1559
1622
  row.innerHTML = `
1560
1623
  <div class="avatar butler">🎩</div>
1561
1624
  <div class="msg-body">
1562
- <div class="msg-name">管家 · 调度规划</div>
1625
+ <div class="msg-name">管家 · 调度规划 <button class="copy-btn" onclick="copyText(decodeURIComponent('${encodeURIComponent((p && p.thought) || m.content || '')}'), this)" title="复制规划思路">复制</button></div>
1563
1626
  <div class="bubble left plan-card">${inner}</div>
1564
1627
  </div>`;
1565
1628
  return row;
@@ -1708,7 +1771,7 @@ function msgNode(m) {
1708
1771
  row.className = 'msg-row self';
1709
1772
  row.innerHTML = `
1710
1773
  <div class="avatar me">我</div>
1711
- <div class="msg-body"><div class="bubble right">${esc(m.content)}</div></div>`;
1774
+ <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
1775
  return row;
1713
1776
  }
1714
1777
  if (m.phase === 'plan') return planNode(m);
@@ -1722,7 +1785,7 @@ function msgNode(m) {
1722
1785
  row.innerHTML = `
1723
1786
  <div class="avatar ${m.agentId === butlerId ? 'butler' : ''}" style="${m.agentId === butlerId ? '' : `background:${colorOf(m.agentId || 'x')}`}">${avIcon}</div>
1724
1787
  <div class="msg-body">
1725
- <div class="msg-name">${esc(m.agentName || m.agentId || 'AI')}${m.agentId === butlerId ? ' · 管家' : ''}</div>
1788
+ <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
1789
  ${bodyHtml}
1727
1790
  ${m.outputPath ? `<div class="msg-file" ${/\.md$/i.test(m.outputPath) ? `data-path="${escAttr(m.outputPath)}" title="点击预览该 Markdown 存档"` : `title="系统过程存档:该智能体本阶段完整输出(内部交接用)。用户最终要的成果文件在产出存档目录中,以管家交付清单里的完整路径为准"`}>📄 过程存档 ${fileNodeText(m.outputPath)}</div>` : ''}
1728
1791
  </div>`;
@@ -1734,6 +1797,7 @@ function msgNode(m) {
1734
1797
  // 全量重绘当前会话(切换会话 / 载入历史时)
1735
1798
  function renderMessages() {
1736
1799
  const box = document.getElementById('messages');
1800
+ box.classList.add('bulk'); // 批量重建:抑制逐条入场动画
1737
1801
  box.innerHTML = '';
1738
1802
  for (const m of getSess(curKey())) {
1739
1803
  const node = msgNode(m);
@@ -1742,17 +1806,19 @@ function renderMessages() {
1742
1806
  finalizeMsgLinks(m); // 定稿消息中的 .md 路径变成可点击预览链接
1743
1807
  }
1744
1808
  scrollBottom();
1809
+ requestAnimationFrame(() => requestAnimationFrame(() => box.classList.remove('bulk')));
1810
+ const jb = document.getElementById('jumpBtn'); if (jb) jb.style.display = 'none';
1745
1811
  }
1746
1812
 
1747
1813
  // 追加或更新一条流式消息(仅当前会话操作 DOM)
1748
1814
  function ensureMsgDom(m) {
1749
1815
  const key = sessKey(m.taskId || curTaskId);
1750
1816
  if (key !== curKey()) return;
1751
- if (m._dom && document.contains(m._dom)) { if (m._tx) m._tx.textContent = m.content; scrollBottom(); return; }
1817
+ if (m._dom && document.contains(m._dom)) { if (m._tx) m._tx.textContent = m.content; followScroll(); return; }
1752
1818
  const node = msgNode(m);
1753
1819
  m._dom = node;
1754
1820
  document.getElementById('messages').appendChild(node);
1755
- scrollBottom();
1821
+ followScroll();
1756
1822
  }
1757
1823
 
1758
1824
  function pushMsg(m) {
@@ -1851,7 +1917,7 @@ function onSaved(ev) {
1851
1917
  }
1852
1918
  file.innerHTML = '📄 过程存档 ' + fileNodeText(ev.path);
1853
1919
  m._dom.querySelector('.msg-body').appendChild(file);
1854
- scrollBottom();
1920
+ followScroll();
1855
1921
  }
1856
1922
  finalizeMsgLinks(m); // 交付文本中的成果文件路径也变成可点击链接
1857
1923
  return;
@@ -1940,6 +2006,7 @@ async function stopRun(scope) {
1940
2006
  } catch { toast('停止请求失败,请重试'); }
1941
2007
  }
1942
2008
 
2009
+ let lastSentInputs = []; // 输入历史(空输入框按 ↑ 回填最近一条)
1943
2010
  async function send() {
1944
2011
  const text = input.value.trim();
1945
2012
  if (!text || chatBusy) return;
@@ -1950,6 +2017,10 @@ async function send() {
1950
2017
  try {
1951
2018
  input.value = '';
1952
2019
  hideAt();
2020
+ if (lastSentInputs[lastSentInputs.length - 1] !== text) {
2021
+ lastSentInputs.push(text);
2022
+ if (lastSentInputs.length > 20) lastSentInputs.shift();
2023
+ }
1953
2024
  pushUser(text);
1954
2025
  typing = addSys('智能体处理中…');
1955
2026
 
@@ -2131,7 +2202,7 @@ async function openHistory() {
2131
2202
  if (m.role === 'sys') {
2132
2203
  html += `<div class="sys-tip"><span>${esc(m.content)}</span></div>`;
2133
2204
  } 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>`;
2205
+ 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
2206
  } else if (m.phase === 'plan' && m.plan) {
2136
2207
  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
2208
  } else {
@@ -2140,7 +2211,7 @@ async function openHistory() {
2140
2211
  const contentHtml = isFoldPhase(m.phase)
2141
2212
  ? `<div class="bubble left"><div class="fold-wrap">${tag}${linkifyMd(esc(m.content))}</div><div class="fold-toggle" onclick="toggleFoldEl(this)">展开全文 ▾</div></div>`
2142
2213
  : `<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>`;
2214
+ 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
2215
  }
2145
2216
  }
2146
2217
  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.23.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
  }