@iamsamyiok/agents-chat 3.34.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
@@ -26,3 +26,6 @@
26
26
  # AGENTS_CHAT_TIMEOUT_MS=600000
27
27
  # AI 组队生成超时(毫秒,默认 120000;真实内核生成整队提示词耗时较长)
28
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
@@ -27,6 +27,7 @@ npm install -g opencode-ai
27
27
  |---|---|
28
28
  | 群聊编排 | 直接输入需求,管家智能体拆解成阶段计划,调度子智能体并行执行,自动验收不合格返工 |
29
29
  | AI 组队 | 一句话描述需求,执行内核自动生成整套智能体团队(名称/图标/技能/提示词),预览后合并或替换应用 |
30
+ | AI 编排 | 多任务编排页「🪄 AI 编排」聊天式编排:描述目标,AI 反问澄清后一键生成带依赖与并行的任务清单,编辑预览后导入看板 |
30
31
  | 批量任务 | 粘贴一段文本一次导入多条任务(`1. xxx` 每行一条),可拖拽排序、批量执行 |
31
32
  | 定时任务 | 行首写时间即定时(`2026-08-18 13:07 生成日报`),到点自动执行,停机错过的启动时补跑,可开机自启 |
32
33
  | Git 隔离 | 导入时勾选「Git 隔离执行」:每任务独立 worktree 分支互不污染,完成后逐文件看 diff、一键合并或丢弃 |
@@ -34,6 +35,7 @@ npm install -g opencode-ai
34
35
  | 完成通知 | 页面在后台弹系统通知;webhook 转发钉钉/飞书/自建中转 |
35
36
  | 单聊工作台 | 与 OpenCode 单体多轮续聊;任务链支持 `-` 接续上一会话、`//` 并行执行 |
36
37
  | 卡牌协作 | 每个智能体一张卡牌实时直播工作过程,可中途插话纠偏或委派转交 |
38
+ | 三模式标签 | 左上角「👤 单聊 / 👥 群聊 / 🎛 编排」一键切换(编排控制台内嵌主界面),每个标签实时显示完成进度与未读绿点提醒 |
37
39
 
38
40
  ## 安装方式
39
41
 
@@ -85,7 +87,7 @@ OpenCode(推荐)/ Claude Code / Codex CLI / pi,页面右上角可切换。
85
87
  git clone https://github.com/iamsamyiok/agents-chat
86
88
  cd agents-chat && npm install
87
89
  AGENTS_CHAT_MOCK=1 npm start # 演示模式开发,无需内核
88
- npm test # 81 个测试用例
90
+ npm test # 92 个测试用例
89
91
  ```
90
92
 
91
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
+ };
@@ -37,8 +37,8 @@ function buildPrompt(requirements, existingNames) {
37
37
  ].filter(line => line !== '').join('\n');
38
38
  }
39
39
 
40
- // 容错提取 JSON 数组:裸数组 → ```json 代码块 → 首个 [ 到最后一个 ] 的子串
41
- function extractTeamJSON(text) {
40
+ // 通用容错提取 JSON 数组(planner 等模块共享):裸数组 → ```json 代码块 → 首个 [ 到最后一个 ] 的子串
41
+ function extractJSONArray(text) {
42
42
  const raw = String(text || '').trim();
43
43
  if (!raw) return null;
44
44
  // 1. 整体就是合法 JSON
@@ -57,6 +57,11 @@ function extractTeamJSON(text) {
57
57
  return null;
58
58
  }
59
59
 
60
+ // 兼容保留:团队配置提取(实现转用通用函数)
61
+ function extractTeamJSON(text) {
62
+ return extractJSONArray(text);
63
+ }
64
+
60
65
  // 清洗生成结果:字段类型归一 + 长度截断 + 空名补名 + 重名后缀 + 数量上限
61
66
  // existingNames:现有智能体名(含管家),重名自动加序号,保证 @ 点名无歧义
62
67
  function cleanSuggestedTeam(arr, existingNames) {
@@ -135,4 +140,4 @@ async function suggestTeam({ requirements, existingNames, runAgentFn, timeoutMs
135
140
  return { success: true, agents };
136
141
  }
137
142
 
138
- module.exports = { buildPrompt, extractTeamJSON, cleanSuggestedTeam, suggestTeam, LIMITS, TEAM_MIN, TEAM_MAX, REQ_MAX };
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;
@@ -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
 
@@ -769,7 +782,7 @@
769
782
  </div>
770
783
 
771
784
  <script>
772
- const PAGE_VERSION = '3.34.0'; // 与服务端 /api/health.version 互检,不一致说明页面缓存过期
785
+ const PAGE_VERSION = '3.35.0'; // 与服务端 /api/health.version 互检,不一致说明页面缓存过期
773
786
  const AV_COLORS = ['#5b8def','#07c160','#fa9d3b','#10aeff','#8a6fe8','#fa5151','#e8a33d','#3aa7a3'];
774
787
  const PHASE_LABEL = { plan: '调度规划', work: '执行', review: '验收', report: '汇总', talk: '圆桌发言', task: '任务执行' };
775
788
  // 职业图标库(智能体配置可选)
@@ -786,7 +799,8 @@ let globalCwd = ''; // 全局统一工作目录
786
799
  let configKernel = 'auto'; // 配置的执行内核
787
800
  let configApproval = 'off'; // 人工审批关卡:off/plan/verify/all
788
801
  let teamPresets = []; // 团队仓库预设(配置页「📦 团队仓库」)
789
- let chatMode = 'group'; // 当前模式:group=群聊(多智能体)| solo=单聊(OpenCode 工作台)
802
+ let chatMode = 'group'; // 当前模式:group=群聊(多智能体)| solo=单聊(OpenCode 工作台)| cards=多任务编排(内嵌控制台)
803
+ let lastMainMsgCount = -1; // 主会话消息数快照(-1=首次加载不计数;用于非群聊模式的未读提醒)
790
804
  const sessions = new Map(); // key: 'main' | taskId | oc会话id → 消息数组
791
805
 
792
806
  // ---------- 单聊工作台状态 ----------
@@ -881,6 +895,13 @@ async function init() {
881
895
  restoreApprovals(); // 恢复仍在等待的人工审批卡片(刷新/断线场景)
882
896
  updateFooterBtns();
883
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
+
884
905
  // 版本互检:页面与服务端不一致 → 页面是旧缓存,必须强刷
885
906
  if (health.version && health.version !== PAGE_VERSION) {
886
907
  addSys(`⚠ 页面版本(${PAGE_VERSION})与服务端(${health.version})不一致:浏览器缓存了旧页面,请按 Ctrl+F5 强制刷新`);
@@ -949,19 +970,39 @@ async function init() {
949
970
  setInterval(() => {
950
971
  if (taskBusy || document.hidden) return;
951
972
  loadTasks(true);
973
+ refreshCardsStats(); // 编排标签角标同步(stats 接口轻量 <1KB)
952
974
  }, 8000);
953
975
  }
954
976
 
955
- // ---------- 单聊/群聊模式切换(左上角开关) ----------
977
+ // ---------- 单聊/群聊/编排三模式切换(左上角标签) ----------
978
+ const MODE_STORE_KEY = 'acChatMode'; // 模式持久化:刷新后恢复上次所在标签
979
+ let cardsStatsCache = null; // /api/cards/stats 最近一次结果(角标计算)
980
+ let groupMsgUnread = 0; // 非群聊模式下主会话收到的未读消息数(切回群聊清零)
981
+
956
982
  function setChatMode(mode) {
957
- 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; // 回到群聊即视为已读主会话
958
986
  document.getElementById('modeSoloBtn').classList.toggle('active', chatMode === 'solo');
959
987
  document.getElementById('modeGroupBtn').classList.toggle('active', chatMode === 'group');
988
+ document.getElementById('modeCardsBtn').classList.toggle('active', chatMode === 'cards');
960
989
  document.getElementById('groupView').style.display = chatMode === 'group' ? 'flex' : 'none';
961
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
+ }
962
1002
  renderSidebar();
963
1003
  renderHeader();
964
1004
  updateFooterBtns();
1005
+ updateModeBadges();
965
1006
  if (chatMode === 'solo') {
966
1007
  loadOcSessions();
967
1008
  loadOcModels();
@@ -969,6 +1010,58 @@ function setChatMode(mode) {
969
1010
  }
970
1011
  }
971
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
+
972
1065
  // ==================== 单聊工作台(OpenCode) ====================
973
1066
 
974
1067
  // ---------- 模型列表 ----------
@@ -1396,6 +1489,7 @@ async function loadTasks(silent) {
1396
1489
  tasks = data.tasks || [];
1397
1490
  detectBackgroundDone();
1398
1491
  renderSidebar();
1492
+ updateModeBadges(); // 三标签角标随任务状态同步
1399
1493
  }
1400
1494
 
1401
1495
  // ---------- 后台完成检测:定时任务由服务端调度执行,前端轮询感知状态变化 ----------
@@ -1667,6 +1761,7 @@ function switchSession(taskId) {
1667
1761
  unreadSess.delete(curKey()); // 切到该会话 = 已读,清除绿点
1668
1762
  renderSidebar();
1669
1763
  renderHeader();
1764
+ updateModeBadges(); // 已读清除后角标即时更新
1670
1765
  // 有未读(后台完成过)或本地空但任务已结束(定时调度后台跑完):从服务端拉最新消息
1671
1766
  const t = curTaskId ? tasks.find(x => x.id === curTaskId) : null;
1672
1767
  if (wasUnread || (t && (t.status === 'done' || t.status === 'failed') && getSess(curKey()).length === 0)) {
@@ -1685,6 +1780,7 @@ function markUnread(key) {
1685
1780
  if (key === curKey()) return;
1686
1781
  unreadSess.add(key);
1687
1782
  renderSidebar();
1783
+ updateModeBadges(); // 未读绿点变化同步标签角标
1688
1784
  }
1689
1785
 
1690
1786
  // 取某会话最后一条汇总消息文本(群聊取 report 阶段;单聊任务取最后一条 assistant),作为弹窗预览内容
@@ -3204,12 +3300,20 @@ async function rerunFrom(run, stage) {
3204
3300
  async function loadMessages() {
3205
3301
  const data = await api('/api/messages');
3206
3302
  sessions.clear();
3303
+ let mainCount = 0;
3207
3304
  for (const m of data.messages || []) {
3208
3305
  getSess(sessKey(m.taskId)).push({
3209
3306
  role: m.role, content: m.content, agentId: m.agentId, agentName: m.agentName,
3210
3307
  phase: m.phase, plan: m.plan, taskId: m.taskId, outputPath: m.outputPath, ts: m.timestamp
3211
3308
  });
3309
+ if (!m.taskId) mainCount++;
3310
+ }
3311
+ // 非群聊模式下主会话出现新消息:群聊标签计入未读提醒(回群聊即清零)
3312
+ if (chatMode !== 'group' && mainCount > lastMainMsgCount && lastMainMsgCount >= 0) {
3313
+ groupMsgUnread += mainCount - lastMainMsgCount;
3314
+ updateModeBadges();
3212
3315
  }
3316
+ lastMainMsgCount = mainCount;
3213
3317
  renderMessages();
3214
3318
  }
3215
3319
 
package/app/server.js CHANGED
@@ -71,7 +71,9 @@ const PUBLIC_DIR = path.join(__dirname, 'public');
71
71
  const store = require('./lib/store');
72
72
  const { runAgent, resolveRunner, missingHint, stopScope, stopAllChildren } = require('./lib/agent');
73
73
  const teamgen = require('./lib/teamgen');
74
+ const planner = require('./lib/planner');
74
75
  let suggestInFlight = false; // AI 组队生成互斥:同刻仅一个(内核单次调用,防并发浪费)
76
+ const plannerLocks = new Set(); // AI 编排按会话互斥(每会话同时一个内核调用)
75
77
  const { runButler, runMentioned, runRoundtable, runTasks, prepareRerun } = require('./lib/orchestrator');
76
78
  const oc = require('./lib/oc');
77
79
  const memoryMod = require('./lib/memory');
@@ -633,6 +635,71 @@ const server = http.createServer(async (req, res) => {
633
635
  return;
634
636
  }
635
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
+
636
703
  if (p === '/api/tasks' && req.method === 'GET') {
637
704
  // 按 seq(拖拽可调)排序返回
638
705
  json(res, 200, { success: true, tasks: store.getTasks() });
@@ -1212,6 +1279,71 @@ const server = http.createServer(async (req, res) => {
1212
1279
  return;
1213
1280
  }
1214
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
+
1215
1347
  if (p === '/api/cards/clear' && req.method === 'POST') {
1216
1348
  const all = CardStore.list();
1217
1349
  for (const c of all) CardStore.remove(c.id);
@@ -1609,6 +1741,9 @@ function pruneOldDataQuiet() {
1609
1741
  require('./lib/worktree').pruneStale(PRUNE_DAYS, store.getTasks())
1610
1742
  .then(ws => { if (ws.worktrees) { stat.worktrees = ws.worktrees; console.log('[maintenance] 清理过期 Git 隔离区:', ws.worktrees); } })
1611
1743
  .catch(() => {});
1744
+ // AI 编排聊天会话同步滚动清理(与任务/会话同一保留窗口)
1745
+ const pl = require('./lib/planner').pruneStale(PRUNE_DAYS);
1746
+ if (pl) { stat.plannerSessions = pl; console.log('[maintenance] 清理过期编排会话:', pl); }
1612
1747
  const touched = Object.values(stat).reduce((a, b) => a + b, 0);
1613
1748
  if (touched) console.log(`[maintenance] 自动清理超 ${PRUNE_DAYS} 天的历史数据:`, JSON.stringify(stat));
1614
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.34.0",
3
+ "version": "3.35.0",
4
4
  "description": "多智能体群聊工具 - 支持 OpenCode/Claude Code/Codex/pi 内核,微信风格聊天界面",
5
5
  "main": "lib/start.js",
6
6
  "bin": {