mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 07:53:20 +08:00
fix(feishu): harden interactive card flows
This commit is contained in:
parent
5b33249599
commit
9d1dc71794
6 changed files with 1198 additions and 609 deletions
357
lib/index.js
357
lib/index.js
File diff suppressed because one or more lines are too long
|
|
@ -54,7 +54,6 @@ import {
|
|||
modelCard,
|
||||
presetCard,
|
||||
sessionListCard,
|
||||
settingsCard,
|
||||
statusCard,
|
||||
steerCard,
|
||||
watchListCard,
|
||||
|
|
@ -100,7 +99,7 @@ const REPAIR_URL_HOSTS = new Set([
|
|||
|
||||
const ARCHIVED_COMMAND = /^\/archived(?:\s+(on|off))?$/i;
|
||||
/** Matches fast card commands that should not be queued behind a running task. */
|
||||
const CARD_COMMAND = /^\/(?:m(?:enu)?|help|new|status|compact|sessionlist(?:\s|$)|workspacelist|watchlist|archived(?:\s+(on|off))?)$/i;
|
||||
const CARD_COMMAND = /^\/(?:m(?:enu)?|new|help|status|compact|sessionlist(?:\s|$)|workspacelist|watchlist|archived(?:\s+(on|off))?)$/i;
|
||||
|
||||
/** Canonical workspace/session help advertised by every bridge family. */
|
||||
const WORKSPACE_HELP_LINES = [
|
||||
|
|
@ -299,16 +298,12 @@ export class FeishuHarnessBridge {
|
|||
#menus = new Map();
|
||||
/** Interactive-card message id → route context for button callbacks. */
|
||||
#cardKeys = new Map();
|
||||
/** chatId → last card message id (for in-place update). */
|
||||
#lastCardMessages = new Map();
|
||||
/** The global event-mux watcher (one per bridge). */
|
||||
#eventWatcher = null;
|
||||
/** Serializes live completions and reconnect compensation. */
|
||||
#eventTail = Promise.resolve();
|
||||
/** Earliest completion that still needs delivery for each watch. */
|
||||
#failedWatchSeqs = new Map();
|
||||
/** 临时保存最近一次多选下拉(watch_add/watch_remove)的选中项,供批处理消费。 */
|
||||
#lastMultiSelection = [];
|
||||
|
||||
constructor({
|
||||
client,
|
||||
|
|
@ -637,6 +632,10 @@ export class FeishuHarnessBridge {
|
|||
]);
|
||||
}
|
||||
|
||||
#hasPendingInteraction(key) {
|
||||
return this.#pendingInteractions.has(key) || this.#approvals.hasPending(key);
|
||||
}
|
||||
|
||||
async #processFastCommand(event, messageId, key, message, runner) {
|
||||
this.#signal?.throwIfAborted();
|
||||
if (this.#state.hasSeen(messageId)) return;
|
||||
|
|
@ -652,8 +651,7 @@ export class FeishuHarnessBridge {
|
|||
signal: this.#signal,
|
||||
hasImages: hasInboundImages(message),
|
||||
hasFiles: hasInboundFiles(message),
|
||||
pendingInteraction: this.#pendingInteractions.has(key)
|
||||
|| this.#approvals.hasPending(key),
|
||||
pendingInteraction: this.#hasPendingInteraction(key),
|
||||
control: { owner: this, key },
|
||||
},
|
||||
);
|
||||
|
|
@ -698,10 +696,17 @@ export class FeishuHarnessBridge {
|
|||
return;
|
||||
}
|
||||
if (MENU_COMMAND.test(commandText)) {
|
||||
await this.#sendMenuCard(key, event.message.chat_id, { newCard: true });
|
||||
await this.#sendMenuCard(key, event.message.chat_id);
|
||||
return;
|
||||
}
|
||||
if (commandText === '/new') {
|
||||
if (this.#queues.has(key) || this.#hasPendingInteraction(key)) {
|
||||
await this.#send(
|
||||
event.message.chat_id,
|
||||
t('当前任务仍在运行,请先停止任务或等待任务完成后再开启新会话。'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await this.#state.clearSession(key);
|
||||
await this.#send(event.message.chat_id, t('已开启全新 Harness 会话。'));
|
||||
await this.#sendMenuCard(key, event.message.chat_id);
|
||||
|
|
@ -1155,9 +1160,6 @@ export class FeishuHarnessBridge {
|
|||
: Array.isArray(event?.action?.options)
|
||||
? event.action.options.map((opt) => (opt?.value ?? opt)).filter((v) => typeof v === 'string')
|
||||
: [];
|
||||
if (action === 'watch_add' || action === 'watch_remove') {
|
||||
this.#lastMultiSelection = multiValues;
|
||||
}
|
||||
const resolvedAction = action === 'workspace_pick' && typeof option === 'string'
|
||||
? `workspace:${option}`
|
||||
: action === 'session_pick' && typeof option === 'string'
|
||||
|
|
@ -1172,8 +1174,8 @@ export class FeishuHarnessBridge {
|
|||
? `steer:${option}`
|
||||
: action;
|
||||
const messageId = nonEmptyString(event?.context?.open_message_id);
|
||||
const entry = messageId ? this.#cardKeys.get(messageId) : null;
|
||||
if (!entry) {
|
||||
const route = messageId ? this.#cardKeys.get(messageId) : null;
|
||||
if (!route) {
|
||||
// The card predates this process (the in-memory mapping resets on
|
||||
// restart) or never came from us: nudge instead of staying silent.
|
||||
const chatId = nonEmptyString(event?.context?.open_chat_id);
|
||||
|
|
@ -1182,12 +1184,16 @@ export class FeishuHarnessBridge {
|
|||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
const entry = { ...route, messageId, selections: multiValues };
|
||||
// 补充指令卡片:快捷下拉(source=quick, option=指令) / 表单提交(source=form)
|
||||
if (action === 'steer') {
|
||||
const source = event?.action?.value?.source;
|
||||
if (source === 'quick' && typeof option === 'string') {
|
||||
if (option === STEER_CUSTOM_SENTINEL) {
|
||||
return this.#sendCard(entry.chatId, customSteerCard(), entry).catch((error) => {
|
||||
return this.#sendCard(entry.chatId, customSteerCard(), {
|
||||
key: entry.key,
|
||||
updateMessageId: entry.messageId,
|
||||
}).catch((error) => {
|
||||
this.#logger.warn?.('[dsh-feishu] steer custom card failed:', error.message);
|
||||
});
|
||||
}
|
||||
|
|
@ -1202,7 +1208,7 @@ export class FeishuHarnessBridge {
|
|||
this.#logger.warn?.('[dsh-feishu] steer (form) failed:', error.message);
|
||||
});
|
||||
}
|
||||
this.#send(entry.chatId, '请输入补充指令后再提交。').catch(() => undefined);
|
||||
this.#send(entry.chatId, t('请输入补充指令后再提交。')).catch(() => undefined);
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
|
@ -1213,36 +1219,47 @@ export class FeishuHarnessBridge {
|
|||
});
|
||||
}
|
||||
|
||||
async #handleCardAction(action, { chatId, key, sessionWorkspace = null }) {
|
||||
async #handleCardAction(action, {
|
||||
chatId,
|
||||
key,
|
||||
messageId = null,
|
||||
sessionWorkspace = null,
|
||||
selections = [],
|
||||
}) {
|
||||
if (action === 'sessions' || /^sessions:\d+$/.test(action)) {
|
||||
const page = action === 'sessions' ? 0 : Number(action.slice('sessions:'.length));
|
||||
await this.#showSessions({ chatId, key }, sessionWorkspace, page);
|
||||
await this.#showSessions(
|
||||
{ chatId, key },
|
||||
sessionWorkspace,
|
||||
page,
|
||||
{ updateMessageId: messageId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (action === 'workspaces') {
|
||||
await this.#showWorkspaces({ chatId, key });
|
||||
await this.#showWorkspaces({ chatId, key }, { updateMessageId: messageId });
|
||||
return;
|
||||
}
|
||||
if (action === 'watchlist') {
|
||||
await this.#showWatchList(key, chatId);
|
||||
await this.#showWatchList(key, chatId, { updateMessageId: messageId });
|
||||
return;
|
||||
}
|
||||
// 多选关注下拉:action=watch_add / watch_remove,选中项在 selections 数组
|
||||
if (action === 'watch_add' || action === 'watch_remove') {
|
||||
const selections = this.#lastMultiSelection ?? [];
|
||||
this.#lastMultiSelection = null;
|
||||
if (action === 'watch_add') {
|
||||
let added = 0;
|
||||
let skipped = 0;
|
||||
for (const sessionId of selections) {
|
||||
try {
|
||||
await this.#runWatch(key, chatId, sessionId);
|
||||
added += 1;
|
||||
} catch {
|
||||
skipped += 1;
|
||||
}
|
||||
} catch { /* skip individual failure */ }
|
||||
}
|
||||
await this.#send(chatId, added > 0 ? `已批量关注 ${added} 个会话。` : '已关注(或已达关注上限)。');
|
||||
await this.#send(
|
||||
chatId,
|
||||
added > 0
|
||||
? t('已批量关注 {count} 个会话。', { count: added })
|
||||
: t('已关注(或已达关注上限)。'),
|
||||
);
|
||||
} else {
|
||||
let removed = 0;
|
||||
for (const sessionId of selections) {
|
||||
|
|
@ -1251,35 +1268,47 @@ export class FeishuHarnessBridge {
|
|||
removed += 1;
|
||||
} catch { /* skip individual failure */ }
|
||||
}
|
||||
await this.#send(chatId, removed > 0 ? `已取消关注 ${removed} 个会话。` : '未取消任何关注。');
|
||||
await this.#send(
|
||||
chatId,
|
||||
removed > 0
|
||||
? t('已取消关注 {count} 个会话。', { count: removed })
|
||||
: t('未取消任何关注。'),
|
||||
);
|
||||
}
|
||||
await this.#showWatchList(key, chatId);
|
||||
await this.#showWatchList(key, chatId, { updateMessageId: messageId });
|
||||
return;
|
||||
}
|
||||
if (action === 'new') {
|
||||
if (this.#queues.has(key) || this.#hasPendingInteraction(key)) {
|
||||
await this.#send(chatId, t('当前任务仍在运行,请先停止任务或等待任务完成后再开启新会话。'));
|
||||
return;
|
||||
}
|
||||
await this.#state.clearSession(key);
|
||||
await this.#send(chatId, t('已开启全新 Harness 会话。'));
|
||||
await this.#sendMenuCard(key, chatId);
|
||||
await this.#sendMenuCard(key, chatId, { updateMessageId: messageId });
|
||||
return;
|
||||
}
|
||||
if (action === 'use:current') {
|
||||
const sessionId = this.#state.sessionFor(key);
|
||||
if (typeof sessionId !== 'string' || !sessionId) {
|
||||
await this.#send(chatId, '当前没有绑定的会话,请先从会话列表选择。');
|
||||
await this.#send(chatId, t('当前没有绑定的会话,请先从会话列表选择。'));
|
||||
return;
|
||||
}
|
||||
await this.#send(chatId, '已就绪,直接发消息即可继续当前会话。');
|
||||
await this.#send(chatId, t('已就绪,直接发消息即可继续当前会话。'));
|
||||
return;
|
||||
}
|
||||
if (action === 'archive_toggle' || action === 'archive:on' || action === 'archive:off') {
|
||||
const next = action === 'archive:on' ? true : action === 'archive:off' ? false : !(this.#state?.includesArchivedSessions?.() ?? false);
|
||||
await this.#state?.setIncludeArchivedSessions?.(next);
|
||||
await this.#send(chatId, next ? '已开启:会话列表包含归档会话。' : '已关闭:会话列表隐藏归档会话。');
|
||||
await this.#sendMenuCard(key, chatId);
|
||||
await this.#send(
|
||||
chatId,
|
||||
next ? t('已开启:会话列表包含归档会话。') : t('已关闭:会话列表隐藏归档会话。'),
|
||||
);
|
||||
await this.#sendMenuCard(key, chatId, { updateMessageId: messageId });
|
||||
return;
|
||||
}
|
||||
if (action === 'repair') {
|
||||
await this.#send(chatId, '修复需在私聊中验证接入者身份,请直接发送 /repair 开始。');
|
||||
await this.#send(chatId, t('修复需在私聊中验证接入者身份,请直接发送 /repair 开始。'));
|
||||
return;
|
||||
}
|
||||
if (action === 'compact') {
|
||||
|
|
@ -1291,68 +1320,64 @@ export class FeishuHarnessBridge {
|
|||
return;
|
||||
}
|
||||
if (action === 'steer') {
|
||||
await this.#showSteerCard(key, chatId);
|
||||
await this.#showSteerCard(key, chatId, { updateMessageId: messageId });
|
||||
return;
|
||||
}
|
||||
// 主菜单「补充指令」下拉:option = steer:<指令> / steer:custom
|
||||
if (action.startsWith('steer:')) {
|
||||
const raw = action.slice('steer:'.length);
|
||||
if (raw === 'custom') {
|
||||
await this.#sendCard(chatId, customSteerCard(), { key });
|
||||
await this.#sendCard(chatId, customSteerCard(), { key, updateMessageId: messageId });
|
||||
return;
|
||||
}
|
||||
await this.#sendSteer({ key, chatId }, raw);
|
||||
return;
|
||||
}
|
||||
if (action === 'presets') {
|
||||
await this.#showPresetCard(key, chatId);
|
||||
return;
|
||||
}
|
||||
if (action === 'settings') {
|
||||
await this.#showSettingsCard(key, chatId);
|
||||
await this.#showPresetCard(key, chatId, { updateMessageId: messageId });
|
||||
return;
|
||||
}
|
||||
if (action === 'models') {
|
||||
await this.#showModelCard(key, chatId);
|
||||
await this.#showModelCard(key, chatId, { updateMessageId: messageId });
|
||||
return;
|
||||
}
|
||||
if (action === 'status') {
|
||||
await this.#showStatusCard(key, chatId);
|
||||
await this.#showStatusCard(key, chatId, { updateMessageId: messageId });
|
||||
return;
|
||||
}
|
||||
if (action === 'help') {
|
||||
await this.#showHelpCard(key, chatId);
|
||||
await this.#showHelpCard(key, chatId, { updateMessageId: messageId });
|
||||
return;
|
||||
}
|
||||
if (action === 'back_to_menu') {
|
||||
await this.#sendMenuCard(key, chatId);
|
||||
await this.#sendMenuCard(key, chatId, { updateMessageId: messageId });
|
||||
return;
|
||||
}
|
||||
if (action === 'preset_default') {
|
||||
await this.#handlePresetDefault(key, chatId);
|
||||
await this.#handlePresetDefault(key, chatId, { updateMessageId: messageId });
|
||||
return;
|
||||
}
|
||||
if (action.startsWith('preset:select:')) {
|
||||
const presetId = action.slice('preset:select:'.length);
|
||||
// 哨兵值 = 用户在预设下拉里选了「跟随默认」
|
||||
if (presetId === PRESET_FOLLOW_DEFAULT_SENTINEL) {
|
||||
await this.#handlePresetDefault(key, chatId);
|
||||
await this.#handlePresetDefault(key, chatId, { updateMessageId: messageId });
|
||||
return;
|
||||
}
|
||||
await this.#handlePresetSelect(key, chatId, presetId);
|
||||
await this.#handlePresetSelect(key, chatId, presetId, { updateMessageId: messageId });
|
||||
return;
|
||||
}
|
||||
if (action.startsWith('model:select:')) {
|
||||
const modelId = action.slice('model:select:'.length);
|
||||
await this.#handleModelSelect(key, chatId, modelId);
|
||||
await this.#handleModelSelect(key, chatId, modelId, { updateMessageId: messageId });
|
||||
return;
|
||||
}
|
||||
if (action.startsWith('use:')) {
|
||||
await this.#bindSession(key, chatId, action.slice('use:'.length));
|
||||
await this.#bindSession(key, chatId, action.slice('use:'.length), { updateMessageId: messageId });
|
||||
return;
|
||||
}
|
||||
if (action.startsWith('workspace:')) {
|
||||
await this.#switchWorkspace(key, chatId, action.slice('workspace:'.length));
|
||||
await this.#switchWorkspace(key, chatId, action.slice('workspace:'.length), { updateMessageId: messageId });
|
||||
return;
|
||||
}
|
||||
if (action.startsWith('unwatch:')) {
|
||||
|
|
@ -1437,7 +1462,12 @@ export class FeishuHarnessBridge {
|
|||
return sessions;
|
||||
}
|
||||
|
||||
async #showSessions({ chatId, key }, selector, page = 0) {
|
||||
async #showSessions(
|
||||
{ chatId, key },
|
||||
selector,
|
||||
page = 0,
|
||||
{ updateMessageId = null } = {},
|
||||
) {
|
||||
try {
|
||||
const resolved = await resolveSessionListWorkspace(selector ?? '', this.#harness);
|
||||
if (resolved.error) {
|
||||
|
|
@ -1466,6 +1496,7 @@ export class FeishuHarnessBridge {
|
|||
sessionListCard(workspace, sessions, safePage, sessions.length, watchedSet),
|
||||
{
|
||||
key,
|
||||
updateMessageId,
|
||||
// Keep the canonical selector result for later page callbacks. The
|
||||
// list response's workspace is display data and is not authoritative.
|
||||
sessionWorkspace: resolved.workspace,
|
||||
|
|
@ -1477,55 +1508,73 @@ export class FeishuHarnessBridge {
|
|||
}
|
||||
}
|
||||
|
||||
async #showWorkspaces({ chatId, key }) {
|
||||
async #showWorkspaces({ chatId, key }, { updateMessageId = null } = {}) {
|
||||
try {
|
||||
const { current, paths } = await workspacePathSnapshot(this.#harness);
|
||||
this.#rememberMenu(key, { kind: 'workspaces', paths });
|
||||
await this.#sendCard(chatId, workspaceListCard(paths, current), { key });
|
||||
await this.#sendCard(
|
||||
chatId,
|
||||
workspaceListCard(paths, current),
|
||||
{ key, updateMessageId },
|
||||
);
|
||||
} catch (error) {
|
||||
this.#logger.warn?.('[dsh-feishu] workspace list failed:', error.message);
|
||||
await this.#send(chatId, t('暂时无法获取工作区列表,请稍后重试。'));
|
||||
}
|
||||
}
|
||||
|
||||
async #bindSession(key, chatId, sessionId) {
|
||||
async #bindSession(key, chatId, sessionId, { updateMessageId = null } = {}) {
|
||||
try {
|
||||
const bound = await this.#harness.bindWorkspaceSession(key, sessionId);
|
||||
const title = String(bound?.title ?? '').replace(/\s+/gu, ' ').trim() || t('暂无标题');
|
||||
await this.#send(chatId, t('已绑定会话「{title}」\nID:{id}', { title, id: bound?.sessionId ?? sessionId }));
|
||||
await this.#sendMenuCard(key, chatId);
|
||||
await this.#sendMenuCard(key, chatId, { updateMessageId });
|
||||
} catch (error) {
|
||||
await this.#send(chatId, t('绑定失败:{message}', { message: safeErrorText(error) }));
|
||||
}
|
||||
}
|
||||
|
||||
async #switchWorkspace(key, chatId, workspace) {
|
||||
async #switchWorkspace(key, chatId, workspace, { updateMessageId = null } = {}) {
|
||||
try {
|
||||
const current = await this.#harness.switchWorkspace(workspace);
|
||||
await this.#send(chatId, t('工作区已切换为:{workspace}', { workspace: current }));
|
||||
await this.#sendMenuCard(key, chatId);
|
||||
await this.#sendMenuCard(key, chatId, { updateMessageId });
|
||||
} catch (error) {
|
||||
await this.#send(chatId, t('切换失败:{message}', { message: safeErrorText(error) }));
|
||||
}
|
||||
}
|
||||
|
||||
async #sendCard(chatId, cardJson, options = {}) {
|
||||
const { update = false } = options;
|
||||
#rememberCardRoute(messageId, chatId, options) {
|
||||
if (!options.key || !messageId) return;
|
||||
this.#cardKeys.set(messageId, {
|
||||
key: options.key,
|
||||
chatId,
|
||||
sessionWorkspace: typeof options.sessionWorkspace === 'string' && options.sessionWorkspace
|
||||
? options.sessionWorkspace
|
||||
: null,
|
||||
});
|
||||
if (this.#cardKeys.size > 200) {
|
||||
const oldest = this.#cardKeys.keys().next().value;
|
||||
if (oldest !== undefined) this.#cardKeys.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试更新已有卡片(不发送新消息)
|
||||
if (update) {
|
||||
const existingId = this.#lastCardMessages.get(chatId);
|
||||
if (existingId) {
|
||||
try {
|
||||
await this.#client.request({
|
||||
method: 'PATCH',
|
||||
url: `/open-apis/im/v1/messages/${existingId}`,
|
||||
data: { content: cardJson },
|
||||
});
|
||||
return existingId;
|
||||
} catch (error) {
|
||||
this.#logger.warn?.('[dsh-feishu] card update failed:', error?.code ?? error?.message ?? error, 'sending new');
|
||||
async #sendCard(chatId, cardJson, options = {}) {
|
||||
const updateMessageId = nonEmptyString(options.updateMessageId);
|
||||
|
||||
if (updateMessageId) {
|
||||
try {
|
||||
const response = await this.#client.im.v1.message.patch({
|
||||
path: { message_id: updateMessageId },
|
||||
data: { content: cardJson },
|
||||
});
|
||||
if (response?.code && response.code !== 0) {
|
||||
throw new Error(`Feishu card update failed: ${response.msg || response.code}`);
|
||||
}
|
||||
this.#rememberCardRoute(updateMessageId, chatId, options);
|
||||
return updateMessageId;
|
||||
} catch (error) {
|
||||
this.#logger.warn?.('[dsh-feishu] card update failed:', error?.code ?? error?.message ?? error, 'sending new');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1537,26 +1586,11 @@ export class FeishuHarnessBridge {
|
|||
throw new Error(`Feishu card send failed: ${response.msg || response.code}`);
|
||||
}
|
||||
const messageId = nonEmptyString(response?.data?.message_id);
|
||||
if (options.key && messageId) {
|
||||
this.#cardKeys.set(messageId, {
|
||||
key: options.key,
|
||||
chatId,
|
||||
sessionWorkspace: typeof options.sessionWorkspace === 'string' && options.sessionWorkspace
|
||||
? options.sessionWorkspace
|
||||
: null,
|
||||
});
|
||||
if (this.#cardKeys.size > 200) {
|
||||
const oldest = this.#cardKeys.keys().next().value;
|
||||
if (oldest !== undefined) this.#cardKeys.delete(oldest);
|
||||
}
|
||||
}
|
||||
if (messageId) {
|
||||
this.#lastCardMessages.set(chatId, messageId);
|
||||
}
|
||||
this.#rememberCardRoute(messageId, chatId, options);
|
||||
return messageId;
|
||||
}
|
||||
|
||||
async #sendMenuCard(key, chatId, { newCard = false } = {}) {
|
||||
async #sendMenuCard(key, chatId, { updateMessageId = null } = {}) {
|
||||
let workspaces = [];
|
||||
let currentWorkspace = null;
|
||||
try {
|
||||
|
|
@ -1575,10 +1609,7 @@ export class FeishuHarnessBridge {
|
|||
currentSessionId = sessionId;
|
||||
currentSessionTitle = await this.#resolveSessionTitle(key, sessionId) || sessionId;
|
||||
}
|
||||
} catch {
|
||||
currentSessionId = this.#state.sessionFor(key);
|
||||
currentSessionTitle = currentSessionId;
|
||||
}
|
||||
} catch { /* render without a selected session */ }
|
||||
// 最近会话列表(会话下拉切换用;失败则留空数组 → 菜单回退按钮)
|
||||
let sessions = [];
|
||||
try {
|
||||
|
|
@ -1587,7 +1618,7 @@ export class FeishuHarnessBridge {
|
|||
: null;
|
||||
if (current && typeof this.#harness.listWorkspaceSessions === 'function') {
|
||||
const listed = await this.#harness.listWorkspaceSessions(current);
|
||||
sessions = (Array.isArray(listed?.sessions) ? listed.sessions : [])
|
||||
sessions = this.#visibleSessions(Array.isArray(listed?.sessions) ? listed.sessions : [])
|
||||
.map((s) => ({ id: s.sessionId, title: s.title ?? s.name ?? s.sessionId }))
|
||||
.slice(0, 20);
|
||||
}
|
||||
|
|
@ -1599,12 +1630,6 @@ export class FeishuHarnessBridge {
|
|||
sessions.unshift({ id: currentSessionId, title: currentSessionTitle ?? currentSessionId });
|
||||
sessions = sessions.slice(0, 20);
|
||||
}
|
||||
// 未绑定会话时:默认展示最近会话的第一个(选定才真正绑定),
|
||||
// 避免会话下拉 initial_index=0 显示空白占位。
|
||||
if (!currentSessionId && sessions.length > 0) {
|
||||
currentSessionId = sessions[0].id;
|
||||
currentSessionTitle = sessions[0].title ?? sessions[0].id;
|
||||
}
|
||||
const archiveVisible = this.#state?.includesArchivedSessions?.() ?? false;
|
||||
// 加载预设/模型目录供主卡内联配置使用
|
||||
let presetCatalog = null;
|
||||
|
|
@ -1633,7 +1658,7 @@ export class FeishuHarnessBridge {
|
|||
currentSession: currentSessionId ? { id: currentSessionId, title: currentSessionTitle } : null,
|
||||
sessions, archiveVisible, presetCatalog, modelCatalog,
|
||||
}),
|
||||
{ key, update: !newCard },
|
||||
{ key, updateMessageId },
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1672,65 +1697,26 @@ export class FeishuHarnessBridge {
|
|||
|
||||
// ── Sub-card handlers: preset, model, status, help, compact ──────────────
|
||||
|
||||
/**
|
||||
* Show the collapsed low-frequency configuration panel ("更多设置").
|
||||
* Bundles the current preset / model catalogs so the panel can render its
|
||||
* in-place dropdowns; any fetch failure degrades that section to a button.
|
||||
*/
|
||||
async #showSettingsCard(key, chatId) {
|
||||
const archiveVisible = this.#state?.includesArchivedSessions?.() ?? false;
|
||||
let presetCatalog = null;
|
||||
let modelCatalog = null;
|
||||
let workspaces = [];
|
||||
let currentWorkspace = null;
|
||||
try {
|
||||
const snapshot = await workspacePathSnapshot(this.#harness);
|
||||
workspaces = snapshot.paths;
|
||||
currentWorkspace = snapshot.current ?? null;
|
||||
} catch { /* workspace section degrades to button */ }
|
||||
try {
|
||||
const settings = await this.#harness.agentPresetSettings({ signal: this.#signal });
|
||||
presetCatalog = settings.agentPresetCatalog;
|
||||
presetCatalog._currentId = settings.agentPreset;
|
||||
} catch { /* preset section degrades to button */ }
|
||||
try {
|
||||
await this.#harness.ensureRunning({ signal: this.#signal });
|
||||
const sessionId = this.#state?.sessionFor?.(key);
|
||||
let catalog;
|
||||
if (typeof sessionId === 'string' && sessionId) {
|
||||
const session = this.#harness.workspaceSession(sessionId);
|
||||
if (session?.models) catalog = await session.models({ signal: this.#signal });
|
||||
}
|
||||
if (!catalog) catalog = await this.#harness.listModels({ signal: this.#signal });
|
||||
modelCatalog = catalog;
|
||||
} catch { /* model section degrades to button */ }
|
||||
await this.#sendCard(
|
||||
chatId,
|
||||
settingsCard({ archiveVisible, presetCatalog, modelCatalog, workspaces, currentWorkspace }),
|
||||
{ key },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the preset catalog and show the preset selection card.
|
||||
*/
|
||||
async #showPresetCard(key, chatId) {
|
||||
async #showPresetCard(key, chatId, { updateMessageId = null } = {}) {
|
||||
try {
|
||||
const settings = await this.#harness.agentPresetSettings({ signal: this.#signal });
|
||||
const catalog = settings.agentPresetCatalog;
|
||||
// Inject the current preset id so the card can render the selection
|
||||
catalog._currentId = settings.agentPreset;
|
||||
await this.#sendCard(chatId, presetCard(catalog), { key });
|
||||
await this.#sendCard(chatId, presetCard(catalog), { key, updateMessageId });
|
||||
} catch (error) {
|
||||
this.#logger.warn?.('[dsh-feishu] preset card failed:', error.message);
|
||||
await this.#send(chatId, '暂时无法获取预设列表,请稍后重试。');
|
||||
await this.#send(chatId, t('暂时无法获取预设列表,请稍后重试。'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the model catalog and show the model selection card.
|
||||
*/
|
||||
async #showModelCard(key, chatId) {
|
||||
async #showModelCard(key, chatId, { updateMessageId = null } = {}) {
|
||||
try {
|
||||
await this.#harness.ensureRunning({ signal: this.#signal });
|
||||
// Try to get the session-bound catalog first, fall back to harness-level
|
||||
|
|
@ -1745,10 +1731,10 @@ export class FeishuHarnessBridge {
|
|||
if (!catalog) {
|
||||
catalog = await this.#harness.listModels({ signal: this.#signal });
|
||||
}
|
||||
await this.#sendCard(chatId, modelCard(catalog), { key });
|
||||
await this.#sendCard(chatId, modelCard(catalog), { key, updateMessageId });
|
||||
} catch (error) {
|
||||
this.#logger.warn?.('[dsh-feishu] model card failed:', error.message);
|
||||
await this.#send(chatId, '暂时无法获取模型列表,请稍后重试。');
|
||||
await this.#send(chatId, t('暂时无法获取模型列表,请稍后重试。'));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1758,26 +1744,30 @@ export class FeishuHarnessBridge {
|
|||
async #showStatusText(key, chatId) {
|
||||
try {
|
||||
await this.#harness.ensureRunning({ signal: this.#signal });
|
||||
const lines = ['连接正常'];
|
||||
const lines = [t('连接正常')];
|
||||
const ws = typeof this.#harness.currentWorkspace === 'function'
|
||||
? this.#harness.currentWorkspace()
|
||||
: null;
|
||||
if (ws) lines.push(`工作区:${ws}`);
|
||||
if (ws) lines.push(t('工作区:{workspace}', { workspace: ws }));
|
||||
const settings = typeof this.#harness.agentPresetSettings === 'function'
|
||||
? await this.#harness.agentPresetSettings({ signal: this.#signal }).catch(() => null)
|
||||
: null;
|
||||
if (settings) {
|
||||
const item = settings.agentPresetCatalog?.items?.find((i) => i.id === settings.agentPreset);
|
||||
lines.push(`预设:${item ? `${item.label}(${item.id})` : (settings.agentPreset || '跟随默认')}`);
|
||||
lines.push(t('预设:{preset}', {
|
||||
preset: item
|
||||
? `${item.label}(${item.id})`
|
||||
: (settings.agentPreset || t('跟随默认')),
|
||||
}));
|
||||
}
|
||||
await this.#send(chatId, lines.join('\n'));
|
||||
} catch (error) {
|
||||
this.#logger.warn?.('[dsh-feishu] status text failed:', error.message);
|
||||
await this.#send(chatId, '暂时无法获取系统状态,请稍后重试。');
|
||||
await this.#send(chatId, t('暂时无法获取系统状态,请稍后重试。'));
|
||||
}
|
||||
}
|
||||
|
||||
async #showStatusCard(key, chatId) {
|
||||
async #showStatusCard(key, chatId, { updateMessageId = null } = {}) {
|
||||
try {
|
||||
await this.#harness.ensureRunning({ signal: this.#signal });
|
||||
const info = { connected: true, workspace: null, preset: null, model: null, sessionCount: 0 };
|
||||
|
|
@ -1787,14 +1777,16 @@ export class FeishuHarnessBridge {
|
|||
const ws = typeof this.#harness.currentWorkspace === 'function'
|
||||
? this.#harness.currentWorkspace()
|
||||
: null;
|
||||
info.workspace = ws || '未知';
|
||||
info.workspace = ws || t('未知');
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Preset
|
||||
try {
|
||||
const settings = await this.#harness.agentPresetSettings({ signal: this.#signal });
|
||||
const item = settings.agentPresetCatalog.items.find((i) => i.id === settings.agentPreset);
|
||||
info.preset = item ? `${item.label}(${item.id})` : (settings.agentPreset || '跟随默认');
|
||||
info.preset = item
|
||||
? `${item.label}(${item.id})`
|
||||
: (settings.agentPreset || t('跟随默认'));
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Model (from bound session or harness)
|
||||
|
|
@ -1820,18 +1812,22 @@ export class FeishuHarnessBridge {
|
|||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
await this.#sendCard(chatId, statusCard(info), { key });
|
||||
await this.#sendCard(chatId, statusCard(info), { key, updateMessageId });
|
||||
} catch (error) {
|
||||
this.#logger.warn?.('[dsh-feishu] status card failed:', error.message);
|
||||
await this.#send(chatId, '暂时无法获取系统状态,请稍后重试。');
|
||||
await this.#send(chatId, t('暂时无法获取系统状态,请稍后重试。'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the help card with all command descriptions.
|
||||
*/
|
||||
async #showHelpCard(key, chatId) {
|
||||
await this.#sendCard(chatId, helpCard(WORKSPACE_HELP_LINES), { key });
|
||||
async #showHelpCard(key, chatId, { updateMessageId = null } = {}) {
|
||||
await this.#sendCard(
|
||||
chatId,
|
||||
helpCard(WORKSPACE_HELP_LINES.map((line) => t(line))),
|
||||
{ key, updateMessageId },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1839,14 +1835,13 @@ export class FeishuHarnessBridge {
|
|||
*/
|
||||
async #handleCompact(key, chatId) {
|
||||
try {
|
||||
const compactCommand = await runCompactCommand(
|
||||
const result = await runCompactCommand(
|
||||
'/compact', this.#harness, this.#state, key, { signal: this.#signal },
|
||||
);
|
||||
const text = compactCommand?.message || '上下文压缩失败。';
|
||||
await this.#send(chatId, text);
|
||||
await this.#send(chatId, result?.message || t('上下文压缩失败。'));
|
||||
} catch (error) {
|
||||
this.#logger.warn?.('[dsh-feishu] compact failed:', error.message, error.code);
|
||||
await this.#send(chatId, compactCommand?.message || '上下文压缩失败,请稍后重试。');
|
||||
await this.#send(chatId, t('上下文压缩失败,请稍后重试。'));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1861,20 +1856,26 @@ export class FeishuHarnessBridge {
|
|||
control: { owner: this, key },
|
||||
},
|
||||
);
|
||||
await this.#send(chatId, result?.message || '/stop 执行完成。');
|
||||
if (result?.stopped) {
|
||||
await Promise.allSettled([
|
||||
this.#cancelPendingInteraction(key),
|
||||
this.#approvals.closeRoute(key),
|
||||
]);
|
||||
}
|
||||
await this.#send(chatId, result?.message || t('/stop 执行完成。'));
|
||||
} catch (error) {
|
||||
this.#logger.warn?.('[dsh-feishu] stop failed:', error.message);
|
||||
await this.#send(chatId, '停止任务失败,请稍后重试。');
|
||||
await this.#send(chatId, t('停止任务失败,请稍后重试。'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the steer card (quick-select dropdown + free-text input).
|
||||
*/
|
||||
async #showSteerCard(key, chatId) {
|
||||
async #showSteerCard(key, chatId, { updateMessageId = null } = {}) {
|
||||
const hasSession = Boolean(this.#state.sessionFor?.(key));
|
||||
this.#rememberMenu(key, { kind: 'steer' });
|
||||
await this.#sendCard(chatId, steerCard({ hasSession }), { key });
|
||||
await this.#sendCard(chatId, steerCard({ hasSession }), { key, updateMessageId });
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1885,46 +1886,57 @@ export class FeishuHarnessBridge {
|
|||
const result = await runControlCommand(
|
||||
`/steer ${text}`, this.#harness, this.#state, key, {
|
||||
signal: this.#signal,
|
||||
pendingInteraction: this.#hasPendingInteraction(key),
|
||||
control: { owner: this, key },
|
||||
},
|
||||
);
|
||||
const message = result?.message || '已提交补充指令。';
|
||||
this.#send(chatId, message).catch(() => undefined);
|
||||
await this.#send(chatId, result?.message || t('已提交补充指令。'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the preset to follow the Host default.
|
||||
*/
|
||||
async #handlePresetDefault(key, chatId) {
|
||||
async #handlePresetDefault(key, chatId, { updateMessageId = null } = {}) {
|
||||
try {
|
||||
if (typeof this.#harness?.updateAgentPreset !== 'function') {
|
||||
await this.#send(chatId, '当前 Host 暂不支持修改 Agent Preset。');
|
||||
return;
|
||||
const result = await runPresetCommand(
|
||||
'/preset --default', this.#harness, this.#state, key, { signal: this.#signal },
|
||||
);
|
||||
for (const reply of result?.messages ?? [result?.message]) {
|
||||
if (reply) await this.#send(chatId, reply);
|
||||
}
|
||||
await this.#harness.updateAgentPreset(null, { signal: this.#signal });
|
||||
await this.#send(chatId, '已恢复跟随 Host 默认预设。');
|
||||
await this.#sendMenuCard(key, chatId);
|
||||
} catch (error) {
|
||||
this.#logger.warn?.('[dsh-feishu] preset default failed:', error.message);
|
||||
await this.#send(chatId, '预设重置失败,请稍后重试。');
|
||||
await this.#send(chatId, t('预设重置失败,请稍后重试。'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.#sendMenuCard(key, chatId, { updateMessageId });
|
||||
} catch (error) {
|
||||
this.#logger.warn?.('[dsh-feishu] menu refresh failed after preset reset:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle preset selection from the preset dropdown.
|
||||
*/
|
||||
async #handlePresetSelect(key, chatId, presetId) {
|
||||
async #handlePresetSelect(key, chatId, presetId, { updateMessageId = null } = {}) {
|
||||
try {
|
||||
if (typeof this.#harness?.updateAgentPreset !== 'function') {
|
||||
await this.#send(chatId, '当前 Host 暂不支持修改 Agent Preset。');
|
||||
return;
|
||||
const selector = /^\d+$/u.test(presetId) ? `id:${presetId}` : presetId;
|
||||
const result = await runPresetCommand(
|
||||
`/preset ${selector}`, this.#harness, this.#state, key, { signal: this.#signal },
|
||||
);
|
||||
for (const reply of result?.messages ?? [result?.message]) {
|
||||
if (reply) await this.#send(chatId, reply);
|
||||
}
|
||||
await this.#harness.updateAgentPreset(presetId, { signal: this.#signal });
|
||||
await this.#send(chatId, `预设已切换为:${presetId}`);
|
||||
await this.#sendMenuCard(key, chatId);
|
||||
} catch (error) {
|
||||
this.#logger.warn?.('[dsh-feishu] preset select failed:', error.message);
|
||||
await this.#send(chatId, `预设切换失败:${error.message}`);
|
||||
await this.#send(chatId, t('预设切换失败,请稍后重试。'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.#sendMenuCard(key, chatId, { updateMessageId });
|
||||
} catch (error) {
|
||||
this.#logger.warn?.('[dsh-feishu] menu refresh failed after preset select:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1937,20 +1949,27 @@ export class FeishuHarnessBridge {
|
|||
* catalog validation, busy checks, pending-interaction checks and the
|
||||
* session binding lock stay in one place.
|
||||
*/
|
||||
async #handleModelSelect(key, chatId, modelId) {
|
||||
async #handleModelSelect(key, chatId, modelId, { updateMessageId = null } = {}) {
|
||||
try {
|
||||
const result = await runModelCommand(
|
||||
`/model ${modelId}`, this.#harness, this.#state, key, {
|
||||
signal: this.#signal,
|
||||
pendingInteraction: this.#hasPendingInteraction(key),
|
||||
control: { owner: this, key },
|
||||
},
|
||||
);
|
||||
const message = result?.message || '模型切换失败,请稍后重试。';
|
||||
await this.#send(chatId, message);
|
||||
await this.#sendMenuCard(key, chatId);
|
||||
for (const reply of result?.messages ?? [result?.message]) {
|
||||
if (reply) await this.#send(chatId, reply);
|
||||
}
|
||||
} catch (error) {
|
||||
this.#logger.warn?.('[dsh-feishu] model select failed:', error.message);
|
||||
await this.#send(chatId, `模型切换失败:${error.message}`);
|
||||
await this.#send(chatId, t('模型切换失败,请稍后重试。'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.#sendMenuCard(key, chatId, { updateMessageId });
|
||||
} catch (error) {
|
||||
this.#logger.warn?.('[dsh-feishu] menu refresh failed after model select:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2098,7 +2117,7 @@ export class FeishuHarnessBridge {
|
|||
}
|
||||
}
|
||||
|
||||
async #showWatchList(key, chatId) {
|
||||
async #showWatchList(key, chatId, { updateMessageId = null } = {}) {
|
||||
const entries = this.#state.watchEntries?.(key) ?? [];
|
||||
// 收集可选会话(用于「添加关注」多选下拉);失败则传空数组 → 只渲染移除/列表。
|
||||
let availableSessions = [];
|
||||
|
|
@ -2108,12 +2127,18 @@ export class FeishuHarnessBridge {
|
|||
: null;
|
||||
if (current && typeof this.#harness?.listWorkspaceSessions === 'function') {
|
||||
const listed = await this.#harness.listWorkspaceSessions(current);
|
||||
availableSessions = (Array.isArray(listed?.sessions) ? listed.sessions : [])
|
||||
availableSessions = this.#visibleSessions(
|
||||
Array.isArray(listed?.sessions) ? listed.sessions : [],
|
||||
)
|
||||
.map((s) => ({ sessionId: s.sessionId, title: s.title ?? s.name ?? s.sessionId }));
|
||||
}
|
||||
} catch { /* add-select section degrades to remove-only */ }
|
||||
this.#rememberMenu(key, { kind: 'watches', entries });
|
||||
await this.#sendCard(chatId, watchListCard(entries, availableSessions), { key });
|
||||
await this.#sendCard(
|
||||
chatId,
|
||||
watchListCard(entries, availableSessions),
|
||||
{ key, updateMessageId },
|
||||
);
|
||||
}
|
||||
|
||||
/** Queue live turn completions behind any reconnect compensation. */
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ function cardWith(headerText, elements) {
|
|||
// ── Shared helper: a "back to menu" button ────────────────────────────────
|
||||
|
||||
function backButton() {
|
||||
return button('🔙 返回菜单', 'back_to_menu');
|
||||
return button(t('🔙 返回菜单'), 'back_to_menu');
|
||||
}
|
||||
|
||||
// ── Main menu card ────────────────────────────────────────────────────────
|
||||
|
|
@ -147,7 +147,7 @@ export function menuCard(ctx) {
|
|||
const elements = [];
|
||||
|
||||
// ── 设置区 ──────────────────────────────────────────────────
|
||||
elements.push({ tag: 'div', text: markdown('**设置**') });
|
||||
elements.push({ tag: 'div', text: markdown(t('**设置**')) });
|
||||
|
||||
// ── 四个下拉菜单 2×2 网格 ────────────────────────────────────
|
||||
|
||||
|
|
@ -161,20 +161,14 @@ export function menuCard(ctx) {
|
|||
sessionDropdown = {
|
||||
tag: 'select_static',
|
||||
name: 'session_pick',
|
||||
placeholder: { tag: 'plain_text', content: '切换会话' },
|
||||
placeholder: {
|
||||
tag: 'plain_text',
|
||||
content: currentSessionId ? t('切换会话') : t('选择会话(当前未绑定)'),
|
||||
},
|
||||
initial_index: initialIndex(sessionPickOptions, currentSessionId),
|
||||
options: sessionPickOptions,
|
||||
behaviors: [{ type: 'callback', value: { action: 'session_pick' } }],
|
||||
};
|
||||
} else {
|
||||
sessionDropdown = {
|
||||
tag: 'select_static',
|
||||
name: 'session_pick',
|
||||
placeholder: { tag: 'plain_text', content: '无可用会话' },
|
||||
initial_index: 0,
|
||||
options: [],
|
||||
disabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
let workspaceDropdown = null;
|
||||
|
|
@ -186,7 +180,7 @@ export function menuCard(ctx) {
|
|||
workspaceDropdown = {
|
||||
tag: 'select_static',
|
||||
name: 'workspace_pick',
|
||||
placeholder: { tag: 'plain_text', content: '切换工作区' },
|
||||
placeholder: { tag: 'plain_text', content: t('切换工作区') },
|
||||
initial_index: initialIndex(wsOptions, currentWorkspace),
|
||||
options: wsOptions,
|
||||
behaviors: [{ type: 'callback', value: { action: 'workspace_pick' } }],
|
||||
|
|
@ -205,13 +199,13 @@ export function menuCard(ctx) {
|
|||
}));
|
||||
const presetSelected = presetFollowDefault ? PRESET_FOLLOW_DEFAULT_SENTINEL : curPresetId;
|
||||
setPresetOptions.unshift({
|
||||
text: { tag: 'plain_text', content: `${presetFollowDefault ? '✓ ' : ''}跟随默认` },
|
||||
text: { tag: 'plain_text', content: `${presetFollowDefault ? '✓ ' : ''}${t('跟随默认')}` },
|
||||
value: PRESET_FOLLOW_DEFAULT_SENTINEL,
|
||||
});
|
||||
presetDropdown = {
|
||||
tag: 'select_static',
|
||||
name: 'preset_pick',
|
||||
placeholder: { tag: 'plain_text', content: '切换预设' },
|
||||
placeholder: { tag: 'plain_text', content: t('切换预设') },
|
||||
initial_index: initialIndex(setPresetOptions, presetSelected),
|
||||
options: setPresetOptions,
|
||||
behaviors: [{ type: 'callback', value: { action: 'preset_pick' } }],
|
||||
|
|
@ -234,7 +228,7 @@ export function menuCard(ctx) {
|
|||
modelDropdown = {
|
||||
tag: 'select_static',
|
||||
name: 'model_pick',
|
||||
placeholder: { tag: 'plain_text', content: '切换模型' },
|
||||
placeholder: { tag: 'plain_text', content: t('切换模型') },
|
||||
initial_index: initialIndex(setModelOptions, curModelId),
|
||||
options: setModelOptions,
|
||||
behaviors: [{ type: 'callback', value: { action: 'model_pick' } }],
|
||||
|
|
@ -261,10 +255,13 @@ export function menuCard(ctx) {
|
|||
} else if (row1[0]) {
|
||||
elements.push(row1[0]);
|
||||
}
|
||||
if (!hasSessions) {
|
||||
elements.push({ tag: 'div', text: markdown(t('当前工作区暂无可用会话。')) });
|
||||
}
|
||||
|
||||
const row2 = [];
|
||||
const presetBtn = button('🤖 切换预设', 'presets');
|
||||
const modelBtn = button('🧠 切换模型', 'models');
|
||||
const presetBtn = button(t('🤖 切换预设'), 'presets');
|
||||
const modelBtn = button(t('🧠 切换模型'), 'models');
|
||||
if (presetDropdown) row2.push(presetDropdown); else row2.push(presetBtn);
|
||||
if (modelDropdown) row2.push(modelDropdown); else row2.push(modelBtn);
|
||||
elements.push({
|
||||
|
|
@ -273,15 +270,15 @@ export function menuCard(ctx) {
|
|||
});
|
||||
|
||||
// 新会话 + 全部会话按钮
|
||||
elements.push(buttonPair('🆕 新会话', 'new', '📋 会话/关注', 'sessions'));
|
||||
elements.push(buttonPair(t('🆕 新会话'), 'new', t('📋 会话/关注'), 'sessions'));
|
||||
if (!hasSessions && !hasWorkspaces) {
|
||||
elements.push(button('🗂 工作区列表', 'workspaces'));
|
||||
elements.push(button(t('🗂 工作区列表'), 'workspaces'));
|
||||
}
|
||||
elements.push({ tag: 'hr' });
|
||||
|
||||
// ── 任务控制(对运行中任务的操作)────────────────────────
|
||||
elements.push({ tag: 'div', text: markdown('**任务控制**') });
|
||||
elements.push(buttonPair('⏹ 停止', 'stop', '📐 压缩', 'compact'));
|
||||
elements.push({ tag: 'div', text: markdown(t('**任务控制**')) });
|
||||
elements.push(buttonPair(t('⏹ 停止'), 'stop', t('📐 压缩'), 'compact'));
|
||||
elements.push({ tag: 'hr' });
|
||||
|
||||
// ── 补充指令 + 归档切换(并列)────────────────────────────
|
||||
|
|
@ -291,18 +288,18 @@ export function menuCard(ctx) {
|
|||
{
|
||||
tag: 'column', width: 'weighted', weight: 1,
|
||||
elements: [
|
||||
{ tag: 'div', text: markdown('**补充指令**') },
|
||||
{ tag: 'div', text: markdown(t('**补充指令**')) },
|
||||
{
|
||||
tag: 'select_static',
|
||||
name: 'steer_pick',
|
||||
placeholder: { tag: 'plain_text', content: '选择补充指令' },
|
||||
initial_index: 1,
|
||||
placeholder: { tag: 'plain_text', content: t('选择补充指令') },
|
||||
initial_index: 0,
|
||||
options: [
|
||||
...QUICK_STEER_OPTIONS.map((text) => ({
|
||||
text: { tag: 'plain_text', content: text },
|
||||
value: text,
|
||||
...QUICK_STEER_OPTIONS.map((source) => ({
|
||||
text: { tag: 'plain_text', content: t(source) },
|
||||
value: t(source),
|
||||
})),
|
||||
{ text: { tag: 'plain_text', content: '✏️ 更多 / 自定义…' }, value: 'custom' },
|
||||
{ text: { tag: 'plain_text', content: t('✏️ 更多 / 自定义…') }, value: 'custom' },
|
||||
],
|
||||
behaviors: [{ type: 'callback', value: { action: 'steer_pick' } }],
|
||||
},
|
||||
|
|
@ -311,8 +308,10 @@ export function menuCard(ctx) {
|
|||
{
|
||||
tag: 'column', width: 'weighted', weight: 1,
|
||||
elements: [
|
||||
{ tag: 'div', text: markdown(`🗄 归档:${archiveVisible ? '已显示' : '已隐藏'}`) },
|
||||
button('切换归档显示', 'archive_toggle'),
|
||||
{ tag: 'div', text: markdown(t('🗄 归档:{state}', {
|
||||
state: archiveVisible ? t('已显示') : t('已隐藏'),
|
||||
})) },
|
||||
button(t('切换归档显示'), 'archive_toggle'),
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
@ -320,122 +319,13 @@ export function menuCard(ctx) {
|
|||
elements.push({ tag: 'hr' });
|
||||
|
||||
// ── 底部操作(系统功能)────────────────────────────────────
|
||||
elements.push(buttonPair('📊 状态', 'status', '📖 帮助', 'help'));
|
||||
elements.push(buttonPair(t('📊 状态'), 'status', t('📖 帮助'), 'help'));
|
||||
|
||||
// 命令与数字兜底说明
|
||||
elements.push({ tag: 'div', text: markdown(
|
||||
'**数字兜底**\n'
|
||||
+ '**1**工作区列表 · **2**新会话 · **3**会话列表 · **4**状态\n'
|
||||
+ '**5**🔧修复 · **6**帮助',
|
||||
) });
|
||||
return cardWith('🤖 助手中心', elements);
|
||||
}
|
||||
|
||||
// ── Settings card (低频配置收敛面板) ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The collapsed low-frequency configuration panel opened from the main menu's
|
||||
* "更多设置" button. Setting-type commands are exposed directly as dropdowns
|
||||
* (预设 / 模型 / 工作区) so they apply in place; 归档 is a simple two-state
|
||||
* toggle. `presetCatalog` matches the shape presetCard accepts
|
||||
* ({ items, defaultId, _currentId }); `modelCatalog` matches modelCard
|
||||
* ({ groups, current }).
|
||||
*/
|
||||
export function settingsCard({ archiveVisible, presetCatalog, modelCatalog, workspaces, currentWorkspace }) {
|
||||
const elements = [
|
||||
{ tag: 'div', text: markdown('低频配置:直接在下拉中选择,即改即用。') },
|
||||
{ tag: 'hr' },
|
||||
];
|
||||
|
||||
// ── 工作区下拉 ──────────────────────────────────────────────
|
||||
const wsList = Array.isArray(workspaces) ? workspaces : [];
|
||||
elements.push({ tag: 'div', text: markdown(`**🗂 工作区**:${currentWorkspace || '未设置'}`) });
|
||||
if (wsList.length > 0) {
|
||||
const wsSetOptions = wsList.slice(0, 20).map((path) => ({
|
||||
text: { tag: 'plain_text', content: `${path === currentWorkspace ? '✓ ' : ''}${path}` },
|
||||
value: path,
|
||||
}));
|
||||
elements.push({
|
||||
tag: 'select_static',
|
||||
name: 'workspace_pick',
|
||||
placeholder: { tag: 'plain_text', content: '切换工作区' },
|
||||
initial_index: initialIndex(wsSetOptions, currentWorkspace),
|
||||
options: wsSetOptions,
|
||||
behaviors: [{ type: 'callback', value: { action: 'workspace_pick' } }],
|
||||
});
|
||||
} else {
|
||||
elements.push(button('查看工作区列表', 'workspaces'));
|
||||
}
|
||||
elements.push({ tag: 'hr' });
|
||||
|
||||
// ── 预设下拉 ────────────────────────────────────────────────
|
||||
const presetItems = Array.isArray(presetCatalog?.items) ? presetCatalog.items : [];
|
||||
const curPresetId = presetCatalog?._currentId ?? null;
|
||||
const presetFollowDefault = curPresetId === null;
|
||||
const defaultPresetItem = presetItems.find((i) => i.id === presetCatalog?.defaultId);
|
||||
const curPresetLabel = presetItems.find((i) => i.id === curPresetId)?.label
|
||||
?? (presetFollowDefault
|
||||
? `跟随默认(${defaultPresetItem ? `${defaultPresetItem.label}·${presetCatalog?.defaultId}` : '未设置'})`
|
||||
: String(curPresetId));
|
||||
elements.push({ tag: 'div', text: markdown(`**🤖 预设**:${curPresetLabel}`) });
|
||||
if (presetItems.length >= 1) {
|
||||
// 「跟随默认」当前仅在一项时仍是有效选择;null 用 __default 哨兵值承载,
|
||||
// 保证下拉始终有一项能命中 initial_index(否则会显示占位文本)。
|
||||
const setPresetOptions = presetItems.slice(0, 30).map((item) => ({
|
||||
text: { tag: 'plain_text', content: `${item.id === curPresetId ? '✓ ' : ''}${item.label}` },
|
||||
value: item.id,
|
||||
}));
|
||||
const presetSelected = presetFollowDefault ? PRESET_FOLLOW_DEFAULT_SENTINEL : curPresetId;
|
||||
setPresetOptions.unshift({
|
||||
text: { tag: 'plain_text', content: `${presetFollowDefault ? '✓ ' : ''}跟随默认` },
|
||||
value: PRESET_FOLLOW_DEFAULT_SENTINEL,
|
||||
});
|
||||
elements.push({
|
||||
tag: 'select_static',
|
||||
name: 'preset_pick',
|
||||
placeholder: { tag: 'plain_text', content: '切换预设' },
|
||||
initial_index: initialIndex(setPresetOptions, presetSelected),
|
||||
options: setPresetOptions,
|
||||
behaviors: [{ type: 'callback', value: { action: 'preset_pick' } }],
|
||||
});
|
||||
} else {
|
||||
elements.push(button('🤖 切换预设', 'presets'));
|
||||
}
|
||||
elements.push({ tag: 'hr' });
|
||||
|
||||
// ── 模型下拉 ────────────────────────────────────────────────
|
||||
const groups = Array.isArray(modelCatalog?.groups) ? modelCatalog.groups : [];
|
||||
const curModel = modelCatalog?.current;
|
||||
const curModelId = curModel ? `${curModel.provider}/${curModel.model}` : null;
|
||||
const flat = [];
|
||||
for (const group of groups) {
|
||||
for (const model of (group.models || [])) flat.push({ id: `${group.id}/${model.id}`, name: `${group.name} - ${model.name}` });
|
||||
}
|
||||
elements.push({ tag: 'div', text: markdown(`**🧠 模型**:${curModelId || '未设置'}`) });
|
||||
if (flat.length > 1) {
|
||||
const setModelOptions = flat.slice(0, 30).map((opt) => ({
|
||||
text: { tag: 'plain_text', content: `${opt.id === curModelId ? '✓ ' : ''}${opt.name}` },
|
||||
value: opt.id,
|
||||
}));
|
||||
elements.push({
|
||||
tag: 'select_static',
|
||||
name: 'model_pick',
|
||||
placeholder: { tag: 'plain_text', content: '切换模型' },
|
||||
initial_index: initialIndex(setModelOptions, curModelId),
|
||||
options: setModelOptions,
|
||||
behaviors: [{ type: 'callback', value: { action: 'model_pick' } }],
|
||||
});
|
||||
} else {
|
||||
elements.push(button('🧠 切换模型', 'models'));
|
||||
}
|
||||
elements.push({ tag: 'hr' });
|
||||
|
||||
// ── 归档切换(二选一 → 点击切换)──────────────────────────
|
||||
elements.push({ tag: 'div', text: markdown(`**🗄 归档**:${archiveVisible ? '已显示' : '已隐藏'}`) });
|
||||
elements.push(button('切换归档显示', 'archive_toggle'));
|
||||
elements.push({ tag: 'hr' });
|
||||
elements.push(buttonPair('🔙 返回菜单', 'back_to_menu', '📖 帮助', 'help'));
|
||||
return cardWith('⚙ 更多设置', elements);
|
||||
elements.push({ tag: 'div', text: markdown(t(
|
||||
'**数字兜底**\n**1**工作区列表 · **2**新会话 · **3**会话列表 · **4**状态\n**5**🔧修复 · **6**帮助',
|
||||
)) });
|
||||
return cardWith(t('🤖 助手中心'), elements);
|
||||
}
|
||||
|
||||
// ── Preset management card ────────────────────────────────────────────────
|
||||
|
|
@ -451,16 +341,20 @@ export function presetCard(catalog) {
|
|||
const defaultItem = items.find((i) => i.id === defaultId);
|
||||
|
||||
const currentText = current === null
|
||||
? `跟随 Host 默认${defaultItem ? `(${defaultItem.label})` : ''}`
|
||||
: currentItem ? `${currentItem.label}(${currentItem.id})` : `${current}(已不可用)`;
|
||||
? t('跟随 Host 默认{default}', { default: defaultItem ? `(${defaultItem.label})` : '' })
|
||||
: currentItem
|
||||
? `${currentItem.label}(${currentItem.id})`
|
||||
: t('{id}(已不可用)', { id: current });
|
||||
|
||||
const elements = [
|
||||
{ tag: 'div', text: markdown(`**当前**:${currentText}`) },
|
||||
{ tag: 'div', text: markdown(`**Host 默认**:${defaultItem ? `${defaultItem.label}(${defaultItem.id})` : '未设置'}`) },
|
||||
{ tag: 'div', text: markdown(t('**当前**:{value}', { value: currentText })) },
|
||||
{ tag: 'div', text: markdown(t('**Host 默认**:{value}', {
|
||||
value: defaultItem ? `${defaultItem.label}(${defaultItem.id})` : t('未设置'),
|
||||
})) },
|
||||
{ tag: 'hr' },
|
||||
];
|
||||
|
||||
if (items.length > 1) {
|
||||
if (items.length >= 1) {
|
||||
const presetCardOptions = items.map((item) => ({
|
||||
text: { tag: 'plain_text', content: `${item.label}(${item.id})${item.id === current ? ' ✓' : ''}` },
|
||||
value: item.id,
|
||||
|
|
@ -469,21 +363,21 @@ export function presetCard(catalog) {
|
|||
{
|
||||
tag: 'select_static',
|
||||
name: 'preset_pick',
|
||||
placeholder: { tag: 'plain_text', content: '选择预设' },
|
||||
placeholder: { tag: 'plain_text', content: t('选择预设') },
|
||||
initial_index: current === null ? 0 : initialIndex(presetCardOptions, current),
|
||||
options: presetCardOptions,
|
||||
behaviors: [{ type: 'callback', value: { action: 'preset_pick' } }],
|
||||
},
|
||||
{ tag: 'hr' },
|
||||
buttonPair('🔄 跟随默认', 'preset_default', '🔙 返回菜单', 'back_to_menu'),
|
||||
buttonPair(t('🔄 跟随默认'), 'preset_default', t('🔙 返回菜单'), 'back_to_menu'),
|
||||
);
|
||||
} else {
|
||||
elements.push(
|
||||
{ tag: 'div', text: markdown('当前没有可选择的预设。') },
|
||||
{ tag: 'div', text: markdown(t('当前没有可选择的预设。')) },
|
||||
backButton(),
|
||||
);
|
||||
}
|
||||
return cardWith('🤖 预设列表', elements);
|
||||
return cardWith(t('🤖 预设列表'), elements);
|
||||
}
|
||||
|
||||
// ── Model management card ─────────────────────────────────────────────────
|
||||
|
|
@ -498,7 +392,7 @@ export function modelCard(catalog) {
|
|||
const currentId = current ? `${current.provider}/${current.model}` : null;
|
||||
|
||||
const elements = [
|
||||
{ tag: 'div', text: markdown(`**当前模型**:${currentId || '未设置'}`) },
|
||||
{ tag: 'div', text: markdown(t('**当前模型**:{model}', { model: currentId || t('未设置') })) },
|
||||
{ tag: 'hr' },
|
||||
];
|
||||
|
||||
|
|
@ -508,7 +402,7 @@ export function modelCard(catalog) {
|
|||
|
||||
if (total === 0) {
|
||||
elements.push(
|
||||
{ tag: 'div', text: markdown('当前没有可用模型。') },
|
||||
{ tag: 'div', text: markdown(t('当前没有可用模型。')) },
|
||||
backButton(),
|
||||
);
|
||||
} else {
|
||||
|
|
@ -539,7 +433,7 @@ export function modelCard(catalog) {
|
|||
{
|
||||
tag: 'select_static',
|
||||
name: 'model_pick',
|
||||
placeholder: { tag: 'plain_text', content: '选择模型' },
|
||||
placeholder: { tag: 'plain_text', content: t('选择模型') },
|
||||
initial_index: currentId === null ? 0 : initialIndex(allOptions, currentId),
|
||||
options: allOptions.map((opt) => ({
|
||||
text: { tag: 'plain_text', content: `${opt.text.content}${opt.value === currentId ? ' ✓' : ''}` },
|
||||
|
|
@ -551,7 +445,7 @@ export function modelCard(catalog) {
|
|||
backButton(),
|
||||
);
|
||||
}
|
||||
return cardWith('🧠 模型列表', elements);
|
||||
return cardWith(t('🧠 模型列表'), elements);
|
||||
}
|
||||
|
||||
// ── Status card ───────────────────────────────────────────────────────────
|
||||
|
|
@ -567,15 +461,18 @@ export function modelCard(catalog) {
|
|||
*/
|
||||
export function statusCard(info) {
|
||||
const elements = [
|
||||
{ tag: 'div', text: markdown(`${info.connected ? '✅' : '❌'} 飞书机器人${info.connected ? '已连接' : '未连接'}`) },
|
||||
{ tag: 'div', text: markdown(`📂 工作区:\`${info.workspace || '未设置'}\``) },
|
||||
{ tag: 'div', text: markdown(`🤖 预设:${info.preset || '未设置'}`) },
|
||||
{ tag: 'div', text: markdown(`🧠 模型:${info.model || '未设置'}`) },
|
||||
{ tag: 'div', text: markdown(`💬 会话:${info.sessionCount} 个`) },
|
||||
{ tag: 'div', text: markdown(t('{icon} 飞书机器人{state}', {
|
||||
icon: info.connected ? '✅' : '❌',
|
||||
state: info.connected ? t('已连接') : t('未连接'),
|
||||
})) },
|
||||
{ tag: 'div', text: markdown(t('📂 工作区:`{workspace}`', { workspace: info.workspace || t('未设置') })) },
|
||||
{ tag: 'div', text: markdown(t('🤖 预设:{preset}', { preset: info.preset || t('未设置') })) },
|
||||
{ tag: 'div', text: markdown(t('🧠 模型:{model}', { model: info.model || t('未设置') })) },
|
||||
{ tag: 'div', text: markdown(t('💬 会话:{count} 个', { count: info.sessionCount })) },
|
||||
{ tag: 'hr' },
|
||||
backButton(),
|
||||
];
|
||||
return cardWith('📊 系统状态', elements);
|
||||
return cardWith(t('📊 系统状态'), elements);
|
||||
}
|
||||
|
||||
// ── Help card ─────────────────────────────────────────────────────────────
|
||||
|
|
@ -618,60 +515,75 @@ export function menuHelpText() {
|
|||
'/stop 停止当前任务',
|
||||
'/steer 指令 给 Agent 补充指令',
|
||||
'/repair 修复卡片按钮回调',
|
||||
].join('\n');
|
||||
].map((line) => t(line)).join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Help card with all available commands and their descriptions.
|
||||
*/
|
||||
const HELP_CARD_FEATURES = [
|
||||
'**📋 卡片功能**',
|
||||
'',
|
||||
'1. 会话下拉 — 切换当前绑定会话',
|
||||
'2. 工作区下拉 — 切换工作区',
|
||||
'3. 🤖 预设下拉 — 切换 Agent 预设',
|
||||
'4. 🧠 模型下拉 — 切换模型',
|
||||
'5. 🆕 新会话 — 开启全新会话',
|
||||
'6. 📋 会话/关注 — 查看/绑定会话,管理关注',
|
||||
'7. ⏹ 停止 — 停止当前任务',
|
||||
'8. 📐 压缩 — 压缩当前会话上下文',
|
||||
'9. 补充指令 — 给 Agent 发送指令',
|
||||
'10. 🗄 归档切换 — 显示/隐藏归档会话',
|
||||
'11. 📊 状态 — 查看系统连接状态',
|
||||
'12. 📖 帮助 — 查看本帮助',
|
||||
].join('\n');
|
||||
|
||||
const HELP_TEXT_COMMANDS = [
|
||||
'**⌨️ 文本命令**',
|
||||
'',
|
||||
'`/m` — 打开菜单卡片',
|
||||
'`/new` — 开启全新会话',
|
||||
'`/session ID` — 绑定已有会话',
|
||||
'`/sessionlist [工作区]` — 列出会话',
|
||||
'`/workspace 路径` — 切换工作区',
|
||||
'`/workspacelist` — 列出工作区',
|
||||
'`/status` — 查看连接状态',
|
||||
'`/compact` — 压缩上下文',
|
||||
'`/stop` — 停止当前任务',
|
||||
'`/steer 指令` — 补充指令',
|
||||
'`/watch ID` — 关注会话',
|
||||
'`/watchlist` — 关注列表',
|
||||
'`/unwatch ID` — 取消关注',
|
||||
'`/archived on/off` — 归档显隐',
|
||||
'`/presetlist` — 列出预设',
|
||||
'`/preset [序号/ID]` — 切换预设',
|
||||
'`/preset --default` — 跟随默认',
|
||||
'`/models` — 列出模型',
|
||||
'`/model 2` — 切换模型',
|
||||
'`/repair` — 修复卡片按钮',
|
||||
].join('\n');
|
||||
|
||||
const HELP_NUMBER_FALLBACK = [
|
||||
'**💡 数字兜底**',
|
||||
'回复数字快速操作:',
|
||||
'**1**工作区列表 · **2**新会话 · **3**会话/关注',
|
||||
'**4**状态 · **5**修复 · **6**帮助',
|
||||
].join('\n');
|
||||
|
||||
export function helpCard(extraTextLines = []) {
|
||||
const extraText = Array.isArray(extraTextLines) && extraTextLines.length > 0
|
||||
? ('\n' + extraTextLines.join('\n'))
|
||||
: '';
|
||||
const elements = [
|
||||
{ tag: 'div', text: markdown('**📋 卡片功能**\n\n' +
|
||||
'1. 会话下拉 — 切换当前绑定会话\n' +
|
||||
'2. 工作区下拉 — 切换工作区\n' +
|
||||
'3. 🤖 预设下拉 — 切换 Agent 预设\n' +
|
||||
'4. 🧠 模型下拉 — 切换模型\n' +
|
||||
'5. 🆕 新会话 — 开启全新会话\n' +
|
||||
'6. 📋 会话/关注 — 查看/绑定会话,管理关注\n' +
|
||||
'7. ⏹ 停止 — 停止当前任务\n' +
|
||||
'8. 📐 压缩 — 压缩当前会话上下文\n' +
|
||||
'9. 补充指令 — 给 Agent 发送指令\n' +
|
||||
'10. 🗄 归档切换 — 显示/隐藏归档会话\n' +
|
||||
'11. 📊 状态 — 查看系统连接状态\n' +
|
||||
'12. 📖 帮助 — 查看本帮助') },
|
||||
{ tag: 'div', text: markdown(t(HELP_CARD_FEATURES)) },
|
||||
{ tag: 'hr' },
|
||||
{ tag: 'div', text: markdown('**⌨️ 文本命令**\n\n' +
|
||||
'`/m` — 打开菜单卡片\n' +
|
||||
'`/new` — 开启全新会话\n' +
|
||||
'`/session ID` — 绑定已有会话\n' +
|
||||
'`/sessionlist [工作区]` — 列出会话\n' +
|
||||
'`/workspace 路径` — 切换工作区\n' +
|
||||
'`/workspacelist` — 列出工作区\n' +
|
||||
'`/status` — 查看连接状态\n' +
|
||||
'`/compact` — 压缩上下文\n' +
|
||||
'`/stop` — 停止当前任务\n' +
|
||||
'`/steer 指令` — 补充指令\n' +
|
||||
'`/watch ID` — 关注会话\n' +
|
||||
'`/watchlist` — 关注列表\n' +
|
||||
'`/unwatch ID` — 取消关注\n' +
|
||||
'`/archived on/off` — 归档显隐\n' +
|
||||
'`/presetlist` — 列出预设\n' +
|
||||
'`/preset [序号/ID]` — 切换预设\n' +
|
||||
'`/preset --default` — 跟随默认\n' +
|
||||
'`/models` — 列出模型\n' +
|
||||
'`/model 2` — 切换模型\n' +
|
||||
'`/repair` — 修复卡片按钮') },
|
||||
{ tag: 'div', text: markdown(t(HELP_TEXT_COMMANDS) + extraText) },
|
||||
{ tag: 'hr' },
|
||||
{ tag: 'div', text: markdown('**💡 数字兜底**\n回复数字快速操作:\n' +
|
||||
'**1**工作区列表 · **2**新会话 · **3**会话/关注\n' +
|
||||
'**4**状态 · **5**修复 · **6**帮助') },
|
||||
{ tag: 'div', text: markdown(t(HELP_NUMBER_FALLBACK)) },
|
||||
{ tag: 'hr' },
|
||||
backButton(),
|
||||
];
|
||||
return cardWith('📖 帮助', elements);
|
||||
return cardWith(t('📖 帮助'), elements);
|
||||
}
|
||||
|
||||
// ── One-shot callback probe (repair verification) ─────────────────────────
|
||||
|
|
@ -751,14 +663,14 @@ export function sessionListCard(workspace, sessions, page, total, watchedSession
|
|||
);
|
||||
}),
|
||||
];
|
||||
if (page > 0) elements.push(button('◀ 上一页', `sessions:${page - 1}`));
|
||||
if (page > 0) elements.push(button(t('◀ 上一页'), `sessions:${page - 1}`));
|
||||
if (page + 1 < pageCount) elements.push(button(t('下一页 ▶'), `sessions:${page + 1}`));
|
||||
elements.push(
|
||||
{ tag: 'hr' },
|
||||
buttonPair('🔙 返回菜单', 'back_to_menu', t('🔍 关注列表'), 'watchlist'),
|
||||
buttonPair(t('🔙 返回菜单'), 'back_to_menu', t('🔍 关注列表'), 'watchlist'),
|
||||
{ tag: 'div', text: markdown(t('回复数字(1~N)绑定本页会话。')) },
|
||||
);
|
||||
return cardWith('📂 会话列表', elements);
|
||||
return cardWith(t('📂 会话列表'), elements);
|
||||
}
|
||||
|
||||
// ── Workspace list card (preserved, with back button) ─────────────────────
|
||||
|
|
@ -879,34 +791,34 @@ export const STEER_CUSTOM_SENTINEL = '__steer_custom__';
|
|||
/** 补充指令卡片:单个下拉,快捷指令 + 「更多/自定义」。 */
|
||||
export function steerCard({ hasSession }) {
|
||||
const hint = hasSession
|
||||
? '从下方下拉选择补充指令;最后一项可自定义输入。'
|
||||
: '当前没有绑定会话,请先绑定会话再补充指令。';
|
||||
const options = QUICK_STEER_OPTIONS.map((text) => ({
|
||||
text: { tag: 'plain_text', content: text },
|
||||
value: text,
|
||||
? t('从下方下拉选择补充指令;最后一项可自定义输入。')
|
||||
: t('当前没有绑定会话,请先绑定会话再补充指令。');
|
||||
const options = QUICK_STEER_OPTIONS.map((source) => ({
|
||||
text: { tag: 'plain_text', content: t(source) },
|
||||
value: t(source),
|
||||
}));
|
||||
options.push({ text: { tag: 'plain_text', content: '✏️ 更多 / 自定义…' }, value: STEER_CUSTOM_SENTINEL });
|
||||
options.push({ text: { tag: 'plain_text', content: t('✏️ 更多 / 自定义…') }, value: STEER_CUSTOM_SENTINEL });
|
||||
const elements = [
|
||||
{ tag: 'div', text: markdown(hint) },
|
||||
{ tag: 'hr' },
|
||||
{
|
||||
tag: 'select_static',
|
||||
name: 'steer_quick',
|
||||
placeholder: { tag: 'plain_text', content: '选择补充指令' },
|
||||
initial_index: 1,
|
||||
placeholder: { tag: 'plain_text', content: t('选择补充指令') },
|
||||
initial_index: 0,
|
||||
options,
|
||||
behaviors: [{ type: 'callback', value: { action: 'steer', source: 'quick' } }],
|
||||
},
|
||||
{ tag: 'hr' },
|
||||
button('🔙 返回菜单', 'back_to_menu'),
|
||||
button(t('🔙 返回菜单'), 'back_to_menu'),
|
||||
];
|
||||
return cardWith('➕ 补充指令', elements);
|
||||
return cardWith(t('➕ 补充指令'), elements);
|
||||
}
|
||||
|
||||
/** 自定义输入卡:输入框 + 提交(从下拉「更多/自定义」切过来)。 */
|
||||
export function customSteerCard() {
|
||||
const elements = [
|
||||
{ tag: 'div', text: markdown('输入补充指令后点「提交」,发送给当前运行的任务。') },
|
||||
{ tag: 'div', text: markdown(t('输入补充指令后点「提交」,发送给当前运行的任务。')) },
|
||||
{ tag: 'hr' },
|
||||
{
|
||||
tag: 'form',
|
||||
|
|
@ -915,11 +827,11 @@ export function customSteerCard() {
|
|||
{
|
||||
tag: 'input',
|
||||
name: 'steer_text',
|
||||
placeholder: { tag: 'plain_text', content: '输入你的补充指令' },
|
||||
placeholder: { tag: 'plain_text', content: t('输入你的补充指令') },
|
||||
},
|
||||
{
|
||||
tag: 'button',
|
||||
text: { tag: 'plain_text', content: '提交' },
|
||||
text: { tag: 'plain_text', content: t('提交') },
|
||||
type: 'primary',
|
||||
width: 'fill',
|
||||
form_action_type: 'submit',
|
||||
|
|
@ -928,7 +840,7 @@ export function customSteerCard() {
|
|||
],
|
||||
},
|
||||
{ tag: 'hr' },
|
||||
button('🔙 返回菜单', 'back_to_menu'),
|
||||
button(t('🔙 返回菜单'), 'back_to_menu'),
|
||||
];
|
||||
return cardWith('➕ 自定义指令', elements);
|
||||
}
|
||||
return cardWith(t('➕ 自定义指令'), elements);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,8 +131,119 @@ export default {
|
|||
'已取消关注「{title}」。': 'Unwatched "{title}".',
|
||||
'取消失败:{message}': 'Could not unwatch: {message}',
|
||||
'飞书交互问题发送失败。': 'Failed to send the Feishu interaction question.',
|
||||
'当前任务仍在运行,请先停止任务或等待任务完成后再开启新会话。':
|
||||
'The current task is still running. Stop it or wait for it to finish before starting a new session.',
|
||||
'请输入补充指令后再提交。': 'Enter an instruction before submitting.',
|
||||
'已批量关注 {count} 个会话。': 'Now watching {count} sessions.',
|
||||
'已关注(或已达关注上限)。': 'Sessions are already watched, or the watch limit has been reached.',
|
||||
'已取消关注 {count} 个会话。': 'Stopped watching {count} sessions.',
|
||||
'未取消任何关注。': 'No watches were removed.',
|
||||
'当前没有绑定的会话,请先从会话列表选择。':
|
||||
'No session is currently bound. Select one from the session list first.',
|
||||
'已就绪,直接发消息即可继续当前会话。':
|
||||
'Ready. Send a message to continue the current session.',
|
||||
'修复需在私聊中验证接入者身份,请直接发送 /repair 开始。':
|
||||
'Repair must verify the owner in a direct chat. Send /repair there to begin.',
|
||||
'暂时无法获取预设列表,请稍后重试。':
|
||||
'Could not load the preset list. Please try again later.',
|
||||
'暂时无法获取系统状态,请稍后重试。':
|
||||
'Could not load system status. Please try again later.',
|
||||
'连接正常': 'Connected',
|
||||
'预设:{preset}': 'Preset: {preset}',
|
||||
'未知': 'Unknown',
|
||||
'/stop 执行完成。': '/stop completed.',
|
||||
'停止任务失败,请稍后重试。': 'Could not stop the task. Please try again later.',
|
||||
'已提交补充指令。': 'Instruction submitted.',
|
||||
'预设重置失败,请稍后重试。': 'Could not reset the preset. Please try again later.',
|
||||
'预设切换失败,请稍后重试。': 'Could not switch the preset. Please try again later.',
|
||||
|
||||
// feishu/feishu-cards.mjs — interactive cards
|
||||
'🤖 助手中心': '🤖 Assistant center',
|
||||
'**设置**': '**Settings**',
|
||||
'切换会话': 'Switch session',
|
||||
'选择会话(当前未绑定)': 'Select a session (none currently bound)',
|
||||
'跟随默认': 'Follow default',
|
||||
'切换预设': 'Switch preset',
|
||||
'切换模型': 'Switch model',
|
||||
'当前工作区暂无可用会话。': 'There are no available sessions in the current workspace.',
|
||||
'🤖 切换预设': '🤖 Switch preset',
|
||||
'🧠 切换模型': '🧠 Switch model',
|
||||
'🆕 新会话': '🆕 New session',
|
||||
'📋 会话/关注': '📋 Sessions / watches',
|
||||
'🗂 工作区列表': '🗂 Workspace list',
|
||||
'**任务控制**': '**Task controls**',
|
||||
'⏹ 停止': '⏹ Stop',
|
||||
'📐 压缩': '📐 Compact',
|
||||
'**补充指令**': '**Steer task**',
|
||||
'选择补充指令': 'Choose an instruction',
|
||||
'继续': 'Continue',
|
||||
'加速运行': 'Move faster',
|
||||
'总结当前进展': 'Summarize current progress',
|
||||
'更简洁些': 'Be more concise',
|
||||
'更详细些': 'Be more detailed',
|
||||
'✏️ 更多 / 自定义…': '✏️ More / custom…',
|
||||
'🗄 归档:{state}': '🗄 Archived: {state}',
|
||||
'已显示': 'shown',
|
||||
'已隐藏': 'hidden',
|
||||
'切换归档显示': 'Toggle archived sessions',
|
||||
'📊 状态': '📊 Status',
|
||||
'📖 帮助': '📖 Help',
|
||||
'**数字兜底**\n**1**工作区列表 · **2**新会话 · **3**会话列表 · **4**状态\n**5**🔧修复 · **6**帮助':
|
||||
'**Number fallback**\n**1** Workspace list · **2** New session · **3** Session list · **4** Status\n**5** 🔧 Repair · **6** Help',
|
||||
'跟随 Host 默认{default}': 'Follow Host default{default}',
|
||||
'**当前**:{value}': '**Current**: {value}',
|
||||
'**Host 默认**:{value}': '**Host default**: {value}',
|
||||
'未设置': 'Not set',
|
||||
'选择预设': 'Select a preset',
|
||||
'🔄 跟随默认': '🔄 Follow default',
|
||||
'当前没有可选择的预设。': 'There are no presets to choose from.',
|
||||
'🤖 预设列表': '🤖 Preset list',
|
||||
'**当前模型**:{model}': '**Current model**: {model}',
|
||||
'选择模型': 'Select a model',
|
||||
'🧠 模型列表': '🧠 Model list',
|
||||
'{icon} 飞书机器人{state}': '{icon} Feishu bot {state}',
|
||||
'已连接': 'connected',
|
||||
'未连接': 'disconnected',
|
||||
'📂 工作区:`{workspace}`': '📂 Workspace: `{workspace}`',
|
||||
'🤖 预设:{preset}': '🤖 Preset: {preset}',
|
||||
'🧠 模型:{model}': '🧠 Model: {model}',
|
||||
'💬 会话:{count} 个': '💬 Sessions: {count}',
|
||||
'📊 系统状态': '📊 System status',
|
||||
'📋 会话 / 工作区': '📋 Sessions / workspace',
|
||||
'/sessionlist 列出工作区会话': '/sessionlist List workspace sessions',
|
||||
'/session ID 绑定已有会话': '/session ID Bind an existing session',
|
||||
'/workspacelist 列出工作区': '/workspacelist List workspaces',
|
||||
'/workspace 路径 切换工作区': '/workspace PATH Switch workspace',
|
||||
'/new 开启全新会话': '/new Start a new session',
|
||||
'📊 状态 / 压缩': '📊 Status / compact',
|
||||
'/status 连接状态': '/status Connection status',
|
||||
'/compact 压缩当前会话上下文': '/compact Compact the current session context',
|
||||
'/archived on/off 会话列表显示/隐藏归档': '/archived on/off Show/hide archived sessions',
|
||||
'👁 关注': '👁 Watches',
|
||||
'/watch ID 关注会话(完成后推送)': '/watch ID Watch a session (push on completion)',
|
||||
'/watchlist 关注列表': '/watchlist List watched sessions',
|
||||
'/unwatch ID 取消关注': '/unwatch ID Stop watching a session',
|
||||
'🤖 预设 / 模型': '🤖 Presets / models',
|
||||
'/models 列出模型': '/models List models',
|
||||
'/model 2 按序号切换模型': '/model 2 Switch model by index',
|
||||
'🎮 任务控制': '🎮 Task controls',
|
||||
'/steer 指令 给 Agent 补充指令': '/steer INSTRUCTION Steer the Agent',
|
||||
'**📋 卡片功能**\n\n1. 会话下拉 — 切换当前绑定会话\n2. 工作区下拉 — 切换工作区\n3. 🤖 预设下拉 — 切换 Agent 预设\n4. 🧠 模型下拉 — 切换模型\n5. 🆕 新会话 — 开启全新会话\n6. 📋 会话/关注 — 查看/绑定会话,管理关注\n7. ⏹ 停止 — 停止当前任务\n8. 📐 压缩 — 压缩当前会话上下文\n9. 补充指令 — 给 Agent 发送指令\n10. 🗄 归档切换 — 显示/隐藏归档会话\n11. 📊 状态 — 查看系统连接状态\n12. 📖 帮助 — 查看本帮助':
|
||||
'**📋 Card features**\n\n1. Session dropdown — switch the bound session\n2. Workspace dropdown — switch workspace\n3. 🤖 Preset dropdown — switch Agent Preset\n4. 🧠 Model dropdown — switch model\n5. 🆕 New session — start fresh\n6. 📋 Sessions/watches — view or bind sessions and manage watches\n7. ⏹ Stop — stop the current task\n8. 📐 Compact — compact the current session context\n9. Steer task — send an instruction to the Agent\n10. 🗄 Archived toggle — show or hide archived sessions\n11. 📊 Status — view connection status\n12. 📖 Help — view this help',
|
||||
'**⌨️ 文本命令**\n\n`/m` — 打开菜单卡片\n`/new` — 开启全新会话\n`/session ID` — 绑定已有会话\n`/sessionlist [工作区]` — 列出会话\n`/workspace 路径` — 切换工作区\n`/workspacelist` — 列出工作区\n`/status` — 查看连接状态\n`/compact` — 压缩上下文\n`/stop` — 停止当前任务\n`/steer 指令` — 补充指令\n`/watch ID` — 关注会话\n`/watchlist` — 关注列表\n`/unwatch ID` — 取消关注\n`/archived on/off` — 归档显隐\n`/presetlist` — 列出预设\n`/preset [序号/ID]` — 切换预设\n`/preset --default` — 跟随默认\n`/models` — 列出模型\n`/model 2` — 切换模型\n`/repair` — 修复卡片按钮':
|
||||
'**⌨️ Text commands**\n\n`/m` — open the menu card\n`/new` — start a new session\n`/session ID` — bind an existing session\n`/sessionlist [workspace]` — list sessions\n`/workspace PATH` — switch workspace\n`/workspacelist` — list workspaces\n`/status` — view connection status\n`/compact` — compact context\n`/stop` — stop the current task\n`/steer INSTRUCTION` — steer the task\n`/watch ID` — watch a session\n`/watchlist` — list watched sessions\n`/unwatch ID` — stop watching\n`/archived on/off` — show or hide archived sessions\n`/presetlist` — list presets\n`/preset [index/ID]` — switch preset\n`/preset --default` — follow default\n`/models` — list models\n`/model 2` — switch model\n`/repair` — repair card buttons',
|
||||
'**💡 数字兜底**\n回复数字快速操作:\n**1**工作区列表 · **2**新会话 · **3**会话/关注\n**4**状态 · **5**修复 · **6**帮助':
|
||||
'**💡 Number fallback**\nReply with a number for a quick action:\n**1** Workspace list · **2** New session · **3** Sessions/watches\n**4** Status · **5** Repair · **6** Help',
|
||||
'从下方下拉选择补充指令;最后一项可自定义输入。':
|
||||
'Choose an instruction below; the last option lets you enter a custom one.',
|
||||
'当前没有绑定会话,请先绑定会话再补充指令。':
|
||||
'No session is bound. Bind one before steering the task.',
|
||||
'➕ 补充指令': '➕ Steer task',
|
||||
'输入补充指令后点「提交」,发送给当前运行的任务。':
|
||||
'Enter an instruction and press Submit to send it to the running task.',
|
||||
'输入你的补充指令': 'Enter your instruction',
|
||||
'提交': 'Submit',
|
||||
'➕ 自定义指令': '➕ Custom instruction',
|
||||
'🤖 助手菜单': '🤖 Assistant menu',
|
||||
'**点击按钮或直接回复数字**': '**Tap a button or reply with a number**',
|
||||
'1 · 会话列表': '1 · Sessions',
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { join } from 'node:path';
|
|||
import { FeishuHarnessBridge } from '../../../src/channels/feishu/bridge.mjs';
|
||||
import { DEFAULT_IMAGE_PROMPT } from '../../../src/channels/shared/image-prompt.mjs';
|
||||
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||
import { setImHostLanguage } from '../../../src/channels/shared/i18n.mjs';
|
||||
import {
|
||||
OUTBOUND_ARTIFACT_TOOL,
|
||||
OutboundArtifactRegistry,
|
||||
|
|
@ -96,6 +97,97 @@ function textClient(sendText) {
|
|||
};
|
||||
}
|
||||
|
||||
test('English Feishu status responses contain no bridge-level Chinese fallbacks', async () => {
|
||||
const fixture = stateFixture();
|
||||
const textSent = [];
|
||||
const cardSent = [];
|
||||
const patches = [];
|
||||
const harness = {
|
||||
ensureRunning: async () => true,
|
||||
currentWorkspace: () => null,
|
||||
agentPresetSettings: async () => ({
|
||||
agentPreset: null,
|
||||
agentPresetCatalog: {
|
||||
defaultId: null,
|
||||
items: [],
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
setImHostLanguage('en');
|
||||
try {
|
||||
const textBridge = new FeishuHarnessBridge({
|
||||
client: textClient(async (outgoing) => textSent.push(outgoing.text)),
|
||||
channel: {},
|
||||
harness,
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||||
});
|
||||
await textBridge.accept(event('english-status-text', '/status', {
|
||||
senderOpenId: 'ou_owner',
|
||||
}));
|
||||
|
||||
const cardBridge = new FeishuHarnessBridge({
|
||||
client: cardClient(
|
||||
async (outgoing) => cardSent.push(outgoing),
|
||||
async (request) => patches.push(request),
|
||||
),
|
||||
channel: {},
|
||||
harness,
|
||||
state: stateFixture().state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||||
});
|
||||
await cardBridge.accept(event('english-status-menu', '/m', {
|
||||
senderOpenId: 'ou_owner',
|
||||
}));
|
||||
await cardBridge.onCardAction(cardActionEvent('om_card_1', 'status', 'ou_owner'));
|
||||
|
||||
assert.equal(textSent.length, 1);
|
||||
assert.doesNotMatch(textSent[0], /[\u3400-\u9fff]/u);
|
||||
assert.equal(cardSent.length, 1);
|
||||
assert.equal(patches.length, 1);
|
||||
assert.doesNotMatch(patches[0].data.content, /[\u3400-\u9fff]/u);
|
||||
} finally {
|
||||
setImHostLanguage('zh');
|
||||
}
|
||||
});
|
||||
|
||||
test('text new-session waits until the active turn is finished before clearing the binding', async () => {
|
||||
const fixture = stateFixture([['p2p:ou_owner', 'session-active']]);
|
||||
const started = deferred();
|
||||
const release = deferred();
|
||||
const sent = [];
|
||||
const { harness } = activeTurnHarness();
|
||||
harness.ask = async () => {
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
return 'task finished';
|
||||
};
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: textClient(async (outgoing) => sent.push(outgoing.text)),
|
||||
channel: {},
|
||||
harness,
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||||
});
|
||||
|
||||
const turn = bridge.accept(event('new-active-turn', 'run a task', {
|
||||
senderOpenId: 'ou_owner',
|
||||
}));
|
||||
await started.promise;
|
||||
await bridge.accept(event('new-while-active', '/new', { senderOpenId: 'ou_owner' }));
|
||||
assert.equal(fixture.sessions.get('p2p:ou_owner'), 'session-active');
|
||||
assert.match(sent.at(-1), /当前任务仍在运行/);
|
||||
|
||||
release.resolve();
|
||||
await turn;
|
||||
await bridge.accept(event('new-after-active', '/new', { senderOpenId: 'ou_owner' }));
|
||||
assert.equal(fixture.sessions.has('p2p:ou_owner'), false);
|
||||
});
|
||||
|
||||
async function committedArtifact(t, fileName, content, suffix = '') {
|
||||
const workspace = await mkdtemp(join(tmpdir(), `dsh-im-feishu-artifact-${suffix}`));
|
||||
t.after(() => rm(workspace, { recursive: true, force: true }));
|
||||
|
|
@ -2558,9 +2650,9 @@ test('bridge does not expose internal error details in a Feishu failure reply',
|
|||
|
||||
// ── Interactive cards: menus, session lists, workspace lists ───────────────
|
||||
|
||||
function cardClient(onSend) {
|
||||
function cardClient(onSend, onPatch = null) {
|
||||
let sequence = 0;
|
||||
return {
|
||||
const client = {
|
||||
im: { v1: { message: { create: async (request) => {
|
||||
const outgoing = {
|
||||
chatId: request.data.receive_id,
|
||||
|
|
@ -2574,6 +2666,12 @@ function cardClient(onSend) {
|
|||
return { code: 0, data: { message_id: `om_card_${sequence}` } };
|
||||
} } } },
|
||||
};
|
||||
if (typeof onPatch === 'function') {
|
||||
client.im.v1.message.patch = async (request) => (
|
||||
await onPatch(request) ?? { code: 0 }
|
||||
);
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
function cardActionEvent(messageId, action, operatorOpenId) {
|
||||
|
|
@ -2599,6 +2697,21 @@ function buttonsFromCard(content) {
|
|||
return buttons;
|
||||
}
|
||||
|
||||
function selectsFromCard(content) {
|
||||
const selects = [];
|
||||
const visit = (value) => {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) visit(item);
|
||||
return;
|
||||
}
|
||||
if (!value || typeof value !== 'object') return;
|
||||
if (value.tag === 'select_static') selects.push(value);
|
||||
for (const child of Object.values(value)) visit(child);
|
||||
};
|
||||
visit(content.body?.elements);
|
||||
return selects;
|
||||
}
|
||||
|
||||
function callbackAction(button) {
|
||||
return button.behaviors?.find((behavior) => behavior?.type === 'callback')?.value?.action;
|
||||
}
|
||||
|
|
@ -2702,6 +2815,174 @@ test('card buttons honor the wildcard sender allowlist', async () => {
|
|||
assert.equal(sent.length, 2, 'wildcard access must apply to card callbacks too');
|
||||
});
|
||||
|
||||
test('card refresh updates the callback message instead of a newer card', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
const patches = [];
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: cardClient(
|
||||
async (outgoing) => sent.push(outgoing),
|
||||
async (request) => patches.push(request),
|
||||
),
|
||||
channel: {},
|
||||
harness: sessionsHarness(3),
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||||
});
|
||||
|
||||
await bridge.accept(event('menu-first', '/m', { senderOpenId: 'ou_owner' }));
|
||||
await bridge.waitForIdle();
|
||||
await bridge.accept(event('menu-second', '/m', { senderOpenId: 'ou_owner' }));
|
||||
await bridge.waitForIdle();
|
||||
assert.equal(cards(sent).length, 2);
|
||||
|
||||
await bridge.onCardAction(cardActionEvent('om_card_1', 'archive_toggle', 'ou_owner'));
|
||||
await bridge.waitForIdle();
|
||||
|
||||
assert.equal(patches.length, 1);
|
||||
assert.equal(patches[0].path.message_id, 'om_card_1');
|
||||
assert.equal(typeof patches[0].data.content, 'string');
|
||||
assert.equal(cards(sent).length, 2, 'a successful refresh must not create another card');
|
||||
});
|
||||
|
||||
test('a Feishu PATCH business error falls back to a new card', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: cardClient(
|
||||
async (outgoing) => sent.push(outgoing),
|
||||
async () => ({ code: 230099, msg: 'parse json error' }),
|
||||
),
|
||||
channel: {},
|
||||
harness: sessionsHarness(1),
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(event('menu-patch-error', '/m', { senderOpenId: 'ou_owner' }));
|
||||
await bridge.onCardAction(cardActionEvent('om_card_1', 'archive_toggle', 'ou_owner'));
|
||||
|
||||
assert.equal(cards(sent).length, 2, 'business failure must create a usable replacement card');
|
||||
});
|
||||
|
||||
test('an unbound menu leaves recent sessions unselected', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: cardClient(async (outgoing) => sent.push(outgoing)),
|
||||
channel: {},
|
||||
harness: sessionsHarness(3),
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||||
});
|
||||
|
||||
await bridge.accept(event('menu-unbound', '/m', { senderOpenId: 'ou_owner' }));
|
||||
await bridge.waitForIdle();
|
||||
|
||||
const sessionPick = selectsFromCard(cards(sent).at(-1).content)
|
||||
.find((select) => select.name === 'session_pick');
|
||||
assert.ok(sessionPick, 'recent sessions should remain selectable');
|
||||
assert.equal(sessionPick.initial_index, 0, 'no session is selected until the user binds one');
|
||||
assert.deepEqual(
|
||||
sessionPick.options.map((option) => option.value),
|
||||
['session-01', 'session-02', 'session-03'],
|
||||
);
|
||||
assert.equal(
|
||||
sessionPick.options.some((option) => option.text?.content?.includes('✓')),
|
||||
false,
|
||||
'an unbound chat must not display a fake current session',
|
||||
);
|
||||
});
|
||||
|
||||
test('compact card action contains session lookup failures', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
let failSessionLookup = false;
|
||||
const sessionFor = fixture.state.sessionFor;
|
||||
fixture.state.sessionFor = (key) => {
|
||||
if (failSessionLookup) {
|
||||
throw new Error('secret-shaped compact detail /private/path');
|
||||
}
|
||||
return sessionFor(key);
|
||||
};
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: cardClient(async (outgoing) => sent.push(outgoing)),
|
||||
channel: {},
|
||||
harness: sessionsHarness(1),
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(event('compact-card-open', '/m', { senderOpenId: 'ou_owner' }));
|
||||
await bridge.waitForIdle();
|
||||
failSessionLookup = true;
|
||||
await bridge.onCardAction(cardActionEvent('om_card_1', 'compact', 'ou_owner'));
|
||||
await bridge.waitForIdle();
|
||||
|
||||
const replies = sent
|
||||
.filter((message) => message.msgType === 'text')
|
||||
.map((message) => JSON.parse(message.content).text);
|
||||
assert.equal(replies.length, 1);
|
||||
assert.match(replies[0], /上下文压缩失败,请稍后重试/);
|
||||
assert.doesNotMatch(replies[0], /secret-shaped|private\/path|compactCommand/);
|
||||
});
|
||||
|
||||
test('preset card selection does not expose internal update errors', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
const updates = [];
|
||||
const catalog = {
|
||||
defaultId: 'preset-one',
|
||||
items: [
|
||||
{ id: 'preset-one', label: 'Preset One' },
|
||||
{ id: 'preset-two', label: 'Preset Two' },
|
||||
],
|
||||
};
|
||||
const harness = {
|
||||
...sessionsHarness(1),
|
||||
agentPresetSettings: async () => ({
|
||||
agentPreset: 'preset-one',
|
||||
agentPresetCatalog: { ...catalog, items: catalog.items.map((item) => ({ ...item })) },
|
||||
}),
|
||||
updateAgentPreset: async (presetId) => {
|
||||
updates.push(presetId);
|
||||
throw new Error('secret-shaped preset detail /private/path');
|
||||
},
|
||||
};
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: cardClient(async (outgoing) => sent.push(outgoing)),
|
||||
channel: {},
|
||||
harness,
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(event('preset-card-open', '/m', { senderOpenId: 'ou_owner' }));
|
||||
await bridge.waitForIdle();
|
||||
await bridge.onCardAction({
|
||||
operator: { open_id: 'ou_owner' },
|
||||
action: { value: { action: 'preset_pick' }, option: 'preset-two' },
|
||||
context: { open_message_id: 'om_card_1' },
|
||||
});
|
||||
await bridge.waitForIdle();
|
||||
|
||||
const replies = sent
|
||||
.filter((message) => message.msgType === 'text')
|
||||
.map((message) => JSON.parse(message.content).text);
|
||||
assert.deepEqual(updates, ['preset-two']);
|
||||
assert.equal(replies.length, 1);
|
||||
assert.match(replies[0], /失败,请稍后重试/);
|
||||
assert.doesNotMatch(replies[0], /secret-shaped|private\/path/);
|
||||
});
|
||||
|
||||
function cards(messages) { return messages.filter((m) => m.msgType === 'interactive'); }
|
||||
|
||||
test('session list paginates by page number across 25 sessions', async () => {
|
||||
|
|
@ -3462,24 +3743,31 @@ test('archived sessions are hidden by default; /archived on reveals them', async
|
|||
|
||||
function activeTurnHarness({ stopped = true, steered = true } = {}) {
|
||||
const calls = { stop: [], steer: [], sessions: [] };
|
||||
const harness = {
|
||||
ensureRunning: async () => true,
|
||||
workspaceSession: (id) => {
|
||||
calls.sessions.push(id);
|
||||
return {
|
||||
async sessionExists() {
|
||||
return true;
|
||||
},
|
||||
async ask(text, options) {
|
||||
return harness.ask(id, text, options);
|
||||
},
|
||||
async stopActiveTurn(control, options) {
|
||||
calls.stop.push({ control, options });
|
||||
return stopped;
|
||||
},
|
||||
async steerActiveTurn(text, control, options) {
|
||||
calls.steer.push({ text, control, options });
|
||||
return steered;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
return {
|
||||
calls,
|
||||
harness: {
|
||||
ensureRunning: async () => true,
|
||||
workspaceSession: (id) => {
|
||||
calls.sessions.push(id);
|
||||
return {
|
||||
async stopActiveTurn(control, options) {
|
||||
calls.stop.push({ control, options });
|
||||
return stopped;
|
||||
},
|
||||
async steerActiveTurn(text, control, options) {
|
||||
calls.steer.push({ text, control, options });
|
||||
return steered;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
harness,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -3491,6 +3779,150 @@ function steerDropdownEvent(messageId, option, operatorOpenId) {
|
|||
};
|
||||
}
|
||||
|
||||
test('pending question blocks card steer and card stop cancels the question', async () => {
|
||||
const fixture = stateFixture([['p2p:ou_owner', 'session-active']]);
|
||||
const sent = [];
|
||||
const questionReady = deferred();
|
||||
const cancelled = deferred();
|
||||
const { calls, harness } = activeTurnHarness();
|
||||
harness.ask = async (sessionId, _text, options) => {
|
||||
await options.onInteraction({
|
||||
kind: 'question',
|
||||
interactionId: 'card-question',
|
||||
rpcId: 'card-question',
|
||||
sessionId,
|
||||
payload: {
|
||||
type: 'question/requested',
|
||||
sessionId,
|
||||
questions: [{ id: 'answer', question: 'Please answer before continuing' }],
|
||||
},
|
||||
respond: async (result) => {
|
||||
cancelled.resolve(result);
|
||||
return { accepted: true };
|
||||
},
|
||||
});
|
||||
questionReady.resolve();
|
||||
await cancelled.promise;
|
||||
const error = new Error('turn stopped');
|
||||
error.code = 'turn-stopped';
|
||||
throw error;
|
||||
};
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: cardClient(async (outgoing) => sent.push(outgoing)),
|
||||
channel: {},
|
||||
harness,
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||||
});
|
||||
|
||||
await bridge.accept(event('question-menu-open', '/m', { senderOpenId: 'ou_owner' }));
|
||||
await bridge.waitForIdle();
|
||||
const turn = bridge.accept(event('question-task-start', 'start a question', {
|
||||
senderOpenId: 'ou_owner',
|
||||
}));
|
||||
await questionReady.promise;
|
||||
|
||||
await bridge.accept(event('question-new-blocked', '/new', { senderOpenId: 'ou_owner' }));
|
||||
await bridge.onCardAction(cardActionEvent('om_card_1', 'new', 'ou_owner'));
|
||||
assert.equal(
|
||||
fixture.sessions.get('p2p:ou_owner'),
|
||||
'session-active',
|
||||
'text and card new-session actions must not clear a pending interaction',
|
||||
);
|
||||
|
||||
await bridge.onCardAction(steerDropdownEvent('om_card_1', '继续', 'ou_owner'));
|
||||
assert.equal(calls.steer.length, 0, 'a pending question must block card steer');
|
||||
assert.match(JSON.parse(sent.at(-1).content).text, /等待你的回答或审批/);
|
||||
|
||||
await bridge.onCardAction(cardActionEvent('om_card_1', 'stop', 'ou_owner'));
|
||||
const cancellation = await cancelled.promise;
|
||||
await turn;
|
||||
|
||||
assert.equal(calls.stop.length, 1);
|
||||
assert.equal(calls.stop[0].control.key, 'p2p:ou_owner');
|
||||
assert.deepEqual(cancellation, {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'cancelled',
|
||||
message: 'The Feishu interaction ended before the user answered.',
|
||||
details: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('pending approval blocks card steer and card stop rejects the approval', async () => {
|
||||
const fixture = stateFixture([['p2p:ou_owner', 'session-active']]);
|
||||
const sent = [];
|
||||
const approvalReady = deferred();
|
||||
const decided = deferred();
|
||||
const { calls, harness } = activeTurnHarness();
|
||||
harness.ask = async (sessionId, _text, options) => {
|
||||
await options.onInteraction({
|
||||
kind: 'approval',
|
||||
interactionId: 'card-approval',
|
||||
rpcId: 'card-approval-rpc',
|
||||
sessionId,
|
||||
payload: {
|
||||
type: 'approval/requested',
|
||||
sessionId,
|
||||
approvalId: 'card-approval',
|
||||
toolName: 'bash',
|
||||
callId: 'card-approval-call',
|
||||
reason: 'Run a protected command',
|
||||
},
|
||||
toolCall: {
|
||||
callId: 'card-approval-call',
|
||||
name: 'bash',
|
||||
arguments: JSON.stringify({ command: 'true' }),
|
||||
},
|
||||
respond: async (result) => {
|
||||
decided.resolve(result);
|
||||
return { accepted: true };
|
||||
},
|
||||
});
|
||||
approvalReady.resolve();
|
||||
await decided.promise;
|
||||
const error = new Error('turn stopped');
|
||||
error.code = 'turn-stopped';
|
||||
throw error;
|
||||
};
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: cardClient(async (outgoing) => sent.push(outgoing)),
|
||||
channel: {},
|
||||
harness,
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||||
});
|
||||
|
||||
await bridge.accept(event('approval-menu-open', '/m', { senderOpenId: 'ou_owner' }));
|
||||
await bridge.waitForIdle();
|
||||
const turn = bridge.accept(event('approval-card-task', 'start an approval', {
|
||||
senderOpenId: 'ou_owner',
|
||||
}));
|
||||
await approvalReady.promise;
|
||||
|
||||
await bridge.onCardAction(steerDropdownEvent('om_card_1', '继续', 'ou_owner'));
|
||||
assert.equal(calls.steer.length, 0, 'a pending approval must block card steer');
|
||||
assert.match(JSON.parse(sent.at(-1).content).text, /等待你的回答或审批/);
|
||||
|
||||
await bridge.onCardAction(cardActionEvent('om_card_1', 'stop', 'ou_owner'));
|
||||
const decision = await decided.promise;
|
||||
await turn;
|
||||
|
||||
assert.equal(calls.stop.length, 1);
|
||||
assert.equal(calls.stop[0].control.key, 'p2p:ou_owner');
|
||||
assert.deepEqual(decision, {
|
||||
ok: true,
|
||||
value: {
|
||||
sessionId: 'session-active',
|
||||
approvalId: 'card-approval',
|
||||
outcome: 'rejected',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('menu stop button stops the bound active turn without touching the model', async () => {
|
||||
const fixture = stateFixture([['p2p:ou_owner', 'session-active']]);
|
||||
const sent = [];
|
||||
|
|
|
|||
|
|
@ -2,11 +2,20 @@ import assert from 'node:assert/strict';
|
|||
import test from 'node:test';
|
||||
import {
|
||||
cardActionProbeCard,
|
||||
completionCard,
|
||||
customSteerCard,
|
||||
helpCard,
|
||||
menuCard,
|
||||
menuHelpText,
|
||||
modelCard,
|
||||
presetCard,
|
||||
sessionListCard,
|
||||
statusCard,
|
||||
steerCard,
|
||||
watchListCard,
|
||||
workspaceListCard,
|
||||
} from '../../../src/channels/feishu/feishu-cards.mjs';
|
||||
import { setImHostLanguage } from '../../../src/channels/shared/i18n.mjs';
|
||||
|
||||
function buttons(value, result = []) {
|
||||
if (Array.isArray(value)) {
|
||||
|
|
@ -127,3 +136,98 @@ test('model card dropdown highlights the current model via initial_index', () =>
|
|||
// though the id itself contains a `/`.
|
||||
assert.equal(pick.initial_index, 2);
|
||||
});
|
||||
|
||||
test('a single preset remains selectable alongside follow-default', () => {
|
||||
const card = JSON.parse(presetCard({
|
||||
defaultId: 'preset-one',
|
||||
_currentId: null,
|
||||
items: [{ id: 'preset-one', label: 'Preset One' }],
|
||||
}));
|
||||
const pick = selects(card).find((select) => select.name === 'preset_pick');
|
||||
assert.deepEqual(pick?.options.map((option) => option.value), ['preset-one']);
|
||||
const reset = buttons(card).find((button) => (
|
||||
button.behaviors?.[0]?.value?.action === 'preset_default'
|
||||
));
|
||||
assert.ok(reset, 'follow-default must remain available beside the sole preset');
|
||||
});
|
||||
|
||||
test('menu without sessions does not emit an empty session dropdown', () => {
|
||||
const card = JSON.parse(menuCard({ sessions: [] }));
|
||||
const pick = selects(card).find((select) => select.name === 'session_pick');
|
||||
assert.equal(pick, undefined, 'Feishu must not receive select_static with options: []');
|
||||
});
|
||||
|
||||
test('quick steer dropdowns start without a preselected command', () => {
|
||||
const menuPick = selects(JSON.parse(menuCard()))
|
||||
.find((select) => select.name === 'steer_pick');
|
||||
const cardPick = selects(JSON.parse(steerCard({ hasSession: true })))
|
||||
.find((select) => select.name === 'steer_quick');
|
||||
assert.equal(menuPick?.initial_index ?? 0, 0);
|
||||
assert.equal(cardPick?.initial_index ?? 0, 0);
|
||||
});
|
||||
|
||||
test('reachable Feishu cards contain no Chinese literals in English mode', () => {
|
||||
const presetCatalog = {
|
||||
defaultId: 'preset-one',
|
||||
_currentId: 'preset-two',
|
||||
items: [
|
||||
{ id: 'preset-one', label: 'Preset One' },
|
||||
{ id: 'preset-two', label: 'Preset Two' },
|
||||
],
|
||||
};
|
||||
const modelCatalog = {
|
||||
groups: [{
|
||||
id: 'provider',
|
||||
name: 'Provider',
|
||||
models: [
|
||||
{ id: 'model-one', name: 'Model One' },
|
||||
{ id: 'model-two', name: 'Model Two' },
|
||||
],
|
||||
}],
|
||||
current: { provider: 'provider', model: 'model-two' },
|
||||
};
|
||||
const sessions = [{ sessionId: 'session-one', title: 'Session One' }];
|
||||
const rendered = [];
|
||||
|
||||
setImHostLanguage('en');
|
||||
try {
|
||||
rendered.push(
|
||||
menuCard({
|
||||
workspaces: ['/work'],
|
||||
currentWorkspace: '/work',
|
||||
currentSession: { id: 'session-one', title: 'Session One' },
|
||||
sessions: [{ id: 'session-one', title: 'Session One' }],
|
||||
archiveVisible: false,
|
||||
presetCatalog,
|
||||
modelCatalog,
|
||||
}),
|
||||
presetCard(presetCatalog),
|
||||
modelCard(modelCatalog),
|
||||
statusCard({
|
||||
connected: true,
|
||||
workspace: '/work',
|
||||
preset: 'Preset Two',
|
||||
model: 'provider/model-two',
|
||||
sessionCount: 1,
|
||||
}),
|
||||
helpCard(['Additional help']),
|
||||
sessionListCard('/work', sessions, 0, 1),
|
||||
workspaceListCard(['/work'], '/work'),
|
||||
watchListCard(
|
||||
[{ sessionId: 'session-one', title: 'Session One' }],
|
||||
[{ sessionId: 'session-two', title: 'Session Two' }],
|
||||
),
|
||||
completionCard('session-one', 'Session One', 'completed'),
|
||||
steerCard({ hasSession: true }),
|
||||
customSteerCard(),
|
||||
cardActionProbeCard('0123456789abcdef0123456789abcdef'),
|
||||
menuHelpText(),
|
||||
);
|
||||
} finally {
|
||||
setImHostLanguage('zh');
|
||||
}
|
||||
|
||||
for (const output of rendered) {
|
||||
assert.doesNotMatch(output, /[\u3400-\u9fff]/u);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue