@iamsamyiok/agents-chat 3.33.0 → 3.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example CHANGED
@@ -24,3 +24,8 @@
24
24
  # AGENTS_CHAT_OPENCODE_CMD=
25
25
  # 单任务超时毫秒(默认 10 分钟)
26
26
  # AGENTS_CHAT_TIMEOUT_MS=600000
27
+ # AI 组队生成超时(毫秒,默认 120000;真实内核生成整队提示词耗时较长)
28
+ # AGENTS_CHAT_SUGGEST_TIMEOUT_MS=120000
29
+ # AI 编排(卡牌控制台聊天编排)超时:对话(默认 120000)与生成清单(默认 180000,输出量大)
30
+ # AGENTS_CHAT_PLANNER_CHAT_TIMEOUT_MS=120000
31
+ # AGENTS_CHAT_PLANNER_PLAN_TIMEOUT_MS=180000
package/README.md CHANGED
@@ -26,6 +26,8 @@ npm install -g opencode-ai
26
26
  | 能力 | 说明 |
27
27
  |---|---|
28
28
  | 群聊编排 | 直接输入需求,管家智能体拆解成阶段计划,调度子智能体并行执行,自动验收不合格返工 |
29
+ | AI 组队 | 一句话描述需求,执行内核自动生成整套智能体团队(名称/图标/技能/提示词),预览后合并或替换应用 |
30
+ | AI 编排 | 多任务编排页「🪄 AI 编排」聊天式编排:描述目标,AI 反问澄清后一键生成带依赖与并行的任务清单,编辑预览后导入看板 |
29
31
  | 批量任务 | 粘贴一段文本一次导入多条任务(`1. xxx` 每行一条),可拖拽排序、批量执行 |
30
32
  | 定时任务 | 行首写时间即定时(`2026-08-18 13:07 生成日报`),到点自动执行,停机错过的启动时补跑,可开机自启 |
31
33
  | Git 隔离 | 导入时勾选「Git 隔离执行」:每任务独立 worktree 分支互不污染,完成后逐文件看 diff、一键合并或丢弃 |
@@ -33,6 +35,7 @@ npm install -g opencode-ai
33
35
  | 完成通知 | 页面在后台弹系统通知;webhook 转发钉钉/飞书/自建中转 |
34
36
  | 单聊工作台 | 与 OpenCode 单体多轮续聊;任务链支持 `-` 接续上一会话、`//` 并行执行 |
35
37
  | 卡牌协作 | 每个智能体一张卡牌实时直播工作过程,可中途插话纠偏或委派转交 |
38
+ | 三模式标签 | 左上角「👤 单聊 / 👥 群聊 / 🎛 编排」一键切换(编排控制台内嵌主界面),每个标签实时显示完成进度与未读绿点提醒 |
36
39
 
37
40
  ## 安装方式
38
41
 
@@ -84,7 +87,7 @@ OpenCode(推荐)/ Claude Code / Codex CLI / pi,页面右上角可切换。
84
87
  git clone https://github.com/iamsamyiok/agents-chat
85
88
  cd agents-chat && npm install
86
89
  AGENTS_CHAT_MOCK=1 npm start # 演示模式开发,无需内核
87
- npm test # 66 个测试用例
90
+ npm test # 92 个测试用例
88
91
  ```
89
92
 
90
93
  零运行时依赖,Node 原生模块实现(HTTP/SSE/存储均为自研轻量层)。
@@ -0,0 +1,248 @@
1
+ // AI 智能编排:卡牌控制台的聊天式任务编排(多轮对话澄清 + 生成结构化清单)
2
+ // 会话持久化于 <DATA>/planner/<sid>.json;内核调用复用 agent.runAgent(runAgentFn 可注入便于测试)
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const agentMod = require('./agent');
6
+ const { extractJSONArray } = require('./teamgen');
7
+
8
+ const ROOT = path.join(__dirname, '..');
9
+ const DATA_DIR = process.env.AGENTS_CHAT_DATA || path.join(ROOT, '.data');
10
+ const PLANNER_DIR = path.join(DATA_DIR, 'planner');
11
+
12
+ const MSG_MAX = 4000; // 单条消息长度上限
13
+ const ROUNDS_MAX = 80; // messages 条数上限(约 40 轮),超出裁最旧
14
+ const PLAN_MIN = 2;
15
+ const PLAN_MAX = 12;
16
+ const CHAT_TIMEOUT_MS = Number(process.env.AGENTS_CHAT_PLANNER_CHAT_TIMEOUT_MS) > 0
17
+ ? Number(process.env.AGENTS_CHAT_PLANNER_CHAT_TIMEOUT_MS) : 120000;
18
+ const PLAN_TIMEOUT_MS = Number(process.env.AGENTS_CHAT_PLANNER_PLAN_TIMEOUT_MS) > 0
19
+ ? Number(process.env.AGENTS_CHAT_PLANNER_PLAN_TIMEOUT_MS) : 180000;
20
+
21
+ // 编排设计师人设:先澄清再拆解;任务模型对齐卡牌控制台(mode/deps/priority)
22
+ const PLANNER_SYSTEM = [
23
+ '你是一位资深项目经理与任务编排设计师,帮助用户把目标拆解为可执行的多任务编排。',
24
+ '',
25
+ '【对话方式】',
26
+ '1. 用户描述目标后,若关键信息缺失(做什么、技术栈/工具、范围边界、期望产出),先提出 1-3 个具体的澄清问题,不要凭空编造',
27
+ '2. 信息足够时,简要确认你的理解(一两句话),并提示用户可以点「生成编排」按钮产出任务清单',
28
+ '3. 回复简洁务实,不输出与编排无关的长篇内容;语言跟随用户语言',
29
+ '',
30
+ '【任务模型(生成清单时遵守,对话中不必展开)】',
31
+ '- 每个任务:title(一句话标题)、content(完整可独立执行的任务描述,包含必要上下文,不写「继续上面」这类脱离清单后无法理解的话)',
32
+ '- mode:new=新进程独立执行 / continue=同会话续聊(同一件事分步骤、需要记住前一步成果时用,并依赖前一步)/ parallel=并行独立进程(互不依赖可同时跑)',
33
+ '- deps:依赖任务的序号数组(1 起始),任务只能等依赖完成后执行',
34
+ '- 拆解原则:先准备后执行再检查验收;有依赖关系的按顺序;独立可并行的标记 parallel;任务粒度适中,总数 2-12 个'
35
+ ].join('\n');
36
+
37
+ // ---------- 会话存取 ----------
38
+
39
+ function ensureDir() {
40
+ try { fs.mkdirSync(PLANNER_DIR, { recursive: true }); } catch { /* ignore */ }
41
+ }
42
+
43
+ function sidValid(sid) {
44
+ return /^pl-[a-z0-9-]+$/i.test(String(sid || ''));
45
+ }
46
+
47
+ function sessionPath(sid) {
48
+ return path.join(PLANNER_DIR, `${sid}.json`);
49
+ }
50
+
51
+ function readSession(sid) {
52
+ if (!sidValid(sid)) return null;
53
+ try {
54
+ const s = JSON.parse(fs.readFileSync(sessionPath(sid), 'utf8'));
55
+ if (!s || !Array.isArray(s.messages)) return null;
56
+ return s;
57
+ } catch { return null; }
58
+ }
59
+
60
+ function writeSession(s) {
61
+ ensureDir();
62
+ // 轮次上限:超出裁最旧(保留最近 ROUNDS_MAX 条)
63
+ if (s.messages.length > ROUNDS_MAX) s.messages = s.messages.slice(-ROUNDS_MAX);
64
+ s.updatedAt = new Date().toISOString();
65
+ fs.writeFileSync(sessionPath(s.sid), JSON.stringify(s, null, 1));
66
+ }
67
+
68
+ function createSession() {
69
+ const sid = `pl-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
70
+ const s = { sid, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), messages: [] };
71
+ writeSession(s);
72
+ return s;
73
+ }
74
+
75
+ function latestSession() {
76
+ ensureDir();
77
+ let best = null;
78
+ for (const f of fs.readdirSync(PLANNER_DIR)) {
79
+ if (!f.endsWith('.json')) continue;
80
+ const s = readSession(f.slice(0, -5));
81
+ if (s && (!best || s.updatedAt > best.updatedAt)) best = s;
82
+ }
83
+ return best;
84
+ }
85
+
86
+ function deleteSession(sid) {
87
+ if (!sidValid(sid)) return false;
88
+ try { fs.unlinkSync(sessionPath(sid)); return true; } catch { return false; }
89
+ }
90
+
91
+ // 过期清理(随每日 prune 调用):删除 updatedAt 早于 days 天前的会话
92
+ function pruneStale(days) {
93
+ const keep = (Number(days) || 15) * 86400000;
94
+ const cut = Date.now() - keep;
95
+ let n = 0;
96
+ try {
97
+ ensureDir();
98
+ for (const f of fs.readdirSync(PLANNER_DIR)) {
99
+ if (!f.endsWith('.json')) continue;
100
+ const p = path.join(PLANNER_DIR, f);
101
+ try {
102
+ const st = fs.statSync(p);
103
+ if (st.mtimeMs < cut) { fs.unlinkSync(p); n++; }
104
+ } catch { /* ignore */ }
105
+ }
106
+ } catch { /* ignore */ }
107
+ return n;
108
+ }
109
+
110
+ // ---------- 内核调用(与 teamgen.suggestTeam 同模式:超时 stopScope + done 回调聚合) ----------
111
+
112
+ function runOnce(prompt, timeout, scope) {
113
+ return new Promise((resolve) => {
114
+ let settled = false;
115
+ let content = '';
116
+ let runError = '';
117
+ const timer = setTimeout(() => {
118
+ if (settled) return;
119
+ settled = true;
120
+ runError = `生成超时(${Math.round(timeout / 1000)} 秒),请稍后重试`;
121
+ try { agentMod.stopScope(scope); } catch { /* ignore */ }
122
+ resolve({ content, error: runError });
123
+ }, timeout);
124
+ try {
125
+ agentMod.runAgent(
126
+ { id: scope, name: scope, model: '', behavior: 'echo', systemPrompt: '' },
127
+ prompt,
128
+ (chunk) => {
129
+ if (chunk && chunk.content) content += chunk.content;
130
+ if (chunk && chunk.error) runError = runError || String(chunk.error);
131
+ if (chunk && chunk.done && !settled) { settled = true; clearTimeout(timer); resolve({ content, error: runError }); }
132
+ },
133
+ scope
134
+ );
135
+ } catch (e) {
136
+ if (!settled) { settled = true; clearTimeout(timer); resolve({ content: '', error: `调用内核失败:${e.message}` }); }
137
+ }
138
+ });
139
+ }
140
+
141
+ function historyText(messages) {
142
+ return messages.map(m => `${m.role === 'user' ? '用户' : '助手'}:${m.content}`).join('\n\n');
143
+ }
144
+
145
+ // 多轮对话:追加用户消息 → 内核回复(携带 system + 全部历史)→ 落盘
146
+ async function chat({ sid, message, runAgentFn, timeoutMs }) {
147
+ const s = sid ? readSession(sid) : null;
148
+ const sess = s || createSession();
149
+ if (runAgentFn) {
150
+ // 测试注入:替换 agentMod.runAgent
151
+ const orig = agentMod.runAgent;
152
+ agentMod.runAgent = runAgentFn;
153
+ try { return await doChat(sess, message, timeoutMs); }
154
+ finally { agentMod.runAgent = orig; }
155
+ }
156
+ return doChat(sess, message, timeoutMs);
157
+ }
158
+
159
+ async function doChat(sess, message, timeoutMs) {
160
+ const text = String(message || '').trim().slice(0, MSG_MAX);
161
+ const timeout = Number(timeoutMs) > 0 ? Number(timeoutMs) : CHAT_TIMEOUT_MS;
162
+ sess.messages.push({ role: 'user', content: text });
163
+ const prompt = `${PLANNER_SYSTEM}\n\n【对话记录】\n${historyText(sess.messages)}\n\n请以助手身份回复最后一条用户消息。`;
164
+ const r = await runOnce(prompt, timeout, 'planner-chat');
165
+ if (r.error) {
166
+ sess.messages.pop(); // 失败不污染历史(用户可重发)
167
+ return { sid: sess.sid, reply: '', error: r.error };
168
+ }
169
+ const reply = String(r.content || '').trim().slice(0, MSG_MAX * 2);
170
+ sess.messages.push({ role: 'assistant', content: reply });
171
+ writeSession(sess);
172
+ return { sid: sess.sid, reply };
173
+ }
174
+
175
+ // 生成编排清单:以历史为上下文追加生成指令 → 容错解析 → cleanPlan 清洗
176
+ async function plan({ sid, runAgentFn, timeoutMs }) {
177
+ const sess = readSession(sid);
178
+ if (!sess) return { error: '编排会话不存在,请先发送一条消息' };
179
+ if (!sess.messages.some(m => m.role === 'user')) return { error: '会话为空,请先描述你的目标' };
180
+ if (runAgentFn) {
181
+ const orig = agentMod.runAgent;
182
+ agentMod.runAgent = runAgentFn;
183
+ try { return await doPlan(sess, timeoutMs); }
184
+ finally { agentMod.runAgent = orig; }
185
+ }
186
+ return doPlan(sess, timeoutMs);
187
+ }
188
+
189
+ async function doPlan(sess, timeoutMs) {
190
+ const timeout = Number(timeoutMs) > 0 ? Number(timeoutMs) : PLAN_TIMEOUT_MS;
191
+ const instruct = [
192
+ '基于以上对话内容,生成最终任务编排清单。',
193
+ '只输出一个 JSON 数组(2-12 项),不要输出其他文字或代码块标记。每项字段:',
194
+ '- title:一句话标题(30 字内)',
195
+ '- content:完整可独立执行的任务描述(包含必要上下文与期望产出)',
196
+ '- mode:"new" | "continue" | "parallel"',
197
+ '- deps:依赖任务序号数组(1 起始,引用清单内其他任务,不含自身);mode 为 continue 时依赖其续聊的前一步',
198
+ '同会话续聊(continue)用于同一件事分步推进;互不依赖的可并行任务用 parallel 且 deps 为空。'
199
+ ].join('\n');
200
+ const prompt = `${PLANNER_SYSTEM}\n\n【对话记录】\n${historyText(sess.messages)}\n\n【当前指令】\n${instruct}`;
201
+ const r = await runOnce(prompt, timeout, 'planner-plan');
202
+ if (r.error) return { error: r.error };
203
+ const parsed = extractJSONArray(r.content);
204
+ if (!parsed) return { error: '生成结果无法解析为任务清单,请补充信息后重试' };
205
+ const items = cleanPlan(parsed);
206
+ if (items.length < PLAN_MIN) {
207
+ return { error: `生成结果只有 ${items.length} 个任务,至少需要 ${PLAN_MIN} 个,请补充更多细节后重试` };
208
+ }
209
+ // 生成记录落盘(便于继续对话时 LLM 知道已产出过清单;只存摘要防膨胀)
210
+ sess.messages.push({ role: 'user', content: '【生成编排清单】' });
211
+ sess.messages.push({ role: 'assistant', content: `已生成 ${items.length} 个任务的编排清单:\n${items.map((t, i) => `${i + 1}. ${t.title}(${t.mode}${t.deps.length ? ',依赖 ' + t.deps.join('/') : ''})`).join('\n')}\n如需调整请直接说明。` });
212
+ writeSession(sess);
213
+ return { plan: items };
214
+ }
215
+
216
+ // 清洗:字段归一 + deps 校验(1 起始序号、去自引用/悬空/重复)+ 数量截断
217
+ function cleanPlan(arr) {
218
+ if (!Array.isArray(arr)) return [];
219
+ const raw = arr.filter(x => x && typeof x === 'object');
220
+ const len = Math.min(raw.length, PLAN_MAX);
221
+ const items = [];
222
+ for (let i = 0; i < len; i++) {
223
+ const a = raw[i];
224
+ let content = String(a.content || '').trim().slice(0, 8000);
225
+ let title = String(a.title || '').trim().slice(0, 100);
226
+ if (!title) title = content.slice(0, 40) || `任务${i + 1}`;
227
+ let mode = String(a.mode || 'new').toLowerCase();
228
+ if (!['new', 'continue', 'parallel'].includes(mode)) mode = 'new';
229
+ let deps = Array.isArray(a.deps) ? a.deps : [];
230
+ deps = [...new Set(deps.map(d => parseInt(d, 10)).filter(Number.isInteger))]
231
+ .map(d => d - 1) // 转 0 起始
232
+ .filter(d => d >= 0 && d < len && d !== i) // 去悬空与自引用
233
+ .sort((x, y) => x - y)
234
+ .map(d => d + 1); // 回 1 起始
235
+ // continue 无有效链首(依赖为空)时退化为 new:续聊必须有前序会话
236
+ if (mode === 'continue' && deps.length === 0) mode = 'new';
237
+ if (!content) content = title;
238
+ items.push({ title, content, mode, deps });
239
+ }
240
+ return items;
241
+ }
242
+
243
+ module.exports = {
244
+ PLANNER_DIR, MSG_MAX, ROUNDS_MAX, PLAN_MIN, PLAN_MAX,
245
+ PLANNER_SYSTEM,
246
+ readSession, createSession, latestSession, deleteSession, pruneStale,
247
+ chat, plan, cleanPlan
248
+ };
@@ -0,0 +1,143 @@
1
+ // AI 智能组队:由执行内核根据用户需求生成子智能体团队配置
2
+ // 纯函数模块(buildPrompt/extractTeamJSON/cleanSuggestedTeam)可独立单测;
3
+ // suggestTeam 注入 runAgentFn 便于测试内核交互链路
4
+ const agentMod = require('./agent');
5
+
6
+ const LIMITS = { name: 20, icon: 8, desc: 100, systemPrompt: 8000 };
7
+ const TEAM_MIN = 2;
8
+ const TEAM_MAX = 6;
9
+ const REQ_MAX = 2000;
10
+ const SUGGEST_TIMEOUT_MS = Number(process.env.AGENTS_CHAT_SUGGEST_TIMEOUT_MS) > 0
11
+ ? Number(process.env.AGENTS_CHAT_SUGGEST_TIMEOUT_MS)
12
+ : 120000; // 真实内核生成整队提示词输出量大,60s 偏紧;.env 可覆盖
13
+
14
+ // 结构化生成提示词:要求内核输出纯 JSON 数组(容错解析见 extractTeamJSON)
15
+ function buildPrompt(requirements, existingNames) {
16
+ const avoid = (Array.isArray(existingNames) ? existingNames : [])
17
+ .map(n => String(n || '').trim()).filter(Boolean);
18
+ return [
19
+ '你是一位资深多智能体系统团队设计师。请根据用户需求,设计一支协作完成该需求的智能体团队。',
20
+ '',
21
+ '【用户需求】',
22
+ String(requirements || '').trim(),
23
+ '',
24
+ '【输出要求】只输出一个 JSON 数组,不要输出任何其他文字、解释或 markdown 代码块标记。数组包含 2 到 6 个对象,每个对象字段:',
25
+ '- name:智能体名称,简短(不超过 8 个字),团队内不得重名',
26
+ '- icon:单个 emoji 图标',
27
+ '- desc:一句话技能说明(不超过 50 字),明确该智能体的能力边界,供调度者据此分派任务',
28
+ '- systemPrompt:自定义提示词,包含角色定位、专业技能与输出要求,100 到 300 字,具体可执行',
29
+ '',
30
+ avoid.length ? `【命名避让】以下名称已被现有智能体占用,生成的 name 不得与之重复:${avoid.join('、')}` : '',
31
+ '',
32
+ '【设计原则】',
33
+ '1. 按需求拆解出真正需要的分工,每个智能体职责单一且清晰,避免出现职责重叠的成员',
34
+ '2. 语言与用户需求语言一致(用户用中文则全部用中文)',
35
+ '3. systemPrompt 要具体可执行,写清楚该角色拿到任务后应该怎么做、输出什么格式的成果',
36
+ '4. 若需求较简单,团队从简(2 到 3 个);需求复杂才增加分工'
37
+ ].filter(line => line !== '').join('\n');
38
+ }
39
+
40
+ // 通用容错提取 JSON 数组(planner 等模块共享):裸数组 → ```json 代码块 → 首个 [ 到最后一个 ] 的子串
41
+ function extractJSONArray(text) {
42
+ const raw = String(text || '').trim();
43
+ if (!raw) return null;
44
+ // 1. 整体就是合法 JSON
45
+ try { const v = JSON.parse(raw); if (Array.isArray(v)) return v; } catch { /* 继续尝试 */ }
46
+ // 2. markdown 代码块(```json ... ``` 或 ``` ... ```)
47
+ const fence = raw.match(/```(?:json)?\s*([\s\S]*?)```/i);
48
+ if (fence) {
49
+ try { const v = JSON.parse(fence[1].trim()); if (Array.isArray(v)) return v; } catch { /* 继续尝试 */ }
50
+ }
51
+ // 3. 首个 [ 到最后一个 ] 之间(容忍前后杂文;字符串中含 ] 不影响,因为取最后一个 ])
52
+ const start = raw.indexOf('[');
53
+ const end = raw.lastIndexOf(']');
54
+ if (start >= 0 && end > start) {
55
+ try { const v = JSON.parse(raw.slice(start, end + 1)); if (Array.isArray(v)) return v; } catch { /* 放弃 */ }
56
+ }
57
+ return null;
58
+ }
59
+
60
+ // 兼容保留:团队配置提取(实现转用通用函数)
61
+ function extractTeamJSON(text) {
62
+ return extractJSONArray(text);
63
+ }
64
+
65
+ // 清洗生成结果:字段类型归一 + 长度截断 + 空名补名 + 重名后缀 + 数量上限
66
+ // existingNames:现有智能体名(含管家),重名自动加序号,保证 @ 点名无歧义
67
+ function cleanSuggestedTeam(arr, existingNames) {
68
+ if (!Array.isArray(arr)) return [];
69
+ const names = new Set(['管家', 'butler']);
70
+ for (const n of existingNames || []) {
71
+ const s = String(n || '').trim();
72
+ if (s) names.add(s);
73
+ }
74
+ const clean = [];
75
+ for (const a of arr) {
76
+ if (!a || typeof a !== 'object') continue;
77
+ let name = String(a.name || '').replace(/\s+/g, '').slice(0, LIMITS.name);
78
+ if (!name) name = `智能体${clean.length + 1}`;
79
+ let final = name;
80
+ let i = 2;
81
+ while (names.has(final)) final = `${name}${i++}`;
82
+ names.add(final);
83
+ clean.push({
84
+ name: final,
85
+ icon: String(a.icon || '').trim().slice(0, LIMITS.icon),
86
+ desc: String(a.desc || '').slice(0, LIMITS.desc),
87
+ systemPrompt: String(a.systemPrompt || '').slice(0, LIMITS.systemPrompt)
88
+ });
89
+ if (clean.length >= TEAM_MAX) break;
90
+ }
91
+ return clean;
92
+ }
93
+
94
+ // 完整生成链路:调内核一次 → 容错解析 → 清洗 → 数量校验
95
+ // 返回 { success, agents, error };任何失败都不影响现有配置(调用方不落盘)
96
+ async function suggestTeam({ requirements, existingNames, runAgentFn, timeoutMs }) {
97
+ const run = runAgentFn || agentMod.runAgent;
98
+ const timeout = Number(timeoutMs) > 0 ? Number(timeoutMs) : SUGGEST_TIMEOUT_MS;
99
+ const scope = 'teamgen';
100
+ const prompt = buildPrompt(requirements, existingNames);
101
+
102
+ let settled = false;
103
+ let content = '';
104
+ let runError = '';
105
+ const done = new Promise((resolve) => {
106
+ const timer = setTimeout(() => {
107
+ if (settled) return;
108
+ settled = true;
109
+ runError = `生成超时(${Math.round(timeout / 1000)} 秒),请稍后重试或简化需求描述`;
110
+ try { agentMod.stopScope(scope); } catch { /* ignore */ }
111
+ resolve();
112
+ }, timeout);
113
+ try {
114
+ run(
115
+ { id: scope, name: scope, model: '', behavior: 'echo', systemPrompt: '' },
116
+ prompt,
117
+ (chunk) => {
118
+ if (chunk && chunk.content) content += chunk.content;
119
+ if (chunk && chunk.error) runError = runError || String(chunk.error);
120
+ if (chunk && chunk.done && !settled) { settled = true; clearTimeout(timer); resolve(); }
121
+ },
122
+ scope
123
+ );
124
+ } catch (e) {
125
+ if (!settled) { settled = true; clearTimeout(timer); runError = `调用内核失败:${e.message}`; resolve(); }
126
+ }
127
+ });
128
+ await done;
129
+
130
+ if (runError) return { success: false, agents: [], error: runError };
131
+
132
+ const parsed = extractTeamJSON(content);
133
+ if (!parsed) {
134
+ return { success: false, agents: [], error: '生成结果无法解析为团队配置,请换个说法重试(例如补充更多分工细节)' };
135
+ }
136
+ const agents = cleanSuggestedTeam(parsed, existingNames);
137
+ if (agents.length < TEAM_MIN) {
138
+ return { success: false, agents: [], error: `生成结果只有 ${agents.length} 个智能体,至少需要 ${TEAM_MIN} 个,请补充更多分工细节后重试` };
139
+ }
140
+ return { success: true, agents };
141
+ }
142
+
143
+ module.exports = { buildPrompt, extractTeamJSON, extractJSONArray, cleanSuggestedTeam, suggestTeam, LIMITS, TEAM_MIN, TEAM_MAX, REQ_MAX };
@@ -100,6 +100,30 @@
100
100
  .empty{color:var(--dim);font-size:12px;text-align:center;padding:18px 0}
101
101
  .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)}
102
102
  .mask.show{display:flex;animation:fade-in .18s ease}
103
+ /* ---------- AI 编排聊天抽屉 ---------- */
104
+ #plDrawer{position:fixed;top:0;right:0;bottom:0;width:min(430px,100vw);background:var(--panel);border-left:1px solid var(--line);box-shadow:var(--shadow);z-index:55;display:none;flex-direction:column;transform:translateX(100%);transition:transform .22s cubic-bezier(.2,.7,.3,1)}
105
+ #plDrawer.open{display:flex;transform:translateX(0)}
106
+ .pl-head{display:flex;align-items:center;gap:8px;padding:12px 14px;border-bottom:1px solid var(--line);font-size:14px}
107
+ .pl-head .btn{padding:4px 10px;font-size:12px}
108
+ .pl-banner{margin:10px 14px 0;padding:9px 12px;border-radius:9px;font-size:12.5px;line-height:1.7;background:#fff7e6;border:1px solid #ffe58f;color:#614700;white-space:pre-wrap}
109
+ .pl-msgs{flex:1;overflow-y:auto;padding:12px 14px;display:flex;flex-direction:column;gap:10px}
110
+ .pl-bubble{max-width:88%;padding:9px 12px;border-radius:12px;font-size:13px;line-height:1.7;white-space:pre-wrap;word-break:break-word}
111
+ .pl-user{align-self:flex-end;background:var(--acc,#10aefa);color:#fff;border-bottom-right-radius:4px}
112
+ .pl-ai{align-self:flex-start;background:#f1f3f7;border:1px solid var(--line);border-bottom-left-radius:4px}
113
+ .pl-ai.err{background:#fff1f0;border-color:#ffccc7;color:#c0392b}
114
+ .pl-thinking{align-self:flex-start;color:var(--dim);font-size:12px;padding:4px 2px}
115
+ .pl-plan{border-top:1px dashed var(--line);padding:10px 14px;max-height:44vh;overflow-y:auto;background:#fafbfc}
116
+ .pl-plan h4{margin:0 0 8px;font-size:12.5px;color:var(--acc,#10aefa)}
117
+ .pl-task{display:flex;gap:6px;align-items:center;margin-bottom:6px}
118
+ .pl-task .no{font-size:11px;color:var(--dim);width:20px;flex:none;text-align:right}
119
+ .pl-task input{padding:5px 8px;font-size:12.5px}
120
+ .pl-task .t-title{flex:2.2}
121
+ .pl-task select,.pl-task .t-deps{width:86px;flex:none;padding:5px 6px;font-size:12px}
122
+ .pl-plan .foot{display:flex;gap:8px;margin-top:10px;flex-wrap:wrap}
123
+ .pl-input{display:flex;gap:8px;padding:10px 14px 4px;border-top:1px solid var(--line)}
124
+ .pl-input textarea{min-height:64px;flex:1}
125
+ .pl-tools{display:flex;gap:8px;padding:8px 14px 12px}
126
+ .pl-tools .btn{padding:5px 12px;font-size:12.5px}
103
127
  @keyframes fade-in{from{opacity:0}to{opacity:1}}
104
128
  @keyframes modal-in{from{opacity:0;transform:translateY(14px) scale(.985)}to{opacity:1;transform:none}}
105
129
  .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)}
@@ -236,8 +260,9 @@
236
260
  <option>1</option><option>2</option><option>3</option><option>4</option><option>5</option><option>6</option><option>7</option><option>8</option>
237
261
  </select>
238
262
  </div>
239
- <button class="btn" id="btnNew">+ 新建任务</button>
240
- <button class="btn primary" id="btnRun" title="按依赖与优先级调度全部待执行任务">▶ 开始编排</button>
263
+ <button class="btn" id="btnNew">+ 新建任务</button>
264
+ <button class="btn" id="btnPlanner" title="与 AI 聊天式编排:描述目标,AI 澄清后一键生成任务编排">🪄 AI 编排</button>
265
+ <button class="btn primary" id="btnRun" title="按依赖与优先级调度全部待执行任务">▶ 开始编排</button>
241
266
  <button class="btn danger" id="btnStop" disabled title="停止编排:进行中的任务复位为待执行(不影响追加聊天),未开始的任务保留">⏹ 停止</button>
242
267
  <button class="btn" id="btnClear">清空</button>
243
268
  <button class="btn" id="btnHelp" title="多任务编排使用帮助">❓ 帮助</button>
@@ -341,6 +366,28 @@
341
366
  </div>
342
367
  </div>
343
368
 
369
+ <!-- AI 编排聊天抽屉 -->
370
+ <div id="plDrawer">
371
+ <div class="pl-head">
372
+ <b>🪄 AI 编排</b>
373
+ <span style="flex:1"></span>
374
+ <button class="btn" id="plNew" title="开启全新编排会话(当前会话保留在历史中可随时删)">🆕 新会话</button>
375
+ <button class="btn" id="plClose" title="收起抽屉">✕</button>
376
+ </div>
377
+ <div class="pl-banner" id="plBanner" style="display:none"></div>
378
+ <div class="pl-msgs" id="plMsgs"></div>
379
+ <div class="pl-plan" id="plPlanBox" style="display:none"></div>
380
+ <div class="pl-input">
381
+ <textarea id="plInput" placeholder="描述你的目标,如:给项目做一轮完整的发布前检查,生成清单后我来看"></textarea>
382
+ <button class="btn primary" id="plSend">发送</button>
383
+ </div>
384
+ <div class="pl-tools">
385
+ <button class="btn" id="plPlanBtn" title="基于当前对话生成任务编排清单(可编辑后导入看板)">📝 生成编排</button>
386
+ <span style="flex:1"></span>
387
+ <button class="btn danger" id="plDel" title="删除当前编排会话">🗑 删会话</button>
388
+ </div>
389
+ </div>
390
+
344
391
  <!-- 垃圾桶 -->
345
392
  <!-- 使用帮助 -->
346
393
  <div class="mask" id="helpMask">
@@ -661,6 +708,140 @@ function depCycle(id, deps){
661
708
  return false;
662
709
  }
663
710
  $('#btnNew').onclick=()=>openEdit(null);
711
+
712
+ // ---------- AI 智能编排:聊天抽屉(描述目标 → 澄清 → 生成清单 → 导入看板) ----------
713
+ let plSid = ''; // 当前编排会话 id
714
+ let plBusy = false; // 内核调用进行中(发送/生成互斥)
715
+ let plDraft = []; // 生成的编排清单预览(可编辑)
716
+
717
+ $('#btnPlanner').onclick = () => { const d=$('#plDrawer'); const opening=!d.classList.contains('open'); d.classList.toggle('open', opening); if (opening) plInit(); };
718
+ $('#plClose').onclick = () => $('#plDrawer').classList.remove('open');
719
+
720
+ function plScrollBottom(){ const m=$('#plMsgs'); m.scrollTop=m.scrollHeight; }
721
+
722
+ // 打开抽屉:恢复最近会话 + 内核状态检查
723
+ async function plInit(){
724
+ const k = RUNNER_INFO && RUNNER_INFO.kind;
725
+ const banner=$('#plBanner');
726
+ if (k === 'demo' || k === 'missing'){
727
+ banner.style.display='block';
728
+ banner.textContent='⚠ AI 编排需要真实执行内核' + (k==='demo' ? ':当前为演示模式(AGENTS_CHAT_MOCK=1),删除 .env 中该配置并重启后即可使用' : ':请先安装 opencode(npm install -g opencode-ai)并登录,再刷新本页');
729
+ $('#plInput').disabled=true; $('#plSend').disabled=true; $('#plPlanBtn').disabled=true;
730
+ } else {
731
+ banner.style.display='none';
732
+ $('#plInput').disabled=false; $('#plSend').disabled=false; $('#plPlanBtn').disabled=false;
733
+ }
734
+ if (!plSid){
735
+ try{
736
+ const r=await api('/api/planner/session');
737
+ if (r.found && r.sid){ plSid=r.sid; plRender(r.messages||[]); }
738
+ else plRender([]);
739
+ }catch{ plRender([]); }
740
+ }
741
+ }
742
+
743
+ function plRender(msgs){
744
+ const m=$('#plMsgs');
745
+ if (!msgs.length){
746
+ m.innerHTML='<div class="pl-thinking">和我聊聊你想完成什么:例如「给项目做一轮发布前检查,覆盖依赖、测试、文档」。<br>信息不足时我会先问你几个问题,聊清楚后点下方「📝 生成编排」产出任务清单。</div>';
747
+ return;
748
+ }
749
+ m.innerHTML=msgs.map(x=>x.role==='user'
750
+ ? `<div class="pl-bubble pl-user">${esc(x.content)}</div>`
751
+ : `<div class="pl-bubble pl-ai">${esc(x.content)}</div>`).join('');
752
+ plScrollBottom();
753
+ }
754
+
755
+ function plAppend(role, text){ $('#plMsgs').insertAdjacentHTML('beforeend', `<div class="pl-bubble pl-${role}">${esc(text)}</div>`); plScrollBottom(); }
756
+
757
+ async function plSendMsg(){
758
+ if (plBusy) return;
759
+ const ta=$('#plInput');
760
+ const message=ta.value.trim();
761
+ if (!message){ toast('先输入你的目标或回复'); return; }
762
+ ta.value='';
763
+ plAppend('user', message);
764
+ plBusy=true; $('#plSend').disabled=true; $('#plPlanBtn').disabled=true;
765
+ const think=document.createElement('div'); think.className='pl-thinking'; think.textContent='思考中…(最长约 2 分钟)';
766
+ $('#plMsgs').appendChild(think); plScrollBottom();
767
+ try{
768
+ const r=await post('/api/planner/chat', { sid: plSid, message });
769
+ think.remove();
770
+ if (r.success){ plSid=r.sid; plAppend('ai', r.reply||'(空回复)'); }
771
+ else { plAppend('ai', r.error||'发送失败'); $('#plMsgs').lastChild.classList.add('err'); }
772
+ }catch(e){ think.remove(); plAppend('ai', '发送失败:'+e.message); $('#plMsgs').lastChild.classList.add('err'); }
773
+ finally{ plBusy=false; $('#plSend').disabled=false; $('#plPlanBtn').disabled=false; }
774
+ }
775
+ $('#plSend').onclick=plSendMsg;
776
+ $('#plInput').addEventListener('keydown', e=>{ if (e.key==='Enter' && !e.shiftKey){ e.preventDefault(); plSendMsg(); } });
777
+
778
+ // 生成编排清单(可编辑预览)
779
+ $('#plPlanBtn').onclick=async()=>{
780
+ if (plBusy) return;
781
+ if (!plSid){ toast('先发送一条消息描述目标'); return; }
782
+ plBusy=true; $('#plPlanBtn').disabled=true; $('#plSend').disabled=true;
783
+ $('#plPlanBox').style.display='block';
784
+ $('#plPlanBox').innerHTML='<h4>🤖 正在生成任务编排清单…(最长约 3 分钟)</h4>';
785
+ try{
786
+ const r=await post('/api/planner/plan', { sid: plSid });
787
+ if (r.success){ plDraft=r.plan||[]; plRenderPlan(); }
788
+ else { $('#plPlanBox').innerHTML=`<h4 style="color:#c0392b">生成失败:${esc(r.error||'')}</h4>`; }
789
+ }catch(e){ $('#plPlanBox').innerHTML=`<h4 style="color:#c0392b">生成失败:${esc(e.message)}</h4>`; }
790
+ finally{ plBusy=false; $('#plPlanBtn').disabled=false; $('#plSend').disabled=false; }
791
+ };
792
+
793
+ // 预览编辑:每任务标题/模式/依赖可改可删
794
+ function plRenderPlan(){
795
+ const box=$('#plPlanBox');
796
+ box.style.display='block';
797
+ if (!plDraft.length){ box.innerHTML='<h4>暂无清单,点「📝 生成编排」</h4>'; return; }
798
+ box.innerHTML='<h4>📋 编排清单(可直接修改,依赖填清单序号如 1,2)</h4>' +
799
+ plDraft.map((t,i)=>`
800
+ <div class="pl-task">
801
+ <span class="no">${i+1}</span>
802
+ <input class="t-title" value="${esc(t.title)}" oninput="plDraft[${i}].title=this.value">
803
+ <select onchange="plDraft[${i}].mode=this.value">
804
+ <option value="new" ${t.mode==='new'?'selected':''}>新进程</option>
805
+ <option value="continue" ${t.mode==='continue'?'selected':''}>续聊</option>
806
+ <option value="parallel" ${t.mode==='parallel'?'selected':''}>并行</option>
807
+ </select>
808
+ <input class="t-deps" value="${esc((t.deps||[]).join(','))}" placeholder="依赖" oninput="plDraft[${i}].deps=this.value.split(',').map(s=>parseInt(s.trim())).filter(Number.isInteger)">
809
+ <button class="icon-btn" title="删除该任务" onclick="plDraft.splice(${i},1);plRenderPlan()">✕</button>
810
+ </div>`).join('') +
811
+ `<div class="foot">
812
+ <button class="btn" onclick="plDraft=[];plRenderPlan()">清空</button>
813
+ <button class="btn primary" style="flex:1" onclick="plImport('append')">+ 导入看板(追加 ${plDraft.length} 项)</button>
814
+ <button class="btn danger" onclick="plImport('replace')">替换全部</button>
815
+ </div>`;
816
+ }
817
+
818
+ // 导入看板:append 追加 | replace 清空后导入(二次确认)
819
+ async function plImport(mode){
820
+ const tasks=plDraft.filter(t=>String(t.title||'').trim()||String(t.content||'').trim())
821
+ .map((t,i)=>({ title:String(t.title||'').trim(), content:String(t.content||'').trim(), mode:t.mode||'new', deps:Array.isArray(t.deps)?t.deps:[] }));
822
+ if (!tasks.length){ toast('清单为空'); return; }
823
+ if (mode==='replace'){
824
+ const cur=(CARDS||[]).length;
825
+ if (!confirm(`将清空现有 ${cur} 个看板任务(移入垃圾桶,30 天内可还原),再导入 ${tasks.length} 个新任务。确定替换?`)) return;
826
+ }
827
+ try{
828
+ const r=await post('/api/cards/batch', { tasks, importMode: mode });
829
+ if (!r.success){ toast(r.error||'导入失败'); return; }
830
+ (r.warnings||[]).forEach(w=>toast(w));
831
+ toast(`已导入 ${tasks.length} 个任务${mode==='replace'?'(已清空原看板)':''},点「▶ 开始编排」执行`);
832
+ plDraft=[]; $('#plPlanBox').style.display='none';
833
+ $('#plDrawer').classList.remove('open');
834
+ await loadCards();
835
+ }catch(e){ toast('导入失败:'+e.message); }
836
+ }
837
+
838
+ $('#plNew').onclick=()=>{ plSid=''; plDraft=[]; $('#plPlanBox').style.display='none'; plRender([]); toast('已开启新编排会话'); };
839
+ $('#plDel').onclick=async()=>{
840
+ if (!plSid || !confirm('删除当前编排会话(聊天记录将不可恢复)?')) return;
841
+ await post('/api/planner/session/delete', { sid: plSid });
842
+ plSid=''; plDraft=[]; $('#plPlanBox').style.display='none'; plRender([]); toast('会话已删除');
843
+ };
844
+
664
845
  $('#btnEditCancel').onclick=()=>$('#maskEdit').classList.remove('show');
665
846
  $('#btnEditSave').onclick=async()=>{
666
847
  const mode=$('#fMode').value, chainId=$('#fChain').value;
@@ -215,14 +215,14 @@
215
215
  .at-tx i { font-style: normal; color: #999; font-size: 11px; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 380px; }
216
216
 
217
217
  /* ---------- 弹窗 ---------- */
218
- #modalMask, #batchMask, #agentsMask, #historyMask, #helpMask, #flowMask, #cardMask, #teamsMask, #memMask, #diffMask, #welcomeMask { position: fixed; inset: 0; background: rgba(0,0,0,.4); display: none; align-items: center; justify-content: center; z-index: 51; }
218
+ #modalMask, #batchMask, #agentsMask, #historyMask, #helpMask, #flowMask, #cardMask, #teamsMask, #memMask, #diffMask, #welcomeMask, #suggestMask { position: fixed; inset: 0; background: rgba(0,0,0,.4); display: none; align-items: center; justify-content: center; z-index: 51; }
219
219
  /* 弹窗出现动画:遮罩淡入(含毛玻璃)+ 内容上浮,全站弹窗统一节奏 */
220
220
  div[id$="Mask"] { backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px); }
221
221
  div[id$="Mask"].show { animation: fade-in .18s ease; }
222
222
  .modal, .preview-card { animation: modal-in .24s cubic-bezier(.2,.7,.3,1); }
223
223
  @keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
224
224
  @keyframes modal-in { from { opacity: 0; transform: translateY(14px) scale(.985); } to { opacity: 1; transform: none; } }
225
- #modalMask.show, #batchMask.show, #agentsMask.show, #historyMask.show, #helpMask.show, #flowMask.show, #cardMask.show, #teamsMask.show, #memMask.show, #diffMask.show, #welcomeMask.show { display: flex; }
225
+ #modalMask.show, #batchMask.show, #agentsMask.show, #historyMask.show, #helpMask.show, #flowMask.show, #cardMask.show, #teamsMask.show, #memMask.show, #diffMask.show, #welcomeMask.show, #suggestMask.show { display: flex; }
226
226
  /* 团队仓库卡片 */
227
227
  .team-card { border: 1px solid #e5e7eb; border-radius: 10px; padding: 14px 16px; margin-bottom: 12px; }
228
228
  .team-card .tc-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
@@ -349,7 +349,7 @@
349
349
  /* ---------- 侧栏顶部:单聊/群聊开关 + 任务操作按钮 ---------- */
350
350
  .side-header { padding: 12px 12px 10px; display: flex; flex-direction: column; gap: 8px; border-bottom: 1px solid #f0f0f0; }
351
351
  .mode-switch { display: inline-flex; border: 1px solid #ddd; border-radius: 8px; overflow: hidden; }
352
- .mode-switch button { border: none; background: #fff; font-size: 13px; padding: 5px 22px; min-width: 104px; cursor: pointer; color: #666; flex: 1; white-space: nowrap; }
352
+ .mode-switch button { border: none; background: #fff; font-size: 13px; padding: 5px 22px; min-width: 104px; cursor: pointer; color: #666; flex: 1; white-space: nowrap; position: relative; }
353
353
 
354
354
  /* ---------- 侧栏中部:会话区(上,约5条)+ 任务区(下)各占一半,总高固定 ---------- */
355
355
  .side-mid { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
@@ -358,6 +358,12 @@
358
358
  #taskList { flex: 1; min-height: 0; overflow-y: auto; }
359
359
  .mode-switch button + button { border-left: 1px solid #ddd; }
360
360
  .mode-switch button.active { background: #07c160; color: #fff; font-weight: 600; }
361
+ /* 三模式标签角标:完成进度(✓d/t)+ 未读绿点红数字 */
362
+ .mode-switch .tab-prog { font-size: 10.5px; color: #999; margin-left: 3px; }
363
+ .mode-switch button.active .tab-prog { color: rgba(255,255,255,.85); }
364
+ .mode-switch .tab-dot { display: none; position: absolute; top: -5px; right: 4px; min-width: 15px; height: 15px; padding: 0 4px; border-radius: 8px; background: #fa5151; color: #fff; font-size: 10px; line-height: 15px; text-align: center; font-weight: 600; }
365
+ .mode-switch .tab-dot.show { display: block; }
366
+ .mode-switch .tab-dot::before { content: ''; display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: #07c160; margin-right: 2px; vertical-align: 1px; }
361
367
  .side-header .side-actions { display: flex; gap: 6px; width: 100%; }
362
368
  .side-header .side-actions .mini-btn { flex: 1; padding: 4px 0; text-align: center; }
363
369
  .ttab .badge { margin-left: 4px; vertical-align: 1px; }
@@ -433,8 +439,9 @@
433
439
  <aside id="sidebar">
434
440
  <div class="side-header">
435
441
  <div class="mode-switch" id="modeSwitch">
436
- <button id="modeSoloBtn" onclick="setChatMode('solo')">👤 单聊</button>
437
- <button id="modeGroupBtn" class="active" onclick="setChatMode('group')">👥 群聊</button>
442
+ <button id="modeSoloBtn" onclick="setChatMode('solo')">👤 单聊<span class="tab-prog" id="soloProg"></span><span class="tab-dot" id="soloDot"></span></button>
443
+ <button id="modeGroupBtn" class="active" onclick="setChatMode('group')">👥 群聊<span class="tab-prog" id="groupProg"></span><span class="tab-dot" id="groupDot"></span></button>
444
+ <button id="modeCardsBtn" onclick="setChatMode('cards')">🎛 编排<span class="tab-prog" id="cardsProg"></span><span class="tab-dot" id="cardsDot"></span></button>
438
445
  </div>
439
446
  <div class="side-actions">
440
447
  <button class="mini-btn" onclick="openImport()">导入任务</button>
@@ -506,6 +513,12 @@
506
513
  </footer>
507
514
  </div>
508
515
  </div>
516
+ <div id="cardsView" style="flex:1;min-height:0;display:none;flex-direction:column;position:relative">
517
+ <iframe id="cardsFrame" title="多任务编排控制台" style="flex:1;width:100%;border:0;display:block"></iframe>
518
+ <div id="cardsFallback" style="display:none;padding:40px;text-align:center;color:#888">
519
+ 控制台加载失败。<button class="mini-btn" onclick="window.open('/cards','_blank')">在新标签打开</button>
520
+ </div>
521
+ </div>
509
522
  </main>
510
523
  </div>
511
524
 
@@ -588,6 +601,7 @@
588
601
  <div class="tip">每个智能体都是一个独立的 AI Agent:执行内核由顶部下拉框统一选择(OpenCode / Claude Code / Codex / pi),<b>图标</b>用于辨认,<b>名称</b>用于 @ 点名,<b>技能说明</b>供管家调度参考,<b>自定义提示词</b>会在任务下发时附加在用户提示词前面。所有智能体使用所选内核的默认模型。「管家」为内置调度智能体,不可修改。</div>
589
602
  <div class="butler-ro" id="butlerRo"></div>
590
603
  <div class="ag-row" style="margin:8px 0 2px">
604
+ <button class="add-agent" style="flex:1" onclick="openSuggest()">🪄 AI 组队 · 一句话生成团队</button>
591
605
  <button class="add-agent" style="flex:1" onclick="openTeams()">📦 团队仓库 · 一键换经典团队</button>
592
606
  </div>
593
607
  <div class="global-cwd-box">
@@ -658,6 +672,25 @@
658
672
  </div>
659
673
  </div>
660
674
  </div>
675
+ <!-- AI 智能组队弹窗 -->
676
+ <div id="suggestMask">
677
+ <div class="modal wide">
678
+ <h3>🪄 AI 组队</h3>
679
+ <div class="tip">用一句话描述你的需求,AI 扮演团队设计师,生成一套分工明确的智能体团队(名称/图标/技能说明/提示词)。先预览再应用,应用后仍可自由增删改,点配置页「保存」才生效。</div>
680
+ <div class="ta-wrap"><textarea id="sgReqInput" style="min-height:88px" placeholder="例如:我要从零做一个跨境电商独立站,需要市场调研、选品分析、网站前后端开发、内容文案和上线测试,帮我组建一支能覆盖全流程的团队"></textarea></div>
681
+ <div class="ag-row" style="margin:8px 0">
682
+ <button id="sgRunBtn" class="primary" style="flex:1" onclick="runSuggest()">✨ 生成团队</button>
683
+ </div>
684
+ <div id="sgResult"></div>
685
+ <div class="modal-actions">
686
+ <button class="mini-btn" onclick="runSuggest()">🔄 重新生成</button>
687
+ <span style="flex:1"></span>
688
+ <button onclick="closeSuggest()">放弃</button>
689
+ <button class="mini-btn" style="background:#07c160;color:#fff" onclick="applySuggest('append')">+ 应用(合并到现有团队)</button>
690
+ <button class="primary" onclick="applySuggest('replace')">应用(替换全部子智能体)</button>
691
+ </div>
692
+ </div>
693
+ </div>
661
694
  <!-- 聊天历史弹窗 -->
662
695
  <div id="historyMask">
663
696
  <div class="modal wide">
@@ -749,7 +782,7 @@
749
782
  </div>
750
783
 
751
784
  <script>
752
- const PAGE_VERSION = '3.33.0'; // 与服务端 /api/health.version 互检,不一致说明页面缓存过期
785
+ const PAGE_VERSION = '3.35.0'; // 与服务端 /api/health.version 互检,不一致说明页面缓存过期
753
786
  const AV_COLORS = ['#5b8def','#07c160','#fa9d3b','#10aeff','#8a6fe8','#fa5151','#e8a33d','#3aa7a3'];
754
787
  const PHASE_LABEL = { plan: '调度规划', work: '执行', review: '验收', report: '汇总', talk: '圆桌发言', task: '任务执行' };
755
788
  // 职业图标库(智能体配置可选)
@@ -766,7 +799,8 @@ let globalCwd = ''; // 全局统一工作目录
766
799
  let configKernel = 'auto'; // 配置的执行内核
767
800
  let configApproval = 'off'; // 人工审批关卡:off/plan/verify/all
768
801
  let teamPresets = []; // 团队仓库预设(配置页「📦 团队仓库」)
769
- let chatMode = 'group'; // 当前模式:group=群聊(多智能体)| solo=单聊(OpenCode 工作台)
802
+ let chatMode = 'group'; // 当前模式:group=群聊(多智能体)| solo=单聊(OpenCode 工作台)| cards=多任务编排(内嵌控制台)
803
+ let lastMainMsgCount = -1; // 主会话消息数快照(-1=首次加载不计数;用于非群聊模式的未读提醒)
770
804
  const sessions = new Map(); // key: 'main' | taskId | oc会话id → 消息数组
771
805
 
772
806
  // ---------- 单聊工作台状态 ----------
@@ -861,6 +895,13 @@ async function init() {
861
895
  restoreApprovals(); // 恢复仍在等待的人工审批卡片(刷新/断线场景)
862
896
  updateFooterBtns();
863
897
 
898
+ // 恢复上次所在模式标签(单聊/群聊/编排),并拉一次编排统计驱动角标
899
+ try {
900
+ const saved = localStorage.getItem(MODE_STORE_KEY);
901
+ if (saved === 'solo' || saved === 'cards') setChatMode(saved);
902
+ } catch { /* localStorage 不可用时保持群聊 */ }
903
+ refreshCardsStats();
904
+
864
905
  // 版本互检:页面与服务端不一致 → 页面是旧缓存,必须强刷
865
906
  if (health.version && health.version !== PAGE_VERSION) {
866
907
  addSys(`⚠ 页面版本(${PAGE_VERSION})与服务端(${health.version})不一致:浏览器缓存了旧页面,请按 Ctrl+F5 强制刷新`);
@@ -929,19 +970,39 @@ async function init() {
929
970
  setInterval(() => {
930
971
  if (taskBusy || document.hidden) return;
931
972
  loadTasks(true);
973
+ refreshCardsStats(); // 编排标签角标同步(stats 接口轻量 <1KB)
932
974
  }, 8000);
933
975
  }
934
976
 
935
- // ---------- 单聊/群聊模式切换(左上角开关) ----------
977
+ // ---------- 单聊/群聊/编排三模式切换(左上角标签) ----------
978
+ const MODE_STORE_KEY = 'acChatMode'; // 模式持久化:刷新后恢复上次所在标签
979
+ let cardsStatsCache = null; // /api/cards/stats 最近一次结果(角标计算)
980
+ let groupMsgUnread = 0; // 非群聊模式下主会话收到的未读消息数(切回群聊清零)
981
+
936
982
  function setChatMode(mode) {
937
- chatMode = mode === 'solo' ? 'solo' : 'group';
983
+ chatMode = ['solo', 'cards'].includes(mode) ? mode : 'group';
984
+ try { localStorage.setItem(MODE_STORE_KEY, chatMode); } catch { /* ignore */ }
985
+ if (chatMode === 'group') groupMsgUnread = 0; // 回到群聊即视为已读主会话
938
986
  document.getElementById('modeSoloBtn').classList.toggle('active', chatMode === 'solo');
939
987
  document.getElementById('modeGroupBtn').classList.toggle('active', chatMode === 'group');
988
+ document.getElementById('modeCardsBtn').classList.toggle('active', chatMode === 'cards');
940
989
  document.getElementById('groupView').style.display = chatMode === 'group' ? 'flex' : 'none';
941
990
  document.getElementById('soloView').style.display = chatMode === 'solo' ? 'flex' : 'none';
991
+ const cv = document.getElementById('cardsView');
992
+ cv.style.display = chatMode === 'cards' ? 'flex' : 'none';
993
+ if (chatMode === 'cards') {
994
+ // 内嵌控制台懒加载:首次才注入 src,切回不重载(保留看板状态)
995
+ const fr = document.getElementById('cardsFrame');
996
+ if (!fr.getAttribute('src')) {
997
+ fr.src = '/cards';
998
+ fr.onerror = () => { fr.style.display = 'none'; document.getElementById('cardsFallback').style.display = 'block'; };
999
+ }
1000
+ markCardsSeen(); // 控制台可见即视为已查看:清未读、记当前完成数
1001
+ }
942
1002
  renderSidebar();
943
1003
  renderHeader();
944
1004
  updateFooterBtns();
1005
+ updateModeBadges();
945
1006
  if (chatMode === 'solo') {
946
1007
  loadOcSessions();
947
1008
  loadOcModels();
@@ -949,6 +1010,58 @@ function setChatMode(mode) {
949
1010
  }
950
1011
  }
951
1012
 
1013
+ // ---------- 三标签角标:完成进度(✓d/t)+ 未读绿点数字 ----------
1014
+ async function refreshCardsStats() {
1015
+ try {
1016
+ const r = await api('/api/cards/stats');
1017
+ cardsStatsCache = r.success ? r.stats : null;
1018
+ } catch { cardsStatsCache = null; }
1019
+ if (chatMode === 'cards') markCardsSeen();
1020
+ }
1021
+
1022
+ // 编排已查看:更新 localStorage 基准(未读=当前完成数−基准,恒 ≥0)
1023
+ function markCardsSeen() {
1024
+ if (cardsStatsCache) {
1025
+ try { localStorage.setItem('acCardsLastSeenDone', String(cardsStatsCache.done)); } catch { /* ignore */ }
1026
+ }
1027
+ }
1028
+
1029
+ function updateModeBadges() {
1030
+ try {
1031
+ // 单聊/群聊:任务维度统计(按 runner 过滤)+ 各自未读会话数
1032
+ const soloTasks = tasks.filter(t => t.runner === 'solo');
1033
+ const groupTasks = tasks.filter(t => t.runner !== 'solo');
1034
+ const doneOf = (arr) => arr.filter(t => t.status === 'done').length;
1035
+ const unreadOf = (arr) => arr.filter(t => unreadSess.has(t.id)).length;
1036
+ const prog = (el, d, t) => {
1037
+ const n = document.getElementById(el);
1038
+ if (n) n.textContent = t > 0 ? `✓${d}/${t}` : '';
1039
+ };
1040
+ const dot = (el, n) => {
1041
+ const d = document.getElementById(el);
1042
+ if (!d) return;
1043
+ d.textContent = n > 99 ? '99+' : String(n);
1044
+ d.classList.toggle('show', n > 0);
1045
+ };
1046
+ prog('soloProg', doneOf(soloTasks), soloTasks.length);
1047
+ prog('groupProg', doneOf(groupTasks), groupTasks.length);
1048
+ let groupUnread = unreadOf(groupTasks);
1049
+ if (chatMode !== 'group') groupUnread += groupMsgUnread; // 非群聊时主会话新消息也提醒
1050
+ dot('soloDot', chatMode === 'solo' ? 0 : unreadOf(soloTasks));
1051
+ dot('groupDot', groupUnread);
1052
+ // 编排:stats 接口 + localStorage 差值
1053
+ const st = cardsStatsCache;
1054
+ prog('cardsProg', st ? st.done : 0, st ? st.total : 0);
1055
+ let cardsUnread = 0;
1056
+ if (st && chatMode !== 'cards') {
1057
+ let seen = 0;
1058
+ try { seen = parseInt(localStorage.getItem('acCardsLastSeenDone'), 10) || 0; } catch { /* ignore */ }
1059
+ cardsUnread = Math.max(0, st.done - seen);
1060
+ }
1061
+ dot('cardsDot', cardsUnread);
1062
+ } catch { /* 数据未就绪时静默跳过(init 早期) */ }
1063
+ }
1064
+
952
1065
  // ==================== 单聊工作台(OpenCode) ====================
953
1066
 
954
1067
  // ---------- 模型列表 ----------
@@ -1376,6 +1489,7 @@ async function loadTasks(silent) {
1376
1489
  tasks = data.tasks || [];
1377
1490
  detectBackgroundDone();
1378
1491
  renderSidebar();
1492
+ updateModeBadges(); // 三标签角标随任务状态同步
1379
1493
  }
1380
1494
 
1381
1495
  // ---------- 后台完成检测:定时任务由服务端调度执行,前端轮询感知状态变化 ----------
@@ -1647,6 +1761,7 @@ function switchSession(taskId) {
1647
1761
  unreadSess.delete(curKey()); // 切到该会话 = 已读,清除绿点
1648
1762
  renderSidebar();
1649
1763
  renderHeader();
1764
+ updateModeBadges(); // 已读清除后角标即时更新
1650
1765
  // 有未读(后台完成过)或本地空但任务已结束(定时调度后台跑完):从服务端拉最新消息
1651
1766
  const t = curTaskId ? tasks.find(x => x.id === curTaskId) : null;
1652
1767
  if (wasUnread || (t && (t.status === 'done' || t.status === 'failed') && getSess(curKey()).length === 0)) {
@@ -1665,6 +1780,7 @@ function markUnread(key) {
1665
1780
  if (key === curKey()) return;
1666
1781
  unreadSess.add(key);
1667
1782
  renderSidebar();
1783
+ updateModeBadges(); // 未读绿点变化同步标签角标
1668
1784
  }
1669
1785
 
1670
1786
  // 取某会话最后一条汇总消息文本(群聊取 report 阶段;单聊任务取最后一条 assistant),作为弹窗预览内容
@@ -3184,12 +3300,20 @@ async function rerunFrom(run, stage) {
3184
3300
  async function loadMessages() {
3185
3301
  const data = await api('/api/messages');
3186
3302
  sessions.clear();
3303
+ let mainCount = 0;
3187
3304
  for (const m of data.messages || []) {
3188
3305
  getSess(sessKey(m.taskId)).push({
3189
3306
  role: m.role, content: m.content, agentId: m.agentId, agentName: m.agentName,
3190
3307
  phase: m.phase, plan: m.plan, taskId: m.taskId, outputPath: m.outputPath, ts: m.timestamp
3191
3308
  });
3309
+ if (!m.taskId) mainCount++;
3192
3310
  }
3311
+ // 非群聊模式下主会话出现新消息:群聊标签计入未读提醒(回群聊即清零)
3312
+ if (chatMode !== 'group' && mainCount > lastMainMsgCount && lastMainMsgCount >= 0) {
3313
+ groupMsgUnread += mainCount - lastMainMsgCount;
3314
+ updateModeBadges();
3315
+ }
3316
+ lastMainMsgCount = mainCount;
3193
3317
  renderMessages();
3194
3318
  }
3195
3319
 
@@ -3354,6 +3478,94 @@ function applyTeamPreset(i, mode) {
3354
3478
  toast(`已应用「${t.name}」(${mode === 'replace' ? '替换' : '追加'} ${incoming.length} 个智能体),点「保存」后生效`);
3355
3479
  }
3356
3480
 
3481
+ // ---------- AI 智能组队:一句话需求 → 内核生成团队 → 预览应用 ----------
3482
+ let sgDraft = []; // 预览态候选(仅前端内存,应用才进 editing)
3483
+
3484
+ function openSuggest() {
3485
+ document.getElementById('sgResult').innerHTML = '';
3486
+ sgDraft = [];
3487
+ document.getElementById('suggestMask').classList.add('show');
3488
+ setTimeout(() => document.getElementById('sgReqInput').focus(), 50);
3489
+ }
3490
+ function closeSuggest() { document.getElementById('suggestMask').classList.remove('show'); }
3491
+
3492
+ async function runSuggest() {
3493
+ const reqEl = document.getElementById('sgReqInput');
3494
+ const requirements = String(reqEl.value || '').trim();
3495
+ if (!requirements) { toast('先描述一下你的需求,AI 才知道要组什么团队'); reqEl.focus(); return; }
3496
+ const btn = document.getElementById('sgRunBtn');
3497
+ const out = document.getElementById('sgResult');
3498
+ btn.disabled = true;
3499
+ btn.textContent = '⏳ 生成中…(AI 正在设计团队分工,最长约 2 分钟)';
3500
+ out.innerHTML = '<div class="task-empty" style="padding:16px">AI 正在根据你的需求设计团队分工,请稍候…</div>';
3501
+ sgDraft = [];
3502
+ try {
3503
+ const r = await api('/api/agents/suggest', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ requirements }) });
3504
+ if (r.success) {
3505
+ sgDraft = r.agents || [];
3506
+ renderSuggestCards();
3507
+ toast(`已生成 ${sgDraft.length} 个智能体,确认后点下方「应用」`);
3508
+ } else {
3509
+ out.innerHTML = `<div class="task-empty" style="padding:16px;color:#c0392b">❌ ${esc(r.error || '生成失败,请稍后重试')}</div>`;
3510
+ }
3511
+ } catch (e) {
3512
+ out.innerHTML = `<div class="task-empty" style="padding:16px;color:#c0392b">❌ 生成失败:${esc(e.message)}</div>`;
3513
+ } finally {
3514
+ btn.disabled = false;
3515
+ btn.textContent = '✨ 生成团队';
3516
+ }
3517
+ }
3518
+
3519
+ function renderSuggestCards() {
3520
+ const el = document.getElementById('sgResult');
3521
+ if (!sgDraft.length) {
3522
+ el.innerHTML = '<div class="task-empty" style="padding:16px">暂无生成结果,输入需求后点「生成团队」</div>';
3523
+ return;
3524
+ }
3525
+ el.innerHTML = sgDraft.map((a, i) => `
3526
+ <div class="ag-card">
3527
+ <div class="ag-row">
3528
+ <span style="font-size:20px;align-self:center">${esc(a.icon || '🤖')}</span>
3529
+ <b style="align-self:center;flex:1">${esc(a.name)}</b>
3530
+ <button class="mini-btn gray" onclick="sgDraft.splice(${i},1);renderSuggestCards()">删除</button>
3531
+ </div>
3532
+ <input value="${escAttr(a.desc || '')}" placeholder="技能说明" oninput="sgDraft[${i}].desc=this.value">
3533
+ <details>
3534
+ <summary style="font-size:12px;color:#666;cursor:pointer;margin:4px 0">自定义提示词(点击展开查看,可直接修改)</summary>
3535
+ <textarea oninput="sgDraft[${i}].systemPrompt=this.value">${esc(a.systemPrompt || '')}</textarea>
3536
+ </details>
3537
+ </div>`).join('');
3538
+ }
3539
+
3540
+ // 应用候选:append=合并到现有名单(重名自动加序号);replace=替换全部子智能体
3541
+ function applySuggest(mode) {
3542
+ if (!sgDraft.length) { toast('还没有可应用的生成结果'); return; }
3543
+ const incoming = sgDraft.map(a => ({
3544
+ id: '', behavior: 'echo',
3545
+ name: String(a.name || '').trim(),
3546
+ icon: String(a.icon || ''),
3547
+ desc: String(a.desc || ''),
3548
+ systemPrompt: String(a.systemPrompt || '')
3549
+ })).filter(a => a.name);
3550
+ if (!incoming.length) { toast('候选智能体缺少名称,无法应用'); return; }
3551
+ if (mode === 'replace') {
3552
+ editing = incoming;
3553
+ } else {
3554
+ const names = new Set(['管家', 'butler', ...editing.map(a => String(a.name || '').trim())]);
3555
+ for (const a of incoming) {
3556
+ let final = a.name, n = 2;
3557
+ while (names.has(final)) final = `${a.name}${n++}`;
3558
+ a.name = final;
3559
+ names.add(final);
3560
+ editing.push(a);
3561
+ }
3562
+ }
3563
+ sgDraft = [];
3564
+ renderAgentCards();
3565
+ closeSuggest();
3566
+ toast(`已应用 AI 生成的团队(${mode === 'replace' ? '替换' : '合并'} ${incoming.length} 个智能体),点配置页「保存」后生效`);
3567
+ }
3568
+
3357
3569
  function iconPicker(i) {
3358
3570
  const cur = editing[i].icon || '';
3359
3571
  return '<div class="icon-row">' + ICON_PRESETS.map(ic =>
@@ -3605,6 +3817,7 @@ function openHelp() {
3605
3817
  <li>本机安装任一执行内核并登录模型(模型与密钥由内核自己管理,本程序不做任何 API 配置):<br>
3606
3818
  <code>${KERNEL_INSTALL.opencode}</code><br><code>${KERNEL_INSTALL.claude}</code><br><code>${KERNEL_INSTALL.codex}</code><br><code>${KERNEL_INSTALL.pi}</code></li>
3607
3819
  <li>启动本程序后浏览器打开 <code>http://localhost:3456</code>,会自动识别已安装的内核</li>
3820
+ <li>不知道配什么团队?「⚙ 智能体配置」里点「🪄 AI 组队」,一句话描述需求,AI 自动生成整套智能体团队(先预览再应用);也可从「📦 团队仓库」一键换经典团队</li>
3608
3821
  <li>直接在输入框说话即可开始;粘贴任务清单可批量执行</li>
3609
3822
  </ol>
3610
3823
  </div>
package/app/server.js CHANGED
@@ -69,7 +69,11 @@ process.on('unhandledRejection', (err) => {
69
69
  const PORT = Number(process.argv.includes('--port') ? process.argv[process.argv.indexOf('--port') + 1] : (process.env.PORT || 3456));
70
70
  const PUBLIC_DIR = path.join(__dirname, 'public');
71
71
  const store = require('./lib/store');
72
- const { runAgent, stopScope, stopAllChildren } = require('./lib/agent');
72
+ const { runAgent, resolveRunner, missingHint, stopScope, stopAllChildren } = require('./lib/agent');
73
+ const teamgen = require('./lib/teamgen');
74
+ const planner = require('./lib/planner');
75
+ let suggestInFlight = false; // AI 组队生成互斥:同刻仅一个(内核单次调用,防并发浪费)
76
+ const plannerLocks = new Set(); // AI 编排按会话互斥(每会话同时一个内核调用)
73
77
  const { runButler, runMentioned, runRoundtable, runTasks, prepareRerun } = require('./lib/orchestrator');
74
78
  const oc = require('./lib/oc');
75
79
  const memoryMod = require('./lib/memory');
@@ -598,6 +602,104 @@ const server = http.createServer(async (req, res) => {
598
602
  return;
599
603
  }
600
604
 
605
+ if (p === '/api/agents/suggest' && req.method === 'POST') {
606
+ // AI 智能组队:需求文本 → 执行内核生成团队配置(预览态,不落盘;应用走 /api/agents 保存链路)
607
+ const body = await readBody(req);
608
+ const requirements = String(body.requirements || '').trim();
609
+ if (!requirements || requirements.length > teamgen.REQ_MAX) {
610
+ json(res, 400, { success: false, error: `需求描述不能为空且不超过 ${teamgen.REQ_MAX} 字` });
611
+ return;
612
+ }
613
+ if (suggestInFlight) {
614
+ json(res, 409, { success: false, error: '上一次生成还在进行中,请稍候' });
615
+ return;
616
+ }
617
+ const runner = resolveRunner();
618
+ if (runner.kind === 'demo' || runner.kind === 'missing') {
619
+ const why = runner.kind === 'demo'
620
+ ? '当前为演示模式(AGENTS_CHAT_MOCK=1),删除 .env 中该配置并重启本程序后即可使用真实生成'
621
+ : missingHint(runner);
622
+ json(res, 200, { success: false, error: `AI 组队需要真实执行内核:${why}` });
623
+ return;
624
+ }
625
+ suggestInFlight = true;
626
+ try {
627
+ const r = await teamgen.suggestTeam({
628
+ requirements,
629
+ existingNames: store.getAgents().map(a => a.name)
630
+ });
631
+ json(res, 200, r);
632
+ } finally {
633
+ suggestInFlight = false;
634
+ }
635
+ return;
636
+ }
637
+
638
+ // ---------- AI 智能编排(卡牌控制台聊天式编排) ----------
639
+ if (p === '/api/planner/chat' && req.method === 'POST') {
640
+ const body = await readBody(req);
641
+ const message = String(body.message || '').trim();
642
+ if (!message || message.length > planner.MSG_MAX) {
643
+ json(res, 400, { success: false, error: `消息不能为空且不超过 ${planner.MSG_MAX} 字` });
644
+ return;
645
+ }
646
+ const runner = resolveRunner();
647
+ if (runner.kind === 'demo' || runner.kind === 'missing') {
648
+ const why = runner.kind === 'demo'
649
+ ? '当前为演示模式(AGENTS_CHAT_MOCK=1),删除 .env 中该配置并重启本程序后即可使用'
650
+ : missingHint(runner);
651
+ json(res, 200, { success: false, error: `AI 编排需要真实执行内核:${why}` });
652
+ return;
653
+ }
654
+ const sid = body.sid ? String(body.sid) : '';
655
+ if (plannerLocks.has(sid)) { json(res, 409, { success: false, error: '上一条还在处理中,请稍候' }); return; }
656
+ plannerLocks.add(sid);
657
+ try {
658
+ const r = await planner.chat({ sid, message });
659
+ if (r.error) json(res, 200, { success: false, error: r.error });
660
+ else json(res, 200, { success: true, sid: r.sid, reply: r.reply });
661
+ } finally {
662
+ plannerLocks.delete(sid);
663
+ }
664
+ return;
665
+ }
666
+
667
+ if (p === '/api/planner/plan' && req.method === 'POST') {
668
+ const body = await readBody(req);
669
+ const sid = String(body.sid || '');
670
+ const runner = resolveRunner();
671
+ if (runner.kind === 'demo' || runner.kind === 'missing') {
672
+ const why = runner.kind === 'demo'
673
+ ? '当前为演示模式(AGENTS_CHAT_MOCK=1),删除 .env 中该配置并重启本程序后即可使用'
674
+ : missingHint(runner);
675
+ json(res, 200, { success: false, error: `AI 编排需要真实执行内核:${why}` });
676
+ return;
677
+ }
678
+ if (plannerLocks.has(sid)) { json(res, 409, { success: false, error: '上一条还在处理中,请稍候' }); return; }
679
+ plannerLocks.add(sid);
680
+ try {
681
+ const r = await planner.plan({ sid });
682
+ if (r.error) json(res, 200, { success: false, error: r.error });
683
+ else json(res, 200, { success: true, plan: r.plan });
684
+ } finally {
685
+ plannerLocks.delete(sid);
686
+ }
687
+ return;
688
+ }
689
+
690
+ if (p === '/api/planner/session' && req.method === 'GET') {
691
+ const sid = String(parsed.query.sid || '');
692
+ const s = sid ? planner.readSession(sid) : planner.latestSession();
693
+ json(res, 200, { success: true, found: !!s, sid: s ? s.sid : '', messages: s ? s.messages : [] });
694
+ return;
695
+ }
696
+ if (p === '/api/planner/session/delete' && req.method === 'POST') {
697
+ const body = await readBody(req);
698
+ const ok = planner.deleteSession(String(body.sid || ''));
699
+ json(res, 200, { success: ok });
700
+ return;
701
+ }
702
+
601
703
  if (p === '/api/tasks' && req.method === 'GET') {
602
704
  // 按 seq(拖拽可调)排序返回
603
705
  json(res, 200, { success: true, tasks: store.getTasks() });
@@ -1177,6 +1279,71 @@ const server = http.createServer(async (req, res) => {
1177
1279
  return;
1178
1280
  }
1179
1281
 
1282
+ if (p === '/api/cards/stats' && req.method === 'GET') {
1283
+ // 三模式标签角标:轻量统计(<1KB),避免主界面拉全量卡牌
1284
+ const stat = { total: 0, pending: 0, running: 0, done: 0, failed: 0 };
1285
+ for (const c of CardStore.list()) {
1286
+ stat.total++;
1287
+ if (stat[c.status] !== undefined) stat[c.status]++;
1288
+ }
1289
+ json(res, 200, { success: true, stats: stat });
1290
+ return;
1291
+ }
1292
+
1293
+ if (p === '/api/cards/batch' && req.method === 'POST') {
1294
+ // AI 编排清单批量导入:importMode=append 追加 | replace 先清空全部再导入
1295
+ const body = await readBody(req);
1296
+ const tasksIn = Array.isArray(body.tasks) ? body.tasks : [];
1297
+ if (!tasksIn.length || tasksIn.length > 50) {
1298
+ json(res, 400, { success: false, error: 'tasks 必须是 1-50 个任务的数组' });
1299
+ return;
1300
+ }
1301
+ const { wouldCycle } = require('./lib/cards');
1302
+ let replaced = 0;
1303
+ if (String(body.importMode) === 'replace') {
1304
+ for (const c of CardStore.list()) { CardStore.remove(c.id); replaced++; }
1305
+ }
1306
+ // 第一步:先全部创建(暂不带依赖),拿到序号 → id 映射
1307
+ const idMap = new Map();
1308
+ const cards = [];
1309
+ tasksIn.forEach((t, i) => {
1310
+ const card = CardStore.add({
1311
+ title: String(t.title || '').trim() || String(t.content || '').slice(0, 40) || `任务${i + 1}`,
1312
+ content: String(t.content || ''),
1313
+ priority: Number.isFinite(Number(t.priority)) ? Number(t.priority) : 100 + i,
1314
+ mode: 'new'
1315
+ });
1316
+ idMap.set(i + 1, card.id);
1317
+ cards.push(card);
1318
+ });
1319
+ // 第二步:按清单回填 mode / deps(映射为批内 id)与续聊链首;环依赖丢弃并计警告
1320
+ const warnings = [];
1321
+ tasksIn.forEach((t, i) => {
1322
+ const id = idMap.get(i + 1);
1323
+ let mode = String(t.mode || 'new');
1324
+ if (!['new', 'continue', 'parallel'].includes(mode)) mode = 'new';
1325
+ let deps = Array.isArray(t.deps) ? t.deps : [];
1326
+ deps = [...new Set(deps.map(d => parseInt(d, 10)).filter(Number.isInteger))]
1327
+ .map(d => idMap.get(d)).filter(x => x && x !== id);
1328
+ let chainId = '';
1329
+ if (mode === 'continue' && deps.length) {
1330
+ chainId = deps[0]; // 续聊链首 = 第一个依赖(清单内前一步)
1331
+ } else if (mode === 'continue') {
1332
+ mode = 'new'; // 无链首的续聊退化为新进程
1333
+ }
1334
+ const safe = [];
1335
+ for (const d of deps) {
1336
+ if (wouldCycle(id, [...safe, d])) { warnings.push(`任务 ${i + 1} 的依赖(序号 ${d})会形成循环,已跳过`); continue; }
1337
+ safe.push(d);
1338
+ }
1339
+ CardStore.update(id, { mode, dependsOn: safe, chainId });
1340
+ const c = cards.find(x => x.id === id);
1341
+ if (c) { c.mode = mode; c.dependsOn = safe; c.chainId = chainId; }
1342
+ });
1343
+ json(res, 200, { success: true, cards, warnings, replaced });
1344
+ return;
1345
+ }
1346
+
1180
1347
  if (p === '/api/cards/clear' && req.method === 'POST') {
1181
1348
  const all = CardStore.list();
1182
1349
  for (const c of all) CardStore.remove(c.id);
@@ -1574,6 +1741,9 @@ function pruneOldDataQuiet() {
1574
1741
  require('./lib/worktree').pruneStale(PRUNE_DAYS, store.getTasks())
1575
1742
  .then(ws => { if (ws.worktrees) { stat.worktrees = ws.worktrees; console.log('[maintenance] 清理过期 Git 隔离区:', ws.worktrees); } })
1576
1743
  .catch(() => {});
1744
+ // AI 编排聊天会话同步滚动清理(与任务/会话同一保留窗口)
1745
+ const pl = require('./lib/planner').pruneStale(PRUNE_DAYS);
1746
+ if (pl) { stat.plannerSessions = pl; console.log('[maintenance] 清理过期编排会话:', pl); }
1577
1747
  const touched = Object.values(stat).reduce((a, b) => a + b, 0);
1578
1748
  if (touched) console.log(`[maintenance] 自动清理超 ${PRUNE_DAYS} 天的历史数据:`, JSON.stringify(stat));
1579
1749
  } catch (e) { console.error('[maintenance] 自动清理失败:', e && (e.message || e)); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iamsamyiok/agents-chat",
3
- "version": "3.33.0",
3
+ "version": "3.35.0",
4
4
  "description": "多智能体群聊工具 - 支持 OpenCode/Claude Code/Codex/pi 内核,微信风格聊天界面",
5
5
  "main": "lib/start.js",
6
6
  "bin": {