@sidleo3/dsh-chat-feishu 0.0.4
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/client/index.js +1190 -0
- package/cordis.patch.yml +5 -0
- package/host/bridge.mjs +1605 -0
- package/host/config-store.mjs +240 -0
- package/host/controller.mjs +1514 -0
- package/host/index.mjs +227 -0
- package/host/lark-cli.mjs +543 -0
- package/host/lark-gateway.mjs +1362 -0
- package/host/lark-guard.mjs +200 -0
- package/host/panel-card.mjs +681 -0
- package/host/provision.mjs +247 -0
- package/host/state-store.mjs +160 -0
- package/host/turn-presenter.mjs +778 -0
- package/lib/client.js +1132 -0
- package/lib/index.js +132126 -0
- package/package.json +54 -0
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 控制面板卡片(飞书):把 hub 的 `panel.read()` 状态渲染成**可交互卡片**。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要有它(真机反馈):以前的 `/menu` 只是一堆命令按钮——点一下等于替你打一条命令,
|
|
5
|
+
* 想换模型还得先点 `/model`、再看列表、再手打 `provider/model`。这里改成真控件:
|
|
6
|
+
* 下拉里直接选,选完 `behaviors.callback` 立刻回调、hub 应用、**同一张卡片就地刷新**。
|
|
7
|
+
*
|
|
8
|
+
* 组件依据(Card 2.0):
|
|
9
|
+
* - `select_static` + `behaviors: [{ type: 'callback', value: { action } }]`:选中即回调,
|
|
10
|
+
* 选中值在 `action.option`(`lark-gateway` 归一化成 `action.options`);
|
|
11
|
+
* - `initial_index` 指到当前值(有 `✓` 前缀),用户一眼看到现在是什么。
|
|
12
|
+
*
|
|
13
|
+
* 只做渲染,不做判定:能不能改、改完是什么结果,全由 hub 的 `panel.apply` 说了算。
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-chat-feishu/host/panel-card
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** 下拉最多列多少个选项(模型可能几十个,卡片放不下)。 */
|
|
19
|
+
const MAX_OPTIONS = 30;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* "恢复默认"在下拉里的哨兵值。
|
|
23
|
+
*
|
|
24
|
+
* 不能用空串:飞书对 `value: ''` 的选项不可靠(dsh-im 用的是同样的哨兵做法);
|
|
25
|
+
* 哨兵在 `panelPick` 里翻译回 `''`(= hub 侧"默认/清除"的语义)。
|
|
26
|
+
*/
|
|
27
|
+
const FOLLOW_DEFAULT = '__default__';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 「跟随全局」(本会话的上下文增强)的哨兵值。
|
|
31
|
+
*
|
|
32
|
+
* 与 `FOLLOW_DEFAULT` 同理:飞书对 `value: ''` 的选项不可靠,而且空取值在 `panelPick`
|
|
33
|
+
* 里是"没认出来"的信号——真正的"跟随全局"必须有自己的值,回调时再翻译回空串。
|
|
34
|
+
*/
|
|
35
|
+
const CONTEXT_GLOBAL = '__global__';
|
|
36
|
+
|
|
37
|
+
const h = (value) => String(value ?? '');
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 控制面板按钮一行放几个。
|
|
41
|
+
*
|
|
42
|
+
* 3 而不是 4:手机端卡片宽度有限,一行 4 个会把「新会话」「状态」这类短标签都截成
|
|
43
|
+
* `📊 ...`(真机截图如此)。标签也跟着收短,宁可行数多一点,也不要一排认不出的图标。
|
|
44
|
+
*/
|
|
45
|
+
const PANEL_ROW_SIZE = 3;
|
|
46
|
+
|
|
47
|
+
function mark(current, value, label) {
|
|
48
|
+
return `${current === value ? '✓ ' : ''}${label}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 构造一个下拉元素。
|
|
53
|
+
*
|
|
54
|
+
* @param options - { name, action, placeholder, items, current }。
|
|
55
|
+
* @returns select_static 元素;`items` 为空时返回 null(调用方改用说明行)。
|
|
56
|
+
*/
|
|
57
|
+
function dropdown({ name, action, placeholder, items, current }) {
|
|
58
|
+
const visible = items.slice(0, MAX_OPTIONS);
|
|
59
|
+
/**
|
|
60
|
+
* 当前项必须在可见列表里:否则下拉看起来"什么都没选"(`initial_index` 只能是 0)。
|
|
61
|
+
* 命令清单那次教训是静默丢内容,这里同理——超出的数量交给调用方写在卡上。
|
|
62
|
+
*/
|
|
63
|
+
if (current && !visible.some((item) => item.value === current)) {
|
|
64
|
+
const found = items.find((item) => item.value === current);
|
|
65
|
+
if (found) visible.push(found);
|
|
66
|
+
}
|
|
67
|
+
const hidden = Math.max(0, items.length - visible.length);
|
|
68
|
+
const list = visible.map((item) => ({
|
|
69
|
+
text: { tag: 'plain_text', content: mark(current, item.value, item.label).slice(0, 100) },
|
|
70
|
+
value: h(item.value),
|
|
71
|
+
}));
|
|
72
|
+
if (list.length === 0) return { element: null, hidden: 0 };
|
|
73
|
+
const index = visible.findIndex((item) => item.value === current);
|
|
74
|
+
const element = {
|
|
75
|
+
tag: 'select_static',
|
|
76
|
+
name,
|
|
77
|
+
// 放在 column 里时按列宽撑满(默认宽度会缩成内容宽,半栏看起来会挤成一团)。
|
|
78
|
+
width: 'fill',
|
|
79
|
+
placeholder: { tag: 'plain_text', content: placeholder },
|
|
80
|
+
/**
|
|
81
|
+
* 预选当前值。两个真机坑(dsh-im 记下来的):
|
|
82
|
+
* - `initial_index` 是 **1 起**(0 = 不预选),写成 0 起的下标就会选错一项;
|
|
83
|
+
* - `options` 上**不能**写 `selected`/`selected_index`,会直接报 230099 解析错误。
|
|
84
|
+
*/
|
|
85
|
+
initial_index: index >= 0 ? index + 1 : 0,
|
|
86
|
+
options: list,
|
|
87
|
+
behaviors: [{ type: 'callback', value: { action } }],
|
|
88
|
+
};
|
|
89
|
+
return { element, hidden };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 缩短路径用于下拉展示。
|
|
94
|
+
*
|
|
95
|
+
* 飞书会把过长的选项从**尾巴**截断——正好截掉目录名(真机上 27 个字符的路径就显示成
|
|
96
|
+
* `/Users/zhang3/yh_zhang3/张三b...`)。所以自己折中截断,**短到飞书不会再截**:保留开头
|
|
97
|
+
* 与最后两级;取值的仍然是完整路径。
|
|
98
|
+
*/
|
|
99
|
+
/*
|
|
100
|
+
* 默认 22:真机上 24 个字符左右就开始被截,留点余量。
|
|
101
|
+
*/
|
|
102
|
+
function shortPath(path, max = 22) {
|
|
103
|
+
const text = String(path ?? '');
|
|
104
|
+
if (text.length <= max) return text;
|
|
105
|
+
const parts = text.split('/').filter(Boolean);
|
|
106
|
+
const tail = parts.slice(-2).join('/');
|
|
107
|
+
const short = `${text.startsWith('/') ? '/' : ''}…/${tail}`;
|
|
108
|
+
return short.length <= max ? short : `…/${parts[parts.length - 1] ?? text.slice(-max)}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 一个"带名称的设置格":名称在上、控件在下。
|
|
113
|
+
*
|
|
114
|
+
* 飞书的 `select_static` **没有 label 字段**(查过 Card 2.0 组件文档),所以名称只能自己放:
|
|
115
|
+
* 格子内先一行 markdown 名称,再放下拉。两格并排时一行两个设置,比"四个全宽下拉"还矮。
|
|
116
|
+
*/
|
|
117
|
+
function field(label, element) {
|
|
118
|
+
return {
|
|
119
|
+
tag: 'column',
|
|
120
|
+
width: 'weighted',
|
|
121
|
+
weight: 1,
|
|
122
|
+
elements: [
|
|
123
|
+
{ tag: 'markdown', content: `**${label}**` },
|
|
124
|
+
element,
|
|
125
|
+
],
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 设置格子的栅格。
|
|
131
|
+
*
|
|
132
|
+
* `flex_mode: 'stretch'`:窄屏(手机)时自动变成上下堆叠,每个格子仍占满宽度——
|
|
133
|
+
* 否则半栏里的模型 id / 工作区路径会被压成几个字。
|
|
134
|
+
*/
|
|
135
|
+
function grid(cells) {
|
|
136
|
+
return { tag: 'column_set', flex_mode: 'stretch', columns: cells };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 下拉项太长时截断(保留头尾信息:标题在头、时间在尾)。 */
|
|
140
|
+
function shortLabel(text, max) {
|
|
141
|
+
const value = String(text ?? '');
|
|
142
|
+
if (value.length <= max) return value;
|
|
143
|
+
return `${value.slice(0, Math.max(1, max - 12))}…${value.slice(-10)}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function button(label, action, type = 'default') {
|
|
147
|
+
return {
|
|
148
|
+
tag: 'button',
|
|
149
|
+
type,
|
|
150
|
+
width: 'fill',
|
|
151
|
+
text: { tag: 'plain_text', content: label },
|
|
152
|
+
behaviors: [{ type: 'callback', value: { dsh_panel: action } }],
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 渠道自带的动作按钮(飞书:重连)。
|
|
158
|
+
*
|
|
159
|
+
* 与面板按钮的区别:它的动作名由**渠道**给(`panel.actions`),点击后走 `panel.act`。
|
|
160
|
+
* `confirm` 用卡片原生的二次确认弹窗——重连这类动作不该一按就执行(真机上误触就是掉线)。
|
|
161
|
+
*/
|
|
162
|
+
function channelActionButton(item) {
|
|
163
|
+
return {
|
|
164
|
+
tag: 'button',
|
|
165
|
+
type: item.type ?? 'default',
|
|
166
|
+
width: 'fill',
|
|
167
|
+
text: { tag: 'plain_text', content: String(item.label ?? '').slice(0, 40) },
|
|
168
|
+
behaviors: [{
|
|
169
|
+
type: 'callback',
|
|
170
|
+
value: {
|
|
171
|
+
dsh_action: item.action,
|
|
172
|
+
dsh_action_label: item.label,
|
|
173
|
+
// 动作慢/会断长连接:桥要先把应答发出去再执行(见 bridge 的 afterResponse)。
|
|
174
|
+
...(item.deferred === true ? { dsh_action_deferred: true } : {}),
|
|
175
|
+
},
|
|
176
|
+
}],
|
|
177
|
+
...(item.confirm
|
|
178
|
+
? {
|
|
179
|
+
confirm: {
|
|
180
|
+
title: { tag: 'plain_text', content: item.confirm.title },
|
|
181
|
+
text: { tag: 'plain_text', content: item.confirm.text },
|
|
182
|
+
},
|
|
183
|
+
}
|
|
184
|
+
: {}),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** 只有外观的按钮壳(行为由调用方补,确认/取消这类按钮的 value 与面板动作不同)。 */
|
|
189
|
+
function buttonLike(label, type = 'default') {
|
|
190
|
+
return {
|
|
191
|
+
tag: 'button',
|
|
192
|
+
type,
|
|
193
|
+
width: 'fill',
|
|
194
|
+
text: { tag: 'plain_text', content: label },
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function row(elements) {
|
|
199
|
+
return { tag: 'column_set', flex_mode: 'none', columns: elements.map((el) => ({ tag: 'column', width: 'weighted', weight: 1, elements: [el] })) };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 渲染控制面板卡片。
|
|
204
|
+
*
|
|
205
|
+
* @param state - hub `panel.read()` 的返回值。
|
|
206
|
+
* @param options - { last, at }:`last` 是上一次动作的结果 `{ label, message, ok }`(失败也要留在卡上);
|
|
207
|
+
* `at` 是本次渲染的时间(HH:MM:SS),写进标题栏——聊天里可能躺着不止一张面板卡
|
|
208
|
+
* (旧卡、重启前的卡),**标题上的时间就是"哪张是最新的"最直接的判据**。
|
|
209
|
+
* @returns Card 2.0 对象。
|
|
210
|
+
*/
|
|
211
|
+
export function panelCard(state, { last = null, at = null, pending = null } = {}) {
|
|
212
|
+
const elements = [];
|
|
213
|
+
/**
|
|
214
|
+
* 显示项(设置页里配的,私聊/群聊分开):`state.sections` 为假值 = 全显示。
|
|
215
|
+
*
|
|
216
|
+
* 这是**显示**配置:关掉只是不画,功能照旧(策略、上下文增强、渠道设置都还在生效)。
|
|
217
|
+
*/
|
|
218
|
+
const sections = state?.sections ?? null;
|
|
219
|
+
const shows = (id) => sections === null || sections[id] !== false;
|
|
220
|
+
const bound = state?.bound === true;
|
|
221
|
+
const model = state?.model ?? {};
|
|
222
|
+
const current = model.current ?? null;
|
|
223
|
+
/** 机器人默认模型:没有会话时"当前生效的模型"就是它(下一条消息新建的会话用它)。 */
|
|
224
|
+
const botDefault = model.botDefault ?? null;
|
|
225
|
+
const hostDefault = model.hostDefault ?? null;
|
|
226
|
+
/**
|
|
227
|
+
* "当前生效的模型":有会话看**会话选择**(会话没显式选过就是跟随 Host 默认,
|
|
228
|
+
* 机器人默认模型只影响"新建的会话",跟已有会话无关);没有会话才看机器人默认。
|
|
229
|
+
* **读会话失败时不能退回默认**——那会把"读不到"显示成一个具体的模型。
|
|
230
|
+
*/
|
|
231
|
+
const effective = model.selectionFailed === true
|
|
232
|
+
? null
|
|
233
|
+
: (current ?? (bound ? null : botDefault));
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* 卡片只放"能改的东西 + 当前值":每个设置的当前值由它自己的下拉 ✓ 表示,
|
|
237
|
+
* 所以不再重复一整块"当前会话 / 模型 / 预设 / 工作区"状态行(与下拉完全重复,
|
|
238
|
+
* 真机上把卡片撑到 1000+ px)。只在"下拉说不出话"的地方补一行说明。
|
|
239
|
+
*/
|
|
240
|
+
|
|
241
|
+
// ① 模型与推理:有会话时改会话(立即生效);没有会话时改机器人默认模型(只对新会话生效)
|
|
242
|
+
if (shows('model') && !bound) {
|
|
243
|
+
elements.push({
|
|
244
|
+
tag: 'markdown',
|
|
245
|
+
content: '还没有会话:模型与推理改的是**机器人默认模型**(只对新会话生效、只有属主能改)',
|
|
246
|
+
});
|
|
247
|
+
} else if (shows('model') && model.selectionFailed === true) {
|
|
248
|
+
// 读失败 ≠ 没选过:说成"跟随 Host 默认"会让用户以为自己的选择丢了(日志里有 warn)。
|
|
249
|
+
elements.push({
|
|
250
|
+
tag: 'markdown',
|
|
251
|
+
content: '读不到当前会话的模型选择(Host 暂时不可用),稍后再试。',
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
const modelPicker = shows('model') ? dropdown({
|
|
255
|
+
name: 'model_pick',
|
|
256
|
+
action: 'model_pick',
|
|
257
|
+
// 没显式选模型时把"实际会用哪个"写进占位,省掉一整行"跟随 Host 默认(…)"。
|
|
258
|
+
placeholder: hostDefault
|
|
259
|
+
? `跟随 Host 默认(${hostDefault.provider}/${hostDefault.model})`
|
|
260
|
+
: '选择模型',
|
|
261
|
+
items: (model.options ?? []).map((item) => ({ value: item.value, label: item.value })),
|
|
262
|
+
current: effective ? `${effective.provider}/${effective.model}` : null,
|
|
263
|
+
}) : { element: null, hidden: 0 };
|
|
264
|
+
/**
|
|
265
|
+
* 一行两个设置格;没有控件的格子不放(说明行单独跟在下面)。
|
|
266
|
+
* 并发两个下拉时高度比"四个全宽下拉"还矮,且每个都有名称——窄屏会自己堆叠。
|
|
267
|
+
*/
|
|
268
|
+
const modelCells = [];
|
|
269
|
+
if (modelPicker.element) modelCells.push(field('模型', modelPicker.element));
|
|
270
|
+
if (modelPicker.element) {
|
|
271
|
+
// 推理等级与模型并排:两格都放得下(窄屏会自己堆叠)。
|
|
272
|
+
} else if (shows('model')) {
|
|
273
|
+
// 空目录要说清"为什么空":`session/modelCatalog` 会把每个失败 provider 的原因带出来。
|
|
274
|
+
// 不带出来,用户和排查者就只剩一句"没有可用模型"——唯一的线索被丢在 RPC 边界上。
|
|
275
|
+
const failures = Array.isArray(model.failures) ? model.failures : [];
|
|
276
|
+
/**
|
|
277
|
+
* 「一条也读不到」与「确实没有可用模型」是两回事:整目录 RPC 失败时
|
|
278
|
+
* `options` 也是空的,说成"没有可用模型"就是与事实相反的断言(CONTRACT.md 要求如实呈现)。
|
|
279
|
+
* 这种失败项没有 provider id,用它区分最省事。
|
|
280
|
+
*/
|
|
281
|
+
const catalogUnreadable = failures.length > 0 && failures.every((item) => !item.id);
|
|
282
|
+
elements.push({
|
|
283
|
+
tag: 'markdown',
|
|
284
|
+
content: catalogUnreadable
|
|
285
|
+
? `读不到模型目录:${h(String(failures[0].message).slice(0, 120))}`
|
|
286
|
+
: (failures.length > 0
|
|
287
|
+
? `当前没有可用模型,以下 provider 读取失败:${failures
|
|
288
|
+
.map((item) => `\n· ${h(item.id || item.name)}:${h(String(item.message).slice(0, 120))}`)
|
|
289
|
+
.join('')}`
|
|
290
|
+
: '当前 Host 没有可用模型。'),
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
if (shows('model') && modelPicker.hidden > 0) {
|
|
294
|
+
elements.push({
|
|
295
|
+
tag: 'markdown',
|
|
296
|
+
content: `还有 ${modelPicker.hidden} 个模型未列出:手打 \`/model <provider/model>\``,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const efforts = model.efforts ?? [];
|
|
301
|
+
if (shows('model') && effective && efforts.length > 0) {
|
|
302
|
+
const effortPicker = dropdown({
|
|
303
|
+
name: 'reasoning_pick',
|
|
304
|
+
action: 'reasoning_pick',
|
|
305
|
+
placeholder: '选择推理等级',
|
|
306
|
+
// 名称由格子的「推理等级」标签给出,选项本身不用再带前缀。
|
|
307
|
+
items: [
|
|
308
|
+
{ value: FOLLOW_DEFAULT, label: '(模型默认)' },
|
|
309
|
+
...efforts.map((effort) => ({
|
|
310
|
+
value: effort.id,
|
|
311
|
+
label: `${effort.id}${effort.label && effort.label !== effort.id ? ` · ${effort.label}` : ''}`,
|
|
312
|
+
})),
|
|
313
|
+
],
|
|
314
|
+
current: model.currentEffort ?? FOLLOW_DEFAULT,
|
|
315
|
+
});
|
|
316
|
+
if (effortPicker.element) modelCells.push(field('推理等级', effortPicker.element));
|
|
317
|
+
} else if (shows('model') && effective) {
|
|
318
|
+
/**
|
|
319
|
+
* 空 `efforts` 有两种成因,措辞不能混:① 这个模型确实没有推理等级;
|
|
320
|
+
* ② 读不到模型目录(或当前模型不在目录里)。说成①是与事实相反的断言——
|
|
321
|
+
* 同一张卡上面还列着 provider 读取失败的原因。
|
|
322
|
+
*/
|
|
323
|
+
const catalogFailures = Array.isArray(model.failures) ? model.failures : [];
|
|
324
|
+
const listed = (model.options ?? []).some(
|
|
325
|
+
(item) => item.provider === effective.provider && item.model === effective.model,
|
|
326
|
+
);
|
|
327
|
+
// 只看**与当前模型相关**的失败:别的 provider 拉不到模型不代表这个模型列不出等级,
|
|
328
|
+
// 拿它当理由就会把"这个模型确实没有推理等级"说成"读不到目录"(又是一句与事实相反的话)。
|
|
329
|
+
const providerFailed = catalogFailures.some((item) => item.id === effective.provider);
|
|
330
|
+
elements.push({
|
|
331
|
+
tag: 'markdown',
|
|
332
|
+
content: providerFailed || !listed
|
|
333
|
+
? '读不到模型目录,暂时列不出可选推理等级(可以手打 `/reasoning <等级>`)。'
|
|
334
|
+
: '当前模型不支持调节推理等级。',
|
|
335
|
+
});
|
|
336
|
+
} else if (shows('model') && (model.options ?? []).length > 0) {
|
|
337
|
+
// 还有模型可选时才说"先选一个模型";一个可选项都没有时上面那句已经解释过了,
|
|
338
|
+
// 再补一句只是噪音(真机上就是连着三行都在说"没有模型")。
|
|
339
|
+
if (bound) {
|
|
340
|
+
// 读会话失败时上面已经如实说了,这里不能再断言"你还没选过模型"。
|
|
341
|
+
if (model.selectionFailed !== true) {
|
|
342
|
+
elements.push({ tag: 'markdown', content: '先选一个模型,才能调推理等级。' });
|
|
343
|
+
}
|
|
344
|
+
} else {
|
|
345
|
+
elements.push({
|
|
346
|
+
tag: 'markdown',
|
|
347
|
+
content: '还没有会话,也还没设过机器人默认模型:先在上面选一个模型,才能调推理等级。',
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// 「会话」单独一行全宽:标题 + 相对时间比较长,塞进半栏会被截。
|
|
353
|
+
const sessionPicker = shows('session') ? dropdown({
|
|
354
|
+
name: 'session_pick',
|
|
355
|
+
action: 'session_pick',
|
|
356
|
+
placeholder: '选择要绑定的会话',
|
|
357
|
+
// 会话标题来自 Host,可能很长:截到 34 个字符(全宽行比半栏宽,够用)。
|
|
358
|
+
items: (state?.session?.options ?? []).map((item) => ({ value: item.id, label: shortLabel(item.label, 34) })),
|
|
359
|
+
current: state?.session?.current ?? null,
|
|
360
|
+
}) : { element: null, hidden: 0 };
|
|
361
|
+
if (sections === null || sections.session !== false) {
|
|
362
|
+
if (sessionPicker.element) elements.push(grid([field('会话', sessionPicker.element)]));
|
|
363
|
+
if (state?.session?.failed === true) {
|
|
364
|
+
elements.push({ tag: 'markdown', content: '读不到会话列表,稍后再试(当前绑定的会话仍显示在上面)。' });
|
|
365
|
+
} else if ((state?.session?.withheld ?? 0) > 0) {
|
|
366
|
+
// 会话不能被两个聊天共用(会话级增强提示词只有一个槽位):扣下了就得说清是为什么。
|
|
367
|
+
elements.push({
|
|
368
|
+
tag: 'markdown',
|
|
369
|
+
content: `有 ${state.session.withheld} 个会话正被这台机器人的其它聊天使用,不能切过去`
|
|
370
|
+
+ '(会话不能共用)。在那个聊天里点「新会话」解绑,或新建一个。',
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// 模型与推理并排(放在各自的说明行之前:说明行是全宽的,不该夹在两格中间)。
|
|
376
|
+
if (shows('model') && modelCells.length > 0) elements.push(grid(modelCells));
|
|
377
|
+
|
|
378
|
+
// ② Agent 预设与工作区(机器人级:只对新会话生效)
|
|
379
|
+
if (shows('preset')) {
|
|
380
|
+
elements.push({ tag: 'hr' });
|
|
381
|
+
// 名称已经写在两个格子上(Agent 预设 / 工作区),这里只说生效范围。
|
|
382
|
+
elements.push({
|
|
383
|
+
tag: 'markdown',
|
|
384
|
+
content: '只对新会话生效(改完点「🆕 新会话」)',
|
|
385
|
+
});
|
|
386
|
+
const presetPicker = shows('preset') ? dropdown({
|
|
387
|
+
name: 'preset_pick',
|
|
388
|
+
action: 'preset_pick',
|
|
389
|
+
placeholder: '选择 Agent 预设',
|
|
390
|
+
items: [
|
|
391
|
+
{ value: FOLLOW_DEFAULT, label: '跟随 Host 默认' },
|
|
392
|
+
// 用 hub 算好的展示名(`id · name`),并标出哪个是 Host 默认——只有 id 的话
|
|
393
|
+
// 一排相近的 id(yh-olap / yh-olap-2)认不出,也看不出当前跟着谁。
|
|
394
|
+
...(state?.preset?.options ?? []).map((item) => ({
|
|
395
|
+
value: item.id,
|
|
396
|
+
label: `${item.label ?? item.id}${item.isDefault ? '(Host 默认)' : ''}`,
|
|
397
|
+
})),
|
|
398
|
+
],
|
|
399
|
+
current: state?.preset?.current ?? FOLLOW_DEFAULT,
|
|
400
|
+
}) : { element: null, hidden: 0 };
|
|
401
|
+
const presetCells = [];
|
|
402
|
+
if (presetPicker.element) presetCells.push(field('Agent 预设', presetPicker.element));
|
|
403
|
+
if (state?.preset?.failed === true) {
|
|
404
|
+
elements.push({ tag: 'markdown', content: '读不到 Agent Preset 列表,暂时只能跟随 Host 默认。' });
|
|
405
|
+
}
|
|
406
|
+
if (presetPicker.hidden > 0) {
|
|
407
|
+
elements.push({
|
|
408
|
+
tag: 'markdown',
|
|
409
|
+
content: `还有 ${presetPicker.hidden} 个预设未列出:手打 \`/preset <id>\``,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
const workspacePicker = shows('preset') ? dropdown({
|
|
413
|
+
name: 'workspace_pick',
|
|
414
|
+
action: 'workspace_pick',
|
|
415
|
+
placeholder: '选择工作区',
|
|
416
|
+
// 长路径会被飞书从尾巴截掉(正好截掉目录名):自己折中截断,保留开头与目录名。
|
|
417
|
+
items: (state?.workspace?.options ?? []).map((path) => ({ value: path, label: shortPath(path) })),
|
|
418
|
+
current: state?.workspace?.current ?? null,
|
|
419
|
+
}) : { element: null, hidden: 0 };
|
|
420
|
+
if (workspacePicker.element) {
|
|
421
|
+
presetCells.push(field('工作区', workspacePicker.element));
|
|
422
|
+
if (workspacePicker.hidden > 0) {
|
|
423
|
+
// 工作区没有命令兜底:只能在设置页改,所以这里要指路。
|
|
424
|
+
elements.push({
|
|
425
|
+
tag: 'markdown',
|
|
426
|
+
content: `还有 ${workspacePicker.hidden} 个工作区未列出(其余在设置页里选)`,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
} else if (state?.workspace?.current) {
|
|
430
|
+
// 候选被有意扣下(群会话/非属主):不能说成"还没有工作区"——当前值仍然要看得见。
|
|
431
|
+
elements.push({
|
|
432
|
+
tag: 'markdown',
|
|
433
|
+
content: `工作区 \`${h(shortPath(state.workspace.current, 40))}\`:候选只在私聊里给属主,要改请到设置页。`,
|
|
434
|
+
});
|
|
435
|
+
} else {
|
|
436
|
+
elements.push({
|
|
437
|
+
tag: 'markdown',
|
|
438
|
+
content: '还没有工作区:先在设置页填一个绝对路径,否则新会话建不出来。',
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (presetCells.length > 0) elements.push(grid(presetCells));
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* 渠道自带字段(飞书:任务过程展示):一行一个。
|
|
447
|
+
*
|
|
448
|
+
* 标签与选项都由渠道给(`panel.fields`),hub 只负责画——这样新增这类设置不用改 hub。
|
|
449
|
+
*/
|
|
450
|
+
/**
|
|
451
|
+
* 本会话的上下文增强:**只决定"本会话用哪一份"**(跟随全局 / 本会话专属 / 套用另一条),
|
|
452
|
+
* 内容(来源字段与提示词)仍在设置页编辑——卡片上传不了那么长的文本。
|
|
453
|
+
* 非属主时 hub 不给这一项(`state.context` 为 null)。
|
|
454
|
+
*/
|
|
455
|
+
if (shows('context') && state?.context) {
|
|
456
|
+
const context = state.context;
|
|
457
|
+
const picker = dropdown({
|
|
458
|
+
name: 'context_pick',
|
|
459
|
+
action: 'context_pick',
|
|
460
|
+
placeholder: '选择本会话的上下文增强',
|
|
461
|
+
items: (context.options ?? []).map((option) => ({
|
|
462
|
+
value: option.value === '' ? CONTEXT_GLOBAL : option.value,
|
|
463
|
+
label: option.label,
|
|
464
|
+
})),
|
|
465
|
+
current: (context.current ?? '') === '' ? CONTEXT_GLOBAL : context.current,
|
|
466
|
+
});
|
|
467
|
+
if (picker.element) {
|
|
468
|
+
elements.push(grid([field(`上下文增强(${context.label})`, picker.element)]));
|
|
469
|
+
}
|
|
470
|
+
elements.push({
|
|
471
|
+
tag: 'markdown',
|
|
472
|
+
content: context.own
|
|
473
|
+
? `已有专属设置(来源字段 ${context.own.fields} 个、提示词 ${context.own.guidanceLength} 字)`
|
|
474
|
+
+ `,内容到设置页编辑;本项只决定本会话用哪一份(\`${h(context.identity)}\`)。`
|
|
475
|
+
: '内容(来源字段与提示词)在设置页编辑;本项只决定本会话用哪一份。',
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* 本会话类型的访问策略(只给属主,hub 侧已判)。
|
|
481
|
+
*
|
|
482
|
+
* `current` 为 null = 从没设过(口径是"仅属主可用"),如实说,不冒充某种模式。
|
|
483
|
+
*/
|
|
484
|
+
if (shows('policy') && state?.policy) {
|
|
485
|
+
const policy = state.policy;
|
|
486
|
+
const picker = dropdown({
|
|
487
|
+
name: 'policy_pick',
|
|
488
|
+
action: 'policy_pick',
|
|
489
|
+
placeholder: '选择谁能跟机器人说话',
|
|
490
|
+
items: (policy.options ?? []).map((option) => ({ value: option.value, label: option.label })),
|
|
491
|
+
current: policy.current ?? null,
|
|
492
|
+
});
|
|
493
|
+
if (picker.element) {
|
|
494
|
+
elements.push(grid([field(`访问策略(本${policy.kindLabel === 'group' ? '群' : '私聊'})`, picker.element)]));
|
|
495
|
+
}
|
|
496
|
+
elements.push({
|
|
497
|
+
tag: 'markdown',
|
|
498
|
+
content: policy.current
|
|
499
|
+
? `现在:${policy.label}`
|
|
500
|
+
: `现在:${policy.label}(还没设过;改成「任何人可用」会先让你确认一次)`,
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* 待确认的一次改动(放宽访问策略这类):把确认做在**同一张卡**上,
|
|
506
|
+
* 不做成 toast——toast 会消失,用户过两秒就不知道自己在确认什么了。
|
|
507
|
+
*/
|
|
508
|
+
if (pending) {
|
|
509
|
+
elements.push({ tag: 'hr' });
|
|
510
|
+
elements.push({ tag: 'markdown', content: `⚠️ **待确认**\n${h(pending.prompt)}` });
|
|
511
|
+
if (pending.expired) {
|
|
512
|
+
elements.push({
|
|
513
|
+
tag: 'markdown',
|
|
514
|
+
content: '这次确认已经失效(机器人重启过或卡片刷新了),请重新在上面选一次。',
|
|
515
|
+
});
|
|
516
|
+
} else {
|
|
517
|
+
elements.push(row([
|
|
518
|
+
{ ...buttonLike('✅ 确认', 'primary'), behaviors: [{ type: 'callback', value: { dsh_confirm: true } }] },
|
|
519
|
+
{ ...buttonLike('↩️ 取消', 'default'), behaviors: [{ type: 'callback', value: { dsh_cancel: true } }] },
|
|
520
|
+
]));
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const channelCells = [];
|
|
525
|
+
for (const item of (shows('fields') ? (state?.fields ?? []) : [])) {
|
|
526
|
+
const picker = dropdown({
|
|
527
|
+
name: `panel_field_${item.field}`,
|
|
528
|
+
action: `panel_field_${item.field}`,
|
|
529
|
+
placeholder: `选择${item.label ?? item.field}`,
|
|
530
|
+
items: (item.options ?? []).map((option) => ({ value: option.value, label: option.label })),
|
|
531
|
+
current: item.value ?? null,
|
|
532
|
+
});
|
|
533
|
+
if (picker.element) channelCells.push(field(item.label ?? item.field, picker.element));
|
|
534
|
+
}
|
|
535
|
+
// 一行两格(与上面几组同宽),奇数个时最后一个单独占一行——窄屏由 stretch 自动堆叠。
|
|
536
|
+
for (let index = 0; index < channelCells.length; index += 2) {
|
|
537
|
+
elements.push(grid(channelCells.slice(index, index + 2)));
|
|
538
|
+
}
|
|
539
|
+
if (shows('fields') && state?.fieldsFailed === true) {
|
|
540
|
+
elements.push({ tag: 'markdown', content: '读不到渠道设置,稍后再试。' });
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* ③ 上一次动作的结果(成功与失败都留在卡上:toast 会消失,卡不会)。
|
|
545
|
+
*
|
|
546
|
+
* 带一个 HH:MM:SS 时间戳:卡上"到底停在哪一次更新"是可核对的
|
|
547
|
+
* (排查卡片被回滚这类问题时,这就是卡上的现场)。
|
|
548
|
+
*/
|
|
549
|
+
if (last?.message) {
|
|
550
|
+
elements.push({ tag: 'hr' });
|
|
551
|
+
elements.push({
|
|
552
|
+
tag: 'markdown',
|
|
553
|
+
content: `${last.ok === false ? '❌' : '✅'} **${h(last.label)}**`
|
|
554
|
+
+ `${last.at ? `(${h(last.at)})` : ''}\n${h(last.message)}`,
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* ④ 操作按钮(放在最后,手指不用往上找)。
|
|
560
|
+
*
|
|
561
|
+
* **一行最多 3 个**:手机上的卡片比桌面窄得多,一行 4 个时按钮里的字会被截成
|
|
562
|
+
* `📊 ...`(真机截图就是这么显示的,等于一排认不出的图标)。3 个 + 短标签才读得全;
|
|
563
|
+
* 命令与渠道动作排在同一条流里,一起按 3 个一行切——不截断任何按钮。
|
|
564
|
+
*/
|
|
565
|
+
const commandButtons = shows('commands') ? [
|
|
566
|
+
button('🆕 新会话', 'new'),
|
|
567
|
+
button('📊 状态', 'status'),
|
|
568
|
+
button('📖 命令', 'commands'),
|
|
569
|
+
button('🩺 诊断', 'diag'),
|
|
570
|
+
button('📜 历史', 'history'),
|
|
571
|
+
button('🗜 压缩', 'compact'),
|
|
572
|
+
button('⏹ 停止', 'stop', 'danger'),
|
|
573
|
+
] : [];
|
|
574
|
+
// 渠道动作(飞书:重连)排在命令按钮之后,同一条分页规则。
|
|
575
|
+
const actionButtons = shows('actions') ? (state?.actions ?? []).map(channelActionButton) : [];
|
|
576
|
+
const panelButtons = [...commandButtons, ...actionButtons];
|
|
577
|
+
if (panelButtons.length > 0) {
|
|
578
|
+
elements.push({ tag: 'hr' });
|
|
579
|
+
for (let index = 0; index < panelButtons.length; index += PANEL_ROW_SIZE) {
|
|
580
|
+
elements.push(row(panelButtons.slice(index, index + PANEL_ROW_SIZE)));
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
if (shows('actions') && state?.actionsFailed === true) {
|
|
584
|
+
elements.push({ tag: 'markdown', content: '读不到渠道动作按钮,稍后再试。' });
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// 全关掉了也得画点东西:空 body 是平台会拒的形态,而且用户需要知道去哪里打开。
|
|
588
|
+
if (elements.length === 0) {
|
|
589
|
+
elements.push({
|
|
590
|
+
tag: 'markdown',
|
|
591
|
+
content: '控制面板的显示项都被关掉了:到设置页的「控制面板显示项」里打开需要的项。',
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
return {
|
|
596
|
+
schema: '2.0',
|
|
597
|
+
config: { update_multi: true, width_mode: 'default' },
|
|
598
|
+
header: {
|
|
599
|
+
template: 'blue',
|
|
600
|
+
title: { tag: 'plain_text', content: `机器人控制面板${at ? ` · ${at}` : ''}` },
|
|
601
|
+
},
|
|
602
|
+
body: { direction: 'vertical', elements },
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* 下拉回调 → 面板动作。
|
|
608
|
+
*
|
|
609
|
+
* 回调里的 action 名是 `model_pick` / `reasoning_pick` / `preset_pick` / `workspace_pick`,
|
|
610
|
+
* 选中值在 `event.action.options[0]`(网关已把三种形态归一化)。
|
|
611
|
+
*
|
|
612
|
+
* @param action - `event.action.value.action`。
|
|
613
|
+
* @param options - `event.action.options`。
|
|
614
|
+
* @returns `{ field, value, label }`;不是面板下拉时返回 null;
|
|
615
|
+
* **取值没认出来**时返回 `{ field, label, invalid: true }`——绝不能把它当成"恢复默认",
|
|
616
|
+
* 否则用户点一下推理等级就把等级静默清掉、点一下预设就把预设静默清掉,卡上还画 ✅。
|
|
617
|
+
*/
|
|
618
|
+
export function panelPick(action, options) {
|
|
619
|
+
const map = {
|
|
620
|
+
session_pick: { field: 'session', label: '切换会话' },
|
|
621
|
+
model_pick: { field: 'model', label: '切换模型' },
|
|
622
|
+
reasoning_pick: { field: 'reasoning', label: '设置推理等级' },
|
|
623
|
+
preset_pick: { field: 'preset', label: '设置 Agent 预设' },
|
|
624
|
+
workspace_pick: { field: 'workspace', label: '切换工作区' },
|
|
625
|
+
context_pick: { field: 'context', label: '设置本会话的上下文增强' },
|
|
626
|
+
policy_pick: { field: 'policy', label: '设置访问策略' },
|
|
627
|
+
};
|
|
628
|
+
/**
|
|
629
|
+
* 渠道自带字段(如飞书的「任务过程展示」):动作名是 `panel_field_<字段名>`。
|
|
630
|
+
* 名称由渠道自己给出(`panel.fields` 里的 label),hub 只负责翻译回 field。
|
|
631
|
+
*/
|
|
632
|
+
const dynamic = typeof action === 'string' && action.startsWith('panel_field_')
|
|
633
|
+
? { field: action.slice('panel_field_'.length), label: '渠道设置' }
|
|
634
|
+
: null;
|
|
635
|
+
const target = map[action] ?? dynamic;
|
|
636
|
+
if (!target) return null;
|
|
637
|
+
// 归一化后是数组;也容忍调用方直接给单个字符串。
|
|
638
|
+
const values = (Array.isArray(options) ? options : [options])
|
|
639
|
+
.filter((item) => typeof item === 'string' && item !== '');
|
|
640
|
+
// 空取值 = 没认出来(不是"恢复默认"):交回调用方报错,别静默清状态。
|
|
641
|
+
if (values.length === 0) return { field: target.field, label: target.label, invalid: true };
|
|
642
|
+
const picked = values[0];
|
|
643
|
+
// 哨兵 → 空串:hub 侧的空值语义是"恢复默认/清除"(这个才是用户明确选的)。
|
|
644
|
+
const sentinel = picked === FOLLOW_DEFAULT || (target.field === 'context' && picked === CONTEXT_GLOBAL);
|
|
645
|
+
const value = sentinel ? '' : String(picked);
|
|
646
|
+
return { field: target.field, value, label: target.label };
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* 渠道动作按钮 → `{ action, label }`。
|
|
651
|
+
*
|
|
652
|
+
* 回调里带的是 `dsh_action`(渠道自己的动作名,hub 不解释它),
|
|
653
|
+
* 认不出来时返回 null(按钮是别人发的/旧版本卡)。
|
|
654
|
+
*/
|
|
655
|
+
export function panelAction(value) {
|
|
656
|
+
if (typeof value?.dsh_action !== 'string' || !value.dsh_action) return null;
|
|
657
|
+
const label = typeof value.dsh_action_label === 'string' && value.dsh_action_label
|
|
658
|
+
? value.dsh_action_label
|
|
659
|
+
: value.dsh_action;
|
|
660
|
+
return { action: value.dsh_action, label };
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** 面板按钮 → 动作:新会话 / 状态 / 命令清单 / 停止 / 回到面板。 */
|
|
664
|
+
export function panelButton(action) {
|
|
665
|
+
const map = {
|
|
666
|
+
new: { field: 'session', value: 'new', label: '新会话' },
|
|
667
|
+
status: { command: '/status', label: '状态' },
|
|
668
|
+
// 命令清单是另一张卡(命令按钮),卡上有「⬅ 返回控制面板」。
|
|
669
|
+
commands: { menu: true, label: '命令清单' },
|
|
670
|
+
stop: { command: '/stop', label: '停止' },
|
|
671
|
+
// 历史/压缩:输出是文本、压缩还可能跑很久(超过回调应答的 3 秒)——
|
|
672
|
+
// 排在应答之后执行,结果用一条文字消息回,不往面板卡上写(历史可能几十行)。
|
|
673
|
+
history: { command: '/history', label: '历史', asText: true },
|
|
674
|
+
// 诊断同样是"文本输出":走 asText 那条路(不往卡片上写)。
|
|
675
|
+
diag: { command: '/diag', label: '诊断', asText: true },
|
|
676
|
+
compact: { command: '/compact', label: '压缩', asText: true },
|
|
677
|
+
// 命令清单卡上的返回按钮。
|
|
678
|
+
panel: { panel: true, label: '控制面板' },
|
|
679
|
+
};
|
|
680
|
+
return map[action] ?? null;
|
|
681
|
+
}
|