@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 +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 +78 -10
- package/app/public/index.html +92 -21
- 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
|
@@ -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
|
-
.
|
|
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:.
|
|
61
|
-
.card:hover{border-color:var(--acc);box-shadow:0
|
|
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
|
-
|
|
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
|
|
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
|
|
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';
|
package/app/public/index.html
CHANGED
|
@@ -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:
|
|
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: .
|
|
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:
|
|
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:
|
|
79
|
-
.
|
|
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, '"').replace(/'/g, '''); }
|
|
670
698
|
function escAttr(s) { return String(s == null ? '' : s).replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<'); }
|
|
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')}
|
|
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">管家 ·
|
|
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 ? ' · 管家' : ''}
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
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')}
|
|
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.
|
|
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": "
|
|
37
|
+
"test": "node --test \"test/*.test.js\""
|
|
38
38
|
}
|
|
39
39
|
}
|