lark-remote 0.1.12 → 0.2.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/CHANGELOG.md +37 -0
- package/README.en.md +2 -6
- package/README.md +3 -7
- package/dist/bridge/final-usage.d.ts +24 -0
- package/dist/bridge/final-usage.js +55 -0
- package/dist/bridge/inbound-media.d.ts +0 -3
- package/dist/bridge/inbound-media.js +0 -1
- package/dist/bridge/index.d.ts +7 -7
- package/dist/bridge/index.js +100 -133
- package/dist/bridge/queue-manager.js +4 -3
- package/dist/card/approval-render.js +39 -94
- package/dist/card/bash-renderer.js +12 -21
- package/dist/card/card-shared.d.ts +14 -5
- package/dist/card/card-shared.js +23 -34
- package/dist/card/run-renderer.d.ts +0 -22
- package/dist/card/run-renderer.js +206 -153
- package/dist/card/run-state.d.ts +29 -1
- package/dist/card/run-state.js +172 -84
- package/dist/card/text-truncate.d.ts +0 -9
- package/dist/card/text-truncate.js +0 -9
- package/dist/card/tool-render.d.ts +4 -1
- package/dist/card/tool-render.js +76 -32
- package/dist/cli.js +2 -2
- package/dist/common/fs.d.ts +14 -0
- package/dist/common/fs.js +57 -3
- package/dist/common/truncate.d.ts +0 -6
- package/dist/common/truncate.js +1 -1
- package/dist/common/ttl-cache.d.ts +24 -0
- package/dist/common/ttl-cache.js +53 -0
- package/dist/config/codex-config.js +14 -11
- package/dist/config/index.d.ts +0 -8
- package/dist/config/index.js +0 -16
- package/dist/config/kimi-config.d.ts +1 -1
- package/dist/config/kimi-config.js +2 -1
- package/dist/config/opencode-config.d.ts +1 -1
- package/dist/config/opencode-config.js +16 -10
- package/dist/config/qr.d.ts +36 -0
- package/dist/config/qr.js +124 -0
- package/dist/config/wizard.js +69 -27
- package/dist/connector/index.d.ts +3 -2
- package/dist/connector/index.js +5 -3
- package/dist/index.js +14 -41
- package/dist/instance-lock.js +7 -0
- package/dist/persistence/atomic-write.d.ts +2 -0
- package/dist/persistence/atomic-write.js +20 -5
- package/dist/persistence/load-json-file.d.ts +1 -2
- package/dist/persistence/load-json-file.js +2 -3
- package/dist/platform/agent-stopper.d.ts +22 -0
- package/dist/platform/agent-stopper.js +19 -0
- package/dist/platform/command.d.ts +11 -0
- package/dist/platform/command.js +107 -0
- package/dist/platform/identity.d.ts +33 -0
- package/dist/platform/identity.js +187 -0
- package/dist/platform/path.d.ts +24 -0
- package/dist/platform/path.js +110 -0
- package/dist/platform/select.d.ts +9 -0
- package/dist/platform/select.js +12 -0
- package/dist/platform/shell.d.ts +51 -0
- package/dist/platform/shell.js +75 -0
- package/dist/platform/spawn.d.ts +41 -0
- package/dist/platform/spawn.js +77 -0
- package/dist/platform/terminator-posix.d.ts +38 -0
- package/dist/platform/terminator-posix.js +103 -0
- package/dist/platform/terminator-win32.d.ts +21 -0
- package/dist/platform/terminator-win32.js +110 -0
- package/dist/platform/terminator.d.ts +44 -0
- package/dist/platform/terminator.js +24 -0
- package/dist/platform/types.d.ts +23 -0
- package/dist/platform/types.js +5 -0
- package/dist/restart.js +4 -2
- package/dist/router/card-helpers.d.ts +33 -6
- package/dist/router/card-helpers.js +67 -11
- package/dist/router/config/common/render.d.ts +1 -1
- package/dist/router/config/common/render.js +1 -1
- package/dist/router/config-diff.d.ts +4 -0
- package/dist/router/config-diff.js +70 -0
- package/dist/router/index.d.ts +73 -6
- package/dist/router/index.js +663 -590
- package/dist/router/utils.d.ts +6 -0
- package/dist/router/utils.js +12 -0
- package/dist/runner/bash/runner.d.ts +9 -2
- package/dist/runner/bash/runner.js +45 -20
- package/dist/runner/claude/session.d.ts +4 -4
- package/dist/runner/claude/session.js +18 -9
- package/dist/runner/codex/app-server/protocol-types.d.ts +35 -1
- package/dist/runner/codex/app-server/runner.d.ts +1 -16
- package/dist/runner/codex/app-server/runner.js +2 -2
- package/dist/runner/codex/app-server/translator.d.ts +18 -0
- package/dist/runner/codex/app-server/translator.js +146 -0
- package/dist/runner/common/acp/base-acp-runner.d.ts +116 -0
- package/dist/runner/common/acp/base-acp-runner.js +248 -0
- package/dist/runner/common/acp/base-translator.d.ts +141 -0
- package/dist/runner/common/acp/base-translator.js +271 -0
- package/dist/runner/common/acp/protocol-helpers.d.ts +70 -0
- package/dist/runner/common/acp/protocol-helpers.js +82 -0
- package/dist/runner/common/connection-based-runner.d.ts +0 -2
- package/dist/runner/common/connection-based-runner.js +1 -1
- package/dist/runner/common/jsonrpc/transport.d.ts +2 -0
- package/dist/runner/common/jsonrpc/transport.js +25 -5
- package/dist/runner/common/process-stopper.d.ts +4 -26
- package/dist/runner/common/process-stopper.js +4 -76
- package/dist/runner/common/runner-utils.d.ts +0 -5
- package/dist/runner/common/runner-utils.js +0 -7
- package/dist/runner/common/spawning-runner.d.ts +42 -132
- package/dist/runner/common/spawning-runner.js +52 -271
- package/dist/runner/common/usage.d.ts +2 -0
- package/dist/runner/common/usage.js +2 -0
- package/dist/runner/dsh/index.d.ts +0 -2
- package/dist/runner/dsh/index.js +0 -2
- package/dist/runner/dsh/runner.d.ts +0 -2
- package/dist/runner/dsh/runner.js +1 -1
- package/dist/runner/index.d.ts +1 -1
- package/dist/runner/kimi/acp/runner.d.ts +63 -53
- package/dist/runner/kimi/acp/runner.js +189 -334
- package/dist/runner/kimi/acp/translator.d.ts +18 -101
- package/dist/runner/kimi/acp/translator.js +61 -255
- package/dist/runner/opencode/acp/runner.d.ts +22 -46
- package/dist/runner/opencode/acp/runner.js +74 -297
- package/dist/runner/opencode/acp/translator.d.ts +28 -104
- package/dist/runner/opencode/acp/translator.js +54 -249
- package/dist/runner/pi/rpc/index.d.ts +0 -2
- package/dist/runner/pi/rpc/index.js +0 -2
- package/dist/runner/pi/rpc/translator.d.ts +3 -0
- package/dist/runner/pi/rpc/translator.js +47 -9
- package/dist/runner/probe.d.ts +8 -11
- package/dist/runner/probe.js +17 -21
- package/dist/runner/registry.d.ts +4 -4
- package/dist/runner/registry.js +4 -4
- package/dist/runner/resolve-agent-choices.js +6 -2
- package/dist/runner/sync-agent-choices.js +1 -1
- package/dist/runner/types.d.ts +38 -1
- package/dist/session/claude/session-index.js +3 -1
- package/dist/session/claude/sessions.js +37 -64
- package/dist/session/codex/rollout-reader.d.ts +10 -14
- package/dist/session/codex/rollout-reader.js +126 -48
- package/dist/session/common/constants.d.ts +7 -0
- package/dist/session/common/constants.js +9 -0
- package/dist/session/common/pagination.d.ts +0 -2
- package/dist/session/common/pagination.js +1 -1
- package/dist/session/common/recency.d.ts +7 -0
- package/dist/session/common/recency.js +10 -0
- package/dist/session/common/two-pass.d.ts +22 -0
- package/dist/session/common/two-pass.js +70 -0
- package/dist/session/common/usage-accumulator.d.ts +14 -0
- package/dist/session/common/usage-accumulator.js +15 -0
- package/dist/session/dsh/sessions.js +15 -11
- package/dist/session/kimi/sessions.d.ts +24 -4
- package/dist/session/kimi/sessions.js +99 -103
- package/dist/session/opencode/sessions.d.ts +2 -14
- package/dist/session/opencode/sessions.js +35 -15
- package/dist/session/pi/sessions.js +64 -95
- package/dist/update/index.d.ts +2 -3
- package/dist/update/index.js +1 -2
- package/dist/update/install.d.ts +2 -1
- package/dist/update/install.js +58 -9
- package/dist/update/version-check.d.ts +5 -0
- package/dist/update/version-check.js +9 -0
- package/package.json +9 -7
- package/dist/update/startup-hint.d.ts +0 -5
- package/dist/update/startup-hint.js +0 -11
package/dist/router/index.js
CHANGED
|
@@ -2,14 +2,15 @@ import fs from 'node:fs';
|
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import YAML from 'yaml';
|
|
5
|
-
import { getConfigDir, getConfigValue, setConfigValue, setConfigValues, mapAgentKey,
|
|
5
|
+
import { getConfigDir, getConfigValue, setConfigValue, setConfigValues, mapAgentKey, getAgentConfig, } from '../config/index.js';
|
|
6
6
|
import { syncAgentChoices } from '../runner/index.js';
|
|
7
7
|
import { WorkspaceStore } from '../workspace/index.js';
|
|
8
8
|
import { OrderStore } from '../order/index.js';
|
|
9
9
|
import { resolveAlias } from '../order/alias-resolve.js';
|
|
10
|
+
import { diffConfig, setNestedValue } from './config-diff.js';
|
|
10
11
|
import { getLogger } from '../logger/index.js';
|
|
11
12
|
import { activeRunUsage, clampInt } from './utils.js';
|
|
12
|
-
import { markdownDiv, buildSessionHistoryCard, paginationBar } from './card-helpers.js';
|
|
13
|
+
import { markdownDiv, buildSessionHistoryCard, paginationBar, parseJumpOffset, PAGE_JUMP_INVALID_HINT, } from './card-helpers.js';
|
|
13
14
|
import { MAX_FILE_UPLOAD_SIZE } from '../connector/file-limits.js';
|
|
14
15
|
import { atomicWrite } from '../persistence/atomic-write.js';
|
|
15
16
|
import { checkLatestVersion as defaultCheckLatestVersion, isNewer as defaultIsNewer, runInstallLatest as defaultRunInstallLatest, } from '../update/index.js';
|
|
@@ -24,7 +25,40 @@ const AUTO_RESUME_MAX_EVENTS = 5;
|
|
|
24
25
|
/** /resume 列表页大小;`/resume [N]` 的 N clamp 到 [1, RESUME_PAGE_SIZE]。 */
|
|
25
26
|
const RESUME_PAGE_SIZE = 5;
|
|
26
27
|
/** /active 卡片每页显示的最大条目数(agent run + bash run 合计)。 */
|
|
28
|
+
/**
|
|
29
|
+
* 分页算术(原先 5 处内联且 clamp 语义漂移:/ls 不 clamp、/active clamp 到
|
|
30
|
+
* totalCount-1 会把过期 offset 泄漏成残页)。统一 clamp 到最后一页边界。
|
|
31
|
+
*/
|
|
32
|
+
function pageInfo(totalCount, offset, pageSize) {
|
|
33
|
+
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
|
34
|
+
const maxOffset = Math.max(0, (totalPages - 1) * pageSize);
|
|
35
|
+
const safeOffset = clampInt(offset, 0, maxOffset);
|
|
36
|
+
const currentPage = Math.floor(safeOffset / pageSize) + 1;
|
|
37
|
+
return { totalPages, safeOffset, currentPage };
|
|
38
|
+
}
|
|
39
|
+
/** 在 pageInfo 之上切出当前页条目。 */
|
|
40
|
+
function pageSlice(items, offset, pageSize) {
|
|
41
|
+
const { totalPages, safeOffset, currentPage } = pageInfo(items.length, offset, pageSize);
|
|
42
|
+
return {
|
|
43
|
+
totalCount: items.length,
|
|
44
|
+
totalPages,
|
|
45
|
+
safeOffset,
|
|
46
|
+
currentPage,
|
|
47
|
+
pageItems: items.slice(safeOffset, safeOffset + pageSize),
|
|
48
|
+
hasPagination: items.length > pageSize,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
27
51
|
const ACTIVE_PAGE_SIZE = 20;
|
|
52
|
+
/** 人类可读字节数(B/KB/MB/GB);/ls 列表与单文件卡共用。 */
|
|
53
|
+
function formatBytes(bytes) {
|
|
54
|
+
if (bytes < 1024)
|
|
55
|
+
return `${bytes}B`;
|
|
56
|
+
if (bytes < 1024 * 1024)
|
|
57
|
+
return `${(bytes / 1024).toFixed(1)}KB`;
|
|
58
|
+
if (bytes < 1024 * 1024 * 1024)
|
|
59
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
60
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)}GB`;
|
|
61
|
+
}
|
|
28
62
|
/**
|
|
29
63
|
* /order 列表页大小;指令超过此数量时显示分页导航栏。
|
|
30
64
|
* 2026-08-13 实测:每行 2 个元素(div + column_set,行间 hr),20 行 + 分页栏
|
|
@@ -45,6 +79,9 @@ const WS_PAGE_SIZE = 5;
|
|
|
45
79
|
* 归一化卡片分页参数:clamp pageSize 到 [1,maxPageSize],offset 归整为
|
|
46
80
|
* 非负整数并对齐到页边界。非数字 payload(如 'abc')不得产生 NaN slice。
|
|
47
81
|
*
|
|
82
|
+
* 「跳转页码」输入框(paginationBar 内置)的提交值优先于 offset;非法输入
|
|
83
|
+
* 返回 `error` 文案,调用方应回错误 toast 且不刷新卡片。
|
|
84
|
+
*
|
|
48
85
|
* resume.page / active.page 共用,避免两处重复实现分页归一化。
|
|
49
86
|
*/
|
|
50
87
|
function normalizePageArgs(value, maxPageSize) {
|
|
@@ -52,6 +89,13 @@ function normalizePageArgs(value, maxPageSize) {
|
|
|
52
89
|
const pageSize = Number.isFinite(rawPageSize)
|
|
53
90
|
? clampInt(Math.trunc(rawPageSize), 1, maxPageSize)
|
|
54
91
|
: maxPageSize;
|
|
92
|
+
const jump = parseJumpOffset(value.inputValue, pageSize);
|
|
93
|
+
if (jump === null) {
|
|
94
|
+
return { pageSize, alignedOffset: 0, error: PAGE_JUMP_INVALID_HINT };
|
|
95
|
+
}
|
|
96
|
+
if (jump !== undefined) {
|
|
97
|
+
return { pageSize, alignedOffset: Math.floor(jump / pageSize) * pageSize };
|
|
98
|
+
}
|
|
55
99
|
const rawOffset = Number(value.offset);
|
|
56
100
|
const offset = Number.isFinite(rawOffset) ? Math.max(0, Math.trunc(rawOffset)) : 0;
|
|
57
101
|
return { pageSize, alignedOffset: Math.floor(offset / pageSize) * pageSize };
|
|
@@ -65,6 +109,7 @@ const RESUME_CONTENT_PREFETCH = 5;
|
|
|
65
109
|
*/
|
|
66
110
|
const RESUME_SUMMARY_PLACEHOLDERS = new Set(['', '(no user message)', '(无摘要)', 'New Session']);
|
|
67
111
|
import { newSessionButton, resumeCompactButton, agentDisplayName } from '../card/card-shared.js';
|
|
112
|
+
import { displayName } from '../platform/path.js';
|
|
68
113
|
/**
|
|
69
114
|
* Card-action commands that must run with enqueue({ immediate: true }).
|
|
70
115
|
* These bypass the serial queue and execute immediately.
|
|
@@ -91,47 +136,174 @@ export function isImmediateAction(cmd) {
|
|
|
91
136
|
// help.* wildcard: any command starting with "help."
|
|
92
137
|
if (cmd.startsWith('help.'))
|
|
93
138
|
return true;
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
139
|
+
// 名单在 APPROVAL_ACTION_SPECS 之后定义(模块初始化完成后才可能被调用)。
|
|
140
|
+
return IMMEDIATE_ACTION_CMDS.has(cmd);
|
|
141
|
+
}
|
|
142
|
+
const QUESTION_INDEX_PRESENT = (v) => v.questionIndex !== undefined;
|
|
143
|
+
const APPROVAL_ACTION_SPECS = {
|
|
144
|
+
'approval.respond': {
|
|
145
|
+
missing: (v) => !v.runId || v.requestId === undefined || !v.decision || !v.nonce,
|
|
146
|
+
missingToast: '缺少审批响应参数',
|
|
147
|
+
run: (bridge, v) => bridge.handleApprovalRespond({
|
|
148
|
+
runId: v.runId,
|
|
149
|
+
requestId: v.requestId,
|
|
150
|
+
decision: v.decision,
|
|
151
|
+
nonce: v.nonce,
|
|
152
|
+
}),
|
|
153
|
+
successToast: '审批已提交',
|
|
154
|
+
failureToast: (msg) => {
|
|
155
|
+
// 审批已过期:给用户明确反馈,不静默不误导(2026-08-12 事故:点了允许无任何反馈)。
|
|
156
|
+
const content = /state=expired/.test(msg)
|
|
157
|
+
? '⏰ 审批已过期,无法响应'
|
|
158
|
+
: `审批响应失败:${msg}`;
|
|
159
|
+
return { toast: { type: 'error', content } };
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
'approval.toggle': {
|
|
163
|
+
missing: (v) => !v.runId || v.requestId === undefined || !v.permId,
|
|
164
|
+
missingToast: '缺少权限切换参数',
|
|
165
|
+
run: (bridge, v) => bridge.handleApprovalToggle({
|
|
166
|
+
runId: v.runId,
|
|
167
|
+
requestId: v.requestId,
|
|
168
|
+
permId: v.permId,
|
|
169
|
+
selected: v.selected ?? true,
|
|
170
|
+
}),
|
|
171
|
+
successToast: '已切换',
|
|
172
|
+
toastType: 'info',
|
|
173
|
+
failureToast: (msg) => ({ toast: { type: 'error', content: `权限切换失败:${msg}` } }),
|
|
174
|
+
},
|
|
175
|
+
'approval.answer': {
|
|
176
|
+
missing: (v) => !v.runId || v.requestId === undefined || !QUESTION_INDEX_PRESENT(v) || !v.option || !v.nonce,
|
|
177
|
+
missingToast: '缺少问题答案参数',
|
|
178
|
+
run: (bridge, v) => bridge.handleApprovalAnswer({
|
|
179
|
+
runId: v.runId,
|
|
180
|
+
requestId: v.requestId,
|
|
181
|
+
questionIndex: v.questionIndex,
|
|
182
|
+
option: v.option,
|
|
183
|
+
nonce: v.nonce,
|
|
184
|
+
}),
|
|
185
|
+
successToast: '已选择',
|
|
186
|
+
},
|
|
187
|
+
'approval.answerSubmit': {
|
|
188
|
+
missing: (v) => !v.runId || v.requestId === undefined || !QUESTION_INDEX_PRESENT(v) || !v.nonce,
|
|
189
|
+
missingToast: '缺少提交参数',
|
|
190
|
+
run: (bridge, v) => bridge.handleApprovalAnswerSubmit({
|
|
191
|
+
runId: v.runId,
|
|
192
|
+
requestId: v.requestId,
|
|
193
|
+
questionIndex: v.questionIndex,
|
|
194
|
+
nonce: v.nonce,
|
|
195
|
+
}),
|
|
196
|
+
successToast: '答案已提交',
|
|
197
|
+
},
|
|
198
|
+
'approval.answerCustom': {
|
|
199
|
+
missing: (v) => !v.runId ||
|
|
200
|
+
v.requestId === undefined ||
|
|
201
|
+
!QUESTION_INDEX_PRESENT(v) ||
|
|
202
|
+
!v.nonce ||
|
|
203
|
+
!v.inputValue,
|
|
204
|
+
missingToast: '缺少答案文本参数',
|
|
205
|
+
run: (bridge, v) => bridge.handleApprovalAnswerCustom({
|
|
206
|
+
runId: v.runId,
|
|
207
|
+
requestId: v.requestId,
|
|
208
|
+
questionIndex: v.questionIndex,
|
|
209
|
+
text: v.inputValue,
|
|
210
|
+
nonce: v.nonce,
|
|
211
|
+
}),
|
|
212
|
+
successToast: '答案已提交',
|
|
213
|
+
},
|
|
214
|
+
'approval.answerNote': {
|
|
215
|
+
missing: (v) => !v.runId ||
|
|
216
|
+
v.requestId === undefined ||
|
|
217
|
+
!QUESTION_INDEX_PRESENT(v) ||
|
|
218
|
+
!v.nonce ||
|
|
219
|
+
!v.inputValue,
|
|
220
|
+
missingToast: '缺少补充说明参数',
|
|
221
|
+
run: (bridge, v) => bridge.handleApprovalAnswerNote({
|
|
222
|
+
runId: v.runId,
|
|
223
|
+
requestId: v.requestId,
|
|
224
|
+
questionIndex: v.questionIndex,
|
|
225
|
+
text: v.inputValue,
|
|
226
|
+
nonce: v.nonce,
|
|
227
|
+
}),
|
|
228
|
+
successToast: '补充说明已保存',
|
|
229
|
+
},
|
|
230
|
+
'approval.planFeedback': {
|
|
231
|
+
missing: (v) => !v.runId || v.requestId === undefined || !v.nonce || !v.inputValue,
|
|
232
|
+
missingToast: '缺少修改意见参数',
|
|
233
|
+
run: (bridge, v) => bridge.handleApprovalPlanFeedback({
|
|
234
|
+
runId: v.runId,
|
|
235
|
+
requestId: v.requestId,
|
|
236
|
+
text: v.inputValue,
|
|
237
|
+
nonce: v.nonce,
|
|
238
|
+
}),
|
|
239
|
+
successToast: '修改意见已保存',
|
|
240
|
+
failureToast: (msg) => ({ toast: { type: 'error', content: `修改意见保存失败:${msg}` } }),
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
// ===========================================================================
|
|
244
|
+
// W2.1 命令名单单一事实源
|
|
245
|
+
// 直返语义(cardAction 同步返回 toast 给飞书回调,index.ts 控制层据此不经
|
|
246
|
+
// 队列直接处理)与即时语义(绕串行队列 enqueueImmediate,§9.6)正交,两个
|
|
247
|
+
// 独立清单只共享审批命令名来源(APPROVAL_ACTION_SPECS keys)——历史上同一
|
|
248
|
+
// 份知识四份拷贝曾两次漂移(order.textInput 漏 immediate、answer 家族漏直返)。
|
|
249
|
+
// ===========================================================================
|
|
250
|
+
/** 审批命令名清单(APPROVAL_ACTION_SPECS keys,handleCardAction 分发共用)。 */
|
|
251
|
+
export const APPROVAL_ACTION_CMDS = Object.keys(APPROVAL_ACTION_SPECS);
|
|
252
|
+
/**
|
|
253
|
+
* 直返命令:点击后同步返回 toast 给飞书回调,不经队列。
|
|
254
|
+
* index.ts 控制层直返清单的唯一来源(勿在别处复制名单)。
|
|
255
|
+
*/
|
|
256
|
+
export const DIRECT_RETURN_CMDS = new Set([
|
|
257
|
+
'queue.input',
|
|
258
|
+
'order.aliasInput',
|
|
259
|
+
'order.aliasRemove',
|
|
260
|
+
'order.textInput',
|
|
261
|
+
'config.save',
|
|
262
|
+
...APPROVAL_ACTION_CMDS,
|
|
263
|
+
]);
|
|
264
|
+
/** 即时命令清单(绕串行队列 enqueueImmediate)。 */
|
|
265
|
+
const IMMEDIATE_ACTION_CMDS = new Set([
|
|
266
|
+
'new-session',
|
|
267
|
+
'stop',
|
|
268
|
+
'ls.file',
|
|
269
|
+
'ls.refresh',
|
|
270
|
+
'ls.browse',
|
|
271
|
+
'ls.switch',
|
|
272
|
+
'ls.page', // control operation: paginate only, never spawns claude
|
|
273
|
+
'resume.page', // control operation: paginate only, never spawns claude
|
|
274
|
+
'active.page', // control operation: paginate active card
|
|
275
|
+
'ws.page', // control operation: paginate /ws list only
|
|
276
|
+
'ws.remove',
|
|
277
|
+
'ws.sort', // control operation: toggle sort mode only
|
|
278
|
+
'resume.use',
|
|
279
|
+
'ws.use',
|
|
280
|
+
'queue.immediate',
|
|
281
|
+
'queue.cancel',
|
|
282
|
+
'queue.diagnose',
|
|
283
|
+
'queue.edit',
|
|
284
|
+
'queue.input',
|
|
285
|
+
'order.delete',
|
|
286
|
+
'order.page',
|
|
287
|
+
'order.aliasEdit',
|
|
288
|
+
'order.aliasInput',
|
|
289
|
+
'order.aliasRemove',
|
|
290
|
+
'order.textEdit',
|
|
291
|
+
'order.textInput',
|
|
292
|
+
'config.toggle',
|
|
293
|
+
'config.set',
|
|
294
|
+
'config.input',
|
|
295
|
+
'config.save',
|
|
296
|
+
// 审批响应/权限切换必须即时触达在途 run(同 stop 类控制动作)。若走串行
|
|
297
|
+
// 队列会排在等待审批的 run 之后形成死锁:run 不结束审批不执行,run 结束
|
|
298
|
+
// coordinator 已删响应空转(线上复现:approval.respond 排队卡、审批永不生效)。
|
|
299
|
+
...APPROVAL_ACTION_CMDS,
|
|
300
|
+
]);
|
|
301
|
+
/** W2.8 单源:payload.offset → 钳位 offset(原先 9 处逐字副本)。 */
|
|
302
|
+
function payloadOffset(value) {
|
|
303
|
+
return Math.max(0, Math.trunc(Number(value.offset) || 0));
|
|
134
304
|
}
|
|
305
|
+
/** 卡片 cardAction payload 缺字段的统一报错文案(原先 12 处手写且已漂移出两种前缀)。 */
|
|
306
|
+
const CARD_PAYLOAD_MISSING = '⚠️ 卡片 payload 缺少必要信息';
|
|
135
307
|
export class CommandRouter {
|
|
136
308
|
/** Valid agent kinds — single source of truth for resume.use / resume.page / cmdResume. */
|
|
137
309
|
static VALID_AGENTS = ['claude', 'codex', 'opencode', 'pi', 'kimi', 'dsh'];
|
|
@@ -275,6 +447,11 @@ export class CommandRouter {
|
|
|
275
447
|
* Validates payload and dispatches to the corresponding command.
|
|
276
448
|
*/
|
|
277
449
|
async handleCardAction(value, ctx) {
|
|
450
|
+
// W2.1:审批家族以 APPROVAL_ACTION_SPECS keys 为单一来源分发
|
|
451
|
+
// (原 7 个 case 与 spec keys 是同一份知识的两份拷贝,已两次漂移)。
|
|
452
|
+
if (APPROVAL_ACTION_CMDS.includes(value.cmd)) {
|
|
453
|
+
return this.handleApprovalAction(value, ctx);
|
|
454
|
+
}
|
|
278
455
|
switch (value.cmd) {
|
|
279
456
|
case 'ls.file':
|
|
280
457
|
await this.cardLsFile(value.path, ctx);
|
|
@@ -382,14 +559,6 @@ export class CommandRouter {
|
|
|
382
559
|
// 调 updateCardInPlace → Feishu API 乱序到达导致 toggle 卡死。
|
|
383
560
|
// 2026-07-18: 返回 enqueueConfigAction 的结果以支持 toast 响应
|
|
384
561
|
return this.enqueueConfigAction(value, ctx);
|
|
385
|
-
case 'approval.respond':
|
|
386
|
-
case 'approval.toggle':
|
|
387
|
-
case 'approval.answer':
|
|
388
|
-
case 'approval.answerSubmit':
|
|
389
|
-
case 'approval.answerCustom':
|
|
390
|
-
case 'approval.answerNote':
|
|
391
|
-
case 'approval.planFeedback':
|
|
392
|
-
return this.handleApprovalAction(value, ctx);
|
|
393
562
|
case 'codex.compact':
|
|
394
563
|
await this.bridge.handleCodexCompact(value, ctx);
|
|
395
564
|
return;
|
|
@@ -427,6 +596,14 @@ export class CommandRouter {
|
|
|
427
596
|
* Routes to the appropriate bridge method based on value.cmd.
|
|
428
597
|
* Returns a toast response for immediate user feedback.
|
|
429
598
|
*/
|
|
599
|
+
/** queue.* 卡片动作通用守卫:workspace/messageId 缺失时回复错误并返回 null。 */
|
|
600
|
+
async queuePayloadOrReply(value, ctx) {
|
|
601
|
+
if (!value.workspace || !value.messageId) {
|
|
602
|
+
await this.bridge.sendResult({ text: CARD_PAYLOAD_MISSING }, ctx);
|
|
603
|
+
return null;
|
|
604
|
+
}
|
|
605
|
+
return { workspace: value.workspace, messageId: value.messageId };
|
|
606
|
+
}
|
|
430
607
|
/** 答案类操作的统一失败反馈:重复投递(同一 nonce 第二次点击)是已生效的
|
|
431
608
|
* 中性事件,报 error 会误导(首次点击实际已成功)。 */
|
|
432
609
|
answerFailureToast(msg) {
|
|
@@ -436,164 +613,30 @@ export class CommandRouter {
|
|
|
436
613
|
return { toast: { type: 'error', content: `答案提交失败:${msg}` } };
|
|
437
614
|
}
|
|
438
615
|
async handleApprovalAction(value, _ctx) {
|
|
439
|
-
const
|
|
440
|
-
if (
|
|
441
|
-
|
|
442
|
-
return { toast: { type: 'error', content: '缺少审批响应参数' } };
|
|
443
|
-
}
|
|
444
|
-
try {
|
|
445
|
-
await this.bridge.handleApprovalRespond({
|
|
446
|
-
runId,
|
|
447
|
-
requestId,
|
|
448
|
-
decision,
|
|
449
|
-
nonce,
|
|
450
|
-
});
|
|
451
|
-
return { toast: { type: 'success', content: '审批已提交' } };
|
|
452
|
-
}
|
|
453
|
-
catch (err) {
|
|
454
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
455
|
-
// 审批已过期:给用户明确反馈,不静默不误导(2026-08-12 事故:点了允许无任何反馈)。
|
|
456
|
-
const content = /state=expired/.test(msg)
|
|
457
|
-
? '⏰ 审批已过期,无法响应'
|
|
458
|
-
: `审批响应失败:${msg}`;
|
|
459
|
-
return { toast: { type: 'error', content } };
|
|
460
|
-
}
|
|
616
|
+
const spec = APPROVAL_ACTION_SPECS[value.cmd];
|
|
617
|
+
if (!spec) {
|
|
618
|
+
return { toast: { type: 'error', content: '未知的审批操作' } };
|
|
461
619
|
}
|
|
462
|
-
if (value
|
|
463
|
-
|
|
464
|
-
return { toast: { type: 'error', content: '缺少权限切换参数' } };
|
|
465
|
-
}
|
|
466
|
-
try {
|
|
467
|
-
await this.bridge.handleApprovalToggle({
|
|
468
|
-
runId,
|
|
469
|
-
requestId,
|
|
470
|
-
permId,
|
|
471
|
-
selected: value.selected ?? true,
|
|
472
|
-
});
|
|
473
|
-
return { toast: { type: 'info', content: '已切换' } };
|
|
474
|
-
}
|
|
475
|
-
catch (err) {
|
|
476
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
477
|
-
return { toast: { type: 'error', content: `权限切换失败:${msg}` } };
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
if (value.cmd === 'approval.answer') {
|
|
481
|
-
if (!runId ||
|
|
482
|
-
requestId === undefined ||
|
|
483
|
-
value.questionIndex === undefined ||
|
|
484
|
-
!value.option ||
|
|
485
|
-
!nonce) {
|
|
486
|
-
return { toast: { type: 'error', content: '缺少问题答案参数' } };
|
|
487
|
-
}
|
|
488
|
-
try {
|
|
489
|
-
await this.bridge.handleApprovalAnswer({
|
|
490
|
-
runId,
|
|
491
|
-
requestId,
|
|
492
|
-
questionIndex: value.questionIndex,
|
|
493
|
-
option: value.option,
|
|
494
|
-
nonce,
|
|
495
|
-
});
|
|
496
|
-
return { toast: { type: 'success', content: '已选择' } };
|
|
497
|
-
}
|
|
498
|
-
catch (err) {
|
|
499
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
500
|
-
return this.answerFailureToast(msg);
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
if (value.cmd === 'approval.answerSubmit') {
|
|
504
|
-
if (!runId || requestId === undefined || value.questionIndex === undefined || !nonce) {
|
|
505
|
-
return { toast: { type: 'error', content: '缺少提交参数' } };
|
|
506
|
-
}
|
|
507
|
-
try {
|
|
508
|
-
await this.bridge.handleApprovalAnswerSubmit({
|
|
509
|
-
runId,
|
|
510
|
-
requestId,
|
|
511
|
-
questionIndex: value.questionIndex,
|
|
512
|
-
nonce,
|
|
513
|
-
});
|
|
514
|
-
return { toast: { type: 'success', content: '答案已提交' } };
|
|
515
|
-
}
|
|
516
|
-
catch (err) {
|
|
517
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
518
|
-
return this.answerFailureToast(msg);
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
if (value.cmd === 'approval.answerCustom') {
|
|
522
|
-
if (!runId ||
|
|
523
|
-
requestId === undefined ||
|
|
524
|
-
value.questionIndex === undefined ||
|
|
525
|
-
!nonce ||
|
|
526
|
-
!value.inputValue) {
|
|
527
|
-
return { toast: { type: 'error', content: '缺少答案文本参数' } };
|
|
528
|
-
}
|
|
529
|
-
try {
|
|
530
|
-
await this.bridge.handleApprovalAnswerCustom({
|
|
531
|
-
runId,
|
|
532
|
-
requestId,
|
|
533
|
-
questionIndex: value.questionIndex,
|
|
534
|
-
text: value.inputValue,
|
|
535
|
-
nonce,
|
|
536
|
-
});
|
|
537
|
-
return { toast: { type: 'success', content: '答案已提交' } };
|
|
538
|
-
}
|
|
539
|
-
catch (err) {
|
|
540
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
541
|
-
return this.answerFailureToast(msg);
|
|
542
|
-
}
|
|
620
|
+
if (spec.missing(value)) {
|
|
621
|
+
return { toast: { type: 'error', content: spec.missingToast } };
|
|
543
622
|
}
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
value.questionIndex === undefined ||
|
|
548
|
-
!nonce ||
|
|
549
|
-
!value.inputValue) {
|
|
550
|
-
return { toast: { type: 'error', content: '缺少补充说明参数' } };
|
|
551
|
-
}
|
|
552
|
-
try {
|
|
553
|
-
await this.bridge.handleApprovalAnswerNote({
|
|
554
|
-
runId,
|
|
555
|
-
requestId,
|
|
556
|
-
questionIndex: value.questionIndex,
|
|
557
|
-
text: value.inputValue,
|
|
558
|
-
nonce,
|
|
559
|
-
});
|
|
560
|
-
return { toast: { type: 'success', content: '补充说明已保存' } };
|
|
561
|
-
}
|
|
562
|
-
catch (err) {
|
|
563
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
564
|
-
return this.answerFailureToast(msg);
|
|
565
|
-
}
|
|
623
|
+
try {
|
|
624
|
+
await spec.run(this.bridge, value);
|
|
625
|
+
return { toast: { type: spec.toastType ?? 'success', content: spec.successToast } };
|
|
566
626
|
}
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
}
|
|
571
|
-
try {
|
|
572
|
-
await this.bridge.handleApprovalPlanFeedback({
|
|
573
|
-
runId,
|
|
574
|
-
requestId,
|
|
575
|
-
text: value.inputValue,
|
|
576
|
-
nonce,
|
|
577
|
-
});
|
|
578
|
-
return { toast: { type: 'success', content: '修改意见已保存' } };
|
|
579
|
-
}
|
|
580
|
-
catch (err) {
|
|
581
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
582
|
-
return { toast: { type: 'error', content: `修改意见保存失败:${msg}` } };
|
|
583
|
-
}
|
|
627
|
+
catch (err) {
|
|
628
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
629
|
+
return spec.failureToast ? spec.failureToast(msg) : this.answerFailureToast(msg);
|
|
584
630
|
}
|
|
585
|
-
return { toast: { type: 'error', content: '未知的审批操作' } };
|
|
586
631
|
}
|
|
587
632
|
/**
|
|
588
633
|
* Handle queue.cancel: remove this message from the queue.
|
|
589
634
|
*/
|
|
590
635
|
async handleQueueCancel(value, ctx) {
|
|
591
|
-
const
|
|
592
|
-
|
|
593
|
-
if (!workspace || !messageId) {
|
|
594
|
-
await this.bridge.sendResult({ text: '⚠️ 卡片 payload 缺少必要信息' }, ctx);
|
|
636
|
+
const payload = await this.queuePayloadOrReply(value, ctx);
|
|
637
|
+
if (!payload)
|
|
595
638
|
return;
|
|
596
|
-
}
|
|
639
|
+
const { workspace, messageId } = payload;
|
|
597
640
|
const removed = this.bridge.removeFromQueue(workspace, messageId);
|
|
598
641
|
if (removed) {
|
|
599
642
|
// Update the queue card in-place to "cancelled" state
|
|
@@ -610,12 +653,10 @@ export class CommandRouter {
|
|
|
610
653
|
* Instead of asking user to resend, we keep the target task in queue and let it execute.
|
|
611
654
|
*/
|
|
612
655
|
async handleQueueImmediate(value, ctx) {
|
|
613
|
-
const
|
|
614
|
-
|
|
615
|
-
if (!workspace || !messageId) {
|
|
616
|
-
await this.bridge.sendResult({ text: '卡片 payload 缺少必要信息' }, ctx);
|
|
656
|
+
const payload = await this.queuePayloadOrReply(value, ctx);
|
|
657
|
+
if (!payload)
|
|
617
658
|
return;
|
|
618
|
-
}
|
|
659
|
+
const { workspace, messageId } = payload;
|
|
619
660
|
// 1. Check target exists BEFORE any await. The queue chain can advance
|
|
620
661
|
// while later awaits (interruptCurrentRun / markQueueCardExecuting's card
|
|
621
662
|
// send) are in flight, so the target must be read synchronously at entry.
|
|
@@ -751,13 +792,11 @@ export class CommandRouter {
|
|
|
751
792
|
* Handle queue.diagnose: show diagnostic info for why message is queuing.
|
|
752
793
|
*/
|
|
753
794
|
async handleQueueDiagnose(value, ctx) {
|
|
754
|
-
const
|
|
755
|
-
|
|
756
|
-
const targetUserId = value.userId ?? ctx.userId;
|
|
757
|
-
if (!workspace || !messageId) {
|
|
758
|
-
await this.bridge.sendResult({ text: '卡片 payload 缺少必要信息' }, ctx);
|
|
795
|
+
const payload = await this.queuePayloadOrReply(value, ctx);
|
|
796
|
+
if (!payload)
|
|
759
797
|
return;
|
|
760
|
-
}
|
|
798
|
+
const { workspace, messageId } = payload;
|
|
799
|
+
const targetUserId = value.userId ?? ctx.userId;
|
|
761
800
|
const task = this.bridge.getQueuedTask(workspace, messageId);
|
|
762
801
|
const queueInfo = this.bridge.getQueueInfo(workspace);
|
|
763
802
|
const activeRuns = this.bridge.getAllActiveRuns();
|
|
@@ -825,12 +864,10 @@ export class CommandRouter {
|
|
|
825
864
|
* Handle queue.edit: show input field for editing the queued message.
|
|
826
865
|
*/
|
|
827
866
|
async handleQueueEdit(value, ctx) {
|
|
828
|
-
const
|
|
829
|
-
|
|
830
|
-
if (!workspace || !messageId) {
|
|
831
|
-
await this.bridge.sendResult({ text: '⚠️ 卡片 payload 缺少必要信息' }, ctx);
|
|
867
|
+
const payload = await this.queuePayloadOrReply(value, ctx);
|
|
868
|
+
if (!payload)
|
|
832
869
|
return;
|
|
833
|
-
}
|
|
870
|
+
const { workspace, messageId } = payload;
|
|
834
871
|
// Get current message preview
|
|
835
872
|
const task = this.bridge.getQueuedTask(workspace, messageId);
|
|
836
873
|
if (!task) {
|
|
@@ -896,13 +933,11 @@ export class CommandRouter {
|
|
|
896
933
|
* when the callback response has no `card` field).
|
|
897
934
|
*/
|
|
898
935
|
async handleQueueInput(value, ctx) {
|
|
899
|
-
const workspace = value.workspace;
|
|
900
|
-
const messageId = value.messageId;
|
|
901
936
|
const newMessage = value.inputValue ?? value.formValue?.['newMessage'];
|
|
902
|
-
|
|
903
|
-
|
|
937
|
+
const payload = await this.queuePayloadOrReply(value, ctx);
|
|
938
|
+
if (!payload)
|
|
904
939
|
return;
|
|
905
|
-
}
|
|
940
|
+
const { workspace, messageId } = payload;
|
|
906
941
|
if (!newMessage) {
|
|
907
942
|
await this.bridge.sendResult({ text: '⚠️ 缺少新消息内容' }, ctx);
|
|
908
943
|
return;
|
|
@@ -963,6 +998,21 @@ export class CommandRouter {
|
|
|
963
998
|
card: { type: 'raw', data: card },
|
|
964
999
|
};
|
|
965
1000
|
}
|
|
1001
|
+
/**
|
|
1002
|
+
* 分页卡动作的 offset 解析:优先读取「跳转页码」输入框(paginationBar 内置
|
|
1003
|
+
* 的 CardKit 2.0 input),未输入则回退 `payload.offset`(上一页/下一页按钮)。
|
|
1004
|
+
* 非法页码返回 `{ error }`,调用方应回错误 toast 且不刷新卡片。
|
|
1005
|
+
*/
|
|
1006
|
+
pagingOffset(value, defaultPageSize) {
|
|
1007
|
+
const rawSize = Number(value.pageSize);
|
|
1008
|
+
const pageSize = Number.isFinite(rawSize) && rawSize > 0 ? Math.trunc(rawSize) : defaultPageSize;
|
|
1009
|
+
const jump = parseJumpOffset(value.inputValue, pageSize);
|
|
1010
|
+
if (jump === null)
|
|
1011
|
+
return { error: PAGE_JUMP_INVALID_HINT };
|
|
1012
|
+
if (jump !== undefined)
|
|
1013
|
+
return { offset: jump };
|
|
1014
|
+
return { offset: payloadOffset(value) };
|
|
1015
|
+
}
|
|
966
1016
|
/**
|
|
967
1017
|
* Handle ls.switch: switch cwd to target directory after path validation.
|
|
968
1018
|
*/
|
|
@@ -996,8 +1046,10 @@ export class CommandRouter {
|
|
|
996
1046
|
}
|
|
997
1047
|
// Set cwd + auto-resume + user notification (shared with /cd and /ws use)
|
|
998
1048
|
const notifyResult = this.switchCwdAndNotify(ctx.userId, canonical, ctx);
|
|
999
|
-
// Card channel: refresh ls card in-place (existing behavior preserved)
|
|
1000
|
-
|
|
1049
|
+
// Card channel: refresh ls card in-place (existing behavior preserved)。
|
|
1050
|
+
// value.root 是本次 /ls 浏览的起点(绝对路径,不受 cwd 变更影响),透传后
|
|
1051
|
+
// 「返回」仍回到用户最初 /ls 的那个目录。
|
|
1052
|
+
const card = this.cmdLs([canonical], ctx, 0, value.root);
|
|
1001
1053
|
await this.bridge.updateCardInPlace(card.card, ctx);
|
|
1002
1054
|
// Message channel: send switch confirmation / auto-resume card to chat
|
|
1003
1055
|
await this.bridge.sendResult(notifyResult, ctx);
|
|
@@ -1017,11 +1069,11 @@ export class CommandRouter {
|
|
|
1017
1069
|
try {
|
|
1018
1070
|
card =
|
|
1019
1071
|
targetPath && fs.existsSync(targetPath) && fs.statSync(targetPath).isDirectory()
|
|
1020
|
-
? this.cmdLs([targetPath], ctx, offset)
|
|
1021
|
-
: this.cmdLs([], ctx, offset);
|
|
1072
|
+
? this.cmdLs([targetPath], ctx, offset, value.root)
|
|
1073
|
+
: this.cmdLs([], ctx, offset, value.root);
|
|
1022
1074
|
}
|
|
1023
1075
|
catch {
|
|
1024
|
-
card = this.cmdLs([], ctx, offset);
|
|
1076
|
+
card = this.cmdLs([], ctx, offset, value.root);
|
|
1025
1077
|
}
|
|
1026
1078
|
await this.bridge.updateCardInPlace(card.card, ctx);
|
|
1027
1079
|
}
|
|
@@ -1030,10 +1082,13 @@ export class CommandRouter {
|
|
|
1030
1082
|
*/
|
|
1031
1083
|
async handleLsPage(value, ctx) {
|
|
1032
1084
|
const targetPath = value.path;
|
|
1033
|
-
const offset = value.offset ?? 0;
|
|
1034
1085
|
if (!targetPath) {
|
|
1035
1086
|
return { toast: { type: 'error', content: '卡片 payload 缺少 path' } };
|
|
1036
1087
|
}
|
|
1088
|
+
const paging = this.pagingOffset(value, CommandRouter.LS_PAGE_SIZE);
|
|
1089
|
+
if ('error' in paging) {
|
|
1090
|
+
return { toast: { type: 'error', content: paging.error } };
|
|
1091
|
+
}
|
|
1037
1092
|
const resolvedTarget = path.resolve(targetPath);
|
|
1038
1093
|
// TOCTOU guard (review P2-2): existsSync → statSync can race; without the
|
|
1039
1094
|
// try/catch the throw lands in enqueueImmediate's silent .catch and the
|
|
@@ -1048,7 +1103,7 @@ export class CommandRouter {
|
|
|
1048
1103
|
if (!isDir) {
|
|
1049
1104
|
return { toast: { type: 'error', content: `路径无效: ${resolvedTarget}` } };
|
|
1050
1105
|
}
|
|
1051
|
-
const card = this.cmdLs([resolvedTarget], ctx, offset);
|
|
1106
|
+
const card = this.cmdLs([resolvedTarget], ctx, paging.offset, value.root);
|
|
1052
1107
|
// 直接更新卡片,与 ls.browse/ls.refresh 行为一致
|
|
1053
1108
|
await this.bridge.updateCardInPlace(card.card, ctx);
|
|
1054
1109
|
return { toast: { type: 'success', content: '' } };
|
|
@@ -1063,7 +1118,10 @@ export class CommandRouter {
|
|
|
1063
1118
|
const resolvedAgent = validAgents.includes(resumeAgent)
|
|
1064
1119
|
? resumeAgent
|
|
1065
1120
|
: this.config.defaultAgent;
|
|
1066
|
-
const { pageSize, alignedOffset } = normalizePageArgs(value, RESUME_PAGE_SIZE);
|
|
1121
|
+
const { pageSize, alignedOffset, error } = normalizePageArgs(value, RESUME_PAGE_SIZE);
|
|
1122
|
+
if (error) {
|
|
1123
|
+
return { toast: { type: 'error', content: error } };
|
|
1124
|
+
}
|
|
1067
1125
|
const entry = this.sessionStore.get(ctx.userId);
|
|
1068
1126
|
const cwd = entry?.cwd;
|
|
1069
1127
|
if (!cwd) {
|
|
@@ -1090,7 +1148,10 @@ export class CommandRouter {
|
|
|
1090
1148
|
* Mirrors handleResumePage — updates the same card, never sends a new one.
|
|
1091
1149
|
*/
|
|
1092
1150
|
async handleActivePage(value, ctx) {
|
|
1093
|
-
const { alignedOffset } = normalizePageArgs(value, ACTIVE_PAGE_SIZE);
|
|
1151
|
+
const { alignedOffset, error } = normalizePageArgs(value, ACTIVE_PAGE_SIZE);
|
|
1152
|
+
if (error) {
|
|
1153
|
+
return { toast: { type: 'error', content: error } };
|
|
1154
|
+
}
|
|
1094
1155
|
const result = this.cmdActive([], ctx, alignedOffset);
|
|
1095
1156
|
if (!result.card) {
|
|
1096
1157
|
return { toast: { type: 'error', content: '当前没有正在进行中的任务' } };
|
|
@@ -1113,7 +1174,7 @@ export class CommandRouter {
|
|
|
1113
1174
|
return;
|
|
1114
1175
|
}
|
|
1115
1176
|
// Always reset to page 0 when browsing to a new directory
|
|
1116
|
-
const card = this.cmdLs([resolvedTarget], ctx, 0);
|
|
1177
|
+
const card = this.cmdLs([resolvedTarget], ctx, 0, value.root);
|
|
1117
1178
|
await this.bridge.updateCardInPlace(card.card, ctx);
|
|
1118
1179
|
}
|
|
1119
1180
|
/**
|
|
@@ -1167,13 +1228,58 @@ export class CommandRouter {
|
|
|
1167
1228
|
internalKey: `order-${order.id}-${this.orderExecKeyCounter++}`,
|
|
1168
1229
|
};
|
|
1169
1230
|
}
|
|
1231
|
+
/**
|
|
1232
|
+
* W2.8 单源:重建 order/ws 列表卡(不投递,card 进 callback 响应体用)。
|
|
1233
|
+
*/
|
|
1234
|
+
rebuildListCard(kind, offset, ctx) {
|
|
1235
|
+
return kind === 'order'
|
|
1236
|
+
? this.cmdOrder([], ctx, offset).card
|
|
1237
|
+
: this.cmdWs([], ctx, offset).card;
|
|
1238
|
+
}
|
|
1239
|
+
/**
|
|
1240
|
+
* W2.8 单源:「重建列表卡 → updateCardInPlace」原地刷新骨架(列表卡删除/
|
|
1241
|
+
* 翻页/移除/排序动作共用)。返回重建后的卡片;无卡时返回 undefined 且跳过更新。
|
|
1242
|
+
*/
|
|
1243
|
+
async refreshListCard(kind, value, ctx) {
|
|
1244
|
+
const card = this.rebuildListCard(kind, payloadOffset(value), ctx);
|
|
1245
|
+
if (!card)
|
|
1246
|
+
return undefined;
|
|
1247
|
+
await this.bridge.updateCardInPlace(card, ctx);
|
|
1248
|
+
return card;
|
|
1249
|
+
}
|
|
1250
|
+
/**
|
|
1251
|
+
* W2.8 单源:order 三个 input handler(aliasInput/aliasRemove/textInput)
|
|
1252
|
+
* 的公共骨架——校验 orderId → reload+find → mutate → 成功后 toast + raw 卡。
|
|
1253
|
+
* mutate 返回 error 时仅回 error toast,不重渲染。
|
|
1254
|
+
* 注意:本 helper 只服务「toast + card 进响应体」语义(飞书需用响应体原地
|
|
1255
|
+
* 替换 pre-click 编辑卡);updateCardInPlace 原地刷新走 refreshListCard。
|
|
1256
|
+
*/
|
|
1257
|
+
async mutateOrderAndRefreshCard(value, ctx, opts, mutate) {
|
|
1258
|
+
if (!value.orderId) {
|
|
1259
|
+
return { toast: { type: 'error', content: CARD_PAYLOAD_MISSING } };
|
|
1260
|
+
}
|
|
1261
|
+
this.orderStore.reload();
|
|
1262
|
+
const order = this.orderStore.get().find((o) => o.id === value.orderId);
|
|
1263
|
+
if (!order) {
|
|
1264
|
+
return { toast: { type: 'error', content: opts.missingToast } };
|
|
1265
|
+
}
|
|
1266
|
+
const outcome = mutate(order);
|
|
1267
|
+
if (!outcome.ok) {
|
|
1268
|
+
return { toast: { type: 'error', content: outcome.error } };
|
|
1269
|
+
}
|
|
1270
|
+
const result = this.cmdOrder([], ctx, payloadOffset(value));
|
|
1271
|
+
return {
|
|
1272
|
+
toast: { type: 'success', content: outcome.toast },
|
|
1273
|
+
card: { type: 'raw', data: result.card },
|
|
1274
|
+
};
|
|
1275
|
+
}
|
|
1170
1276
|
/**
|
|
1171
1277
|
* Handle order.delete: remove the order and update card in place.
|
|
1172
1278
|
*/
|
|
1173
1279
|
async handleOrderDelete(value, ctx) {
|
|
1174
1280
|
const orderId = value.orderId;
|
|
1175
1281
|
if (!orderId) {
|
|
1176
|
-
return { toast: { type: 'error', content:
|
|
1282
|
+
return { toast: { type: 'error', content: CARD_PAYLOAD_MISSING } };
|
|
1177
1283
|
}
|
|
1178
1284
|
if (!this.orderStore.has(orderId)) {
|
|
1179
1285
|
return { toast: { type: 'error', content: '指令不存在或已被删除' } };
|
|
@@ -1181,9 +1287,7 @@ export class CommandRouter {
|
|
|
1181
1287
|
// Delete the order
|
|
1182
1288
|
this.orderStore.remove(orderId);
|
|
1183
1289
|
// Refresh the order list and update card in place, preserving current page
|
|
1184
|
-
|
|
1185
|
-
const result = this.cmdOrder([], ctx, currentOffset);
|
|
1186
|
-
await this.bridge.updateCardInPlace(result.card, ctx);
|
|
1290
|
+
await this.refreshListCard('order', value, ctx);
|
|
1187
1291
|
return { toast: { type: 'success', content: '已删除指令' } };
|
|
1188
1292
|
}
|
|
1189
1293
|
/**
|
|
@@ -1191,27 +1295,34 @@ export class CommandRouter {
|
|
|
1191
1295
|
* Mirrors handleLsPage — updates the same card, never sends a new one.
|
|
1192
1296
|
*/
|
|
1193
1297
|
async handleOrderPage(value, ctx) {
|
|
1194
|
-
const
|
|
1298
|
+
const paging = this.pagingOffset(value, ORDER_PAGE_SIZE);
|
|
1299
|
+
if ('error' in paging) {
|
|
1300
|
+
return { toast: { type: 'error', content: paging.error } };
|
|
1301
|
+
}
|
|
1195
1302
|
// cmdOrder internally clamps stale/out-of-range offsets
|
|
1196
|
-
|
|
1197
|
-
await this.bridge.updateCardInPlace(result.card, ctx);
|
|
1303
|
+
await this.refreshListCard('order', { offset: paging.offset }, ctx);
|
|
1198
1304
|
return { toast: { type: 'success', content: '' } };
|
|
1199
1305
|
}
|
|
1200
1306
|
/**
|
|
1201
1307
|
* Handle order.aliasEdit: 展示别名编辑卡(input + ✓ 提交图标)。
|
|
1202
1308
|
* 有别名时预填;提交 `order.aliasInput` 处理(含校验与唯一性)。
|
|
1203
1309
|
*/
|
|
1204
|
-
|
|
1310
|
+
/**
|
|
1311
|
+
* order 编辑卡公共骨架:payload 校验 → 查指令 → 构造 input 编辑卡 → 原地更新。
|
|
1312
|
+
* handleOrderAliasEdit / handleOrderTextEdit 原先各持一份 ~90% 相同的 4 段结构。
|
|
1313
|
+
*/
|
|
1314
|
+
async showOrderEditCard(value, ctx, opts) {
|
|
1205
1315
|
const orderId = value.orderId;
|
|
1206
|
-
const offset =
|
|
1316
|
+
const offset = payloadOffset(value);
|
|
1207
1317
|
if (!orderId) {
|
|
1208
|
-
return { toast: { type: 'error', content:
|
|
1318
|
+
return { toast: { type: 'error', content: CARD_PAYLOAD_MISSING } };
|
|
1209
1319
|
}
|
|
1210
1320
|
this.orderStore.reload();
|
|
1211
1321
|
const order = this.orderStore.get().find((o) => o.id === orderId);
|
|
1212
1322
|
if (!order) {
|
|
1213
1323
|
return { toast: { type: 'error', content: '指令不存在或已被删除' } };
|
|
1214
1324
|
}
|
|
1325
|
+
// 列表卡显示的是截断版(≤100 字符 + ...),编辑卡预览保持一致
|
|
1215
1326
|
const displayText = order.text.length > 100 ? order.text.slice(0, 97) + '...' : order.text;
|
|
1216
1327
|
// CardKit 2.0 input 自带 ✓ 提交图标(input_value 经 raw 回传,红线)。
|
|
1217
1328
|
// 不用 form 容器(触发 300123 无 submit button / 200621 嵌套 column)。
|
|
@@ -1220,7 +1331,7 @@ export class CommandRouter {
|
|
|
1220
1331
|
config: { wide_screen_mode: true },
|
|
1221
1332
|
header: {
|
|
1222
1333
|
template: 'blue',
|
|
1223
|
-
title: { tag: 'plain_text', content:
|
|
1334
|
+
title: { tag: 'plain_text', content: opts.title },
|
|
1224
1335
|
},
|
|
1225
1336
|
body: {
|
|
1226
1337
|
elements: [
|
|
@@ -1231,10 +1342,7 @@ export class CommandRouter {
|
|
|
1231
1342
|
{ tag: 'hr' },
|
|
1232
1343
|
{
|
|
1233
1344
|
tag: 'div',
|
|
1234
|
-
text: {
|
|
1235
|
-
tag: 'lark_md',
|
|
1236
|
-
content: '💡 输入别名名后点击右侧 ✓ 提交;留空提交 = 删除该别名',
|
|
1237
|
-
},
|
|
1345
|
+
text: { tag: 'lark_md', content: opts.hint },
|
|
1238
1346
|
},
|
|
1239
1347
|
{
|
|
1240
1348
|
tag: 'column_set',
|
|
@@ -1246,13 +1354,14 @@ export class CommandRouter {
|
|
|
1246
1354
|
elements: [
|
|
1247
1355
|
{
|
|
1248
1356
|
tag: 'input',
|
|
1249
|
-
name:
|
|
1250
|
-
placeholder: { tag: 'plain_text', content:
|
|
1251
|
-
default_value:
|
|
1357
|
+
name: opts.inputName,
|
|
1358
|
+
placeholder: { tag: 'plain_text', content: opts.placeholder },
|
|
1359
|
+
default_value: opts.defaultValue,
|
|
1360
|
+
...(opts.maxLength !== undefined ? { max_length: opts.maxLength } : {}),
|
|
1252
1361
|
behaviors: [
|
|
1253
1362
|
{
|
|
1254
1363
|
type: 'callback',
|
|
1255
|
-
value: { cmd:
|
|
1364
|
+
value: { cmd: opts.inputCmd, orderId, offset },
|
|
1256
1365
|
},
|
|
1257
1366
|
],
|
|
1258
1367
|
},
|
|
@@ -1265,67 +1374,53 @@ export class CommandRouter {
|
|
|
1265
1374
|
};
|
|
1266
1375
|
await this.bridge.updateCardInPlace(editCard, ctx);
|
|
1267
1376
|
}
|
|
1377
|
+
async handleOrderAliasEdit(value, ctx) {
|
|
1378
|
+
this.orderStore.reload();
|
|
1379
|
+
const order = this.orderStore.get().find((o) => o.id === value.orderId);
|
|
1380
|
+
return this.showOrderEditCard(value, ctx, {
|
|
1381
|
+
title: '✏️ 给指令起别名',
|
|
1382
|
+
hint: '💡 输入别名名后点击右侧 ✓ 提交;留空提交 = 删除该别名',
|
|
1383
|
+
inputName: 'aliasName',
|
|
1384
|
+
placeholder: '输入 $别名名(如 all)...',
|
|
1385
|
+
defaultValue: order?.alias ?? '',
|
|
1386
|
+
inputCmd: 'order.aliasInput',
|
|
1387
|
+
});
|
|
1388
|
+
}
|
|
1268
1389
|
/**
|
|
1269
1390
|
* Handle order.aliasInput: 提交别名(绑/改/解绑),随后重渲染 /order 列表卡。
|
|
1270
1391
|
* 留空 = 解绑;非空按命名规则 + 全局唯一校验,失败仅 toast 不重渲染。
|
|
1271
1392
|
*/
|
|
1272
1393
|
async handleOrderAliasInput(value, ctx) {
|
|
1273
|
-
const orderId = value.orderId;
|
|
1274
|
-
const offset = Math.max(0, Math.trunc(Number(value.offset) || 0));
|
|
1275
1394
|
const name = value.inputValue ?? value.formValue?.['aliasName'];
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
return { toast: { type: 'error', content: err.message } };
|
|
1293
|
-
}
|
|
1294
|
-
// 成功:重渲染列表卡(保持页码)。必须把 card 放进 callback 响应体让飞书
|
|
1295
|
-
// 原地替换 pre-click 编辑卡——toast-only 响应会停留在编辑界面(飞书保留
|
|
1296
|
-
// 点击前那张卡),见 handleQueueInput 同款注释。
|
|
1297
|
-
const result = this.cmdOrder([], ctx, offset);
|
|
1298
|
-
return {
|
|
1299
|
-
toast: {
|
|
1300
|
-
type: 'success',
|
|
1301
|
-
content: trimmed === '' ? `✅ 已移除别名 $${prevAlias ?? ''}` : `✅ 已绑定别名 $${trimmed}`,
|
|
1302
|
-
},
|
|
1303
|
-
card: { type: 'raw', data: result.card },
|
|
1304
|
-
};
|
|
1395
|
+
return this.mutateOrderAndRefreshCard(value, ctx, { missingToast: '指令不存在或已被删除' }, (order) => {
|
|
1396
|
+
const trimmed = name?.trim() ?? '';
|
|
1397
|
+
// 变更前快照旧别名:setAlias 会原地变异 order 对象(delete entry.alias),
|
|
1398
|
+
// 删除后 order.alias 已为 undefined,直接用会让成功文案显示 `$undefined`。
|
|
1399
|
+
const prevAlias = order.alias;
|
|
1400
|
+
try {
|
|
1401
|
+
this.orderStore.setAlias(order.id, trimmed === '' ? undefined : trimmed);
|
|
1402
|
+
}
|
|
1403
|
+
catch (err) {
|
|
1404
|
+
return { ok: false, error: err.message };
|
|
1405
|
+
}
|
|
1406
|
+
return {
|
|
1407
|
+
ok: true,
|
|
1408
|
+
toast: trimmed === '' ? `✅ 已移除别名 $${prevAlias ?? ''}` : `✅ 已绑定别名 $${trimmed}`,
|
|
1409
|
+
};
|
|
1410
|
+
});
|
|
1305
1411
|
}
|
|
1306
1412
|
/** Handle order.aliasRemove: 删除某条指令的别名并原地重渲染列表卡。 */
|
|
1307
1413
|
async handleOrderAliasRemove(value, ctx) {
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
return {
|
|
1317
|
-
}
|
|
1318
|
-
// 变更前快照旧别名:setAlias(undefined) 会原地变异 order(delete alias),
|
|
1319
|
-
// 删除后 order.alias 已为 undefined,直接拼会让成功文案显示 `$undefined`。
|
|
1320
|
-
const removedAlias = order.alias;
|
|
1321
|
-
this.orderStore.setAlias(order.id, undefined);
|
|
1322
|
-
// 必须把 card 放进 callback 响应体让飞书原地替换 pre-click 卡片,否则删除
|
|
1323
|
-
// 后卡片停留在旧状态(别名视觉上未消失)。同 handleQueueInput 语义。
|
|
1324
|
-
const result = this.cmdOrder([], ctx, offset);
|
|
1325
|
-
return {
|
|
1326
|
-
toast: { type: 'success', content: `✅ 已移除别名 $${removedAlias}` },
|
|
1327
|
-
card: { type: 'raw', data: result.card },
|
|
1328
|
-
};
|
|
1414
|
+
return this.mutateOrderAndRefreshCard(value, ctx, { missingToast: '指令不存在或没有别名' }, (order) => {
|
|
1415
|
+
if (!order.alias) {
|
|
1416
|
+
return { ok: false, error: '指令不存在或没有别名' };
|
|
1417
|
+
}
|
|
1418
|
+
// 变更前快照旧别名:setAlias(undefined) 会原地变异 order(delete alias),
|
|
1419
|
+
// 删除后 order.alias 已为 undefined,直接拼会让成功文案显示 `$undefined`。
|
|
1420
|
+
const removedAlias = order.alias;
|
|
1421
|
+
this.orderStore.setAlias(order.id, undefined);
|
|
1422
|
+
return { ok: true, toast: `✅ 已移除别名 $${removedAlias}` };
|
|
1423
|
+
});
|
|
1329
1424
|
}
|
|
1330
1425
|
/**
|
|
1331
1426
|
* Handle order.textEdit: 展示指令文本编辑卡(input + ✓ 提交图标)。
|
|
@@ -1335,73 +1430,20 @@ export class CommandRouter {
|
|
|
1335
1430
|
* updateCardInPlace)。
|
|
1336
1431
|
*/
|
|
1337
1432
|
async handleOrderTextEdit(value, ctx) {
|
|
1338
|
-
const orderId = value.orderId;
|
|
1339
|
-
const offset = Math.max(0, Math.trunc(Number(value.offset) || 0));
|
|
1340
|
-
if (!orderId) {
|
|
1341
|
-
return { toast: { type: 'error', content: '卡片 payload 缺少必要信息' } };
|
|
1342
|
-
}
|
|
1343
1433
|
this.orderStore.reload();
|
|
1344
|
-
const order = this.orderStore.get().find((o) => o.id === orderId);
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
template: 'blue',
|
|
1358
|
-
title: { tag: 'plain_text', content: '✏️ 编辑指令' },
|
|
1359
|
-
},
|
|
1360
|
-
body: {
|
|
1361
|
-
elements: [
|
|
1362
|
-
{
|
|
1363
|
-
tag: 'div',
|
|
1364
|
-
text: { tag: 'lark_md', content: `**当前指令:**\n\`${displayText}\`` },
|
|
1365
|
-
},
|
|
1366
|
-
{ tag: 'hr' },
|
|
1367
|
-
{
|
|
1368
|
-
tag: 'div',
|
|
1369
|
-
text: {
|
|
1370
|
-
tag: 'lark_md',
|
|
1371
|
-
content: '💡 修改后点击右侧 ✓ 提交;空白提交 = 报错;最多 200 字符',
|
|
1372
|
-
},
|
|
1373
|
-
},
|
|
1374
|
-
{
|
|
1375
|
-
tag: 'column_set',
|
|
1376
|
-
columns: [
|
|
1377
|
-
{
|
|
1378
|
-
tag: 'column',
|
|
1379
|
-
width: 'weighted',
|
|
1380
|
-
weight: 3,
|
|
1381
|
-
elements: [
|
|
1382
|
-
{
|
|
1383
|
-
tag: 'input',
|
|
1384
|
-
name: 'text',
|
|
1385
|
-
placeholder: { tag: 'plain_text', content: '输入新的指令文本...' },
|
|
1386
|
-
default_value: order.text,
|
|
1387
|
-
// max_length 与 MAX_TEXT_LENGTH=200 对齐:超长在输入侧直接截断,
|
|
1388
|
-
// 避免提交后才报错(后端 updateText 仍会二次校验,双保险)。
|
|
1389
|
-
max_length: 200,
|
|
1390
|
-
behaviors: [
|
|
1391
|
-
{
|
|
1392
|
-
type: 'callback',
|
|
1393
|
-
value: { cmd: 'order.textInput', orderId, offset },
|
|
1394
|
-
},
|
|
1395
|
-
],
|
|
1396
|
-
},
|
|
1397
|
-
],
|
|
1398
|
-
},
|
|
1399
|
-
],
|
|
1400
|
-
},
|
|
1401
|
-
],
|
|
1402
|
-
},
|
|
1403
|
-
};
|
|
1404
|
-
await this.bridge.updateCardInPlace(editCard, ctx);
|
|
1434
|
+
const order = this.orderStore.get().find((o) => o.id === value.orderId);
|
|
1435
|
+
// input 组件 default_value 预填完整 text(不受截断影响),提交后生效完整文本
|
|
1436
|
+
return this.showOrderEditCard(value, ctx, {
|
|
1437
|
+
title: '✏️ 编辑指令',
|
|
1438
|
+
hint: '💡 修改后点击右侧 ✓ 提交;空白提交 = 报错;最多 200 字符',
|
|
1439
|
+
inputName: 'text',
|
|
1440
|
+
placeholder: '输入新的指令文本...',
|
|
1441
|
+
defaultValue: order?.text ?? '',
|
|
1442
|
+
inputCmd: 'order.textInput',
|
|
1443
|
+
// 与 MAX_TEXT_LENGTH=200 对齐:超长在输入侧直接截断,避免提交后才报错
|
|
1444
|
+
// (后端 updateText 仍会二次校验,双保险)
|
|
1445
|
+
maxLength: 200,
|
|
1446
|
+
});
|
|
1405
1447
|
}
|
|
1406
1448
|
/**
|
|
1407
1449
|
* Handle order.textInput: 提交指令文本编辑。校验 orderId → OrderStore.updateText
|
|
@@ -1411,37 +1453,22 @@ export class CommandRouter {
|
|
|
1411
1453
|
* 保留 usedAt / alias / createdAt(OrderStore.updateText 只动 text 字段)。
|
|
1412
1454
|
*/
|
|
1413
1455
|
async handleOrderTextInput(value, ctx) {
|
|
1414
|
-
const orderId = value.orderId;
|
|
1415
|
-
const offset = Math.max(0, Math.trunc(Number(value.offset) || 0));
|
|
1416
1456
|
const raw = value.inputValue ?? value.formValue?.['text'];
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
return {
|
|
1431
|
-
}
|
|
1432
|
-
if (!updated) {
|
|
1433
|
-
// reload + find 后 updateText 不应返回 undefined;防御性兜底(如并发删除)。
|
|
1434
|
-
return { toast: { type: 'error', content: '指令不存在或已被删除' } };
|
|
1435
|
-
}
|
|
1436
|
-
// 成功:重渲染列表卡(保持页码)。必须把 card 放进 callback 响应体让飞书
|
|
1437
|
-
// 原地替换 pre-click 编辑卡——toast-only 响应会停留在编辑界面(飞书保留
|
|
1438
|
-
// 点击前那张卡),见 handleQueueInput / handleOrderAliasInput 同款注释。
|
|
1439
|
-
const result = this.cmdOrder([], ctx, offset);
|
|
1440
|
-
const preview = updated.text.length > 50 ? updated.text.slice(0, 50) + '...' : updated.text;
|
|
1441
|
-
return {
|
|
1442
|
-
toast: { type: 'success', content: `✅ 已更新指令: ${preview}` },
|
|
1443
|
-
card: { type: 'raw', data: result.card },
|
|
1444
|
-
};
|
|
1457
|
+
return this.mutateOrderAndRefreshCard(value, ctx, { missingToast: '指令不存在或已被删除' }, (order) => {
|
|
1458
|
+
let updated;
|
|
1459
|
+
try {
|
|
1460
|
+
updated = this.orderStore.updateText(order.id, raw ?? '');
|
|
1461
|
+
}
|
|
1462
|
+
catch (err) {
|
|
1463
|
+
return { ok: false, error: err.message };
|
|
1464
|
+
}
|
|
1465
|
+
if (!updated) {
|
|
1466
|
+
// reload + find 后 updateText 不应返回 undefined;防御性兜底(如并发删除)。
|
|
1467
|
+
return { ok: false, error: '指令不存在或已被删除' };
|
|
1468
|
+
}
|
|
1469
|
+
const preview = updated.text.length > 50 ? updated.text.slice(0, 50) + '...' : updated.text;
|
|
1470
|
+
return { ok: true, toast: `✅ 已更新指令: ${preview}` };
|
|
1471
|
+
});
|
|
1445
1472
|
}
|
|
1446
1473
|
/**
|
|
1447
1474
|
* Handle ws.use card action: switch to the workspace, then refresh the /ws
|
|
@@ -1473,11 +1500,7 @@ export class CommandRouter {
|
|
|
1473
1500
|
}
|
|
1474
1501
|
}
|
|
1475
1502
|
// Refresh the /ws list card in place so "recent" sort is immediately visible
|
|
1476
|
-
|
|
1477
|
-
const refreshed = this.cmdWs([], ctx, currentOffset);
|
|
1478
|
-
if (refreshed.card) {
|
|
1479
|
-
await this.bridge.updateCardInPlace(refreshed.card, ctx);
|
|
1480
|
-
}
|
|
1503
|
+
await this.refreshListCard('ws', value, ctx);
|
|
1481
1504
|
// Toast feedback (suppress text when a persistent message was already sent)
|
|
1482
1505
|
return {
|
|
1483
1506
|
toast: {
|
|
@@ -1494,16 +1517,14 @@ export class CommandRouter {
|
|
|
1494
1517
|
async handleWsRemove(value, ctx) {
|
|
1495
1518
|
const name = value.name;
|
|
1496
1519
|
if (!name) {
|
|
1497
|
-
return { toast: { type: 'error', content:
|
|
1520
|
+
return { toast: { type: 'error', content: CARD_PAYLOAD_MISSING } };
|
|
1498
1521
|
}
|
|
1499
1522
|
// Execute the removal (consumes the write-side effect; the returned text
|
|
1500
1523
|
// is discarded — feedback flows through the toast + refreshed card).
|
|
1501
1524
|
this.cmdWs(['remove', name], ctx);
|
|
1502
1525
|
// Rebuild the /ws list card and update it in place, preserving the page
|
|
1503
1526
|
// the user was on when they clicked 删除.
|
|
1504
|
-
|
|
1505
|
-
const refreshed = this.cmdWs([], ctx, currentOffset);
|
|
1506
|
-
await this.bridge.updateCardInPlace(refreshed.card, ctx);
|
|
1527
|
+
await this.refreshListCard('ws', value, ctx);
|
|
1507
1528
|
return { toast: { type: 'success', content: `已删除 workspace "${name}"` } };
|
|
1508
1529
|
}
|
|
1509
1530
|
/**
|
|
@@ -1511,10 +1532,12 @@ export class CommandRouter {
|
|
|
1511
1532
|
* Mirrors handleOrderPage — updates the same card, never sends a new one.
|
|
1512
1533
|
*/
|
|
1513
1534
|
async handleWsPage(value, ctx) {
|
|
1514
|
-
const
|
|
1535
|
+
const paging = this.pagingOffset(value, WS_PAGE_SIZE);
|
|
1536
|
+
if ('error' in paging) {
|
|
1537
|
+
return { toast: { type: 'error', content: paging.error } };
|
|
1538
|
+
}
|
|
1515
1539
|
// cmdWs internally clamps stale/out-of-range offsets
|
|
1516
|
-
|
|
1517
|
-
await this.bridge.updateCardInPlace(result.card, ctx);
|
|
1540
|
+
await this.refreshListCard('ws', { offset: paging.offset }, ctx);
|
|
1518
1541
|
return { toast: { type: 'success', content: '' } };
|
|
1519
1542
|
}
|
|
1520
1543
|
/**
|
|
@@ -1526,8 +1549,7 @@ export class CommandRouter {
|
|
|
1526
1549
|
const current = this.wsSortPreference.get(userId) ?? 'recent';
|
|
1527
1550
|
const next = current === 'recent' ? 'alpha' : 'recent';
|
|
1528
1551
|
this.wsSortPreference.set(userId, next);
|
|
1529
|
-
|
|
1530
|
-
await this.bridge.updateCardInPlace(result.card, ctx);
|
|
1552
|
+
await this.refreshListCard('ws', { offset: 0 }, ctx);
|
|
1531
1553
|
const nextLabel = next === 'recent' ? '🕐 最近使用' : '🔤 字母顺序';
|
|
1532
1554
|
return {
|
|
1533
1555
|
toast: { type: 'success', content: `已切换为 ${nextLabel}` },
|
|
@@ -1541,26 +1563,42 @@ export class CommandRouter {
|
|
|
1541
1563
|
await this.bridge.sendResult({ text: '卡片 payload 缺少 path' }, ctx);
|
|
1542
1564
|
return;
|
|
1543
1565
|
}
|
|
1544
|
-
const
|
|
1545
|
-
if (
|
|
1546
|
-
await this.bridge.sendResult(
|
|
1547
|
-
|
|
1566
|
+
const result = await this.deliverFileToFeishu(path.resolve(target), ctx);
|
|
1567
|
+
if (result)
|
|
1568
|
+
await this.bridge.sendResult(result, ctx);
|
|
1569
|
+
}
|
|
1570
|
+
/**
|
|
1571
|
+
* 校验并发送本地文件到飞书(≤30MB)。成功返回 null(文件已发出),失败返回
|
|
1572
|
+
* 错误 CommandResult 由调用方展示。
|
|
1573
|
+
*
|
|
1574
|
+
* `/download` 与 `ls.file` 卡片按钮共用,避免两处重复 30MB/存在性校验。
|
|
1575
|
+
*/
|
|
1576
|
+
async deliverFileToFeishu(resolved, ctx) {
|
|
1577
|
+
if (!fs.existsSync(resolved)) {
|
|
1578
|
+
return { text: `文件不存在: ${resolved}` };
|
|
1548
1579
|
}
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1580
|
+
let stat;
|
|
1581
|
+
try {
|
|
1582
|
+
stat = fs.statSync(resolved);
|
|
1583
|
+
}
|
|
1584
|
+
catch {
|
|
1585
|
+
return { text: `无法读取: ${resolved}` };
|
|
1586
|
+
}
|
|
1587
|
+
if (stat.isDirectory()) {
|
|
1588
|
+
return { text: `这是一个目录,不能作为文件发送: ${resolved}\n用 /ls 查看目录内容` };
|
|
1553
1589
|
}
|
|
1554
|
-
|
|
1555
|
-
|
|
1590
|
+
if (!stat.isFile()) {
|
|
1591
|
+
return { text: `不是普通文件: ${resolved}` };
|
|
1592
|
+
}
|
|
1593
|
+
// 30MB,与飞书 im/v1/files API 对齐
|
|
1556
1594
|
if (stat.size > MAX_FILE_UPLOAD_SIZE) {
|
|
1557
1595
|
const sizeMB = (stat.size / (1024 * 1024)).toFixed(1);
|
|
1558
|
-
|
|
1596
|
+
return {
|
|
1559
1597
|
text: `文件太大 (${sizeMB}MB),超过 ${MAX_FILE_UPLOAD_SIZE / (1024 * 1024)}MB 限制不能发送`,
|
|
1560
|
-
}
|
|
1561
|
-
return;
|
|
1598
|
+
};
|
|
1562
1599
|
}
|
|
1563
|
-
await this.bridge.sendFile(
|
|
1600
|
+
await this.bridge.sendFile(resolved, ctx);
|
|
1601
|
+
return null;
|
|
1564
1602
|
}
|
|
1565
1603
|
// --- PendingConfig 辅助方法 ---
|
|
1566
1604
|
/** 确保 pendingConfig 已初始化(从当前 config 克隆) */
|
|
@@ -1599,6 +1637,8 @@ export class CommandRouter {
|
|
|
1599
1637
|
async dispatchConfigAction(value, ctx) {
|
|
1600
1638
|
switch (value.cmd) {
|
|
1601
1639
|
case 'config.toggle': {
|
|
1640
|
+
// 通用 boolean 翻转:目前无卡片字段使用(output.show* 已移除),
|
|
1641
|
+
// 保留供未来 boolean 配置项复用;configActionQueue 串行化覆盖连击。
|
|
1602
1642
|
const key = value.key;
|
|
1603
1643
|
if (!key) {
|
|
1604
1644
|
await this.bridge.sendResult({ text: '缺少配置项 key' }, ctx);
|
|
@@ -1728,68 +1768,10 @@ export class CommandRouter {
|
|
|
1728
1768
|
/** 设置嵌套属性值(仅修改内存对象,不写盘) */
|
|
1729
1769
|
/** 在 pendingConfig 上按 dot-separated key 设置嵌套值(config 卡片编辑用)。 */
|
|
1730
1770
|
setNestedValue(target, key, value) {
|
|
1731
|
-
|
|
1732
|
-
// pi.xxx/codex.xxx/opencode.xxx → agents.xxx,claude.xxx 保持顶层
|
|
1733
|
-
const mappedKey = mapAgentKey(key);
|
|
1734
|
-
const parts = mappedKey.split('.');
|
|
1735
|
-
// 复用 config 模块的 walkNestedContainer(含 __proto__/prototype/constructor
|
|
1736
|
-
// 守卫与中间段补 {}),router 只保留 value=undefined → delete 分支。
|
|
1737
|
-
const container = walkNestedContainer(target, parts, true);
|
|
1738
|
-
const lastPart = parts[parts.length - 1];
|
|
1739
|
-
assertSafeKeyPart(lastPart);
|
|
1740
|
-
if (value === undefined) {
|
|
1741
|
-
// value=undefined 表示"删除键"(如清空 reasoningEffort),
|
|
1742
|
-
// 不能写成 undefined 值——diffConfig 会把 undefined 转成字面量 "undefined"
|
|
1743
|
-
// 写入 config.yaml 并透传给 codex(ReasoningEffort::Custom("undefined"))。
|
|
1744
|
-
delete container[lastPart];
|
|
1745
|
-
}
|
|
1746
|
-
else {
|
|
1747
|
-
container[lastPart] = value;
|
|
1748
|
-
}
|
|
1771
|
+
setNestedValue(target, key, value);
|
|
1749
1772
|
}
|
|
1750
|
-
/** 对比原始 config 与 pendingConfig,返回变化的 key→string 映射 */
|
|
1751
|
-
/** config diff(public:测试直接调用,替代 as unknown as)。 */
|
|
1752
1773
|
diffConfig(original, pending) {
|
|
1753
|
-
|
|
1754
|
-
this.collectDiff('', original, pending, updates);
|
|
1755
|
-
return updates;
|
|
1756
|
-
}
|
|
1757
|
-
/** 递归收集差异 */
|
|
1758
|
-
collectDiff(prefix, original, pending, result) {
|
|
1759
|
-
if (original === pending)
|
|
1760
|
-
return;
|
|
1761
|
-
// 如果 pending 是对象(非 null)但 original 不是对象,
|
|
1762
|
-
// 把 original 当作空对象递归进入 pending 内部,
|
|
1763
|
-
// 避免把整个对象转成 "[object Object]" 字符串
|
|
1764
|
-
if (typeof pending === 'object' &&
|
|
1765
|
-
pending !== null &&
|
|
1766
|
-
(typeof original !== 'object' || original === null)) {
|
|
1767
|
-
const pendObj = pending;
|
|
1768
|
-
for (const k of Object.keys(pendObj)) {
|
|
1769
|
-
const newKey = prefix ? `${prefix}.${k}` : k;
|
|
1770
|
-
this.collectDiff(newKey, undefined, pendObj[k], result);
|
|
1771
|
-
}
|
|
1772
|
-
return;
|
|
1773
|
-
}
|
|
1774
|
-
if (typeof original !== 'object' ||
|
|
1775
|
-
typeof pending !== 'object' ||
|
|
1776
|
-
original === null ||
|
|
1777
|
-
pending === null) {
|
|
1778
|
-
// 叶子节点,记录差异
|
|
1779
|
-
const key = prefix || 'root';
|
|
1780
|
-
// pending 为 undefined 表示键被删除,必须保留 undefined 语义,
|
|
1781
|
-
// 不能 String(pending) 成 "undefined"
|
|
1782
|
-
result[key] = pending === undefined ? undefined : String(pending);
|
|
1783
|
-
return;
|
|
1784
|
-
}
|
|
1785
|
-
// 两者都是对象,递归比较
|
|
1786
|
-
const origObj = original;
|
|
1787
|
-
const pendObj = pending;
|
|
1788
|
-
const allKeys = new Set([...Object.keys(origObj), ...Object.keys(pendObj)]);
|
|
1789
|
-
for (const k of allKeys) {
|
|
1790
|
-
const newKey = prefix ? `${prefix}.${k}` : k;
|
|
1791
|
-
this.collectDiff(newKey, origObj[k], pendObj[k], result);
|
|
1792
|
-
}
|
|
1774
|
+
return diffConfig(original, pending);
|
|
1793
1775
|
}
|
|
1794
1776
|
/**
|
|
1795
1777
|
* config 写盘后的运行时传播(P1-6:自 config.save 卡片路径抽出,卡片保存与
|
|
@@ -1971,6 +1953,9 @@ export class CommandRouter {
|
|
|
1971
1953
|
return this.cmdCd(args, ctx);
|
|
1972
1954
|
case 'ls':
|
|
1973
1955
|
return this.cmdLs(args, ctx);
|
|
1956
|
+
case 'download':
|
|
1957
|
+
case 'd':
|
|
1958
|
+
return await this.cmdDownload(args, ctx);
|
|
1974
1959
|
case 'ws':
|
|
1975
1960
|
return this.cmdWs(args, ctx);
|
|
1976
1961
|
case 'resume':
|
|
@@ -2015,7 +2000,16 @@ export class CommandRouter {
|
|
|
2015
2000
|
// 文本组(需带参数,不宜用按钮;以纯文本行展示)
|
|
2016
2001
|
const textCommands = [
|
|
2017
2002
|
{ cmd: 'cd', label: '/cd <path>', desc: '切换工作目录' },
|
|
2018
|
-
{
|
|
2003
|
+
{
|
|
2004
|
+
cmd: 'ls',
|
|
2005
|
+
label: '/ls [dir|file]',
|
|
2006
|
+
desc: '列出当前目录(可指定子目录);指定文件时列出该文件',
|
|
2007
|
+
},
|
|
2008
|
+
{
|
|
2009
|
+
cmd: 'download',
|
|
2010
|
+
label: '/download /d <path>',
|
|
2011
|
+
desc: '直接下载(发送)指定文件到飞书,上限 30MB',
|
|
2012
|
+
},
|
|
2019
2013
|
{ cmd: 'resume', label: '/resume /r [agent] [list|id|N]', desc: '列出或切换 Agent session' },
|
|
2020
2014
|
{
|
|
2021
2015
|
cmd: 'order',
|
|
@@ -2515,8 +2509,16 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
2515
2509
|
return actions;
|
|
2516
2510
|
}
|
|
2517
2511
|
static LS_PAGE_SIZE = 30;
|
|
2518
|
-
/**
|
|
2519
|
-
|
|
2512
|
+
/**
|
|
2513
|
+
* `/ls` 实现(public:测试直接调用,替代 as unknown as)。
|
|
2514
|
+
*
|
|
2515
|
+
* @param args [] 列出 cwd;[path] 列出指定目录(或文件)
|
|
2516
|
+
* @param offset 分页起点(条目数)
|
|
2517
|
+
* @param rootDir 浏览起点:卡片「返回」按钮回到这里。省略时默认 = 本次列出的
|
|
2518
|
+
* 目录(`/ls <dir>` 的起点即该目录),因此「返回」不会把用户
|
|
2519
|
+
* 丢回 workspace cwd(用户明确反馈过)。
|
|
2520
|
+
*/
|
|
2521
|
+
cmdLs(args, ctx, offset = 0, rootDir) {
|
|
2520
2522
|
const cwd = this.sessionStore.getCwd(ctx.userId);
|
|
2521
2523
|
if (!cwd) {
|
|
2522
2524
|
return { text: '请先使用 /cd <path> 设置工作目录' };
|
|
@@ -2536,11 +2538,25 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
2536
2538
|
if (!fs.existsSync(potentialDir)) {
|
|
2537
2539
|
return { text: `ls: ${args[0]}: No such file or directory` };
|
|
2538
2540
|
}
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
+
let isDir;
|
|
2542
|
+
try {
|
|
2543
|
+
isDir = fs.statSync(potentialDir).isDirectory();
|
|
2544
|
+
}
|
|
2545
|
+
catch {
|
|
2546
|
+
// TOCTOU:existsSync 与 statSync 之间目标被删
|
|
2547
|
+
return { text: `ls: ${args[0]}: No such file or directory` };
|
|
2548
|
+
}
|
|
2549
|
+
if (!isDir) {
|
|
2550
|
+
// 命中文件:列出该文件本身(用户可能想下载它或确认是否存在),
|
|
2551
|
+
// 不再回 `ls: xxx: Not a directory` 把文件路径当错误。
|
|
2552
|
+
return this.buildFileCard(potentialDir);
|
|
2541
2553
|
}
|
|
2542
2554
|
targetDir = potentialDir;
|
|
2543
2555
|
}
|
|
2556
|
+
// 浏览起点(「返回」按钮的目标):`/ls <dir>` 的起点就是该目录本身,之后
|
|
2557
|
+
// 进入子目录/翻页/刷新时由卡片 payload 的 root 一路带过来。省略时 = 本次
|
|
2558
|
+
// 列出的目录,因此「返回」永远不会把用户丢回 workspace cwd。
|
|
2559
|
+
const browseRoot = rootDir ? path.resolve(cwd, rootDir) : targetDir;
|
|
2544
2560
|
try {
|
|
2545
2561
|
const entries = fs.readdirSync(targetDir, { withFileTypes: true });
|
|
2546
2562
|
// Separate directories and files
|
|
@@ -2557,16 +2573,15 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
2557
2573
|
size: fs.statSync(path.join(targetDir, f.name)).size,
|
|
2558
2574
|
})),
|
|
2559
2575
|
];
|
|
2560
|
-
// Pagination calculations
|
|
2561
|
-
const totalCount = allItems.
|
|
2562
|
-
const totalPages = Math.max(1, Math.ceil(totalCount / CommandRouter.LS_PAGE_SIZE));
|
|
2563
|
-
const currentPage = Math.floor(offset / CommandRouter.LS_PAGE_SIZE) + 1;
|
|
2564
|
-
const pageItems = allItems.slice(offset, offset + CommandRouter.LS_PAGE_SIZE);
|
|
2565
|
-
const hasPagination = totalCount > CommandRouter.LS_PAGE_SIZE;
|
|
2576
|
+
// Pagination calculations(clamp 到最后一页边界,原先不 clamp 会显示空页)
|
|
2577
|
+
const { totalCount, totalPages, safeOffset, currentPage, pageItems, hasPagination } = pageSlice(allItems, offset, CommandRouter.LS_PAGE_SIZE);
|
|
2566
2578
|
// Check if we need to show parent directory button
|
|
2567
2579
|
const parentDir = path.dirname(targetDir);
|
|
2568
2580
|
const hasParent = parentDir !== targetDir;
|
|
2581
|
+
// 「切换」相对 cwd(把工作目录切到当前浏览目录)
|
|
2569
2582
|
const isSubdir = targetDir !== cwd;
|
|
2583
|
+
// 「返回」相对浏览起点:在 `/ls <dir>` 的起点上不显示(无路可返)
|
|
2584
|
+
const canReturn = targetDir !== browseRoot;
|
|
2570
2585
|
// Build header with parent button
|
|
2571
2586
|
const headerElements = [];
|
|
2572
2587
|
if (hasParent) {
|
|
@@ -2575,17 +2590,21 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
2575
2590
|
text: { tag: 'plain_text', content: '上级' },
|
|
2576
2591
|
type: 'default',
|
|
2577
2592
|
size: 'small',
|
|
2578
|
-
behaviors: [
|
|
2593
|
+
behaviors: [
|
|
2594
|
+
{ type: 'callback', value: { cmd: 'ls.browse', path: parentDir, root: browseRoot } },
|
|
2595
|
+
],
|
|
2579
2596
|
});
|
|
2580
2597
|
}
|
|
2581
|
-
// Show "返回" button when
|
|
2582
|
-
if (
|
|
2598
|
+
// Show "返回" button when browsing away from the /ls root (回到 /ls 指定的目录)
|
|
2599
|
+
if (canReturn) {
|
|
2583
2600
|
headerElements.push({
|
|
2584
2601
|
tag: 'button',
|
|
2585
2602
|
text: { tag: 'plain_text', content: '返回' },
|
|
2586
2603
|
type: 'default',
|
|
2587
2604
|
size: 'small',
|
|
2588
|
-
behaviors: [
|
|
2605
|
+
behaviors: [
|
|
2606
|
+
{ type: 'callback', value: { cmd: 'ls.browse', path: browseRoot, root: browseRoot } },
|
|
2607
|
+
],
|
|
2589
2608
|
});
|
|
2590
2609
|
}
|
|
2591
2610
|
headerElements.push({
|
|
@@ -2593,7 +2612,17 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
2593
2612
|
text: { tag: 'plain_text', content: '刷新' },
|
|
2594
2613
|
type: 'default',
|
|
2595
2614
|
size: 'small',
|
|
2596
|
-
behaviors: [
|
|
2615
|
+
behaviors: [
|
|
2616
|
+
{
|
|
2617
|
+
type: 'callback',
|
|
2618
|
+
value: {
|
|
2619
|
+
cmd: 'ls.refresh',
|
|
2620
|
+
path: targetDir,
|
|
2621
|
+
offset: safeOffset,
|
|
2622
|
+
root: browseRoot,
|
|
2623
|
+
},
|
|
2624
|
+
},
|
|
2625
|
+
],
|
|
2597
2626
|
});
|
|
2598
2627
|
// Show "切换" button when viewing a subdirectory (to switch cwd to this directory)
|
|
2599
2628
|
if (isSubdir) {
|
|
@@ -2602,28 +2631,32 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
2602
2631
|
text: { tag: 'plain_text', content: '切换' },
|
|
2603
2632
|
type: 'primary',
|
|
2604
2633
|
size: 'small',
|
|
2605
|
-
|
|
2634
|
+
// 切换只改 cwd,**不重置浏览起点**:root 一路带到切换后的卡片,
|
|
2635
|
+
// 否则「返回」会退化成「回到刚切换到的目录」(用户反馈的缺陷)。
|
|
2636
|
+
behaviors: [
|
|
2637
|
+
{ type: 'callback', value: { cmd: 'ls.switch', path: targetDir, root: browseRoot } },
|
|
2638
|
+
],
|
|
2606
2639
|
});
|
|
2607
2640
|
}
|
|
2608
|
-
const formatSize = (bytes) => {
|
|
2609
|
-
if (bytes < 1024)
|
|
2610
|
-
return `${bytes}B`;
|
|
2611
|
-
if (bytes < 1024 * 1024)
|
|
2612
|
-
return `${(bytes / 1024).toFixed(1)}KB`;
|
|
2613
|
-
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
2614
|
-
};
|
|
2615
2641
|
// Helper to create directory button - use targetDir
|
|
2616
2642
|
const dirButton = (name) => ({
|
|
2617
2643
|
tag: 'button',
|
|
2618
2644
|
text: { tag: 'plain_text', content: `📁 ${name}` },
|
|
2619
2645
|
type: 'default',
|
|
2620
2646
|
behaviors: [
|
|
2621
|
-
{
|
|
2647
|
+
{
|
|
2648
|
+
type: 'callback',
|
|
2649
|
+
value: {
|
|
2650
|
+
cmd: 'ls.browse',
|
|
2651
|
+
path: path.join(targetDir, name),
|
|
2652
|
+
root: browseRoot,
|
|
2653
|
+
},
|
|
2654
|
+
},
|
|
2622
2655
|
],
|
|
2623
2656
|
});
|
|
2624
2657
|
// Helper to create file button with size - use targetDir
|
|
2625
2658
|
const fileButton = (name, size) => {
|
|
2626
|
-
const sizeStr = size !== undefined ? ` (${
|
|
2659
|
+
const sizeStr = size !== undefined ? ` (${formatBytes(size)})` : '';
|
|
2627
2660
|
return {
|
|
2628
2661
|
tag: 'button',
|
|
2629
2662
|
text: { tag: 'plain_text', content: `📄 ${name}${sizeStr}` },
|
|
@@ -2637,10 +2670,9 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
2637
2670
|
// Build each section's elements (no tabs - use section headers)
|
|
2638
2671
|
// Note: div+elements is not supported in CardKit 2.0, use column_set instead
|
|
2639
2672
|
const elements = [];
|
|
2640
|
-
// Status line
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
: `\n共 ${dirs.length} 目录, ${files.length} 文件`;
|
|
2673
|
+
// Status line:只报条目构成;页码/总数交给分页栏文案行(同一信息不重复
|
|
2674
|
+
// 渲染两遍——2026-09-10 分页栏改成整行文案后重复会很明显)。
|
|
2675
|
+
const status = `\n共 ${dirs.length} 目录, ${files.length} 文件`;
|
|
2644
2676
|
// Header info + navigation buttons - show targetDir in header
|
|
2645
2677
|
elements.push({ tag: 'div', text: { tag: 'lark_md', content: `\`${targetDir}\`${status}` } });
|
|
2646
2678
|
elements.push({
|
|
@@ -2701,14 +2733,14 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
2701
2733
|
if (hasPagination) {
|
|
2702
2734
|
const bar = paginationBar({
|
|
2703
2735
|
cmd: 'ls.page',
|
|
2704
|
-
offset,
|
|
2736
|
+
offset: safeOffset,
|
|
2705
2737
|
pageSize: CommandRouter.LS_PAGE_SIZE,
|
|
2706
2738
|
total: totalCount,
|
|
2707
|
-
extra: { path: targetDir },
|
|
2739
|
+
extra: { path: targetDir, root: browseRoot },
|
|
2708
2740
|
label: `**第 ${currentPage}/${totalPages} 页**(共 ${totalCount} 项)`,
|
|
2709
2741
|
});
|
|
2710
2742
|
elements.push({ tag: 'hr' });
|
|
2711
|
-
elements.push(bar);
|
|
2743
|
+
elements.push(...bar);
|
|
2712
2744
|
}
|
|
2713
2745
|
return {
|
|
2714
2746
|
card: {
|
|
@@ -2728,6 +2760,99 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
2728
2760
|
return { text: `读取目录失败: ${err.message}` };
|
|
2729
2761
|
}
|
|
2730
2762
|
}
|
|
2763
|
+
/**
|
|
2764
|
+
* `/download <path>`(别名 `/d`):把本地文件直接发送到飞书。
|
|
2765
|
+
*
|
|
2766
|
+
* 路径解析与 `/ls` 一致:`~` 展开到 home;相对路径基于当前 cwd(无 cwd 时
|
|
2767
|
+
* 必须给绝对路径)。成功时文件本身即回复,返回 null(不额外发文本)。
|
|
2768
|
+
*/
|
|
2769
|
+
async cmdDownload(args, ctx) {
|
|
2770
|
+
const raw = args.join(' ').trim();
|
|
2771
|
+
if (raw === '') {
|
|
2772
|
+
return { text: '用法: /download <path>(别名 /d)' };
|
|
2773
|
+
}
|
|
2774
|
+
// 路径可能含空格:args 按空白切分后再拼回(未加引号也能还原原路径)
|
|
2775
|
+
let input = raw;
|
|
2776
|
+
if (input.startsWith('~')) {
|
|
2777
|
+
input = path.join(os.homedir(), input.slice(1));
|
|
2778
|
+
}
|
|
2779
|
+
if (!path.isAbsolute(input)) {
|
|
2780
|
+
const cwd = this.sessionStore.getCwd(ctx.userId);
|
|
2781
|
+
if (!cwd)
|
|
2782
|
+
return { text: '请先使用 /cd <path> 设置工作目录' };
|
|
2783
|
+
input = path.resolve(cwd, input);
|
|
2784
|
+
}
|
|
2785
|
+
return this.deliverFileToFeishu(path.resolve(input), ctx);
|
|
2786
|
+
}
|
|
2787
|
+
/**
|
|
2788
|
+
* 单文件 ls 结果卡:路径 / 大小 / 修改时间 + 「下载」+「上级」。
|
|
2789
|
+
*
|
|
2790
|
+
* `/ls <file>` 命中普通文件时使用——用户可能想下载该文件或确认它是否存在,
|
|
2791
|
+
* 此前直接回 `ls: xxx: Not a directory` 把文件路径当成错误处理。
|
|
2792
|
+
*/
|
|
2793
|
+
buildFileCard(filePath) {
|
|
2794
|
+
let stat;
|
|
2795
|
+
try {
|
|
2796
|
+
stat = fs.statSync(filePath);
|
|
2797
|
+
}
|
|
2798
|
+
catch {
|
|
2799
|
+
stat = undefined;
|
|
2800
|
+
}
|
|
2801
|
+
const sizeText = stat ? formatBytes(stat.size) : '未知';
|
|
2802
|
+
const mtimeText = stat ? stat.mtime.toLocaleString('zh-CN', { hour12: false }) : '未知';
|
|
2803
|
+
const parentDir = path.dirname(filePath);
|
|
2804
|
+
const buttons = [
|
|
2805
|
+
{
|
|
2806
|
+
tag: 'button',
|
|
2807
|
+
text: { tag: 'plain_text', content: '📎 下载' },
|
|
2808
|
+
type: 'primary',
|
|
2809
|
+
size: 'small',
|
|
2810
|
+
behaviors: [{ type: 'callback', value: { cmd: 'ls.file', path: filePath } }],
|
|
2811
|
+
},
|
|
2812
|
+
];
|
|
2813
|
+
if (parentDir !== filePath) {
|
|
2814
|
+
buttons.push({
|
|
2815
|
+
tag: 'button',
|
|
2816
|
+
text: { tag: 'plain_text', content: '上级' },
|
|
2817
|
+
type: 'default',
|
|
2818
|
+
size: 'small',
|
|
2819
|
+
// 浏览起点 = 文件所在目录:进入后「返回」回到这里,不回 workspace cwd
|
|
2820
|
+
behaviors: [
|
|
2821
|
+
{ type: 'callback', value: { cmd: 'ls.browse', path: parentDir, root: parentDir } },
|
|
2822
|
+
],
|
|
2823
|
+
});
|
|
2824
|
+
}
|
|
2825
|
+
return {
|
|
2826
|
+
card: {
|
|
2827
|
+
schema: '2.0',
|
|
2828
|
+
config: { wide_screen_mode: true, update_multi: true },
|
|
2829
|
+
header: {
|
|
2830
|
+
title: { tag: 'plain_text', content: `📄 ${path.basename(filePath)}` },
|
|
2831
|
+
template: 'blue',
|
|
2832
|
+
},
|
|
2833
|
+
body: {
|
|
2834
|
+
elements: [
|
|
2835
|
+
{
|
|
2836
|
+
tag: 'div',
|
|
2837
|
+
text: {
|
|
2838
|
+
tag: 'lark_md',
|
|
2839
|
+
content: `\`${filePath}\`\n文件 · 大小 ${sizeText} · 修改时间 ${mtimeText}`,
|
|
2840
|
+
},
|
|
2841
|
+
},
|
|
2842
|
+
{
|
|
2843
|
+
tag: 'column_set',
|
|
2844
|
+
columns: buttons.map((b) => ({ tag: 'column', width: 'auto', elements: [b] })),
|
|
2845
|
+
},
|
|
2846
|
+
{ tag: 'hr' },
|
|
2847
|
+
{
|
|
2848
|
+
tag: 'div',
|
|
2849
|
+
text: { tag: 'lark_md', content: '💡 也可用 `/download <path>` 直接下载该文件' },
|
|
2850
|
+
},
|
|
2851
|
+
],
|
|
2852
|
+
},
|
|
2853
|
+
},
|
|
2854
|
+
};
|
|
2855
|
+
}
|
|
2731
2856
|
cmdWs(args, ctx, offset = 0) {
|
|
2732
2857
|
const sub = args[0]?.toLowerCase();
|
|
2733
2858
|
switch (sub) {
|
|
@@ -2795,13 +2920,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
2795
2920
|
}
|
|
2796
2921
|
// Pagination calculations (mirror cmdOrder): clamp stale/out-of-range
|
|
2797
2922
|
// offsets so prev/next always step by WS_PAGE_SIZE.
|
|
2798
|
-
const totalCount = entries
|
|
2799
|
-
const totalPages = Math.max(1, Math.ceil(totalCount / WS_PAGE_SIZE));
|
|
2800
|
-
const maxOffset = Math.max(0, (totalPages - 1) * WS_PAGE_SIZE);
|
|
2801
|
-
const safeOffset = clampInt(offset, 0, maxOffset);
|
|
2802
|
-
const currentPage = Math.floor(safeOffset / WS_PAGE_SIZE) + 1;
|
|
2803
|
-
const pageEntries = entries.slice(safeOffset, safeOffset + WS_PAGE_SIZE);
|
|
2804
|
-
const hasPagination = totalCount > WS_PAGE_SIZE;
|
|
2923
|
+
const { totalCount, totalPages, safeOffset, currentPage, pageItems: pageEntries, hasPagination, } = pageSlice(entries, offset, WS_PAGE_SIZE);
|
|
2805
2924
|
// Build body elements: current cwd + workspace list with dividers
|
|
2806
2925
|
const bodyElements = [];
|
|
2807
2926
|
// Current working directory section
|
|
@@ -2918,7 +3037,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
2918
3037
|
nextText: '➡',
|
|
2919
3038
|
});
|
|
2920
3039
|
bodyElements.push({ tag: 'hr' });
|
|
2921
|
-
bodyElements.push(bar);
|
|
3040
|
+
bodyElements.push(...bar);
|
|
2922
3041
|
}
|
|
2923
3042
|
}
|
|
2924
3043
|
const card = {
|
|
@@ -3214,8 +3333,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
3214
3333
|
}
|
|
3215
3334
|
// Pagination bar (only shown when there are more sessions than pageSize)
|
|
3216
3335
|
if (total > pageSize) {
|
|
3217
|
-
const totalPages =
|
|
3218
|
-
const currentPage = Math.floor(pageOffset / pageSize) + 1;
|
|
3336
|
+
const { totalPages, currentPage } = pageInfo(total, pageOffset, pageSize);
|
|
3219
3337
|
const bar = paginationBar({
|
|
3220
3338
|
cmd: 'resume.page',
|
|
3221
3339
|
offset: pageOffset,
|
|
@@ -3225,7 +3343,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
3225
3343
|
label: `第 ${currentPage}/${totalPages} 页 · 共 ${total} 个会话`,
|
|
3226
3344
|
});
|
|
3227
3345
|
elements.push({ tag: 'hr' });
|
|
3228
|
-
elements.push(bar);
|
|
3346
|
+
elements.push(...bar);
|
|
3229
3347
|
}
|
|
3230
3348
|
const card = {
|
|
3231
3349
|
schema: '2.0',
|
|
@@ -3259,21 +3377,11 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
3259
3377
|
buildActiveCardFromMemory(activeRuns, activeBashRuns, offset = 0) {
|
|
3260
3378
|
const totalCount = activeRuns.length + activeBashRuns.length;
|
|
3261
3379
|
const pageSize = ACTIVE_PAGE_SIZE;
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
const currentPage =
|
|
3380
|
+
// 统一 clamp 到最后一页边界(原先 clamp 到 totalCount-1,过期 offset 会
|
|
3381
|
+
// 泄漏成「第 1 页只显示末尾几条」的残页)
|
|
3382
|
+
const { totalPages, safeOffset, currentPage } = pageInfo(totalCount, offset, pageSize);
|
|
3265
3383
|
// Slice: distribute offset across agent runs first, then bash runs
|
|
3266
3384
|
const elements = [];
|
|
3267
|
-
// Page info
|
|
3268
|
-
if (totalPages > 1) {
|
|
3269
|
-
elements.push({
|
|
3270
|
-
tag: 'div',
|
|
3271
|
-
text: {
|
|
3272
|
-
tag: 'lark_md',
|
|
3273
|
-
content: `**第 ${currentPage}/${totalPages} 页** (共 ${totalCount} 项)`,
|
|
3274
|
-
},
|
|
3275
|
-
});
|
|
3276
|
-
}
|
|
3277
3385
|
let remaining = pageSize;
|
|
3278
3386
|
let skipped = safeOffset;
|
|
3279
3387
|
// Agent runs section
|
|
@@ -3286,7 +3394,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
3286
3394
|
const statusLabel = run.terminal === 'running' ? '运行中' : '处理中';
|
|
3287
3395
|
elements.push({
|
|
3288
3396
|
tag: 'div',
|
|
3289
|
-
text: { tag: 'lark_md', content: `**📂 ${run.cwd
|
|
3397
|
+
text: { tag: 'lark_md', content: `**📂 ${displayName(run.cwd)}**` },
|
|
3290
3398
|
});
|
|
3291
3399
|
elements.push({
|
|
3292
3400
|
tag: 'div',
|
|
@@ -3338,37 +3446,18 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
3338
3446
|
elements.push({ tag: 'hr' });
|
|
3339
3447
|
}
|
|
3340
3448
|
}
|
|
3341
|
-
// Pagination
|
|
3449
|
+
// Pagination bar(W2.9 复用 paginationBar:safeOffset 已对齐页边界,
|
|
3450
|
+
// offset±pageSize 与原先 currentPage±1 的页码换算等价)
|
|
3342
3451
|
if (totalPages > 1) {
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
});
|
|
3353
|
-
}
|
|
3354
|
-
if (currentPage < totalPages) {
|
|
3355
|
-
const nextOffset = currentPage * pageSize;
|
|
3356
|
-
paginationButtons.push({
|
|
3357
|
-
tag: 'button',
|
|
3358
|
-
text: { tag: 'plain_text', content: '下一页 ▶' },
|
|
3359
|
-
type: 'primary',
|
|
3360
|
-
size: 'small',
|
|
3361
|
-
behaviors: [{ type: 'callback', value: { cmd: 'active.page', offset: nextOffset } }],
|
|
3362
|
-
});
|
|
3363
|
-
}
|
|
3364
|
-
elements.push({
|
|
3365
|
-
tag: 'column_set',
|
|
3366
|
-
columns: paginationButtons.map((btn) => ({
|
|
3367
|
-
tag: 'column',
|
|
3368
|
-
width: 'auto',
|
|
3369
|
-
elements: [btn],
|
|
3370
|
-
})),
|
|
3371
|
-
});
|
|
3452
|
+
elements.push(...paginationBar({
|
|
3453
|
+
cmd: 'active.page',
|
|
3454
|
+
offset: safeOffset,
|
|
3455
|
+
pageSize,
|
|
3456
|
+
total: totalCount,
|
|
3457
|
+
label: `**第 ${currentPage}/${totalPages} 页** (共 ${totalCount} 项)`,
|
|
3458
|
+
prevText: '◀ 上一页',
|
|
3459
|
+
nextText: '下一页 ▶',
|
|
3460
|
+
}));
|
|
3372
3461
|
}
|
|
3373
3462
|
const card = {
|
|
3374
3463
|
schema: '2.0',
|
|
@@ -3518,15 +3607,6 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
3518
3607
|
label: '⏱️ 空闲',
|
|
3519
3608
|
fields: [{ key: 'idle.watchdogMinutes', label: '空闲超时(分钟, 0关闭)', type: 'input' }],
|
|
3520
3609
|
},
|
|
3521
|
-
{
|
|
3522
|
-
id: 'output',
|
|
3523
|
-
label: '📤 输出',
|
|
3524
|
-
fields: [
|
|
3525
|
-
{ key: 'output.showThinking', label: '显示思考过程', type: 'boolean' },
|
|
3526
|
-
{ key: 'output.showToolUse', label: '显示工具调用', type: 'boolean' },
|
|
3527
|
-
{ key: 'output.showToolResult', label: '显示工具结果', type: 'boolean' },
|
|
3528
|
-
],
|
|
3529
|
-
},
|
|
3530
3610
|
{
|
|
3531
3611
|
id: 'logging',
|
|
3532
3612
|
label: '📝 日志',
|
|
@@ -3576,14 +3656,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
3576
3656
|
}
|
|
3577
3657
|
else {
|
|
3578
3658
|
// Pagination calculations
|
|
3579
|
-
const totalCount = allOrders
|
|
3580
|
-
const totalPages = Math.max(1, Math.ceil(totalCount / ORDER_PAGE_SIZE));
|
|
3581
|
-
// Clamp offset to last page boundary to avoid empty pages
|
|
3582
|
-
const maxOffset = Math.max(0, (totalPages - 1) * ORDER_PAGE_SIZE);
|
|
3583
|
-
const safeOffset = clampInt(offset, 0, maxOffset);
|
|
3584
|
-
const currentPage = Math.floor(safeOffset / ORDER_PAGE_SIZE) + 1;
|
|
3585
|
-
const pageOrders = allOrders.slice(safeOffset, safeOffset + ORDER_PAGE_SIZE);
|
|
3586
|
-
const hasPagination = totalCount > ORDER_PAGE_SIZE;
|
|
3659
|
+
const { totalCount, totalPages, safeOffset, currentPage, pageItems: pageOrders, hasPagination, } = pageSlice(allOrders, offset, ORDER_PAGE_SIZE);
|
|
3587
3660
|
for (let i = 0; i < pageOrders.length; i++) {
|
|
3588
3661
|
const order = pageOrders[i];
|
|
3589
3662
|
const displayText = order.text.length > 100 ? order.text.slice(0, 97) + '...' : order.text;
|
|
@@ -3747,7 +3820,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
3747
3820
|
label: `**第 ${currentPage}/${totalPages} 页**(共 ${totalCount} 条)`,
|
|
3748
3821
|
});
|
|
3749
3822
|
elements.push({ tag: 'hr' });
|
|
3750
|
-
elements.push(bar);
|
|
3823
|
+
elements.push(...bar);
|
|
3751
3824
|
}
|
|
3752
3825
|
}
|
|
3753
3826
|
return {
|