@xmanrui/dsh-im 2.0.0 → 2.1.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/assets/screenshot-menu-card.png +0 -0
- package/lib/client.js +3 -3
- package/lib/index.js +218 -190
- package/package.json +1 -1
- package/plugin-src/client/channels/feishu/styles.js +1 -1
- package/plugin-src/client/styles.js +2 -2
- package/plugin-src/host/build.mjs +3 -0
- package/plugin-src/host/index.mjs +27 -10
- package/plugin-src/host/lark-sdk-handshake-patch.mjs +181 -0
- package/src/channels/feishu/bridge.mjs +1411 -164
- package/src/channels/feishu/feishu-cards.mjs +663 -56
- package/src/channels/feishu/feishu-runtime.mjs +129 -43
- package/src/channels/shared/control-command.mjs +2 -2
- package/src/channels/shared/harness-client.mjs +5 -0
- package/src/channels/shared/i18n-en/feishu.mjs +134 -0
- package/src/channels/shared/i18n-en/shared-b.mjs +4 -0
- package/src/channels/shared/model-command.mjs +65 -12
- package/src/channels/shared/workspace-command.mjs +4 -4
|
@@ -18,6 +18,9 @@ import { t } from '../shared/i18n.mjs';
|
|
|
18
18
|
|
|
19
19
|
export const MENU_PAGE_SIZE = 10;
|
|
20
20
|
|
|
21
|
+
/** 预设下拉中「跟随默认」(null) 的哨兵值,供 initial_index 命中与回调识别。 */
|
|
22
|
+
export const PRESET_FOLLOW_DEFAULT_SENTINEL = '__preset_follow_default__';
|
|
23
|
+
|
|
21
24
|
function plainText(content) {
|
|
22
25
|
return { tag: 'plain_text', content: String(content) };
|
|
23
26
|
}
|
|
@@ -26,6 +29,7 @@ function markdown(content) {
|
|
|
26
29
|
return { tag: 'lark_md', content: String(content) };
|
|
27
30
|
}
|
|
28
31
|
|
|
32
|
+
/** Full-width button wrapped in a column_set. */
|
|
29
33
|
function button(content, actionValue) {
|
|
30
34
|
return {
|
|
31
35
|
tag: 'column_set',
|
|
@@ -56,6 +60,33 @@ function buttonElement(content, actionValue) {
|
|
|
56
60
|
};
|
|
57
61
|
}
|
|
58
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Two buttons side by side in a `column_set`.
|
|
65
|
+
* `leftContent` / `leftAction` and `rightContent` / `rightAction` define
|
|
66
|
+
* each button's label and callback action value.
|
|
67
|
+
*/
|
|
68
|
+
function buttonPair(leftContent, leftAction, rightContent, rightAction) {
|
|
69
|
+
return {
|
|
70
|
+
tag: 'column_set',
|
|
71
|
+
flex_mode: 'none',
|
|
72
|
+
columns: [
|
|
73
|
+
{ tag: 'column', width: 'weighted', weight: 1, elements: [buttonElement(leftContent, leftAction)] },
|
|
74
|
+
{ tag: 'column', width: 'weighted', weight: 1, elements: [buttonElement(rightContent, rightAction)] },
|
|
75
|
+
],
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Feishu schema 2.0 single-select (`select_static`) does NOT support a
|
|
81
|
+
* `selected` field on options (that triggers 230099 parse json err). The
|
|
82
|
+
* supported way to show a default value is `initial_index` (1-based option
|
|
83
|
+
* order; 0 = show none). Build it from the option array + current value.
|
|
84
|
+
*/
|
|
85
|
+
function initialIndex(options, currentValue) {
|
|
86
|
+
const idx = options.findIndex((o) => o.value === currentValue);
|
|
87
|
+
return idx >= 0 ? idx + 1 : 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
59
90
|
function safeTitle(value) {
|
|
60
91
|
const title = String(value ?? '').replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu, ' ').replace(/\s+/gu, ' ').trim();
|
|
61
92
|
return title || t('暂无标题');
|
|
@@ -69,23 +100,494 @@ function cardWith(headerText, elements) {
|
|
|
69
100
|
});
|
|
70
101
|
}
|
|
71
102
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
103
|
+
// ── Shared helper: a "back to menu" button ────────────────────────────────
|
|
104
|
+
|
|
105
|
+
function backButton() {
|
|
106
|
+
return button(t('🔙 返回菜单'), 'back_to_menu');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Main menu card ────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The hub menu card — a single entry point covering every command, organized
|
|
113
|
+
* by operating flow:
|
|
114
|
+
* 1. 会话 · 工作区(高频率)→ 会话下拉 · 工作区下拉 · 新会话 · 全部会话
|
|
115
|
+
* 2. 任务控制 → 停止 · 压缩 · 补充指令下拉
|
|
116
|
+
* 3. 配置 → 预设下拉 · 模型下拉 · 归档切换
|
|
117
|
+
* 4. 系统 → 状态 · 帮助
|
|
118
|
+
* `ctx` bundle:
|
|
119
|
+
* - workspaces: string[] (工作区下拉选项)
|
|
120
|
+
* - currentWorkspace: string|null (当前工作区,下拉高亮)
|
|
121
|
+
* - currentSession: {id,title}|null (当前绑定会话,续写目标)
|
|
122
|
+
* - sessions: {id,title}[] (最近会话,供会话下拉切换)
|
|
123
|
+
* - archiveVisible: boolean (归档显隐开关当前值)
|
|
124
|
+
* - presetCatalog: object|null (预设目录, {items,defaultId,_currentId})
|
|
125
|
+
* - modelCatalog: object|null (模型目录, {groups,current})
|
|
126
|
+
* Number fallback: 1=续写 2=新会话 3=会话列表 4=状态
|
|
127
|
+
* 5=修复 6=帮助.
|
|
128
|
+
*/
|
|
129
|
+
export function menuCard(ctx) {
|
|
130
|
+
const {
|
|
131
|
+
workspaces = [],
|
|
132
|
+
currentWorkspace = null,
|
|
133
|
+
currentSession = null,
|
|
134
|
+
sessions = [],
|
|
135
|
+
archiveVisible = false,
|
|
136
|
+
presetCatalog = null,
|
|
137
|
+
modelCatalog = null,
|
|
138
|
+
} = ctx || {};
|
|
139
|
+
|
|
140
|
+
// currentSession 提供 id/title;为兼容旧调用,也接受 currentSessionTitle
|
|
141
|
+
const currentSessionId = currentSession?.id ?? null;
|
|
142
|
+
const currentSessionTitle = currentSession?.title ?? ctx?.currentSessionTitle ?? null;
|
|
143
|
+
|
|
144
|
+
const sessionOptions = (Array.isArray(sessions) ? sessions : []).slice(0, 20);
|
|
145
|
+
const hasSessions = sessionOptions.length > 0;
|
|
146
|
+
const hasWorkspaces = Array.isArray(workspaces) && workspaces.length > 0;
|
|
147
|
+
const elements = [];
|
|
148
|
+
|
|
149
|
+
// ── 设置区 ──────────────────────────────────────────────────
|
|
150
|
+
elements.push({ tag: 'div', text: markdown(t('**设置**')) });
|
|
151
|
+
|
|
152
|
+
// ── 四个下拉菜单 2×2 网格 ────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
// 第 1 行:会话 + 工作区
|
|
155
|
+
let sessionDropdown = null;
|
|
156
|
+
if (hasSessions) {
|
|
157
|
+
const sessionPickOptions = sessionOptions.map((s) => ({
|
|
158
|
+
text: { tag: 'plain_text', content: `${s.id === currentSessionId ? '✓ ' : ''}${safeTitle(s.title)}` },
|
|
159
|
+
value: s.id,
|
|
160
|
+
}));
|
|
161
|
+
sessionDropdown = {
|
|
162
|
+
tag: 'select_static',
|
|
163
|
+
name: 'session_pick',
|
|
164
|
+
placeholder: {
|
|
165
|
+
tag: 'plain_text',
|
|
166
|
+
content: currentSessionId ? t('切换会话') : t('选择会话(当前未绑定)'),
|
|
167
|
+
},
|
|
168
|
+
initial_index: initialIndex(sessionPickOptions, currentSessionId),
|
|
169
|
+
options: sessionPickOptions,
|
|
170
|
+
behaviors: [{ type: 'callback', value: { action: 'session_pick' } }],
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let workspaceDropdown = null;
|
|
175
|
+
if (hasWorkspaces) {
|
|
176
|
+
const wsOptions = workspaces.slice(0, 20).map((path) => ({
|
|
177
|
+
text: { tag: 'plain_text', content: `${path === currentWorkspace ? '✓ ' : ''}${path}` },
|
|
178
|
+
value: path,
|
|
179
|
+
}));
|
|
180
|
+
workspaceDropdown = {
|
|
181
|
+
tag: 'select_static',
|
|
182
|
+
name: 'workspace_pick',
|
|
183
|
+
placeholder: { tag: 'plain_text', content: t('切换工作区') },
|
|
184
|
+
initial_index: initialIndex(wsOptions, currentWorkspace),
|
|
185
|
+
options: wsOptions,
|
|
186
|
+
behaviors: [{ type: 'callback', value: { action: 'workspace_pick' } }],
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// 第 2 行:预设 + 模型
|
|
191
|
+
const presetItems = Array.isArray(presetCatalog?.items) ? presetCatalog.items : [];
|
|
192
|
+
const curPresetId = presetCatalog?._currentId ?? null;
|
|
193
|
+
const presetFollowDefault = curPresetId === null;
|
|
194
|
+
let presetDropdown = null;
|
|
195
|
+
if (presetItems.length >= 1) {
|
|
196
|
+
const setPresetOptions = presetItems.slice(0, 30).map((item) => ({
|
|
197
|
+
text: { tag: 'plain_text', content: `${item.id === curPresetId ? '✓ ' : ''}${item.label}` },
|
|
198
|
+
value: item.id,
|
|
199
|
+
}));
|
|
200
|
+
const presetSelected = presetFollowDefault ? PRESET_FOLLOW_DEFAULT_SENTINEL : curPresetId;
|
|
201
|
+
setPresetOptions.unshift({
|
|
202
|
+
text: { tag: 'plain_text', content: `${presetFollowDefault ? '✓ ' : ''}${t('跟随默认')}` },
|
|
203
|
+
value: PRESET_FOLLOW_DEFAULT_SENTINEL,
|
|
204
|
+
});
|
|
205
|
+
presetDropdown = {
|
|
206
|
+
tag: 'select_static',
|
|
207
|
+
name: 'preset_pick',
|
|
208
|
+
placeholder: { tag: 'plain_text', content: t('切换预设') },
|
|
209
|
+
initial_index: initialIndex(setPresetOptions, presetSelected),
|
|
210
|
+
options: setPresetOptions,
|
|
211
|
+
behaviors: [{ type: 'callback', value: { action: 'preset_pick' } }],
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const groups = Array.isArray(modelCatalog?.groups) ? modelCatalog.groups : [];
|
|
216
|
+
const curModel = modelCatalog?.current;
|
|
217
|
+
const curModelId = curModel ? `${curModel.provider}/${curModel.model}` : null;
|
|
218
|
+
const flat = [];
|
|
219
|
+
for (const group of groups) {
|
|
220
|
+
for (const model of (group.models || [])) flat.push({ id: `${group.id}/${model.id}`, name: `${group.name} - ${model.name}` });
|
|
221
|
+
}
|
|
222
|
+
let modelDropdown = null;
|
|
223
|
+
if (flat.length > 1) {
|
|
224
|
+
const setModelOptions = flat.slice(0, 30).map((opt) => ({
|
|
225
|
+
text: { tag: 'plain_text', content: `${opt.id === curModelId ? '✓ ' : ''}${opt.name}` },
|
|
226
|
+
value: opt.id,
|
|
227
|
+
}));
|
|
228
|
+
modelDropdown = {
|
|
229
|
+
tag: 'select_static',
|
|
230
|
+
name: 'model_pick',
|
|
231
|
+
placeholder: { tag: 'plain_text', content: t('切换模型') },
|
|
232
|
+
initial_index: initialIndex(setModelOptions, curModelId),
|
|
233
|
+
options: setModelOptions,
|
|
234
|
+
behaviors: [{ type: 'callback', value: { action: 'model_pick' } }],
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// 渲染 2×2 网格:图标 + 下拉并列
|
|
239
|
+
// 每一行用 4 列 column_set:图标 | 下拉 | 图标 | 下拉
|
|
240
|
+
function iconCol(icon) {
|
|
241
|
+
return { tag: 'column', width: 'weighted', weight: 0.1, vertical_align: 'center', elements: [{ tag: 'div', text: { tag: 'plain_text', content: icon } }] };
|
|
242
|
+
}
|
|
243
|
+
function dropdownCol(el) {
|
|
244
|
+
return { tag: 'column', width: 'weighted', weight: 1, elements: [el] };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const row1 = [];
|
|
248
|
+
if (sessionDropdown) row1.push(sessionDropdown);
|
|
249
|
+
if (workspaceDropdown) row1.push(workspaceDropdown);
|
|
250
|
+
if (row1.length === 2) {
|
|
251
|
+
elements.push({
|
|
252
|
+
tag: 'column_set', flex_mode: 'none',
|
|
253
|
+
columns: [iconCol('💬'), dropdownCol(row1[0]), iconCol('📂'), dropdownCol(row1[1])],
|
|
254
|
+
});
|
|
255
|
+
} else if (row1[0]) {
|
|
256
|
+
elements.push(row1[0]);
|
|
257
|
+
}
|
|
258
|
+
if (!hasSessions) {
|
|
259
|
+
elements.push({ tag: 'div', text: markdown(t('当前工作区暂无可用会话。')) });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const row2 = [];
|
|
263
|
+
const presetBtn = button(t('🤖 切换预设'), 'presets');
|
|
264
|
+
const modelBtn = button(t('🧠 切换模型'), 'models');
|
|
265
|
+
if (presetDropdown) row2.push(presetDropdown); else row2.push(presetBtn);
|
|
266
|
+
if (modelDropdown) row2.push(modelDropdown); else row2.push(modelBtn);
|
|
267
|
+
elements.push({
|
|
268
|
+
tag: 'column_set', flex_mode: 'none',
|
|
269
|
+
columns: [iconCol('🤖'), dropdownCol(row2[0]), iconCol('🧠'), dropdownCol(row2[1])],
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// 新会话 + 全部会话按钮
|
|
273
|
+
elements.push(buttonPair(t('🆕 新会话'), 'new', t('📋 会话/关注'), 'sessions'));
|
|
274
|
+
if (!hasSessions && !hasWorkspaces) {
|
|
275
|
+
elements.push(button(t('🗂 工作区列表'), 'workspaces'));
|
|
276
|
+
}
|
|
277
|
+
elements.push({ tag: 'hr' });
|
|
278
|
+
|
|
279
|
+
// ── 任务控制(对运行中任务的操作)────────────────────────
|
|
280
|
+
elements.push({ tag: 'div', text: markdown(t('**任务控制**')) });
|
|
281
|
+
elements.push(buttonPair(t('⏹ 停止'), 'stop', t('📐 压缩'), 'compact'));
|
|
282
|
+
elements.push({ tag: 'hr' });
|
|
283
|
+
|
|
284
|
+
// ── 补充指令 + 归档切换(并列)────────────────────────────
|
|
285
|
+
elements.push({
|
|
286
|
+
tag: 'column_set', flex_mode: 'none',
|
|
287
|
+
columns: [
|
|
288
|
+
{
|
|
289
|
+
tag: 'column', width: 'weighted', weight: 1,
|
|
290
|
+
elements: [
|
|
291
|
+
{ tag: 'div', text: markdown(t('**补充指令**')) },
|
|
292
|
+
{
|
|
293
|
+
tag: 'select_static',
|
|
294
|
+
name: 'steer_pick',
|
|
295
|
+
placeholder: { tag: 'plain_text', content: t('选择补充指令') },
|
|
296
|
+
initial_index: 0,
|
|
297
|
+
options: [
|
|
298
|
+
...QUICK_STEER_OPTIONS.map((source) => ({
|
|
299
|
+
text: { tag: 'plain_text', content: t(source) },
|
|
300
|
+
value: t(source),
|
|
301
|
+
})),
|
|
302
|
+
{ text: { tag: 'plain_text', content: t('✏️ 更多 / 自定义…') }, value: 'custom' },
|
|
303
|
+
],
|
|
304
|
+
behaviors: [{ type: 'callback', value: { action: 'steer_pick' } }],
|
|
305
|
+
},
|
|
306
|
+
],
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
tag: 'column', width: 'weighted', weight: 1,
|
|
310
|
+
elements: [
|
|
311
|
+
{ tag: 'div', text: markdown(t('🗄 归档:{state}', {
|
|
312
|
+
state: archiveVisible ? t('已显示') : t('已隐藏'),
|
|
313
|
+
})) },
|
|
314
|
+
button(t('切换归档显示'), 'archive_toggle'),
|
|
315
|
+
],
|
|
316
|
+
},
|
|
317
|
+
],
|
|
318
|
+
});
|
|
319
|
+
elements.push({ tag: 'hr' });
|
|
320
|
+
|
|
321
|
+
// ── 底部操作(系统功能)────────────────────────────────────
|
|
322
|
+
elements.push(buttonPair(t('📊 状态'), 'status', t('📖 帮助'), 'help'));
|
|
323
|
+
|
|
324
|
+
// 命令与数字兜底说明
|
|
325
|
+
elements.push({ tag: 'div', text: markdown(t(
|
|
326
|
+
'**数字兜底**\n**1**工作区列表 · **2**新会话 · **3**会话列表 · **4**状态\n**5**🔧修复 · **6**帮助',
|
|
327
|
+
)) });
|
|
328
|
+
return cardWith(t('🤖 助手中心'), elements);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ── Preset management card ────────────────────────────────────────────────
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Preset selection card with a dropdown of available presets.
|
|
335
|
+
* The catalog comes from `harness.agentPresetSettings()`.
|
|
336
|
+
*/
|
|
337
|
+
export function presetCard(catalog) {
|
|
338
|
+
const { items, defaultId } = catalog;
|
|
339
|
+
const current = catalog._currentId; // injected by the caller
|
|
340
|
+
const currentItem = items.find((i) => i.id === current);
|
|
341
|
+
const defaultItem = items.find((i) => i.id === defaultId);
|
|
342
|
+
|
|
343
|
+
const currentText = current === null
|
|
344
|
+
? t('跟随 Host 默认{default}', { default: defaultItem ? `(${defaultItem.label})` : '' })
|
|
345
|
+
: currentItem
|
|
346
|
+
? `${currentItem.label}(${currentItem.id})`
|
|
347
|
+
: t('{id}(已不可用)', { id: current });
|
|
348
|
+
|
|
349
|
+
const elements = [
|
|
350
|
+
{ tag: 'div', text: markdown(t('**当前**:{value}', { value: currentText })) },
|
|
351
|
+
{ tag: 'div', text: markdown(t('**Host 默认**:{value}', {
|
|
352
|
+
value: defaultItem ? `${defaultItem.label}(${defaultItem.id})` : t('未设置'),
|
|
353
|
+
})) },
|
|
354
|
+
{ tag: 'hr' },
|
|
355
|
+
];
|
|
356
|
+
|
|
357
|
+
if (items.length >= 1) {
|
|
358
|
+
const presetCardOptions = items.map((item) => ({
|
|
359
|
+
text: { tag: 'plain_text', content: `${item.label}(${item.id})${item.id === current ? ' ✓' : ''}` },
|
|
360
|
+
value: item.id,
|
|
361
|
+
}));
|
|
362
|
+
elements.push(
|
|
363
|
+
{
|
|
364
|
+
tag: 'select_static',
|
|
365
|
+
name: 'preset_pick',
|
|
366
|
+
placeholder: { tag: 'plain_text', content: t('选择预设') },
|
|
367
|
+
initial_index: current === null ? 0 : initialIndex(presetCardOptions, current),
|
|
368
|
+
options: presetCardOptions,
|
|
369
|
+
behaviors: [{ type: 'callback', value: { action: 'preset_pick' } }],
|
|
370
|
+
},
|
|
371
|
+
{ tag: 'hr' },
|
|
372
|
+
buttonPair(t('🔄 跟随默认'), 'preset_default', t('🔙 返回菜单'), 'back_to_menu'),
|
|
373
|
+
);
|
|
374
|
+
} else {
|
|
375
|
+
elements.push(
|
|
376
|
+
{ tag: 'div', text: markdown(t('当前没有可选择的预设。')) },
|
|
377
|
+
backButton(),
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
return cardWith(t('🤖 预设列表'), elements);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ── Model management card ─────────────────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Model selection card with a dropdown of available models.
|
|
387
|
+
* The catalog comes from `harness.listModels()` or `session.models()`.
|
|
388
|
+
* Groups are shown as text, then a flat dropdown for selection.
|
|
389
|
+
*/
|
|
390
|
+
export function modelCard(catalog) {
|
|
391
|
+
const { groups, current } = catalog;
|
|
392
|
+
const currentId = current ? `${current.provider}/${current.model}` : null;
|
|
393
|
+
|
|
394
|
+
const elements = [
|
|
395
|
+
{ tag: 'div', text: markdown(t('**当前模型**:{model}', { model: currentId || t('未设置') })) },
|
|
396
|
+
{ tag: 'hr' },
|
|
397
|
+
];
|
|
398
|
+
|
|
399
|
+
// Count total models
|
|
400
|
+
let total = 0;
|
|
401
|
+
for (const group of groups) total += group.models.length;
|
|
402
|
+
|
|
403
|
+
if (total === 0) {
|
|
404
|
+
elements.push(
|
|
405
|
+
{ tag: 'div', text: markdown(t('当前没有可用模型。')) },
|
|
406
|
+
backButton(),
|
|
407
|
+
);
|
|
408
|
+
} else {
|
|
409
|
+
// Show groups as text
|
|
410
|
+
for (const group of groups) {
|
|
411
|
+
const modelLines = group.models.map((m) => {
|
|
412
|
+
const id = `${group.id}/${m.id}`;
|
|
413
|
+
return `${id}${id === currentId ? ' ✓' : ''}`;
|
|
414
|
+
});
|
|
415
|
+
elements.push(
|
|
416
|
+
{ tag: 'div', text: markdown(`**${group.name}**\n${modelLines.join('\n')}`) },
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Flatten all models into a dropdown
|
|
421
|
+
const allOptions = [];
|
|
422
|
+
for (const group of groups) {
|
|
423
|
+
for (const model of group.models) {
|
|
424
|
+
allOptions.push({
|
|
425
|
+
value: `${group.id}/${model.id}`,
|
|
426
|
+
text: { tag: 'plain_text', content: `${group.name} - ${model.name}` },
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
elements.push(
|
|
432
|
+
{ tag: 'hr' },
|
|
433
|
+
{
|
|
434
|
+
tag: 'select_static',
|
|
435
|
+
name: 'model_pick',
|
|
436
|
+
placeholder: { tag: 'plain_text', content: t('选择模型') },
|
|
437
|
+
initial_index: currentId === null ? 0 : initialIndex(allOptions, currentId),
|
|
438
|
+
options: allOptions.map((opt) => ({
|
|
439
|
+
text: { tag: 'plain_text', content: `${opt.text.content}${opt.value === currentId ? ' ✓' : ''}` },
|
|
440
|
+
value: opt.value,
|
|
441
|
+
})),
|
|
442
|
+
behaviors: [{ type: 'callback', value: { action: 'model_pick' } }],
|
|
443
|
+
},
|
|
444
|
+
{ tag: 'hr' },
|
|
445
|
+
backButton(),
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
return cardWith(t('🧠 模型列表'), elements);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ── Status card ───────────────────────────────────────────────────────────
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* System status card showing connection, workspace, preset, model and session
|
|
455
|
+
* count. `info` is an object with keys:
|
|
456
|
+
* - connected: boolean
|
|
457
|
+
* - workspace: string | null
|
|
458
|
+
* - preset: string | null
|
|
459
|
+
* - model: string | null
|
|
460
|
+
* - sessionCount: number
|
|
461
|
+
*/
|
|
462
|
+
export function statusCard(info) {
|
|
463
|
+
const elements = [
|
|
464
|
+
{ tag: 'div', text: markdown(t('{icon} 飞书机器人{state}', {
|
|
465
|
+
icon: info.connected ? '✅' : '❌',
|
|
466
|
+
state: info.connected ? t('已连接') : t('未连接'),
|
|
467
|
+
})) },
|
|
468
|
+
{ tag: 'div', text: markdown(t('📂 工作区:`{workspace}`', { workspace: info.workspace || t('未设置') })) },
|
|
469
|
+
{ tag: 'div', text: markdown(t('🤖 预设:{preset}', { preset: info.preset || t('未设置') })) },
|
|
470
|
+
{ tag: 'div', text: markdown(t('🧠 模型:{model}', { model: info.model || t('未设置') })) },
|
|
471
|
+
{ tag: 'div', text: markdown(t('💬 会话:{count} 个', { count: info.sessionCount })) },
|
|
472
|
+
{ tag: 'hr' },
|
|
473
|
+
backButton(),
|
|
474
|
+
];
|
|
475
|
+
return cardWith(t('📊 系统状态'), elements);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ── Help card ─────────────────────────────────────────────────────────────
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Plain-text command reference advertised when no interactive card is
|
|
482
|
+
* available (e.g. non-interactive clients), so every command stays
|
|
483
|
+
* discoverable without card callbacks.
|
|
484
|
+
*/
|
|
485
|
+
export function menuHelpText() {
|
|
486
|
+
return [
|
|
487
|
+
'🤖 助手菜单(回复数字即可,无需记命令)',
|
|
488
|
+
'',
|
|
489
|
+
'📋 会话 / 工作区',
|
|
490
|
+
'/sessionlist 列出工作区会话',
|
|
491
|
+
'/session ID 绑定已有会话',
|
|
492
|
+
'/workspacelist 列出工作区',
|
|
493
|
+
'/workspace 路径 切换工作区',
|
|
494
|
+
'/new 开启全新会话',
|
|
495
|
+
'',
|
|
496
|
+
'📊 状态 / 压缩',
|
|
497
|
+
'/status 连接状态',
|
|
498
|
+
'/compact 压缩当前会话上下文',
|
|
499
|
+
'/archived on/off 会话列表显示/隐藏归档',
|
|
500
|
+
'',
|
|
501
|
+
'👁 关注',
|
|
502
|
+
'/watch ID 关注会话(完成后推送)',
|
|
503
|
+
'/watchlist 关注列表',
|
|
504
|
+
'/unwatch ID 取消关注',
|
|
505
|
+
'',
|
|
506
|
+
'🤖 预设 / 模型',
|
|
507
|
+
'/presetlist 列出可用 Agent Preset',
|
|
508
|
+
'/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset',
|
|
509
|
+
'纯数字 ID:/preset id:<ID>',
|
|
510
|
+
'/preset --default 跟随 Host 默认',
|
|
511
|
+
'/models 列出模型',
|
|
512
|
+
'/model 2 按序号切换模型',
|
|
513
|
+
'',
|
|
514
|
+
'🎮 任务控制',
|
|
515
|
+
'/stop 停止当前任务',
|
|
516
|
+
'/steer 指令 给 Agent 补充指令',
|
|
517
|
+
'/repair 修复卡片按钮回调',
|
|
518
|
+
].map((line) => t(line)).join('\n');
|
|
87
519
|
}
|
|
88
520
|
|
|
521
|
+
/**
|
|
522
|
+
* Help card with all available commands and their descriptions.
|
|
523
|
+
*/
|
|
524
|
+
const HELP_CARD_FEATURES = [
|
|
525
|
+
'**📋 卡片功能**',
|
|
526
|
+
'',
|
|
527
|
+
'1. 会话下拉 — 切换当前绑定会话',
|
|
528
|
+
'2. 工作区下拉 — 切换工作区',
|
|
529
|
+
'3. 🤖 预设下拉 — 切换 Agent 预设',
|
|
530
|
+
'4. 🧠 模型下拉 — 切换模型',
|
|
531
|
+
'5. 🆕 新会话 — 开启全新会话',
|
|
532
|
+
'6. 📋 会话/关注 — 查看/绑定会话,管理关注',
|
|
533
|
+
'7. ⏹ 停止 — 停止当前任务',
|
|
534
|
+
'8. 📐 压缩 — 压缩当前会话上下文',
|
|
535
|
+
'9. 补充指令 — 给 Agent 发送指令',
|
|
536
|
+
'10. 🗄 归档切换 — 显示/隐藏归档会话',
|
|
537
|
+
'11. 📊 状态 — 查看系统连接状态',
|
|
538
|
+
'12. 📖 帮助 — 查看本帮助',
|
|
539
|
+
].join('\n');
|
|
540
|
+
|
|
541
|
+
const HELP_TEXT_COMMANDS = [
|
|
542
|
+
'**⌨️ 文本命令**',
|
|
543
|
+
'',
|
|
544
|
+
'`/m` — 打开菜单卡片',
|
|
545
|
+
'`/new` — 开启全新会话',
|
|
546
|
+
'`/session ID` — 绑定已有会话',
|
|
547
|
+
'`/sessionlist [工作区]` — 列出会话',
|
|
548
|
+
'`/workspace 路径` — 切换工作区',
|
|
549
|
+
'`/workspacelist` — 列出工作区',
|
|
550
|
+
'`/status` — 查看连接状态',
|
|
551
|
+
'`/compact` — 压缩上下文',
|
|
552
|
+
'`/stop` — 停止当前任务',
|
|
553
|
+
'`/steer 指令` — 补充指令',
|
|
554
|
+
'`/watch ID` — 关注会话',
|
|
555
|
+
'`/watchlist` — 关注列表',
|
|
556
|
+
'`/unwatch ID` — 取消关注',
|
|
557
|
+
'`/archived on/off` — 归档显隐',
|
|
558
|
+
'`/presetlist` — 列出预设',
|
|
559
|
+
'`/preset [序号/ID]` — 切换预设',
|
|
560
|
+
'`/preset --default` — 跟随默认',
|
|
561
|
+
'`/models` — 列出模型',
|
|
562
|
+
'`/model 2` — 切换模型',
|
|
563
|
+
'`/repair` — 修复卡片按钮',
|
|
564
|
+
].join('\n');
|
|
565
|
+
|
|
566
|
+
const HELP_NUMBER_FALLBACK = [
|
|
567
|
+
'**💡 数字兜底**',
|
|
568
|
+
'回复数字快速操作:',
|
|
569
|
+
'**1**工作区列表 · **2**新会话 · **3**会话/关注',
|
|
570
|
+
'**4**状态 · **5**修复 · **6**帮助',
|
|
571
|
+
].join('\n');
|
|
572
|
+
|
|
573
|
+
export function helpCard(extraTextLines = []) {
|
|
574
|
+
const extraText = Array.isArray(extraTextLines) && extraTextLines.length > 0
|
|
575
|
+
? ('\n' + extraTextLines.join('\n'))
|
|
576
|
+
: '';
|
|
577
|
+
const elements = [
|
|
578
|
+
{ tag: 'div', text: markdown(t(HELP_CARD_FEATURES)) },
|
|
579
|
+
{ tag: 'hr' },
|
|
580
|
+
{ tag: 'div', text: markdown(t(HELP_TEXT_COMMANDS) + extraText) },
|
|
581
|
+
{ tag: 'hr' },
|
|
582
|
+
{ tag: 'div', text: markdown(t(HELP_NUMBER_FALLBACK)) },
|
|
583
|
+
{ tag: 'hr' },
|
|
584
|
+
backButton(),
|
|
585
|
+
];
|
|
586
|
+
return cardWith(t('📖 帮助'), elements);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// ── One-shot callback probe (repair verification) ─────────────────────────
|
|
590
|
+
|
|
89
591
|
/** One-shot callback probe used only after an existing app was re-authorized. */
|
|
90
592
|
export function cardActionProbeCard(nonce) {
|
|
91
593
|
if (typeof nonce !== 'string' || !/^[A-Za-z0-9_-]{16,128}$/.test(nonce)) {
|
|
@@ -118,6 +620,8 @@ export function cardActionProbeCard(nonce) {
|
|
|
118
620
|
]);
|
|
119
621
|
}
|
|
120
622
|
|
|
623
|
+
// ── Session list card (preserved, with back button) ───────────────────────
|
|
624
|
+
|
|
121
625
|
/**
|
|
122
626
|
* One page of the workspace's sessions. Each row is a `column_set` pair:
|
|
123
627
|
* the fixed-width ⭐ watch toggle (`⭐关注` / `⭐取关` for already-watched
|
|
@@ -154,70 +658,105 @@ export function sessionListCard(workspace, sessions, page, total, watchedSession
|
|
|
154
658
|
const label = `${offset + 1}. ${safeTitle(session.title)}${session.archived === true ? t('(已归档)') : ''}`;
|
|
155
659
|
const watching = watched(session.sessionId);
|
|
156
660
|
return row(
|
|
157
|
-
buttonElement(watching ? t('
|
|
661
|
+
buttonElement(watching ? t('⭐ 取消关注') : t('☆ 关注'), watching ? `unwatch:${session.sessionId}` : `watch:${session.sessionId}`),
|
|
158
662
|
buttonElement(label, `use:${session.sessionId}`),
|
|
159
663
|
);
|
|
160
664
|
}),
|
|
161
665
|
];
|
|
162
666
|
if (page > 0) elements.push(button(t('◀ 上一页'), `sessions:${page - 1}`));
|
|
163
667
|
if (page + 1 < pageCount) elements.push(button(t('下一页 ▶'), `sessions:${page + 1}`));
|
|
164
|
-
elements.push(
|
|
668
|
+
elements.push(
|
|
669
|
+
{ tag: 'hr' },
|
|
670
|
+
buttonPair(t('🔙 返回菜单'), 'back_to_menu', t('🔍 关注列表'), 'watchlist'),
|
|
671
|
+
{ tag: 'div', text: markdown(t('回复数字(1~N)绑定本页会话。')) },
|
|
672
|
+
);
|
|
165
673
|
return cardWith(t('📂 会话列表'), elements);
|
|
166
674
|
}
|
|
167
675
|
|
|
676
|
+
// ── Workspace list card (preserved, with back button) ─────────────────────
|
|
677
|
+
|
|
168
678
|
/** The workspace list card (switch-workspace buttons + reply fallback). */
|
|
169
679
|
export function workspaceListCard(paths, current) {
|
|
170
680
|
const elements = paths.length === 0
|
|
171
|
-
? [
|
|
681
|
+
? [
|
|
682
|
+
{ tag: 'div', text: markdown(t('当前 Host 上没有已登记的工作区。')) },
|
|
683
|
+
backButton(),
|
|
684
|
+
]
|
|
172
685
|
: [
|
|
173
686
|
{ tag: 'div', text: markdown(t('回复数字切换工作区,或点击按钮:')) },
|
|
174
687
|
...paths.map((path, index) => button(
|
|
175
688
|
`${index + 1}. ${path}${path === current ? t('(当前)') : ''}`,
|
|
176
689
|
`workspace:${path}`,
|
|
177
690
|
)),
|
|
691
|
+
{ tag: 'hr' },
|
|
692
|
+
backButton(),
|
|
178
693
|
];
|
|
179
694
|
return cardWith(t('🗂 工作区'), elements);
|
|
180
695
|
}
|
|
181
696
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
t('
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
697
|
+
// ── Watch list card (multi-select add/remove + back button) ──────────────
|
|
698
|
+
|
|
699
|
+
/** Dropdown (multi-select) of sessions not yet watched, to add them in bulk. */
|
|
700
|
+
function watchAddSelect(entries, availableSessions) {
|
|
701
|
+
const watched = new Set(entries.map((e) => e.sessionId));
|
|
702
|
+
const options = (Array.isArray(availableSessions) ? availableSessions : [])
|
|
703
|
+
.filter((s) => s?.sessionId && !watched.has(s.sessionId))
|
|
704
|
+
.slice(0, 30)
|
|
705
|
+
.map((s) => ({
|
|
706
|
+
text: { tag: 'plain_text', content: safeTitle(s.title ?? s.sessionId) },
|
|
707
|
+
value: s.sessionId,
|
|
708
|
+
}));
|
|
709
|
+
if (options.length === 0) return null;
|
|
710
|
+
return {
|
|
711
|
+
tag: 'multi_select_static',
|
|
712
|
+
name: 'watch_add',
|
|
713
|
+
placeholder: { tag: 'plain_text', content: t('勾选要关注的会话') },
|
|
714
|
+
options,
|
|
715
|
+
behaviors: [{ type: 'callback', value: { action: 'watch_add', kind: 'multi' } }],
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/** Dropdown (multi-select) of currently watched sessions, to remove in bulk. */
|
|
720
|
+
function watchRemoveSelect(entries) {
|
|
721
|
+
const options = entries.slice(0, 30).map((entry) => ({
|
|
722
|
+
text: { tag: 'plain_text', content: safeTitle(entry.title) },
|
|
723
|
+
value: entry.sessionId,
|
|
724
|
+
}));
|
|
725
|
+
if (options.length === 0) return null;
|
|
726
|
+
return {
|
|
727
|
+
tag: 'multi_select_static',
|
|
728
|
+
name: 'watch_remove',
|
|
729
|
+
placeholder: { tag: 'plain_text', content: t('勾选要取消关注的会话') },
|
|
730
|
+
options,
|
|
731
|
+
behaviors: [{ type: 'callback', value: { action: 'watch_remove', kind: 'multi' } }],
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/** The watch list for one conversation (multi-select add/remove + buttons). */
|
|
736
|
+
export function watchListCard(entries, availableSessions) {
|
|
737
|
+
const watching = Array.isArray(entries) ? entries : [];
|
|
738
|
+
const addSelect = watchAddSelect(watching, availableSessions);
|
|
739
|
+
const removeSelect = watchRemoveSelect(watching);
|
|
740
|
+
|
|
741
|
+
const elements = [];
|
|
742
|
+
if (watching.length === 0) {
|
|
743
|
+
elements.push({ tag: 'div', text: markdown(t('当前没有关注的会话。任务完成会自动推送结果。')) });
|
|
744
|
+
} else {
|
|
745
|
+
elements.push({ tag: 'div', text: markdown(t('当前关注 **{count}** 个会话:', { count: watching.length })) });
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
if (addSelect) {
|
|
749
|
+
elements.push({ tag: 'hr' }, { tag: 'div', text: markdown(t('**➕ 添加关注**(多选下拉勾选)')) }, addSelect);
|
|
750
|
+
}
|
|
751
|
+
if (removeSelect) {
|
|
752
|
+
elements.push({ tag: 'hr' }, { tag: 'div', text: markdown(t('**➖ 取消关注**(多选下拉勾选)')) }, removeSelect);
|
|
753
|
+
}
|
|
754
|
+
elements.push({ tag: 'hr' }, buttonPair(t('📋 会话列表'), 'sessions', t('🔙 返回菜单'), 'back_to_menu'));
|
|
218
755
|
return cardWith(t('👁 关注列表'), elements);
|
|
219
756
|
}
|
|
220
757
|
|
|
758
|
+
// ── Completion push card ──────────────────────────────────────────────────
|
|
759
|
+
|
|
221
760
|
/**
|
|
222
761
|
* The completion push card. `title` is the session title, `reason` the
|
|
223
762
|
* turn-end kind (completed / stopped / aborted).
|
|
@@ -235,8 +774,76 @@ export function completionCard(sessionId, title, reason) {
|
|
|
235
774
|
return cardWith(t('✅ 任务完成'), [
|
|
236
775
|
{ tag: 'div', text: markdown(`**${safeTitle(title)}**\n\`${sessionId}\``) },
|
|
237
776
|
{ tag: 'div', text: markdown(t('**状态**:{reason}', { reason: reasonText })) },
|
|
238
|
-
|
|
239
|
-
|
|
777
|
+
{ tag: 'hr' },
|
|
778
|
+
buttonPair(t('📋 会话列表'), 'sessions', t('🔙 返回菜单'), 'back_to_menu'),
|
|
240
779
|
{ tag: 'div', text: markdown(t('绑定该会话后可继续追问,输入文字即可。')) },
|
|
241
780
|
]);
|
|
242
781
|
}
|
|
782
|
+
|
|
783
|
+
// ── Steer card (补充指令) ─────────────────────────────────────────────────
|
|
784
|
+
|
|
785
|
+
/** 常用补充指令的快捷选项,供下拉秒选。 */
|
|
786
|
+
const QUICK_STEER_OPTIONS = ['继续', '加速运行', '总结当前进展', '更简洁些', '更详细些'];
|
|
787
|
+
|
|
788
|
+
/** 快捷下拉的最后一项:切换到自定义输入卡片。 */
|
|
789
|
+
export const STEER_CUSTOM_SENTINEL = '__steer_custom__';
|
|
790
|
+
|
|
791
|
+
/** 补充指令卡片:单个下拉,快捷指令 + 「更多/自定义」。 */
|
|
792
|
+
export function steerCard({ hasSession }) {
|
|
793
|
+
const hint = hasSession
|
|
794
|
+
? t('从下方下拉选择补充指令;最后一项可自定义输入。')
|
|
795
|
+
: t('当前没有绑定会话,请先绑定会话再补充指令。');
|
|
796
|
+
const options = QUICK_STEER_OPTIONS.map((source) => ({
|
|
797
|
+
text: { tag: 'plain_text', content: t(source) },
|
|
798
|
+
value: t(source),
|
|
799
|
+
}));
|
|
800
|
+
options.push({ text: { tag: 'plain_text', content: t('✏️ 更多 / 自定义…') }, value: STEER_CUSTOM_SENTINEL });
|
|
801
|
+
const elements = [
|
|
802
|
+
{ tag: 'div', text: markdown(hint) },
|
|
803
|
+
{ tag: 'hr' },
|
|
804
|
+
{
|
|
805
|
+
tag: 'select_static',
|
|
806
|
+
name: 'steer_quick',
|
|
807
|
+
placeholder: { tag: 'plain_text', content: t('选择补充指令') },
|
|
808
|
+
initial_index: 0,
|
|
809
|
+
options,
|
|
810
|
+
behaviors: [{ type: 'callback', value: { action: 'steer', source: 'quick' } }],
|
|
811
|
+
},
|
|
812
|
+
{ tag: 'hr' },
|
|
813
|
+
button(t('🔙 返回菜单'), 'back_to_menu'),
|
|
814
|
+
];
|
|
815
|
+
return cardWith(t('➕ 补充指令'), elements);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/** 自定义输入卡:输入框 + 提交(从下拉「更多/自定义」切过来)。 */
|
|
819
|
+
export function customSteerCard() {
|
|
820
|
+
const elements = [
|
|
821
|
+
{ tag: 'div', text: markdown(t('输入补充指令后点「提交」,发送给当前运行的任务。')) },
|
|
822
|
+
{ tag: 'hr' },
|
|
823
|
+
{
|
|
824
|
+
tag: 'form',
|
|
825
|
+
name: 'steer_form',
|
|
826
|
+
elements: [
|
|
827
|
+
{
|
|
828
|
+
tag: 'input',
|
|
829
|
+
name: 'steer_text',
|
|
830
|
+
placeholder: { tag: 'plain_text', content: t('输入你的补充指令') },
|
|
831
|
+
},
|
|
832
|
+
{
|
|
833
|
+
tag: 'button',
|
|
834
|
+
// Card 2.0 form controls need stable names so the client can
|
|
835
|
+
// construct a submit action (and expose action.name) reliably.
|
|
836
|
+
name: 'steer_submit',
|
|
837
|
+
text: { tag: 'plain_text', content: t('提交') },
|
|
838
|
+
type: 'primary',
|
|
839
|
+
width: 'fill',
|
|
840
|
+
form_action_type: 'submit',
|
|
841
|
+
behaviors: [{ type: 'callback', value: { action: 'steer', source: 'form' } }],
|
|
842
|
+
},
|
|
843
|
+
],
|
|
844
|
+
},
|
|
845
|
+
{ tag: 'hr' },
|
|
846
|
+
button(t('🔙 返回菜单'), 'back_to_menu'),
|
|
847
|
+
];
|
|
848
|
+
return cardWith(t('➕ 自定义指令'), elements);
|
|
849
|
+
}
|