@jesonliu/lark-claudecode-bridge 0.18.1 → 0.20.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/README.md +14 -12
- package/assets/web/css/main.css +509 -111
- package/assets/web/index.html +12 -2
- package/assets/web/js/main.js +19 -11
- package/assets/web/js/pages/apps.js +10 -5
- package/assets/web/js/pages/claude.js +15 -5
- package/assets/web/js/pages/mcp.js +265 -0
- package/assets/web/js/pages/overview.js +5 -4
- package/assets/web/js/pages/permissions.js +56 -30
- package/assets/web/js/pages/plugins.js +29 -6
- package/assets/web/js/pages/skills.js +342 -0
- package/assets/web/js/pages/slash.js +11 -5
- package/assets/web/js/pages/workspaces.js +36 -14
- package/assets/web/js/theme.js +23 -0
- package/assets/web/js/ui.js +75 -4
- package/config.example.yaml +2 -3
- package/dist/access/access-control.d.ts +17 -1
- package/dist/access/access-control.js +31 -2
- package/dist/access/access-control.js.map +1 -1
- package/dist/bin/lcb.js +5 -0
- package/dist/bin/lcb.js.map +1 -1
- package/dist/claude-config.d.ts +14 -2
- package/dist/claude-config.js +75 -4
- package/dist/claude-config.js.map +1 -1
- package/dist/cli/setup-wizard.d.ts +0 -1
- package/dist/cli/setup-wizard.js +2 -4
- package/dist/cli/setup-wizard.js.map +1 -1
- package/dist/config.js +39 -14
- package/dist/config.js.map +1 -1
- package/dist/executor/claude-executor.d.ts +1 -1
- package/dist/executor/claude-executor.js +4 -2
- package/dist/executor/claude-executor.js.map +1 -1
- package/dist/executor/notify-server.d.ts +20 -0
- package/dist/executor/notify-server.js +85 -4
- package/dist/executor/notify-server.js.map +1 -1
- package/dist/executor/plugin-discovery.d.ts +6 -0
- package/dist/executor/plugin-discovery.js +14 -0
- package/dist/executor/plugin-discovery.js.map +1 -1
- package/dist/gateway/card-builder.d.ts +60 -24
- package/dist/gateway/card-builder.js +200 -109
- package/dist/gateway/card-builder.js.map +1 -1
- package/dist/gateway/diff-card.js +1 -1
- package/dist/gateway/diff-card.js.map +1 -1
- package/dist/gateway/feishu-gateway.d.ts +27 -0
- package/dist/gateway/feishu-gateway.js +106 -4
- package/dist/gateway/feishu-gateway.js.map +1 -1
- package/dist/gateway/progress-card.d.ts +77 -0
- package/dist/gateway/progress-card.js +231 -8
- package/dist/gateway/progress-card.js.map +1 -1
- package/dist/index.d.ts +16 -1
- package/dist/index.js +354 -98
- package/dist/index.js.map +1 -1
- package/dist/notify-sop.d.ts +1 -0
- package/dist/notify-sop.js +33 -0
- package/dist/notify-sop.js.map +1 -0
- package/dist/session/commands.d.ts +1 -1
- package/dist/session/commands.js +25 -2
- package/dist/session/commands.js.map +1 -1
- package/dist/session/session-store.d.ts +3 -0
- package/dist/session/session-store.js.map +1 -1
- package/dist/types.d.ts +23 -5
- package/dist/util/log-tee.d.ts +4 -0
- package/dist/util/log-tee.js +99 -0
- package/dist/util/log-tee.js.map +1 -0
- package/dist/util/runtime-dirs.d.ts +4 -0
- package/dist/util/runtime-dirs.js +27 -0
- package/dist/util/runtime-dirs.js.map +1 -0
- package/dist/util/workspace-diff.js +2 -2
- package/dist/util/workspace-diff.js.map +1 -1
- package/dist/web/lifecycle.d.ts +4 -1
- package/dist/web/lifecycle.js +11 -25
- package/dist/web/lifecycle.js.map +1 -1
- package/dist/web/server.js +404 -11
- package/dist/web/server.js.map +1 -1
- package/dist/web/skills-mcp-api.d.ts +120 -0
- package/dist/web/skills-mcp-api.js +348 -0
- package/dist/web/skills-mcp-api.js.map +1 -0
- package/package.json +3 -1
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// 装配主流程:消息 → 访问控制 → 命令 → 通道队列 → 执行 → 确认卡片 → 回传
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import { existsSync } from 'node:fs';
|
|
4
|
-
import { basename, join, resolve } from 'node:path';
|
|
3
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { basename, join, resolve, sep } from 'node:path';
|
|
5
5
|
import { CONFIG_DIR, CONFIG_PATH, DEFAULT_CONTEXT_REMIND_TOKENS, loadConfig, sameApps } from './config.js';
|
|
6
6
|
import { DEFAULT_CLAUDE_DIR, resolveClaudeDir } from './claude-config.js';
|
|
7
7
|
import { warnIfNoClaudeAuth } from './auth-precheck.js';
|
|
@@ -11,12 +11,14 @@ import { clearPidFile, writePidFile, stopExistingBridgeAndWait } from './web/lif
|
|
|
11
11
|
import { AccessControl } from './access/access-control.js';
|
|
12
12
|
import { FeishuGateway } from './gateway/feishu-gateway.js';
|
|
13
13
|
import { ProgressCard } from './gateway/progress-card.js';
|
|
14
|
-
import { DECISION_TEXT,
|
|
14
|
+
import { DECISION_TEXT, PROGRESS_TAIL_CHARS, LONG_OUTPUT_THRESHOLD, buildLongOutputCard, buildLongOutputSettledCard, } from './gateway/card-builder.js';
|
|
15
15
|
import { buildDiffSummaryCards } from './gateway/diff-card.js';
|
|
16
16
|
import { runTask } from './executor/claude-executor.js';
|
|
17
17
|
import { PermissionGate, DEFAULT_ALLOW_TOOLS_LIST, DEFAULT_DANGEROUS_COMMANDS, } from './executor/permission-gate.js';
|
|
18
18
|
import { discoverPlugins, resolvePluginPaths } from './executor/plugin-discovery.js';
|
|
19
19
|
import { createGatewaySender, createNotifyServer, NOTIFY_SERVER_NAME } from './executor/notify-server.js';
|
|
20
|
+
import { BRIDGE_MCP_JSON, readMcpServersFromJsonFile } from './web/skills-mcp-api.js';
|
|
21
|
+
import { FEISHU_NOTIFY_SOP_PROMPT } from './notify-sop.js';
|
|
20
22
|
import { collectWorkspaceDiff } from './util/workspace-diff.js';
|
|
21
23
|
import { isImageFile } from './util/file-types.js';
|
|
22
24
|
import { FileTracker } from './util/file-tracker.js';
|
|
@@ -53,7 +55,31 @@ const PLAN_DECISION_TEXT = {
|
|
|
53
55
|
'plan-approve': '✅ 计划已批准,开始执行(后续写操作免确认)',
|
|
54
56
|
'plan-revise': '📝 修改意见已提交',
|
|
55
57
|
'plan-reject': '❌ 计划已放弃',
|
|
58
|
+
// plan-view-file 不消费挂起项,仅 toast「正在发送方案文件…」——文案不在此出现,留口供编译期完整性
|
|
59
|
+
'plan-view-file': '',
|
|
56
60
|
};
|
|
61
|
+
/**
|
|
62
|
+
* 运行期文档落盘(plan 方案 / 超长回复共用):CONFIG_DIR 下按子目录 + 时间戳 + 工作区
|
|
63
|
+
* slug 命名,返回文件路径;写盘失败返回 ''(调用方回退降级行为,不影响主流程)
|
|
64
|
+
*/
|
|
65
|
+
function writeRuntimeMarkdown(subDir, slugBase, content, logTag) {
|
|
66
|
+
const dir = join(CONFIG_DIR, subDir);
|
|
67
|
+
try {
|
|
68
|
+
mkdirSync(dir, { recursive: true });
|
|
69
|
+
}
|
|
70
|
+
catch { /* 已存在 */ }
|
|
71
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
72
|
+
const slug = (slugBase || subDir).replace(/[^\w-]+/g, '_').slice(0, 32);
|
|
73
|
+
const filePath = join(dir, `${ts}-${slug}.md`);
|
|
74
|
+
try {
|
|
75
|
+
writeFileSync(filePath, content, 'utf8');
|
|
76
|
+
return filePath;
|
|
77
|
+
}
|
|
78
|
+
catch (e) {
|
|
79
|
+
console.error(logTag, e);
|
|
80
|
+
return '';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
57
83
|
export function createBridge(config, app, // 本 bridge 绑定的飞书应用:并发/默认工作区/Claude Code 环境/人格均 per-app
|
|
58
84
|
deps, opts = {}) {
|
|
59
85
|
const sem = new Semaphore(app.concurrency ?? config.concurrency);
|
|
@@ -85,10 +111,18 @@ deps, opts = {}) {
|
|
|
85
111
|
// 用于实现「默认不发非图片文件,用户主动点名才发」的回传规则
|
|
86
112
|
const fileTracker = new FileTracker();
|
|
87
113
|
const confirmPending = new Map();
|
|
88
|
-
// plan 确认挂起项:requestId → 等待中的计划决策(plan-approve/revise/reject
|
|
114
|
+
// plan 确认挂起项:requestId → 等待中的计划决策(plan-approve/revise/reject)。
|
|
115
|
+
// 嵌入主卡片后回调侧无 cardId(主卡 messageId 由 ProgressCard 内部管理),改带 channelKey 定位活跃进度卡
|
|
89
116
|
const planPending = new Map();
|
|
90
|
-
// 提问挂起项:requestId → 等待中的问题答案(qa-pick 逐项收集,qa-submit 一次性 resolve
|
|
117
|
+
// 提问挂起项:requestId → 等待中的问题答案(qa-pick 逐项收集,qa-submit 一次性 resolve)。
|
|
118
|
+
// 嵌入主卡片后无 cardId,改带 channelKey 定位活跃进度卡
|
|
91
119
|
const qaPending = new Map();
|
|
120
|
+
// 长回复卡挂起项(0.20.0):requestId → 收起卡的查看/确认/提意见上下文。与 planPending
|
|
121
|
+
// 不同——无 Promise 要 resolve(任务已结束,确认/提意见是「发起新一轮」),条目仅用于:
|
|
122
|
+
// 发起人校验(仅任务发起人可操作)、settled 防重(确认/提意见只生效一次,查看可反复)、
|
|
123
|
+
// filePath/workspaceName 上下文。条目常驻供用户回看历史卡片时再次查看全文(量级:每次
|
|
124
|
+
// 超长回复一条,几十字节;超 500 条清最老,防无界增长)
|
|
125
|
+
const outputPending = new Map();
|
|
92
126
|
// 通道当前任务的进度卡:gate 的 ask/planAsk/askQuestion 闭包随 gate 通道级复用,
|
|
93
127
|
// 不能捕获任务级 progress 实例(会是首个任务的旧卡)——经此间接引用每次任务的新卡
|
|
94
128
|
const activeProgress = new Map();
|
|
@@ -98,18 +132,28 @@ deps, opts = {}) {
|
|
|
98
132
|
function workspacePath(name) {
|
|
99
133
|
return config.workspaces.find((w) => w.name === name)?.path ?? config.workspaces[0].path;
|
|
100
134
|
}
|
|
135
|
+
/** 配置页管理的 MCP(#12):bridge 自管 mcp/servers.json 每任务现读注入 SDK(改配置即热生效,
|
|
136
|
+
* 无须重启)。notify 名固定后展开防被用户配置同名覆盖;文件缺失/损坏返回空(配置页写坏
|
|
137
|
+
* 不应拖垮任务执行) */
|
|
138
|
+
function loadBridgeMcpServers() {
|
|
139
|
+
return readMcpServersFromJsonFile(BRIDGE_MCP_JSON).servers;
|
|
140
|
+
}
|
|
101
141
|
/** 通道内串行入队(每 key 一条 Promise 链),全局并发由 Semaphore 限制。
|
|
102
142
|
* 有任务在跑时新消息静默 FIFO 排队——不提示的话用户不知道消息已被接收,会反复重发或误以为丢失。
|
|
103
|
-
* queuedCount 计「等待中」消息数:enqueue 一律自增(含首位),executeTask
|
|
143
|
+
* queuedCount 计「等待中」消息数:enqueue 一律自增(含首位),executeTask 启动时递减(该条离开队列)。
|
|
144
|
+
* /new 通过 epoch 代际丢弃旧排队消息:enqueue 捕获当前 epoch 传入 executeTask,不匹配则 no-op */
|
|
104
145
|
function enqueue(key, msg, prompt, wsName) {
|
|
105
146
|
const rt = runtimes.get(key) ?? { queue: Promise.resolve() };
|
|
147
|
+
if (rt.epoch === undefined)
|
|
148
|
+
rt.epoch = 0; // 旧状态无 epoch 字段的运行期兜底
|
|
106
149
|
runtimes.set(key, rt);
|
|
107
150
|
rt.queuedCount = (rt.queuedCount ?? 0) + 1;
|
|
108
151
|
if (rt.busy) {
|
|
109
152
|
void deps.gateway.sendTextTo(msg.chatId, `⏳ 当前有任务在运行,本条消息已排队(第 ${rt.queuedCount} 位),完成后自动开始`)
|
|
110
153
|
.catch(() => { }); // 提示失败静默:不影响任务本身入队
|
|
111
154
|
}
|
|
112
|
-
|
|
155
|
+
const myEpoch = rt.epoch; // 闭包捕获本条消息入队时的代际;executeTask 据此判断是否被 /new 作废
|
|
156
|
+
rt.queue = rt.queue.then(() => executeTask(key, msg, prompt, wsName, myEpoch)).catch((e) => {
|
|
113
157
|
console.error(tag, '[任务异常]', e);
|
|
114
158
|
});
|
|
115
159
|
}
|
|
@@ -152,17 +196,32 @@ deps, opts = {}) {
|
|
|
152
196
|
// 命中后拦截不再交给 Claude Code——避免模型重复 send_file 同一文件造成轰炸
|
|
153
197
|
if (await tryDeliverRequestedFiles(msg, key))
|
|
154
198
|
return;
|
|
155
|
-
// 1.
|
|
199
|
+
// 1. 访问控制:白名单外首个使用者自动成为 admin(免配对),其后未知用户发配对码
|
|
156
200
|
// 每条消息先重读 access.json:lcb pair / 运行终端等独立进程批准写盘后,
|
|
157
201
|
// 长存的内存实例不 reload 会查到旧白名单 → 反复发配对码且整盘覆写抹掉新用户(死循环)
|
|
158
202
|
deps.access.reload();
|
|
159
203
|
// 配置热重载:lcb ws add/remove 独立进程写盘后,本实例下一条消息即读到新工作区
|
|
160
204
|
opts.reloadConfig?.();
|
|
161
205
|
if (!deps.access.isAllowed(msg.userId)) {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
206
|
+
// 首个使用者免配对直接成为 admin——按应用判定:多应用部署下 open_id 按应用隔离,
|
|
207
|
+
// 新应用的第一个使用者同样是「首个」,不应因别的应用已有用户而被配对码拦住
|
|
208
|
+
if (!deps.access.hasUsers(app.appId)) {
|
|
209
|
+
deps.access.addUser(msg.userId, msg.userId, 'admin', app.appId);
|
|
210
|
+
console.log(`${tag}[接入] 应用 ${app.appId} 首位用户 ${msg.userId} 已自动成为 admin(后续用户需配对)`);
|
|
211
|
+
await deps.gateway.sendTextTo(msg.chatId, `🎉 欢迎!你是本机器人的首位用户,已自动成为 **admin**。\n\n后续其他同事首次使用时需由你在桥接器终端批准配对(\`lcb pair <配对码>\`)。`);
|
|
212
|
+
// 不 return:欢迎语之外,本条消息照常进入命令/任务处理
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
const code = deps.access.beginPairing(msg.userId, msg.userId, app.appId);
|
|
216
|
+
console.log(`${tag}[配对] 未知用户 ${msg.userId} 请求接入,配对码:${code}(在终端输入 lcb pair ${code} 批准)`);
|
|
217
|
+
await deps.gateway.sendTextTo(msg.chatId, `🔐 首次使用需配对。\n\n请管理员在桥接器终端确认配对码:**${code}**(15 分钟内有效)`);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
// 老版本 access.json 的用户记录没有 appId:按当前来路补登,
|
|
223
|
+
// 保证 hasUsers(appId) 的按应用统计对存量用户同样成立
|
|
224
|
+
deps.access.ensureAppId(msg.userId, app.appId);
|
|
166
225
|
}
|
|
167
226
|
// 2. 命令:/help /new /resume /stop /status /ws
|
|
168
227
|
const st = deps.store.getChannelState(key);
|
|
@@ -215,17 +274,61 @@ deps, opts = {}) {
|
|
|
215
274
|
if (cmd.handled) {
|
|
216
275
|
if (cmd.reply)
|
|
217
276
|
await deps.gateway.sendTextTo(msg.chatId, cmd.reply);
|
|
218
|
-
// /new
|
|
219
|
-
//
|
|
220
|
-
|
|
277
|
+
// /new 开启新会话:
|
|
278
|
+
// 1) 清空旧代际排队消息(epoch 自增,旧任务入 executeTask 即 no-op)
|
|
279
|
+
// 2) abort 正在执行的任务
|
|
280
|
+
// 3) 结算本用户挂起的 confirm/plan/qa 条目(按取消/拒绝处理:对话继续推进或被放弃)
|
|
281
|
+
// 4) 通道级权限闸 reset(spec:「本次会话不再询问」的记忆跨任务生效,直至 /new)
|
|
282
|
+
// 5) 汇总已取消的排队消息数(用户能看到新建会话的真实影响面)
|
|
283
|
+
// ——handleCommand 无权访问 wiring 的 runtime/pending map,统一在 wiring 侧做
|
|
284
|
+
if (/^\/new(?:\s|$)/.test(msg.text.trim())) {
|
|
285
|
+
const rt = runtimes.get(key);
|
|
286
|
+
const cancelled = rt?.queuedCount ?? 0;
|
|
287
|
+
if (rt) {
|
|
288
|
+
rt.epoch = (rt.epoch ?? 0) + 1;
|
|
289
|
+
rt.queuedCount = 0; // 旧代际消息在 executeTask 入口 no-op,不会再 decrement;此处一次性清零即可
|
|
290
|
+
if (rt.abort)
|
|
291
|
+
rt.abort.abort();
|
|
292
|
+
}
|
|
293
|
+
for (const [id, p] of confirmPending) {
|
|
294
|
+
if (p.channelKey !== key)
|
|
295
|
+
continue;
|
|
296
|
+
confirmPending.delete(id);
|
|
297
|
+
p.resolve('deny');
|
|
298
|
+
}
|
|
299
|
+
for (const [id, p] of planPending) {
|
|
300
|
+
if (p.channelKey !== key)
|
|
301
|
+
continue;
|
|
302
|
+
planPending.delete(id);
|
|
303
|
+
p.resolve({ action: 'reject' });
|
|
304
|
+
}
|
|
305
|
+
for (const [id, p] of qaPending) {
|
|
306
|
+
if (p.channelKey !== key)
|
|
307
|
+
continue;
|
|
308
|
+
qaPending.delete(id);
|
|
309
|
+
p.resolve({});
|
|
310
|
+
}
|
|
311
|
+
// 本用户在本会话的长回复卡确认/提意见按钮一并失效(保留查看全文):新会话已无
|
|
312
|
+
// 「上一轮方案」上下文,旧确认按钮再触发只会得到脱离语境的新一轮任务
|
|
313
|
+
for (const o of outputPending.values()) {
|
|
314
|
+
if (o.chatId === msg.chatId && o.ownerId === msg.userId)
|
|
315
|
+
o.settled = true;
|
|
316
|
+
}
|
|
221
317
|
gates.get(key)?.reset();
|
|
318
|
+
if (cancelled > 0) {
|
|
319
|
+
await deps.gateway.sendTextTo(msg.chatId, `🗑️ 已因新建会话取消 ${cancelled} 条排队消息`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
222
322
|
return;
|
|
223
323
|
}
|
|
224
324
|
// 3. 普通文本:通道内串行入队(每 key 一条 Promise 链),全局并发由 Semaphore 限制
|
|
225
325
|
enqueue(key, msg, cmd.taskText ?? msg.text, currentWorkspace);
|
|
226
326
|
}
|
|
227
|
-
async function executeTask(key, msg, prompt, wsName) {
|
|
327
|
+
async function executeTask(key, msg, prompt, wsName, myEpoch) {
|
|
228
328
|
const rt = runtimes.get(key);
|
|
329
|
+
// /new 丢弃旧代际:静默 no-op(不入队列、不 decrement、不建进度卡;汇总条数已在 /new 时回)
|
|
330
|
+
if (myEpoch !== (rt.epoch ?? 0))
|
|
331
|
+
return;
|
|
229
332
|
rt.busy = true; // enqueue 据此给后续消息发「已排队」提示(sem 等待期同样算占用)
|
|
230
333
|
rt.queuedCount = Math.max(0, (rt.queuedCount ?? 1) - 1); // 本条离开等待队列开始执行
|
|
231
334
|
const release = await sem.acquire();
|
|
@@ -237,7 +340,18 @@ deps, opts = {}) {
|
|
|
237
340
|
deleteCard: deps.gateway.deleteCard
|
|
238
341
|
? (id) => deps.gateway.deleteCard(id)
|
|
239
342
|
: undefined,
|
|
240
|
-
|
|
343
|
+
sendCardEntity: deps.gateway.sendCardEntityTo
|
|
344
|
+
? (c) => deps.gateway.sendCardEntityTo(msg.chatId, c)
|
|
345
|
+
: undefined,
|
|
346
|
+
partialUpdateCard: deps.gateway.partialUpdateCardElements
|
|
347
|
+
? (cardId, seq, actions) => deps.gateway.partialUpdateCardElements(cardId, seq, actions)
|
|
348
|
+
: undefined,
|
|
349
|
+
replaceCard: deps.gateway.replaceCardEntity
|
|
350
|
+
? (cardId, seq, c) => deps.gateway.replaceCardEntity(cardId, seq, c)
|
|
351
|
+
: undefined,
|
|
352
|
+
}, `任务 · ${wsName}`,
|
|
353
|
+
// card.width 热生效:每任务现读 config(与同款「同对象 mutate 即生效」语义一致)
|
|
354
|
+
{ cardWidthMode: config.card?.width ?? 'default' });
|
|
241
355
|
const confirmTimeoutMs = opts.confirmTimeoutMs ?? CONFIRM_TIMEOUT_MS;
|
|
242
356
|
const cardSendTimeoutMs = opts.cardSendTimeoutMs ?? CARD_SEND_TIMEOUT_MS;
|
|
243
357
|
// 通道级复用:已存在则沿用(allow-session 记忆跨任务),不存在才建。
|
|
@@ -279,30 +393,30 @@ deps, opts = {}) {
|
|
|
279
393
|
},
|
|
280
394
|
req,
|
|
281
395
|
ownerId: msg.userId,
|
|
396
|
+
channelKey: key,
|
|
282
397
|
onSettled: settle,
|
|
283
398
|
});
|
|
284
399
|
});
|
|
285
400
|
},
|
|
286
|
-
// plan
|
|
287
|
-
//
|
|
401
|
+
// plan 确认(#3+#6):不再单独发计划卡片组。计划正文先落盘到 ~/.lark-claudecode-bridge/claude/plans/
|
|
402
|
+
// (slug + 时间戳,方便后续 review 与 SOP 要求的 plan 本地归档),然后嵌入当前任务的进度卡:
|
|
403
|
+
// 卡片正文仅露 600 字预览 + 「查看完整方案」按钮(callback send_file 发送原 md 文件,不塞卡片正文
|
|
404
|
+
// ——飞书单卡 30KB 上限与可读性都不允许长方案铺在卡片里);批准/放弃/按意见修改三按钮与意见输入框
|
|
405
|
+
// 仍在主卡片的内嵌 form 内。超时由本闭包自管:到点 clearPlan + resolve reject
|
|
288
406
|
planAsk: async (req) => {
|
|
289
407
|
const planReq = { requestId: randomUUID(), plan: req.plan, workspaceName: req.workspaceName };
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
}
|
|
298
|
-
ids.push(id);
|
|
299
|
-
}
|
|
300
|
-
const cardId = ids[0];
|
|
301
|
-
void activeProgress.get(key)?.sinkToBottom();
|
|
408
|
+
// 落盘计划原文:路径走 ensureRuntimeDirs 已建的 claude/plans 目录,文件名加时间戳避免并发冲突
|
|
409
|
+
const planFilePath = writeRuntimeMarkdown(join('claude', 'plans'), req.workspaceName || 'plan', `# 执行计划 · ${req.workspaceName} · ${new Date().toLocaleString('zh-CN', { hour12: false })}\n\n${req.plan}\n`, `${tag}[计划落盘失败](不影响流程,仅「查看完整方案」可能不可用):`);
|
|
410
|
+
const progress = activeProgress.get(key);
|
|
411
|
+
if (!progress)
|
|
412
|
+
return { action: 'reject' }; // 进度卡不在 = 任务已结束/被 abort,按放弃处理
|
|
413
|
+
progress.setPlan({ requestId: planReq.requestId, plan: req.plan, workspaceName: req.workspaceName, planFilePath });
|
|
414
|
+
// 不再 sinkToBottom:plan 区嵌入主进度卡,flush 自然 PATCH,删旧卡重发只会让用户看到计划区跳一下
|
|
302
415
|
return new Promise((resolve) => {
|
|
303
416
|
const gc = setTimeout(() => {
|
|
304
417
|
planPending.delete(planReq.requestId);
|
|
305
|
-
|
|
418
|
+
progress.setPlan(undefined);
|
|
419
|
+
progress.setStatus(`⏰ 计划确认超时(${Math.round(confirmTimeoutMs / 60000)} 分钟未确认),已自动放弃;模型重新提交计划时会再次弹出`);
|
|
306
420
|
resolve({ action: 'reject' });
|
|
307
421
|
}, confirmTimeoutMs);
|
|
308
422
|
gc.unref();
|
|
@@ -313,12 +427,14 @@ deps, opts = {}) {
|
|
|
313
427
|
},
|
|
314
428
|
req: planReq,
|
|
315
429
|
ownerId: msg.userId,
|
|
316
|
-
|
|
430
|
+
channelKey: key,
|
|
431
|
+
chatId: msg.chatId,
|
|
432
|
+
planFilePath,
|
|
317
433
|
});
|
|
318
434
|
});
|
|
319
435
|
},
|
|
320
|
-
//
|
|
321
|
-
//
|
|
436
|
+
// 提问确认(#3):AskUserQuestion 的选项 + 提交按钮全部嵌入主进度卡(#3 决策:彻底替换独立提问卡)。
|
|
437
|
+
// qa-pick 仅 PATCH 选中态(卡片不刷新整卡,仅更新 answer map),qa-submit 一次性 resolve 并 clearQuestion
|
|
322
438
|
askQuestion: async (req) => {
|
|
323
439
|
const qaReq = {
|
|
324
440
|
requestId: randomUUID(),
|
|
@@ -326,18 +442,16 @@ deps, opts = {}) {
|
|
|
326
442
|
workspaceName: req.workspaceName,
|
|
327
443
|
};
|
|
328
444
|
const answers = {};
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
return {};
|
|
335
|
-
}
|
|
336
|
-
void activeProgress.get(key)?.sinkToBottom();
|
|
445
|
+
const progress = activeProgress.get(key);
|
|
446
|
+
if (!progress)
|
|
447
|
+
return {}; // 进度卡不在 = 任务已结束/被 abort,按未作答处理
|
|
448
|
+
progress.setQuestion({ requestId: qaReq.requestId, questions: req.questions, workspaceName: req.workspaceName, answers });
|
|
449
|
+
// 不再 sinkToBottom:提问区嵌入主进度卡后,flush 自然 PATCH(理由同 planAsk 上方注释)
|
|
337
450
|
return new Promise((resolve) => {
|
|
338
451
|
const gc = setTimeout(() => {
|
|
339
452
|
qaPending.delete(qaReq.requestId);
|
|
340
|
-
|
|
453
|
+
progress.setQuestion(undefined);
|
|
454
|
+
progress.setStatus(`⏰ 提问超时(${Math.round(confirmTimeoutMs / 60000)} 分钟未作答),已按未作答处理;模型自行决策或停下等待你的回复`);
|
|
341
455
|
resolve({});
|
|
342
456
|
}, confirmTimeoutMs);
|
|
343
457
|
gc.unref();
|
|
@@ -348,7 +462,8 @@ deps, opts = {}) {
|
|
|
348
462
|
},
|
|
349
463
|
req: qaReq,
|
|
350
464
|
ownerId: msg.userId,
|
|
351
|
-
|
|
465
|
+
channelKey: key,
|
|
466
|
+
chatId: msg.chatId,
|
|
352
467
|
answers,
|
|
353
468
|
});
|
|
354
469
|
});
|
|
@@ -363,6 +478,8 @@ deps, opts = {}) {
|
|
|
363
478
|
// lcb-notify 发送能力:chatId 硬绑定当前任务(权限闸直通的安全前提);
|
|
364
479
|
// sentPaths 记录中途已推送的文件,任务收尾的产出回传据此去重(用户已收过的不重发)。
|
|
365
480
|
// 每次中途推送后进度卡沉底一次(删旧卡重发),保持「任务是否还在跑」始终可见于会话底部
|
|
481
|
+
// SOP 开关:本任务级现读 config.session.notifySop(缺省 true),决定后续 sendText 是否被强制降级
|
|
482
|
+
const sopEnabled = config.session?.notifySop !== false;
|
|
366
483
|
const sentPaths = new Set();
|
|
367
484
|
const sink = async () => { await activeProgress.get(key)?.sinkToBottom(); };
|
|
368
485
|
const notifySender = createGatewaySender({
|
|
@@ -384,6 +501,10 @@ deps, opts = {}) {
|
|
|
384
501
|
await deps.gateway.uploadAndSendFile(c, p);
|
|
385
502
|
await sink();
|
|
386
503
|
},
|
|
504
|
+
}, {
|
|
505
|
+
enabled: sopEnabled,
|
|
506
|
+
// #11 SOP 提示文案前缀(用户看到的「为什么自动转附件」提示)
|
|
507
|
+
sopTag: '⚠️ 推送上限',
|
|
387
508
|
});
|
|
388
509
|
activeProgress.set(key, progress);
|
|
389
510
|
try {
|
|
@@ -405,15 +526,22 @@ deps, opts = {}) {
|
|
|
405
526
|
resumeSessionId: resumeId,
|
|
406
527
|
signal: abort.signal,
|
|
407
528
|
env: appEnv,
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
529
|
+
// #11 SOP 软约束:app.appendSystemPrompt 后追加 SOP 摘要(关闭时仅传 app 原有部分)
|
|
530
|
+
appendSystemPrompt: (() => {
|
|
531
|
+
const base = app.appendSystemPrompt?.trim() ?? '';
|
|
532
|
+
if (!sopEnabled)
|
|
533
|
+
return base || undefined;
|
|
534
|
+
return base ? `${base}\n\n${FEISHU_NOTIFY_SOP_PROMPT}` : FEISHU_NOTIFY_SOP_PROMPT;
|
|
535
|
+
})(),
|
|
536
|
+
// 计划模式(#6):通道级 /plan 开关——模型先出计划(ExitPlanMode → planAsk 飞书卡片),
|
|
537
|
+
// 用户批准后 SDK 自动切回可编辑模式继续执行(替代旧工作区 code-dev 类型)
|
|
538
|
+
...(state?.planMode ? { permissionMode: 'plan' } : {}),
|
|
412
539
|
// 通道级模型覆盖(/model 设置,每任务现读——改完下一条消息即生效);未设 = 跟随 ~/.claude 全局
|
|
413
540
|
...(state?.model ? { model: state.model } : {}),
|
|
414
541
|
// 进程内通知工具(send_text/send_image/send_file):中间产物实时推给当前聊天。
|
|
415
|
-
// server 实例按任务构造,chatId 在 sender
|
|
416
|
-
|
|
542
|
+
// server 实例按任务构造,chatId 在 sender 闭包内硬绑定——模型无法选择接收者。
|
|
543
|
+
// 配置页 MCP(#12)一并注入:bridge 自管 servers.json 每任务现读(热生效),notify 后展开保名
|
|
544
|
+
mcpServers: { ...loadBridgeMcpServers(), [NOTIFY_SERVER_NAME]: createNotifyServer(notifySender) },
|
|
417
545
|
// 插件:config.yaml 显式配置(开发期指源码目录)+ Claude 配置目录已启用的 marketplace
|
|
418
546
|
// 插件自动发现,同名显式优先;SDK 对无效路径静默跳过,实际加载以 init 清单(/plugins 命令)为准。
|
|
419
547
|
// managed 模式合并发现两处目录(自管 + 本机 ~/.claude):按绝对 installPath 传 SDK,
|
|
@@ -510,7 +638,33 @@ deps, opts = {}) {
|
|
|
510
638
|
// 而 textTail 按序累积全部流式文本块、卡片取尾部 PROGRESS_TAIL_CHARS 字——finalText 不超上限时
|
|
511
639
|
// 必然完整落在进度卡终态里,再发一条就是内容几乎逐字相同的重复消息(0.11.0 修复的双推 bug)
|
|
512
640
|
if (outcome.finalText && outcome.finalText.length > PROGRESS_TAIL_CHARS) {
|
|
513
|
-
|
|
641
|
+
// 结果回传(0.20.0 三段式):
|
|
642
|
+
// - 400 字内:进度卡终态已完整展示,不发独立消息(0.11.0 双推修复)
|
|
643
|
+
// - 400~2000 字:直接发全文卡片(旧 slice 上限对这段区间本就不触发)
|
|
644
|
+
// - >2000 字:落盘 md + 收起卡(查看全文按钮 + 确认方案/按意见修改引导,点击
|
|
645
|
+
// 即发起新一轮任务),替代旧版 4000 字硬截断——超长部分不再静默丢弃
|
|
646
|
+
const text = outcome.finalText;
|
|
647
|
+
if (text.length > LONG_OUTPUT_THRESHOLD) {
|
|
648
|
+
const outFilePath = writeRuntimeMarkdown('outputs', wsName, `# 完整回复 · ${wsName} · ${new Date().toLocaleString('zh-CN', { hour12: false })}\n\n${text}\n`, `${tag}[长回复落盘失败,回退为截断发送]:`);
|
|
649
|
+
if (outFilePath) {
|
|
650
|
+
const outReq = {
|
|
651
|
+
requestId: randomUUID(), filePath: outFilePath,
|
|
652
|
+
charCount: text.length, workspaceName: wsName,
|
|
653
|
+
};
|
|
654
|
+
outputPending.set(outReq.requestId, { req: outReq, ownerId: msg.userId, chatId: msg.chatId, settled: false });
|
|
655
|
+
if (outputPending.size > 500) {
|
|
656
|
+
// 防无界增长:清最老的条目(Map 迭代序即插入序;被清卡片的确认按钮会提示已过期)
|
|
657
|
+
outputPending.delete(outputPending.keys().next().value);
|
|
658
|
+
}
|
|
659
|
+
await deps.gateway.sendCardTo(msg.chatId, buildLongOutputCard(outReq));
|
|
660
|
+
}
|
|
661
|
+
else {
|
|
662
|
+
await deps.gateway.sendTextTo(msg.chatId, text.slice(0, 4000)); // 落盘失败兜底:维持旧截断行为
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
else {
|
|
666
|
+
await deps.gateway.sendTextTo(msg.chatId, text);
|
|
667
|
+
}
|
|
514
668
|
}
|
|
515
669
|
// 图片自动发(im.image.create 通道),非图片文件不自动回传——
|
|
516
670
|
// 用户从下面的文件清单里看到改了哪些文件,主动点名 basename 后由 tryDeliverRequestedFiles 处理
|
|
@@ -532,18 +686,12 @@ deps, opts = {}) {
|
|
|
532
686
|
await deps.gateway.sendTextTo(msg.chatId, `📁 本次修改/新增的文件(共 ${outcome.producedFiles.length} 个):\n${list}`);
|
|
533
687
|
}
|
|
534
688
|
await uploadProducedImages();
|
|
535
|
-
//
|
|
536
|
-
//
|
|
537
|
-
const
|
|
538
|
-
if (
|
|
539
|
-
const wsDiff
|
|
540
|
-
|
|
541
|
-
console.warn(tag, `[任务收尾] 工作区 ${wsName} 不是 git 仓库,非图片文件不再自动发送(git init 后可改用汇总 diff 卡片)`);
|
|
542
|
-
}
|
|
543
|
-
else if (wsDiff.diff.trim()) {
|
|
544
|
-
for (const c of buildDiffSummaryCards(wsDiff.diff, { workspaceName: wsName, files: wsDiff.files })) {
|
|
545
|
-
await deps.gateway.sendCardTo(msg.chatId, c);
|
|
546
|
-
}
|
|
689
|
+
// 汇总 diff 收尾卡片(#6 起不再依赖工作区类型):工作区是 git 仓库且有改动才发——
|
|
690
|
+
// git 仓库即「代码工作区」的客观信号;非 git 仓库(内容生产类目录)天然跳过
|
|
691
|
+
const wsDiff = await collectWorkspaceDiff(workspacePath(wsName));
|
|
692
|
+
if (wsDiff !== null && wsDiff.diff.trim()) {
|
|
693
|
+
for (const c of buildDiffSummaryCards(wsDiff.diff, { workspaceName: wsName, files: wsDiff.files })) {
|
|
694
|
+
await deps.gateway.sendCardTo(msg.chatId, c);
|
|
547
695
|
}
|
|
548
696
|
}
|
|
549
697
|
}
|
|
@@ -590,6 +738,8 @@ deps, opts = {}) {
|
|
|
590
738
|
});
|
|
591
739
|
}
|
|
592
740
|
finally {
|
|
741
|
+
// SOP 降级单文件补发(#7):任务任何结局(完成/停止/出错)都在收尾补齐完整附件
|
|
742
|
+
await notifySender.flushDowngradedFile?.().catch(() => { });
|
|
593
743
|
clearTimeout(hardTimeout);
|
|
594
744
|
activeProgress.delete(key);
|
|
595
745
|
activeQueries.delete(key);
|
|
@@ -610,10 +760,17 @@ deps, opts = {}) {
|
|
|
610
760
|
const plan = planPending.get(action.value.requestId);
|
|
611
761
|
if (plan) {
|
|
612
762
|
if (action.operatorId !== plan.ownerId) {
|
|
613
|
-
console.log(tag, `[卡片回调] 非发起人 ${action.operatorId}
|
|
763
|
+
console.log(tag, `[卡片回调] 非发起人 ${action.operatorId} 点击计划区 ${action.value.requestId},已忽略`);
|
|
614
764
|
return { toast: { type: 'info', content: '仅任务发起人可操作计划' } };
|
|
615
765
|
}
|
|
616
766
|
const decision = action.value.decision;
|
|
767
|
+
// 「查看完整方案」(#6):callback 触发 send_file 直接发原 md 文件,不消耗挂起项
|
|
768
|
+
if (decision === 'plan-view-file') {
|
|
769
|
+
// 文件在 planAsk 时已落盘;这里直接 send_file(feishuGateway 走 file_type:stream,md 等任意后缀可发)
|
|
770
|
+
void deps.gateway.uploadAndSendFile(plan.chatId, plan.planFilePath)
|
|
771
|
+
.catch((e) => console.error('[查看方案发送失败]', action.value.requestId, e));
|
|
772
|
+
return { toast: { type: 'info', content: '正在发送方案文件…' } };
|
|
773
|
+
}
|
|
617
774
|
if (decision !== 'plan-approve' && decision !== 'plan-revise' && decision !== 'plan-reject') {
|
|
618
775
|
return { toast: { type: 'info', content: '该计划确认已被处理或已过期' } };
|
|
619
776
|
}
|
|
@@ -626,19 +783,20 @@ deps, opts = {}) {
|
|
|
626
783
|
plan.resolve(decision === 'plan-approve' ? { action: 'approve' }
|
|
627
784
|
: decision === 'plan-revise' ? { action: 'revise', feedback }
|
|
628
785
|
: { action: 'reject' });
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
};
|
|
786
|
+
// 清除主进度卡的内嵌计划区(按钮随 setPlan(undefined) 的 flush 消失)
|
|
787
|
+
const prog = activeProgress.get(plan.channelKey);
|
|
788
|
+
if (prog) {
|
|
789
|
+
prog.setPlan(undefined);
|
|
790
|
+
prog.setStatus(PLAN_DECISION_TEXT[decision]);
|
|
791
|
+
}
|
|
792
|
+
return { toast: { type: 'success', content: PLAN_DECISION_TEXT[decision] } };
|
|
636
793
|
}
|
|
637
|
-
//
|
|
794
|
+
// 提问回调分流(#3):qa-pick 仅 PATCH 选中态(不消耗挂起项,等所有题答完才 submit);
|
|
795
|
+
// qa-submit 全部作答后一次性 resolve 并 clearQuestion。主进度卡的 PATCH 由 ProgressCard 内部 flush 串行化
|
|
638
796
|
const qa = qaPending.get(action.value.requestId);
|
|
639
797
|
if (qa) {
|
|
640
798
|
if (action.operatorId !== qa.ownerId) {
|
|
641
|
-
console.log(tag, `[卡片回调] 非发起人 ${action.operatorId}
|
|
799
|
+
console.log(tag, `[卡片回调] 非发起人 ${action.operatorId} 点击提问区 ${action.value.requestId},已忽略`);
|
|
642
800
|
return { toast: { type: 'info', content: '仅任务发起人可作答' } };
|
|
643
801
|
}
|
|
644
802
|
const { decision } = action.value;
|
|
@@ -648,38 +806,117 @@ deps, opts = {}) {
|
|
|
648
806
|
if (!q || !label || !q.options.some((o) => o.label === label)) {
|
|
649
807
|
return { toast: { type: 'info', content: '该选项无效' } };
|
|
650
808
|
}
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
}
|
|
661
|
-
void deps.gateway.updateCard(qa.cardId, buildQuestionCard(qa.req, qa.answers)).catch(() => { });
|
|
662
|
-
return { toast: { type: 'success', content: `已选:${label}` } };
|
|
809
|
+
// 切换选中态由 ProgressCard.updateQuestionAnswer 唯一维护(toggle / 至少保留一项);本处只调 PATCH
|
|
810
|
+
const prog = activeProgress.get(qa.channelKey);
|
|
811
|
+
const before = prog?.getQuestionAnswer(action.value.qIndex);
|
|
812
|
+
const willUnpick = !!q.multiSelect && Array.isArray(before) && before.includes(label) && before.length > 1;
|
|
813
|
+
prog?.updateQuestionAnswer(action.value.qIndex, label, !!q.multiSelect);
|
|
814
|
+
// 把 progress 的最新答案同步回 qa.answers 供 submit 校验,避免双 mutate(toggle 重复计数)
|
|
815
|
+
if (prog)
|
|
816
|
+
qa.answers[action.value.qIndex] = prog.getQuestionAnswer(action.value.qIndex)
|
|
817
|
+
?? (q.multiSelect ? [] : label);
|
|
818
|
+
return { toast: { type: 'success', content: willUnpick ? `已取消:${label}` : `已选:${label}` } };
|
|
663
819
|
}
|
|
664
820
|
if (decision === 'qa-submit') {
|
|
665
|
-
|
|
666
|
-
|
|
821
|
+
// 0.20.0:每题答案 = 选项点选 + 表单自定义输入(formValue.custom_N)合并——
|
|
822
|
+
// 单选:custom 非空优先覆盖选中项;多选:custom 追加进选中数组。
|
|
823
|
+
// 已答判定放宽为「选中项或自定义输入至少一项」
|
|
824
|
+
const fv = action.value.formValue ?? {};
|
|
825
|
+
const result = {};
|
|
826
|
+
const missing = [];
|
|
827
|
+
qa.req.questions.forEach((q, i) => {
|
|
828
|
+
const raw = fv[`custom_${i}`];
|
|
829
|
+
const custom = typeof raw === 'string' ? raw.trim() : '';
|
|
830
|
+
const picked = qa.answers[i];
|
|
831
|
+
if (q.multiSelect) {
|
|
832
|
+
const arr = Array.isArray(picked) ? [...picked] : picked !== undefined ? [picked] : [];
|
|
833
|
+
if (custom)
|
|
834
|
+
arr.push(custom);
|
|
835
|
+
if (arr.length === 0) {
|
|
836
|
+
missing.push(i);
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
result[q.question] = arr;
|
|
840
|
+
}
|
|
841
|
+
else {
|
|
842
|
+
const ans = custom || (picked !== undefined ? picked : '');
|
|
843
|
+
if (!ans) {
|
|
844
|
+
missing.push(i);
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
result[q.question] = ans;
|
|
848
|
+
}
|
|
849
|
+
});
|
|
667
850
|
if (missing.length > 0) {
|
|
668
|
-
return { toast: { type: 'warning', content:
|
|
851
|
+
return { toast: { type: 'warning', content: `第 ${missing.map((i) => i + 1).join('、')} 题未作答(点选项或在输入框填写均可)` } };
|
|
669
852
|
}
|
|
670
853
|
qaPending.delete(action.value.requestId);
|
|
671
|
-
const result = {};
|
|
672
|
-
qa.req.questions.forEach((q, i) => { result[q.question] = qa.answers[i]; });
|
|
673
854
|
qa.resolve(result);
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
855
|
+
// 清除主进度卡的内嵌提问区(按钮随 setQuestion(undefined) 的 flush 消失)
|
|
856
|
+
const prog = activeProgress.get(qa.channelKey);
|
|
857
|
+
if (prog) {
|
|
858
|
+
prog.setQuestion(undefined);
|
|
859
|
+
prog.setStatus('✅ 答案已提交,继续执行…');
|
|
860
|
+
}
|
|
861
|
+
return { toast: { type: 'success', content: '✅ 答案已提交' } };
|
|
862
|
+
}
|
|
863
|
+
return { toast: { type: 'info', content: '该提问已被处理或已过期' } };
|
|
864
|
+
}
|
|
865
|
+
// 长回复收起卡(0.20.0):任务已结束,无 Promise 挂起——outputPending 提供发起人校验、
|
|
866
|
+
// settled 防重与文件上下文。「查看完整内容」可反复点击;「确认方案 / 按意见修改」合成
|
|
867
|
+
// 一条用户消息入队,走与真实消息完全相同的排队/resume/进度卡链路发起新一轮任务
|
|
868
|
+
const out = outputPending.get(action.value.requestId);
|
|
869
|
+
if (out) {
|
|
870
|
+
if (action.operatorId !== out.ownerId) {
|
|
871
|
+
console.log(tag, `[卡片回调] 非发起人 ${action.operatorId} 点击长回复卡 ${action.value.requestId},已忽略`);
|
|
872
|
+
return { toast: { type: 'info', content: '仅任务发起人可操作' } };
|
|
873
|
+
}
|
|
874
|
+
const decision = action.value.decision;
|
|
875
|
+
if (decision === 'view-output-file') {
|
|
876
|
+
// 白名单校验:仅允许 outputs 目录内文件(value 虽由桥接器写入卡片,纵深防御不亏)
|
|
877
|
+
const outputsDir = join(CONFIG_DIR, 'outputs');
|
|
878
|
+
if (!out.req.filePath.startsWith(outputsDir + sep)) {
|
|
879
|
+
return { toast: { type: 'error', content: '文件路径校验失败,已忽略' } };
|
|
880
|
+
}
|
|
881
|
+
void deps.gateway.uploadAndSendFile(out.chatId, out.req.filePath)
|
|
882
|
+
.catch((e) => console.error('[完整回复发送失败]', action.value.requestId, e));
|
|
883
|
+
return { toast: { type: 'info', content: '正在发送完整回复…' } };
|
|
884
|
+
}
|
|
885
|
+
if (out.settled) {
|
|
886
|
+
return { toast: { type: 'info', content: '该回复的处理已提交,无需重复操作' } };
|
|
887
|
+
}
|
|
888
|
+
if (decision === 'output-confirm' || decision === 'output-revise') {
|
|
889
|
+
const feedback = (action.value.feedback ?? '').trim();
|
|
890
|
+
if (decision === 'output-revise' && !feedback) {
|
|
891
|
+
return { toast: { type: 'warning', content: '请先在输入框填写修改意见(或点「确认方案」)' } };
|
|
892
|
+
}
|
|
893
|
+
out.settled = true;
|
|
894
|
+
const prompt = decision === 'output-confirm'
|
|
895
|
+
? '用户已在飞书点击「确认方案」,确认了上一轮的完整回复(方案),请继续按该方案推进执行。'
|
|
896
|
+
: `用户要求按以下意见调整上一轮完整回复中的方案:\n${feedback}`;
|
|
897
|
+
// 合成用户消息入队:新一轮会 resume 上一会话(「上一轮方案」上下文天然在场)。
|
|
898
|
+
// 发起人必在访问白名单(能发起任务才拿得到卡片),仍做 isAllowed 纵深校验;
|
|
899
|
+
// chatType 不参与任务链路(仅 gateway 消息解析用),合成消息固定 p2p
|
|
900
|
+
if (deps.access.isAllowed(action.operatorId)) {
|
|
901
|
+
const key = channelKey(out.chatId, action.operatorId);
|
|
902
|
+
enqueue(key, {
|
|
903
|
+
chatId: out.chatId, chatType: 'p2p', userId: action.operatorId,
|
|
904
|
+
text: prompt, messageId: `output-${action.value.requestId}`,
|
|
905
|
+
}, prompt, out.req.workspaceName);
|
|
906
|
+
}
|
|
907
|
+
else {
|
|
908
|
+
console.warn(tag, '[长回复卡] 发起人不在访问白名单,已忽略新一轮请求:', action.operatorId);
|
|
909
|
+
}
|
|
910
|
+
const settledText = decision === 'output-confirm'
|
|
911
|
+
? '✅ 已确认该方案,正在发起新一轮任务(详情见下方新进度卡)'
|
|
912
|
+
: '✏️ 修改意见已收到,正在发起新一轮任务(详情见下方新进度卡)';
|
|
677
913
|
return {
|
|
678
|
-
toast: { type: 'success', content:
|
|
679
|
-
|
|
914
|
+
toast: { type: 'success', content: settledText },
|
|
915
|
+
// 回调响应内联换卡:决策按钮区收为一行文案(查看按钮保留,全文仍可回看)
|
|
916
|
+
card: { type: 'raw', data: buildLongOutputSettledCard(out.req, settledText) },
|
|
680
917
|
};
|
|
681
918
|
}
|
|
682
|
-
return { toast: { type: 'info', content: '
|
|
919
|
+
return { toast: { type: 'info', content: '该操作无效或已过期' } };
|
|
683
920
|
}
|
|
684
921
|
const pending = confirmPending.get(action.value.requestId);
|
|
685
922
|
if (!pending) {
|
|
@@ -709,7 +946,21 @@ deps, opts = {}) {
|
|
|
709
946
|
return { toast: { type: 'error', content: retryable ? '处理失败,请重试' : '处理出现异常,决策可能已生效,请勿盲目重试' } };
|
|
710
947
|
}
|
|
711
948
|
}
|
|
712
|
-
return {
|
|
949
|
+
return {
|
|
950
|
+
onMessage: handleIncoming,
|
|
951
|
+
onCardAction: handleCardAction,
|
|
952
|
+
/**
|
|
953
|
+
* 测试/诊断入口:返回通道当前挂起的内嵌 plan / question requestId。
|
|
954
|
+
* 仅用于测试断言与生产期诊断,不影响业务流。channelKey 取 chatId + userId。
|
|
955
|
+
* 内嵌挂起项被 settle/超时清空时对应字段变 undefined
|
|
956
|
+
*/
|
|
957
|
+
_pendingAsk(channelKey) {
|
|
958
|
+
const prog = activeProgress.get(channelKey);
|
|
959
|
+
if (!prog)
|
|
960
|
+
return {};
|
|
961
|
+
return prog.pendingAsk();
|
|
962
|
+
},
|
|
963
|
+
};
|
|
713
964
|
}
|
|
714
965
|
/**
|
|
715
966
|
* 配置热重载器:lcb ws add/remove 写盘后,运行实例下一条消息即见新工作区
|
|
@@ -810,6 +1061,11 @@ export async function startBridge(configPath = CONFIG_PATH) {
|
|
|
810
1061
|
uploadAndSendFile: (chatId, p) => gateway.uploadAndSendFile(chatId, p),
|
|
811
1062
|
sendImageTo: (chatId, p, caption) => gateway.sendImage(chatId, p, caption),
|
|
812
1063
|
deleteCard: (id) => gateway.deleteCard(id),
|
|
1064
|
+
// 卡片实体模式(cardkit):进度卡局部更新保 plan 表单输入;FeishuGateway 恒定实现,
|
|
1065
|
+
// 权限/接口异常时 ProgressCard.start 捕获降级为普通卡片 + 整卡 PATCH
|
|
1066
|
+
sendCardEntityTo: (chatId, card) => gateway.sendCardEntity(chatId, card),
|
|
1067
|
+
partialUpdateCardElements: (cardId, seq, actions) => gateway.partialUpdateCardElements(cardId, seq, actions),
|
|
1068
|
+
replaceCardEntity: (cardId, seq, card) => gateway.replaceCardEntity(cardId, seq, card),
|
|
813
1069
|
},
|
|
814
1070
|
access,
|
|
815
1071
|
store: SessionStore.load(join(CONFIG_DIR, `sessions.${app.appId}.json`)),
|