@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 +21 -18
- package/app/lib/orchestrator.js +3 -1
- package/app/lib/safejson.js +34 -0
- package/app/lib/store.js +110 -29
- package/app/public/cards.html +58 -2
- package/app/public/index.html +62 -12
- package/app/server.js +3 -1
- package/package.json +2 -2
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
|
-
//
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
166
|
-
const ordered = ids.map(id =>
|
|
167
|
-
const
|
|
168
|
-
|
|
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; }
|
package/app/lib/orchestrator.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
449
|
-
if (taskId === undefined)
|
|
450
|
-
|
|
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
|
-
|
|
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(
|
|
532
|
+
writeJson(msgShardPath(key), msgs);
|
|
472
533
|
}
|
|
473
534
|
|
|
474
535
|
function clearMessages() {
|
|
475
|
-
|
|
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
|
-
|
|
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.
|
|
686
|
+
// 3. 消息(分片):孤儿会话分片整文件删除;主会话分片按 timestamp 过滤
|
|
618
687
|
const validIds = new Set([...keptTasks.map(t => t.id), ...keptSess.map(s => s.id)]);
|
|
619
|
-
|
|
620
|
-
const
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
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
|
};
|
package/app/public/cards.html
CHANGED
|
@@ -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
|
|
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
|
|
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';
|
package/app/public/index.html
CHANGED
|
@@ -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, '"').replace(/'/g, '''); }
|
|
670
679
|
function escAttr(s) { return String(s == null ? '' : s).replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<'); }
|
|
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')}
|
|
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">管家 ·
|
|
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 ? ' · 管家' : ''}
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
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')}
|
|
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.
|
|
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": "
|
|
37
|
+
"test": "node --test \"test/*.test.js\""
|
|
38
38
|
}
|
|
39
39
|
}
|