mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 00:33:20 +08:00
Merge pull request #35 from C3H3-AI/feishu-card-system
feat(feishu): redesign interactive menu with dropdowns, settings panel, and steer input
This commit is contained in:
commit
5b33249599
11 changed files with 1906 additions and 287 deletions
28
.github/workflows/ci.yml
vendored
Normal file
28
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- 'feishu-*'
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Test, build, and verify package
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run checks
|
||||
run: npm run check
|
||||
BIN
assets/screenshot-menu-card.png
Normal file
BIN
assets/screenshot-menu-card.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
374
lib/index.js
374
lib/index.js
File diff suppressed because one or more lines are too long
|
|
@ -44,10 +44,19 @@ import {
|
|||
} from '../shared/semantic/delivery.mjs';
|
||||
import {
|
||||
MENU_PAGE_SIZE,
|
||||
PRESET_FOLLOW_DEFAULT_SENTINEL,
|
||||
STEER_CUSTOM_SENTINEL,
|
||||
completionCard,
|
||||
customSteerCard,
|
||||
helpCard,
|
||||
menuCard,
|
||||
menuHelpText,
|
||||
modelCard,
|
||||
presetCard,
|
||||
sessionListCard,
|
||||
settingsCard,
|
||||
statusCard,
|
||||
steerCard,
|
||||
watchListCard,
|
||||
workspaceListCard,
|
||||
} from './feishu-cards.mjs';
|
||||
|
|
@ -89,39 +98,16 @@ const REPAIR_URL_HOSTS = new Set([
|
|||
'open.larksuite.com',
|
||||
]);
|
||||
|
||||
// Built lazily: t() must run after setImHostLanguage, not at import time.
|
||||
function helpText() {
|
||||
return [
|
||||
t('北汇星河 AIOS 已连接 DeepSeek Harness。'),
|
||||
'',
|
||||
t('直接发送文字、图片或文件即可继续当前会话。'),
|
||||
t('/new 开启一个全新会话'),
|
||||
t('/compact 压缩当前会话的较早上下文'),
|
||||
t('/workspace 工作区绝对路径 切换工作区'),
|
||||
t('/workspacelist 列出工作区绝对路径'),
|
||||
t('/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题'),
|
||||
t('/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话'),
|
||||
t('/models 按序号列出所有可用模型'),
|
||||
t('/model [序号或完整模型ID] 查看或切换当前会话模型'),
|
||||
t('示例:先发 /models,再发 /model 2'),
|
||||
t('/presetlist 按序号列出可用 Agent Preset'),
|
||||
t('/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset'),
|
||||
t('纯数字 ID:/preset id:<ID>'),
|
||||
t('/preset --default 跟随 Host 默认'),
|
||||
t('/stop 停止当前任务'),
|
||||
t('/steer 补充指令 纠偏当前任务'),
|
||||
t('/status 检查连接状态'),
|
||||
t('/repair 修复卡片按钮回调'),
|
||||
t('/m(或 /menu) 打开交互卡片菜单'),
|
||||
t('/watch [Session ID 或序号] 关注会话,任务完成自动推送'),
|
||||
t('/unwatch [Session ID 或序号] 取消关注'),
|
||||
t('/watchlist 查看关注列表'),
|
||||
t('/archived on|off 会话列表是否包含归档会话'),
|
||||
t('/help 显示本帮助'),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
/** Canonical workspace/session help advertised by every bridge family. */
|
||||
const WORKSPACE_HELP_LINES = [
|
||||
'/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话',
|
||||
'/workspacelist 列出工作区绝对路径',
|
||||
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
|
||||
];
|
||||
|
||||
/** Safe user-facing text for bind/workspace failures (no raw messages). */
|
||||
function safeErrorText(error) {
|
||||
|
|
@ -313,12 +299,16 @@ 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,
|
||||
|
|
@ -435,6 +425,21 @@ export class FeishuHarnessBridge {
|
|||
const processingReaction = this.#addReaction(messageId, 'OnIt');
|
||||
const commandMessage = extractInboundMessage(event, this.#client);
|
||||
const commandText = nonEmptyString(commandMessage.content) ?? '';
|
||||
// Card commands (/m, /help, /status, etc.) bypass the queue so they
|
||||
// respond immediately even when a harness task is still streaming.
|
||||
if (CARD_COMMAND.test(commandText)) {
|
||||
const processing = Promise.resolve()
|
||||
.then(() => this.#handle(event, key, { alreadyRecorded: false }))
|
||||
.then(() => this.#finishReaction(messageId, processingReaction, 'DONE'))
|
||||
.catch((error) => this.#handleMessageFailure(
|
||||
event,
|
||||
messageId,
|
||||
processingReaction,
|
||||
error,
|
||||
))
|
||||
.finally(() => this.#acceptedMessageIds.delete(messageId));
|
||||
return processing;
|
||||
}
|
||||
const commandRunner = hasInboundFiles(commandMessage) ? null : isControlCommand(commandText)
|
||||
? runControlCommand
|
||||
: (isModelCommand(commandText)
|
||||
|
|
@ -689,22 +694,28 @@ export class FeishuHarnessBridge {
|
|||
return;
|
||||
}
|
||||
if (commandText === '/help') {
|
||||
await this.#send(event.message.chat_id, helpText());
|
||||
await this.#send(event.message.chat_id, menuHelpText());
|
||||
return;
|
||||
}
|
||||
if (MENU_COMMAND.test(commandText)) {
|
||||
this.#rememberMenu(key, { kind: 'menu', chatId: event.message.chat_id });
|
||||
await this.#sendCard(event.message.chat_id, menuCard(), { key });
|
||||
await this.#sendMenuCard(key, event.message.chat_id, { newCard: true });
|
||||
return;
|
||||
}
|
||||
if (commandText === '/new') {
|
||||
await this.#state.clearSession(key);
|
||||
await this.#send(event.message.chat_id, t('已开启全新 Harness 会话。'));
|
||||
await this.#sendMenuCard(key, event.message.chat_id);
|
||||
return;
|
||||
}
|
||||
if (commandText === '/status') {
|
||||
await this.#harness.ensureRunning({ signal: this.#signal });
|
||||
await this.#send(event.message.chat_id, t('飞书机器人与 DeepSeek Harness 连接正常。'));
|
||||
await this.#showStatusText(key, event.message.chat_id);
|
||||
return;
|
||||
}
|
||||
if (commandText === '/compact') {
|
||||
const compactCommand = await runCompactCommand(commandText, this.#harness, this.#state, key, { signal: this.#signal });
|
||||
if (compactCommand) {
|
||||
await this.#send(event.message.chat_id, compactCommand.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (SESSION_LIST_PREFIX.test(commandText)) {
|
||||
|
|
@ -1135,6 +1146,31 @@ export class FeishuHarnessBridge {
|
|||
? event.action.value.action
|
||||
: null;
|
||||
if (!action) return Promise.resolve();
|
||||
// select_static dropdown: resolve pickers to their target actions
|
||||
const option = event?.action?.option;
|
||||
// multi_select_static: selected option values arrive as an array in
|
||||
// value.options (or echoed option). Capture them for batch handlers.
|
||||
const multiValues = Array.isArray(event?.action?.value?.options)
|
||||
? event.action.value.options.map((opt) => (opt?.value ?? opt)).filter((v) => typeof v === 'string')
|
||||
: 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'
|
||||
? `use:${option}`
|
||||
: action === 'preset_pick' && typeof option === 'string'
|
||||
? `preset:select:${option}`
|
||||
: action === 'model_pick' && typeof option === 'string'
|
||||
? `model:select:${option}`
|
||||
: action === 'archive_pick' && typeof option === 'string'
|
||||
? `archive:${option}`
|
||||
: action === 'steer_pick' && typeof option === 'string'
|
||||
? `steer:${option}`
|
||||
: action;
|
||||
const messageId = nonEmptyString(event?.context?.open_message_id);
|
||||
const entry = messageId ? this.#cardKeys.get(messageId) : null;
|
||||
if (!entry) {
|
||||
|
|
@ -1146,9 +1182,33 @@ export class FeishuHarnessBridge {
|
|||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
// 补充指令卡片:快捷下拉(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) => {
|
||||
this.#logger.warn?.('[dsh-feishu] steer custom card failed:', error.message);
|
||||
});
|
||||
}
|
||||
return this.#sendSteer(entry, option).catch((error) => {
|
||||
this.#logger.warn?.('[dsh-feishu] steer (quick) failed:', error.message);
|
||||
});
|
||||
}
|
||||
if (source === 'form') {
|
||||
const text = nonEmptyString(event?.action?.form_value?.steer_text);
|
||||
if (text) {
|
||||
return this.#sendSteer(entry, text).catch((error) => {
|
||||
this.#logger.warn?.('[dsh-feishu] steer (form) failed:', error.message);
|
||||
});
|
||||
}
|
||||
this.#send(entry.chatId, '请输入补充指令后再提交。').catch(() => undefined);
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
// The promise is returned so tests (and future callers) can await the
|
||||
// action; the runtime dispatcher ignores it.
|
||||
return this.#handleCardAction(action, entry).catch((error) => {
|
||||
return this.#handleCardAction(resolvedAction, entry).catch((error) => {
|
||||
this.#logger.warn?.('[dsh-feishu] card action failed:', error.message);
|
||||
});
|
||||
}
|
||||
|
|
@ -1167,18 +1227,124 @@ export class FeishuHarnessBridge {
|
|||
await this.#showWatchList(key, chatId);
|
||||
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;
|
||||
}
|
||||
}
|
||||
await this.#send(chatId, added > 0 ? `已批量关注 ${added} 个会话。` : '已关注(或已达关注上限)。');
|
||||
} else {
|
||||
let removed = 0;
|
||||
for (const sessionId of selections) {
|
||||
try {
|
||||
await this.#runUnwatch(key, chatId, sessionId);
|
||||
removed += 1;
|
||||
} catch { /* skip individual failure */ }
|
||||
}
|
||||
await this.#send(chatId, removed > 0 ? `已取消关注 ${removed} 个会话。` : '未取消任何关注。');
|
||||
}
|
||||
await this.#showWatchList(key, chatId);
|
||||
return;
|
||||
}
|
||||
if (action === 'new') {
|
||||
await this.#state.clearSession(key);
|
||||
await this.#send(chatId, t('已开启全新 Harness 会话。'));
|
||||
await this.#sendMenuCard(key, chatId);
|
||||
return;
|
||||
}
|
||||
if (action === 'use:current') {
|
||||
const sessionId = this.#state.sessionFor(key);
|
||||
if (typeof sessionId !== 'string' || !sessionId) {
|
||||
await this.#send(chatId, '当前没有绑定的会话,请先从会话列表选择。');
|
||||
return;
|
||||
}
|
||||
await this.#send(chatId, '已就绪,直接发消息即可继续当前会话。');
|
||||
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);
|
||||
return;
|
||||
}
|
||||
if (action === 'repair') {
|
||||
await this.#send(chatId, '修复需在私聊中验证接入者身份,请直接发送 /repair 开始。');
|
||||
return;
|
||||
}
|
||||
if (action === 'compact') {
|
||||
await this.#handleCompact(key, chatId);
|
||||
return;
|
||||
}
|
||||
if (action === 'stop') {
|
||||
await this.#handleStop(key, chatId);
|
||||
return;
|
||||
}
|
||||
if (action === 'steer') {
|
||||
await this.#showSteerCard(key, chatId);
|
||||
return;
|
||||
}
|
||||
// 主菜单「补充指令」下拉:option = steer:<指令> / steer:custom
|
||||
if (action.startsWith('steer:')) {
|
||||
const raw = action.slice('steer:'.length);
|
||||
if (raw === 'custom') {
|
||||
await this.#sendCard(chatId, customSteerCard(), { key });
|
||||
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);
|
||||
return;
|
||||
}
|
||||
if (action === 'models') {
|
||||
await this.#showModelCard(key, chatId);
|
||||
return;
|
||||
}
|
||||
if (action === 'status') {
|
||||
await this.#harness.ensureRunning({ signal: this.#signal });
|
||||
await this.#send(chatId, t('飞书机器人与 DeepSeek Harness 连接正常。'));
|
||||
await this.#showStatusCard(key, chatId);
|
||||
return;
|
||||
}
|
||||
if (action === 'help') {
|
||||
await this.#send(chatId, menuHelpText());
|
||||
await this.#showHelpCard(key, chatId);
|
||||
return;
|
||||
}
|
||||
if (action === 'back_to_menu') {
|
||||
await this.#sendMenuCard(key, chatId);
|
||||
return;
|
||||
}
|
||||
if (action === 'preset_default') {
|
||||
await this.#handlePresetDefault(key, chatId);
|
||||
return;
|
||||
}
|
||||
if (action.startsWith('preset:select:')) {
|
||||
const presetId = action.slice('preset:select:'.length);
|
||||
// 哨兵值 = 用户在预设下拉里选了「跟随默认」
|
||||
if (presetId === PRESET_FOLLOW_DEFAULT_SENTINEL) {
|
||||
await this.#handlePresetDefault(key, chatId);
|
||||
return;
|
||||
}
|
||||
await this.#handlePresetSelect(key, chatId, presetId);
|
||||
return;
|
||||
}
|
||||
if (action.startsWith('model:select:')) {
|
||||
const modelId = action.slice('model:select:'.length);
|
||||
await this.#handleModelSelect(key, chatId, modelId);
|
||||
return;
|
||||
}
|
||||
if (action.startsWith('use:')) {
|
||||
|
|
@ -1219,7 +1385,10 @@ export class FeishuHarnessBridge {
|
|||
|
||||
async #handleMenuPick(menu, number, { chatId, key, event }) {
|
||||
if (menu.kind === 'menu') {
|
||||
const action = ['sessions', 'workspaces', 'new', 'status', 'help', 'repair', 'watchlist'][number - 1];
|
||||
// Number fallback for the total menu:
|
||||
// 1=工作区列表 2=新会话 3=会话列表 4=状态 5=修复 6=帮助
|
||||
const actions = ['workspaces', 'new', 'sessions', 'status', 'repair', 'help'];
|
||||
const action = actions[number - 1];
|
||||
if (!action) {
|
||||
await this.#send(chatId, t('菜单没有这个编号,回复 /m 重新打开。'));
|
||||
return;
|
||||
|
|
@ -1324,6 +1493,7 @@ export class FeishuHarnessBridge {
|
|||
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);
|
||||
} catch (error) {
|
||||
await this.#send(chatId, t('绑定失败:{message}', { message: safeErrorText(error) }));
|
||||
}
|
||||
|
|
@ -1333,12 +1503,32 @@ export class FeishuHarnessBridge {
|
|||
try {
|
||||
const current = await this.#harness.switchWorkspace(workspace);
|
||||
await this.#send(chatId, t('工作区已切换为:{workspace}', { workspace: current }));
|
||||
await this.#sendMenuCard(key, chatId);
|
||||
} catch (error) {
|
||||
await this.#send(chatId, t('切换失败:{message}', { message: safeErrorText(error) }));
|
||||
}
|
||||
}
|
||||
|
||||
async #sendCard(chatId, cardJson, options = {}) {
|
||||
const { update = false } = options;
|
||||
|
||||
// 尝试更新已有卡片(不发送新消息)
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = await this.#client.im.v1.message.create({
|
||||
params: { receive_id_type: 'chat_id' },
|
||||
data: { receive_id: chatId, msg_type: 'interactive', content: cardJson },
|
||||
|
|
@ -1360,9 +1550,410 @@ export class FeishuHarnessBridge {
|
|||
if (oldest !== undefined) this.#cardKeys.delete(oldest);
|
||||
}
|
||||
}
|
||||
if (messageId) {
|
||||
this.#lastCardMessages.set(chatId, messageId);
|
||||
}
|
||||
return messageId;
|
||||
}
|
||||
|
||||
async #sendMenuCard(key, chatId, { newCard = false } = {}) {
|
||||
let workspaces = [];
|
||||
let currentWorkspace = null;
|
||||
try {
|
||||
const snapshot = await workspacePathSnapshot(this.#harness);
|
||||
workspaces = snapshot.paths;
|
||||
currentWorkspace = snapshot.current ?? null;
|
||||
} catch {
|
||||
// workspace query failed; render menu without dropdown
|
||||
}
|
||||
// Current bound session (标题尽力获取,失败退化为 id) — 用于续写入口
|
||||
let currentSessionTitle = null;
|
||||
let currentSessionId = null;
|
||||
try {
|
||||
const sessionId = this.#state.sessionFor(key);
|
||||
if (typeof sessionId === 'string' && sessionId) {
|
||||
currentSessionId = sessionId;
|
||||
currentSessionTitle = await this.#resolveSessionTitle(key, sessionId) || sessionId;
|
||||
}
|
||||
} catch {
|
||||
currentSessionId = this.#state.sessionFor(key);
|
||||
currentSessionTitle = currentSessionId;
|
||||
}
|
||||
// 最近会话列表(会话下拉切换用;失败则留空数组 → 菜单回退按钮)
|
||||
let sessions = [];
|
||||
try {
|
||||
const current = typeof this.#harness.currentWorkspace === 'function'
|
||||
? this.#harness.currentWorkspace()
|
||||
: null;
|
||||
if (current && typeof this.#harness.listWorkspaceSessions === 'function') {
|
||||
const listed = await this.#harness.listWorkspaceSessions(current);
|
||||
sessions = (Array.isArray(listed?.sessions) ? listed.sessions : [])
|
||||
.map((s) => ({ id: s.sessionId, title: s.title ?? s.name ?? s.sessionId }))
|
||||
.slice(0, 20);
|
||||
}
|
||||
} catch { /* session dropdown unavailable; fall back to buttons */ }
|
||||
// 确保当前绑定会话始终出现在下拉最前(它可能不在最近列表里),
|
||||
// 否则 initial_index 找不到默认展示项,下拉会显示占位文本。
|
||||
if (currentSessionId) {
|
||||
sessions = sessions.filter((s) => s.id !== currentSessionId);
|
||||
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;
|
||||
let modelCatalog = null;
|
||||
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 */ }
|
||||
this.#rememberMenu(key, { kind: 'menu', chatId });
|
||||
await this.#sendCard(
|
||||
chatId,
|
||||
menuCard({
|
||||
workspaces, currentWorkspace,
|
||||
currentSession: currentSessionId ? { id: currentSessionId, title: currentSessionTitle } : null,
|
||||
sessions, archiveVisible, presetCatalog, modelCatalog,
|
||||
}),
|
||||
{ key, update: !newCard },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a human-readable title for a bound session when available.
|
||||
* Attempts the workspace-session object first, then falls back to the
|
||||
* current workspace's session list. Returns null on any failure.
|
||||
*/
|
||||
async #resolveSessionTitle(key, sessionId) {
|
||||
try {
|
||||
if (typeof this.#harness.workspaceSession === 'function') {
|
||||
const session = this.#harness.workspaceSession(sessionId);
|
||||
if (session && typeof session === 'object') {
|
||||
const direct = nonEmptyString(session.title)
|
||||
?? nonEmptyString(session.name)
|
||||
?? nonEmptyString(session.displayName);
|
||||
if (direct) return direct;
|
||||
}
|
||||
}
|
||||
// Fallback: scan the current workspace session list for this id
|
||||
const current = typeof this.#harness.currentWorkspace === 'function'
|
||||
? this.#harness.currentWorkspace()
|
||||
: null;
|
||||
if (current && typeof this.#harness.listWorkspaceSessions === 'function') {
|
||||
const listed = await this.#harness.listWorkspaceSessions(current);
|
||||
const match = (Array.isArray(listed?.sessions) ? listed.sessions : [])
|
||||
.find((s) => s.sessionId === sessionId);
|
||||
if (match) {
|
||||
const title = nonEmptyString(match.title) ?? nonEmptyString(match.name);
|
||||
if (title) return title;
|
||||
}
|
||||
}
|
||||
} catch { /* best-effort */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── 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) {
|
||||
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 });
|
||||
} catch (error) {
|
||||
this.#logger.warn?.('[dsh-feishu] preset card failed:', error.message);
|
||||
await this.#send(chatId, '暂时无法获取预设列表,请稍后重试。');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the model catalog and show the model selection card.
|
||||
*/
|
||||
async #showModelCard(key, chatId) {
|
||||
try {
|
||||
await this.#harness.ensureRunning({ signal: this.#signal });
|
||||
// Try to get the session-bound catalog first, fall back to harness-level
|
||||
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 });
|
||||
}
|
||||
await this.#sendCard(chatId, modelCard(catalog), { key });
|
||||
} catch (error) {
|
||||
this.#logger.warn?.('[dsh-feishu] model card failed:', error.message);
|
||||
await this.#send(chatId, '暂时无法获取模型列表,请稍后重试。');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gather system status and show the status card.
|
||||
*/
|
||||
async #showStatusText(key, chatId) {
|
||||
try {
|
||||
await this.#harness.ensureRunning({ signal: this.#signal });
|
||||
const lines = ['连接正常'];
|
||||
const ws = typeof this.#harness.currentWorkspace === 'function'
|
||||
? this.#harness.currentWorkspace()
|
||||
: null;
|
||||
if (ws) lines.push(`工作区:${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 || '跟随默认')}`);
|
||||
}
|
||||
await this.#send(chatId, lines.join('\n'));
|
||||
} catch (error) {
|
||||
this.#logger.warn?.('[dsh-feishu] status text failed:', error.message);
|
||||
await this.#send(chatId, '暂时无法获取系统状态,请稍后重试。');
|
||||
}
|
||||
}
|
||||
|
||||
async #showStatusCard(key, chatId) {
|
||||
try {
|
||||
await this.#harness.ensureRunning({ signal: this.#signal });
|
||||
const info = { connected: true, workspace: null, preset: null, model: null, sessionCount: 0 };
|
||||
|
||||
// Current workspace
|
||||
try {
|
||||
const ws = typeof this.#harness.currentWorkspace === 'function'
|
||||
? this.#harness.currentWorkspace()
|
||||
: null;
|
||||
info.workspace = ws || '未知';
|
||||
} 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 || '跟随默认');
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Model (from bound session or harness)
|
||||
try {
|
||||
const sessionId = this.#state?.sessionFor?.(key);
|
||||
if (typeof sessionId === 'string' && sessionId) {
|
||||
const session = this.#harness.workspaceSession(sessionId);
|
||||
if (session?.models) {
|
||||
const cat = await session.models({ signal: this.#signal });
|
||||
if (cat.current) info.model = `${cat.current.provider}/${cat.current.model}`;
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Session count
|
||||
try {
|
||||
const ws = typeof this.#harness.currentWorkspace === 'function'
|
||||
? this.#harness.currentWorkspace()
|
||||
: null;
|
||||
if (ws) {
|
||||
const listed = await this.#harness.listWorkspaceSessions(ws);
|
||||
if (Array.isArray(listed?.sessions)) info.sessionCount = listed.sessions.length;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
await this.#sendCard(chatId, statusCard(info), { key });
|
||||
} catch (error) {
|
||||
this.#logger.warn?.('[dsh-feishu] status card failed:', error.message);
|
||||
await this.#send(chatId, '暂时无法获取系统状态,请稍后重试。');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the help card with all command descriptions.
|
||||
*/
|
||||
async #showHelpCard(key, chatId) {
|
||||
await this.#sendCard(chatId, helpCard(WORKSPACE_HELP_LINES), { key });
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the /compact command and show the result.
|
||||
*/
|
||||
async #handleCompact(key, chatId) {
|
||||
try {
|
||||
const compactCommand = await runCompactCommand(
|
||||
'/compact', this.#harness, this.#state, key, { signal: this.#signal },
|
||||
);
|
||||
const text = compactCommand?.message || '上下文压缩失败。';
|
||||
await this.#send(chatId, text);
|
||||
} catch (error) {
|
||||
this.#logger.warn?.('[dsh-feishu] compact failed:', error.message, error.code);
|
||||
await this.#send(chatId, compactCommand?.message || '上下文压缩失败,请稍后重试。');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the running task in the bound session (mirrors `/stop`).
|
||||
*/
|
||||
async #handleStop(key, chatId) {
|
||||
try {
|
||||
const result = await runControlCommand(
|
||||
'/stop', this.#harness, this.#state, key, {
|
||||
signal: this.#signal,
|
||||
control: { owner: this, key },
|
||||
},
|
||||
);
|
||||
await this.#send(chatId, result?.message || '/stop 执行完成。');
|
||||
} catch (error) {
|
||||
this.#logger.warn?.('[dsh-feishu] stop failed:', error.message);
|
||||
await this.#send(chatId, '停止任务失败,请稍后重试。');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the steer card (quick-select dropdown + free-text input).
|
||||
*/
|
||||
async #showSteerCard(key, chatId) {
|
||||
const hasSession = Boolean(this.#state.sessionFor?.(key));
|
||||
this.#rememberMenu(key, { kind: 'steer' });
|
||||
await this.#sendCard(chatId, steerCard({ hasSession }), { key });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a steer instruction to the bound session (mirrors `/steer <text>`).
|
||||
*/
|
||||
async #sendSteer(entry, text) {
|
||||
const { key, chatId } = entry;
|
||||
const result = await runControlCommand(
|
||||
`/steer ${text}`, this.#harness, this.#state, key, {
|
||||
signal: this.#signal,
|
||||
control: { owner: this, key },
|
||||
},
|
||||
);
|
||||
const message = result?.message || '已提交补充指令。';
|
||||
this.#send(chatId, message).catch(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the preset to follow the Host default.
|
||||
*/
|
||||
async #handlePresetDefault(key, chatId) {
|
||||
try {
|
||||
if (typeof this.#harness?.updateAgentPreset !== 'function') {
|
||||
await this.#send(chatId, '当前 Host 暂不支持修改 Agent Preset。');
|
||||
return;
|
||||
}
|
||||
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, '预设重置失败,请稍后重试。');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle preset selection from the preset dropdown.
|
||||
*/
|
||||
async #handlePresetSelect(key, chatId, presetId) {
|
||||
try {
|
||||
if (typeof this.#harness?.updateAgentPreset !== 'function') {
|
||||
await this.#send(chatId, '当前 Host 暂不支持修改 Agent Preset。');
|
||||
return;
|
||||
}
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle model selection from the model dropdown.
|
||||
*
|
||||
* Reuses `runModelCommand` (the same path as the `/model <id>` text
|
||||
* command) so model IDs containing `/` (e.g.
|
||||
* `openrouter/anthropic/claude-sonnet-4`) keep working, and all the
|
||||
* catalog validation, busy checks, pending-interaction checks and the
|
||||
* session binding lock stay in one place.
|
||||
*/
|
||||
async #handleModelSelect(key, chatId, modelId) {
|
||||
try {
|
||||
const result = await runModelCommand(
|
||||
`/model ${modelId}`, this.#harness, this.#state, key, {
|
||||
signal: this.#signal,
|
||||
control: { owner: this, key },
|
||||
},
|
||||
);
|
||||
const message = result?.message || '模型切换失败,请稍后重试。';
|
||||
await this.#send(chatId, message);
|
||||
await this.#sendMenuCard(key, chatId);
|
||||
} catch (error) {
|
||||
this.#logger.warn?.('[dsh-feishu] model select failed:', error.message);
|
||||
await this.#send(chatId, `模型切换失败:${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Watches: read-only session tracking + completion pushes ─────────────
|
||||
|
||||
#ensureEventWatcher() {
|
||||
|
|
@ -1509,8 +2100,20 @@ export class FeishuHarnessBridge {
|
|||
|
||||
async #showWatchList(key, chatId) {
|
||||
const entries = this.#state.watchEntries?.(key) ?? [];
|
||||
// 收集可选会话(用于「添加关注」多选下拉);失败则传空数组 → 只渲染移除/列表。
|
||||
let availableSessions = [];
|
||||
try {
|
||||
const current = typeof this.#harness?.currentWorkspace === 'function'
|
||||
? this.#harness.currentWorkspace()
|
||||
: null;
|
||||
if (current && typeof this.#harness?.listWorkspaceSessions === 'function') {
|
||||
const listed = await this.#harness.listWorkspaceSessions(current);
|
||||
availableSessions = (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), { key });
|
||||
await this.#sendCard(chatId, watchListCard(entries, availableSessions), { key });
|
||||
}
|
||||
|
||||
/** Queue live turn completions behind any reconnect compensation. */
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ import { t } from '../shared/i18n.mjs';
|
|||
|
||||
export const MENU_PAGE_SIZE = 10;
|
||||
|
||||
/** 预设下拉中「跟随默认」(null) 的哨兵值,供 initial_index 命中与回调识别。 */
|
||||
export const PRESET_FOLLOW_DEFAULT_SENTINEL = '__preset_follow_default__';
|
||||
|
||||
function plainText(content) {
|
||||
return { tag: 'plain_text', content: String(content) };
|
||||
}
|
||||
|
|
@ -26,6 +29,7 @@ function markdown(content) {
|
|||
return { tag: 'lark_md', content: String(content) };
|
||||
}
|
||||
|
||||
/** Full-width button wrapped in a column_set. */
|
||||
function button(content, actionValue) {
|
||||
return {
|
||||
tag: 'column_set',
|
||||
|
|
@ -56,6 +60,33 @@ function buttonElement(content, actionValue) {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Two buttons side by side in a `column_set`.
|
||||
* `leftContent` / `leftAction` and `rightContent` / `rightAction` define
|
||||
* each button's label and callback action value.
|
||||
*/
|
||||
function buttonPair(leftContent, leftAction, rightContent, rightAction) {
|
||||
return {
|
||||
tag: 'column_set',
|
||||
flex_mode: 'none',
|
||||
columns: [
|
||||
{ tag: 'column', width: 'weighted', weight: 1, elements: [buttonElement(leftContent, leftAction)] },
|
||||
{ tag: 'column', width: 'weighted', weight: 1, elements: [buttonElement(rightContent, rightAction)] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Feishu schema 2.0 single-select (`select_static`) does NOT support a
|
||||
* `selected` field on options (that triggers 230099 parse json err). The
|
||||
* supported way to show a default value is `initial_index` (1-based option
|
||||
* order; 0 = show none). Build it from the option array + current value.
|
||||
*/
|
||||
function initialIndex(options, currentValue) {
|
||||
const idx = options.findIndex((o) => o.value === currentValue);
|
||||
return idx >= 0 ? idx + 1 : 0;
|
||||
}
|
||||
|
||||
function safeTitle(value) {
|
||||
const title = String(value ?? '').replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu, ' ').replace(/\s+/gu, ' ').trim();
|
||||
return title || t('暂无标题');
|
||||
|
|
@ -69,23 +100,582 @@ function cardWith(headerText, elements) {
|
|||
});
|
||||
}
|
||||
|
||||
/** The main command menu (buttons + number-reply fallback). */
|
||||
export function menuCard() {
|
||||
return cardWith(t('🤖 助手菜单'), [
|
||||
{ tag: 'div', text: markdown(t('**点击按钮或直接回复数字**')) },
|
||||
button(t('1 · 会话列表'), 'sessions'),
|
||||
button(t('2 · 工作区'), 'workspaces'),
|
||||
button(t('3 · 新会话'), 'new'),
|
||||
button(t('4 · 状态'), 'status'),
|
||||
button(t('5 · 帮助'), 'help'),
|
||||
// Repair must remain number-driven. Apps that need this command do not
|
||||
// have card.action.trigger yet, so rendering it as a callback button would
|
||||
// send the user straight back to Feishu's broken callback setup popup.
|
||||
{ tag: 'div', text: markdown(t('**6 · 修复卡片按钮**(请直接回复数字 **6**)')) },
|
||||
button(t('7 · 关注列表'), 'watchlist'),
|
||||
]);
|
||||
// ── Shared helper: a "back to menu" button ────────────────────────────────
|
||||
|
||||
function backButton() {
|
||||
return button('🔙 返回菜单', 'back_to_menu');
|
||||
}
|
||||
|
||||
// ── Main menu card ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The hub menu card — a single entry point covering every command, organized
|
||||
* by operating flow:
|
||||
* 1. 会话 · 工作区(高频率)→ 会话下拉 · 工作区下拉 · 新会话 · 全部会话
|
||||
* 2. 任务控制 → 停止 · 压缩 · 补充指令下拉
|
||||
* 3. 配置 → 预设下拉 · 模型下拉 · 归档切换
|
||||
* 4. 系统 → 状态 · 帮助
|
||||
* `ctx` bundle:
|
||||
* - workspaces: string[] (工作区下拉选项)
|
||||
* - currentWorkspace: string|null (当前工作区,下拉高亮)
|
||||
* - currentSession: {id,title}|null (当前绑定会话,续写目标)
|
||||
* - sessions: {id,title}[] (最近会话,供会话下拉切换)
|
||||
* - archiveVisible: boolean (归档显隐开关当前值)
|
||||
* - presetCatalog: object|null (预设目录, {items,defaultId,_currentId})
|
||||
* - modelCatalog: object|null (模型目录, {groups,current})
|
||||
* Number fallback: 1=续写 2=新会话 3=会话列表 4=状态
|
||||
* 5=修复 6=帮助.
|
||||
*/
|
||||
export function menuCard(ctx) {
|
||||
const {
|
||||
workspaces = [],
|
||||
currentWorkspace = null,
|
||||
currentSession = null,
|
||||
sessions = [],
|
||||
archiveVisible = false,
|
||||
presetCatalog = null,
|
||||
modelCatalog = null,
|
||||
} = ctx || {};
|
||||
|
||||
// currentSession 提供 id/title;为兼容旧调用,也接受 currentSessionTitle
|
||||
const currentSessionId = currentSession?.id ?? null;
|
||||
const currentSessionTitle = currentSession?.title ?? ctx?.currentSessionTitle ?? null;
|
||||
|
||||
const sessionOptions = (Array.isArray(sessions) ? sessions : []).slice(0, 20);
|
||||
const hasSessions = sessionOptions.length > 0;
|
||||
const hasWorkspaces = Array.isArray(workspaces) && workspaces.length > 0;
|
||||
const elements = [];
|
||||
|
||||
// ── 设置区 ──────────────────────────────────────────────────
|
||||
elements.push({ tag: 'div', text: markdown('**设置**') });
|
||||
|
||||
// ── 四个下拉菜单 2×2 网格 ────────────────────────────────────
|
||||
|
||||
// 第 1 行:会话 + 工作区
|
||||
let sessionDropdown = null;
|
||||
if (hasSessions) {
|
||||
const sessionPickOptions = sessionOptions.map((s) => ({
|
||||
text: { tag: 'plain_text', content: `${s.id === currentSessionId ? '✓ ' : ''}${safeTitle(s.title)}` },
|
||||
value: s.id,
|
||||
}));
|
||||
sessionDropdown = {
|
||||
tag: 'select_static',
|
||||
name: 'session_pick',
|
||||
placeholder: { tag: 'plain_text', content: '切换会话' },
|
||||
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;
|
||||
if (hasWorkspaces) {
|
||||
const wsOptions = workspaces.slice(0, 20).map((path) => ({
|
||||
text: { tag: 'plain_text', content: `${path === currentWorkspace ? '✓ ' : ''}${path}` },
|
||||
value: path,
|
||||
}));
|
||||
workspaceDropdown = {
|
||||
tag: 'select_static',
|
||||
name: 'workspace_pick',
|
||||
placeholder: { tag: 'plain_text', content: '切换工作区' },
|
||||
initial_index: initialIndex(wsOptions, currentWorkspace),
|
||||
options: wsOptions,
|
||||
behaviors: [{ type: 'callback', value: { action: 'workspace_pick' } }],
|
||||
};
|
||||
}
|
||||
|
||||
// 第 2 行:预设 + 模型
|
||||
const presetItems = Array.isArray(presetCatalog?.items) ? presetCatalog.items : [];
|
||||
const curPresetId = presetCatalog?._currentId ?? null;
|
||||
const presetFollowDefault = curPresetId === null;
|
||||
let presetDropdown = null;
|
||||
if (presetItems.length >= 1) {
|
||||
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,
|
||||
});
|
||||
presetDropdown = {
|
||||
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' } }],
|
||||
};
|
||||
}
|
||||
|
||||
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}` });
|
||||
}
|
||||
let modelDropdown = null;
|
||||
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,
|
||||
}));
|
||||
modelDropdown = {
|
||||
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' } }],
|
||||
};
|
||||
}
|
||||
|
||||
// 渲染 2×2 网格:图标 + 下拉并列
|
||||
// 每一行用 4 列 column_set:图标 | 下拉 | 图标 | 下拉
|
||||
function iconCol(icon) {
|
||||
return { tag: 'column', width: 'weighted', weight: 0.1, vertical_align: 'center', elements: [{ tag: 'div', text: { tag: 'plain_text', content: icon } }] };
|
||||
}
|
||||
function dropdownCol(el) {
|
||||
return { tag: 'column', width: 'weighted', weight: 1, elements: [el] };
|
||||
}
|
||||
|
||||
const row1 = [];
|
||||
if (sessionDropdown) row1.push(sessionDropdown);
|
||||
if (workspaceDropdown) row1.push(workspaceDropdown);
|
||||
if (row1.length === 2) {
|
||||
elements.push({
|
||||
tag: 'column_set', flex_mode: 'none',
|
||||
columns: [iconCol('💬'), dropdownCol(row1[0]), iconCol('📂'), dropdownCol(row1[1])],
|
||||
});
|
||||
} else if (row1[0]) {
|
||||
elements.push(row1[0]);
|
||||
}
|
||||
|
||||
const row2 = [];
|
||||
const presetBtn = button('🤖 切换预设', 'presets');
|
||||
const modelBtn = button('🧠 切换模型', 'models');
|
||||
if (presetDropdown) row2.push(presetDropdown); else row2.push(presetBtn);
|
||||
if (modelDropdown) row2.push(modelDropdown); else row2.push(modelBtn);
|
||||
elements.push({
|
||||
tag: 'column_set', flex_mode: 'none',
|
||||
columns: [iconCol('🤖'), dropdownCol(row2[0]), iconCol('🧠'), dropdownCol(row2[1])],
|
||||
});
|
||||
|
||||
// 新会话 + 全部会话按钮
|
||||
elements.push(buttonPair('🆕 新会话', 'new', '📋 会话/关注', 'sessions'));
|
||||
if (!hasSessions && !hasWorkspaces) {
|
||||
elements.push(button('🗂 工作区列表', 'workspaces'));
|
||||
}
|
||||
elements.push({ tag: 'hr' });
|
||||
|
||||
// ── 任务控制(对运行中任务的操作)────────────────────────
|
||||
elements.push({ tag: 'div', text: markdown('**任务控制**') });
|
||||
elements.push(buttonPair('⏹ 停止', 'stop', '📐 压缩', 'compact'));
|
||||
elements.push({ tag: 'hr' });
|
||||
|
||||
// ── 补充指令 + 归档切换(并列)────────────────────────────
|
||||
elements.push({
|
||||
tag: 'column_set', flex_mode: 'none',
|
||||
columns: [
|
||||
{
|
||||
tag: 'column', width: 'weighted', weight: 1,
|
||||
elements: [
|
||||
{ tag: 'div', text: markdown('**补充指令**') },
|
||||
{
|
||||
tag: 'select_static',
|
||||
name: 'steer_pick',
|
||||
placeholder: { tag: 'plain_text', content: '选择补充指令' },
|
||||
initial_index: 1,
|
||||
options: [
|
||||
...QUICK_STEER_OPTIONS.map((text) => ({
|
||||
text: { tag: 'plain_text', content: text },
|
||||
value: text,
|
||||
})),
|
||||
{ text: { tag: 'plain_text', content: '✏️ 更多 / 自定义…' }, value: 'custom' },
|
||||
],
|
||||
behaviors: [{ type: 'callback', value: { action: 'steer_pick' } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
tag: 'column', width: 'weighted', weight: 1,
|
||||
elements: [
|
||||
{ tag: 'div', text: markdown(`🗄 归档:${archiveVisible ? '已显示' : '已隐藏'}`) },
|
||||
button('切换归档显示', 'archive_toggle'),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
elements.push({ tag: 'hr' });
|
||||
|
||||
// ── 底部操作(系统功能)────────────────────────────────────
|
||||
elements.push(buttonPair('📊 状态', 'status', '📖 帮助', '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);
|
||||
}
|
||||
|
||||
// ── Preset management card ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Preset selection card with a dropdown of available presets.
|
||||
* The catalog comes from `harness.agentPresetSettings()`.
|
||||
*/
|
||||
export function presetCard(catalog) {
|
||||
const { items, defaultId } = catalog;
|
||||
const current = catalog._currentId; // injected by the caller
|
||||
const currentItem = items.find((i) => i.id === current);
|
||||
const defaultItem = items.find((i) => i.id === defaultId);
|
||||
|
||||
const currentText = current === null
|
||||
? `跟随 Host 默认${defaultItem ? `(${defaultItem.label})` : ''}`
|
||||
: currentItem ? `${currentItem.label}(${currentItem.id})` : `${current}(已不可用)`;
|
||||
|
||||
const elements = [
|
||||
{ tag: 'div', text: markdown(`**当前**:${currentText}`) },
|
||||
{ tag: 'div', text: markdown(`**Host 默认**:${defaultItem ? `${defaultItem.label}(${defaultItem.id})` : '未设置'}`) },
|
||||
{ tag: 'hr' },
|
||||
];
|
||||
|
||||
if (items.length > 1) {
|
||||
const presetCardOptions = items.map((item) => ({
|
||||
text: { tag: 'plain_text', content: `${item.label}(${item.id})${item.id === current ? ' ✓' : ''}` },
|
||||
value: item.id,
|
||||
}));
|
||||
elements.push(
|
||||
{
|
||||
tag: 'select_static',
|
||||
name: 'preset_pick',
|
||||
placeholder: { tag: 'plain_text', content: '选择预设' },
|
||||
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'),
|
||||
);
|
||||
} else {
|
||||
elements.push(
|
||||
{ tag: 'div', text: markdown('当前没有可选择的预设。') },
|
||||
backButton(),
|
||||
);
|
||||
}
|
||||
return cardWith('🤖 预设列表', elements);
|
||||
}
|
||||
|
||||
// ── Model management card ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Model selection card with a dropdown of available models.
|
||||
* The catalog comes from `harness.listModels()` or `session.models()`.
|
||||
* Groups are shown as text, then a flat dropdown for selection.
|
||||
*/
|
||||
export function modelCard(catalog) {
|
||||
const { groups, current } = catalog;
|
||||
const currentId = current ? `${current.provider}/${current.model}` : null;
|
||||
|
||||
const elements = [
|
||||
{ tag: 'div', text: markdown(`**当前模型**:${currentId || '未设置'}`) },
|
||||
{ tag: 'hr' },
|
||||
];
|
||||
|
||||
// Count total models
|
||||
let total = 0;
|
||||
for (const group of groups) total += group.models.length;
|
||||
|
||||
if (total === 0) {
|
||||
elements.push(
|
||||
{ tag: 'div', text: markdown('当前没有可用模型。') },
|
||||
backButton(),
|
||||
);
|
||||
} else {
|
||||
// Show groups as text
|
||||
for (const group of groups) {
|
||||
const modelLines = group.models.map((m) => {
|
||||
const id = `${group.id}/${m.id}`;
|
||||
return `${id}${id === currentId ? ' ✓' : ''}`;
|
||||
});
|
||||
elements.push(
|
||||
{ tag: 'div', text: markdown(`**${group.name}**\n${modelLines.join('\n')}`) },
|
||||
);
|
||||
}
|
||||
|
||||
// Flatten all models into a dropdown
|
||||
const allOptions = [];
|
||||
for (const group of groups) {
|
||||
for (const model of group.models) {
|
||||
allOptions.push({
|
||||
value: `${group.id}/${model.id}`,
|
||||
text: { tag: 'plain_text', content: `${group.name} - ${model.name}` },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
elements.push(
|
||||
{ tag: 'hr' },
|
||||
{
|
||||
tag: 'select_static',
|
||||
name: 'model_pick',
|
||||
placeholder: { tag: 'plain_text', content: '选择模型' },
|
||||
initial_index: currentId === null ? 0 : initialIndex(allOptions, currentId),
|
||||
options: allOptions.map((opt) => ({
|
||||
text: { tag: 'plain_text', content: `${opt.text.content}${opt.value === currentId ? ' ✓' : ''}` },
|
||||
value: opt.value,
|
||||
})),
|
||||
behaviors: [{ type: 'callback', value: { action: 'model_pick' } }],
|
||||
},
|
||||
{ tag: 'hr' },
|
||||
backButton(),
|
||||
);
|
||||
}
|
||||
return cardWith('🧠 模型列表', elements);
|
||||
}
|
||||
|
||||
// ── Status card ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* System status card showing connection, workspace, preset, model and session
|
||||
* count. `info` is an object with keys:
|
||||
* - connected: boolean
|
||||
* - workspace: string | null
|
||||
* - preset: string | null
|
||||
* - model: string | null
|
||||
* - sessionCount: number
|
||||
*/
|
||||
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: 'hr' },
|
||||
backButton(),
|
||||
];
|
||||
return cardWith('📊 系统状态', elements);
|
||||
}
|
||||
|
||||
// ── Help card ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Plain-text command reference advertised when no interactive card is
|
||||
* available (e.g. non-interactive clients), so every command stays
|
||||
* discoverable without card callbacks.
|
||||
*/
|
||||
export function menuHelpText() {
|
||||
return [
|
||||
'🤖 助手菜单(回复数字即可,无需记命令)',
|
||||
'',
|
||||
'📋 会话 / 工作区',
|
||||
'/sessionlist 列出工作区会话',
|
||||
'/session ID 绑定已有会话',
|
||||
'/workspacelist 列出工作区',
|
||||
'/workspace 路径 切换工作区',
|
||||
'/new 开启全新会话',
|
||||
'',
|
||||
'📊 状态 / 压缩',
|
||||
'/status 连接状态',
|
||||
'/compact 压缩当前会话上下文',
|
||||
'/archived on/off 会话列表显示/隐藏归档',
|
||||
'',
|
||||
'👁 关注',
|
||||
'/watch ID 关注会话(完成后推送)',
|
||||
'/watchlist 关注列表',
|
||||
'/unwatch ID 取消关注',
|
||||
'',
|
||||
'🤖 预设 / 模型',
|
||||
'/presetlist 列出可用 Agent Preset',
|
||||
'/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset',
|
||||
'纯数字 ID:/preset id:<ID>',
|
||||
'/preset --default 跟随 Host 默认',
|
||||
'/models 列出模型',
|
||||
'/model 2 按序号切换模型',
|
||||
'',
|
||||
'🎮 任务控制',
|
||||
'/stop 停止当前任务',
|
||||
'/steer 指令 给 Agent 补充指令',
|
||||
'/repair 修复卡片按钮回调',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Help card with all available commands and their descriptions.
|
||||
*/
|
||||
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: '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: 'hr' },
|
||||
{ tag: 'div', text: markdown('**💡 数字兜底**\n回复数字快速操作:\n' +
|
||||
'**1**工作区列表 · **2**新会话 · **3**会话/关注\n' +
|
||||
'**4**状态 · **5**修复 · **6**帮助') },
|
||||
{ tag: 'hr' },
|
||||
backButton(),
|
||||
];
|
||||
return cardWith('📖 帮助', elements);
|
||||
}
|
||||
|
||||
// ── One-shot callback probe (repair verification) ─────────────────────────
|
||||
|
||||
/** One-shot callback probe used only after an existing app was re-authorized. */
|
||||
export function cardActionProbeCard(nonce) {
|
||||
if (typeof nonce !== 'string' || !/^[A-Za-z0-9_-]{16,128}$/.test(nonce)) {
|
||||
|
|
@ -118,6 +708,8 @@ export function cardActionProbeCard(nonce) {
|
|||
]);
|
||||
}
|
||||
|
||||
// ── Session list card (preserved, with back button) ───────────────────────
|
||||
|
||||
/**
|
||||
* One page of the workspace's sessions. Each row is a `column_set` pair:
|
||||
* the fixed-width ⭐ watch toggle (`⭐关注` / `⭐取关` for already-watched
|
||||
|
|
@ -154,70 +746,105 @@ export function sessionListCard(workspace, sessions, page, total, watchedSession
|
|||
const label = `${offset + 1}. ${safeTitle(session.title)}${session.archived === true ? t('(已归档)') : ''}`;
|
||||
const watching = watched(session.sessionId);
|
||||
return row(
|
||||
buttonElement(watching ? t('⭐取关') : t('⭐关注'), watching ? `unwatch:${session.sessionId}` : `watch:${session.sessionId}`),
|
||||
buttonElement(watching ? t('⭐ 取消关注') : t('☆ 关注'), watching ? `unwatch:${session.sessionId}` : `watch:${session.sessionId}`),
|
||||
buttonElement(label, `use:${session.sessionId}`),
|
||||
);
|
||||
}),
|
||||
];
|
||||
if (page > 0) elements.push(button(t('◀ 上一页'), `sessions:${page - 1}`));
|
||||
if (page > 0) elements.push(button('◀ 上一页', `sessions:${page - 1}`));
|
||||
if (page + 1 < pageCount) elements.push(button(t('下一页 ▶'), `sessions:${page + 1}`));
|
||||
elements.push({ tag: 'div', text: markdown(t('回复数字(1~N)绑定本页会话。')) });
|
||||
return cardWith(t('📂 会话列表'), elements);
|
||||
elements.push(
|
||||
{ tag: 'hr' },
|
||||
buttonPair('🔙 返回菜单', 'back_to_menu', t('🔍 关注列表'), 'watchlist'),
|
||||
{ tag: 'div', text: markdown(t('回复数字(1~N)绑定本页会话。')) },
|
||||
);
|
||||
return cardWith('📂 会话列表', elements);
|
||||
}
|
||||
|
||||
// ── Workspace list card (preserved, with back button) ─────────────────────
|
||||
|
||||
/** The workspace list card (switch-workspace buttons + reply fallback). */
|
||||
export function workspaceListCard(paths, current) {
|
||||
const elements = paths.length === 0
|
||||
? [{ tag: 'div', text: markdown(t('当前 Host 上没有已登记的工作区。')) }]
|
||||
? [
|
||||
{ tag: 'div', text: markdown(t('当前 Host 上没有已登记的工作区。')) },
|
||||
backButton(),
|
||||
]
|
||||
: [
|
||||
{ tag: 'div', text: markdown(t('回复数字切换工作区,或点击按钮:')) },
|
||||
...paths.map((path, index) => button(
|
||||
`${index + 1}. ${path}${path === current ? t('(当前)') : ''}`,
|
||||
`workspace:${path}`,
|
||||
)),
|
||||
{ tag: 'hr' },
|
||||
backButton(),
|
||||
];
|
||||
return cardWith(t('🗂 工作区'), elements);
|
||||
}
|
||||
|
||||
/** The card-menu help text (number-driven, no command memorization). */
|
||||
export function menuHelpText() {
|
||||
return [
|
||||
t('🤖 助手菜单(回复数字即可,无需记命令)'),
|
||||
'',
|
||||
t('1 · /sessionlist 列出会话(回复数字绑定)'),
|
||||
t('2 · /workspacelist 列出工作区(回复数字切换)'),
|
||||
t('3 · /new 开启新会话'),
|
||||
t('4 · /status 连接状态'),
|
||||
t('5 · /help 本帮助'),
|
||||
t('6 · /repair 修复卡片按钮(请回复数字 6)'),
|
||||
t('7 · /watchlist 关注列表'),
|
||||
'',
|
||||
t('直接发送文字/图片即继续当前会话。'),
|
||||
t('/session ID 或序号 绑定已有会话'),
|
||||
t('/watch ID 或序号 关注会话(完成后推送)'),
|
||||
t('/compact 压缩上下文'),
|
||||
t('/workspace 绝对路径 切换工作区'),
|
||||
t('/presetlist 列出可用 Agent Preset'),
|
||||
t('/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset'),
|
||||
t('纯数字 ID:/preset id:<ID>'),
|
||||
t('/preset --default 跟随 Host 默认'),
|
||||
].join('\n');
|
||||
// ── Watch list card (multi-select add/remove + back button) ──────────────
|
||||
|
||||
/** Dropdown (multi-select) of sessions not yet watched, to add them in bulk. */
|
||||
function watchAddSelect(entries, availableSessions) {
|
||||
const watched = new Set(entries.map((e) => e.sessionId));
|
||||
const options = (Array.isArray(availableSessions) ? availableSessions : [])
|
||||
.filter((s) => s?.sessionId && !watched.has(s.sessionId))
|
||||
.slice(0, 30)
|
||||
.map((s) => ({
|
||||
text: { tag: 'plain_text', content: safeTitle(s.title ?? s.sessionId) },
|
||||
value: s.sessionId,
|
||||
}));
|
||||
if (options.length === 0) return null;
|
||||
return {
|
||||
tag: 'multi_select_static',
|
||||
name: 'watch_add',
|
||||
placeholder: { tag: 'plain_text', content: t('勾选要关注的会话') },
|
||||
options,
|
||||
behaviors: [{ type: 'callback', value: { action: 'watch_add', kind: 'multi' } }],
|
||||
};
|
||||
}
|
||||
|
||||
/** The watch list for one conversation (unwatch buttons + reply fallback). */
|
||||
export function watchListCard(entries) {
|
||||
const elements = entries.length === 0
|
||||
? [{ tag: 'div', text: markdown(t('当前没有关注的会话。\n`/watch <ID|序号>` 关注后,任务完成会自动推送。')) }]
|
||||
: [
|
||||
{ tag: 'div', text: markdown(t('任务完成会自动推送,回复数字或点按钮取消关注:')) },
|
||||
...entries.map((entry, index) => button(
|
||||
`${index + 1}. ${safeTitle(entry.title)}`,
|
||||
`unwatch:${entry.sessionId}`,
|
||||
)),
|
||||
];
|
||||
/** Dropdown (multi-select) of currently watched sessions, to remove in bulk. */
|
||||
function watchRemoveSelect(entries) {
|
||||
const options = entries.slice(0, 30).map((entry) => ({
|
||||
text: { tag: 'plain_text', content: safeTitle(entry.title) },
|
||||
value: entry.sessionId,
|
||||
}));
|
||||
if (options.length === 0) return null;
|
||||
return {
|
||||
tag: 'multi_select_static',
|
||||
name: 'watch_remove',
|
||||
placeholder: { tag: 'plain_text', content: t('勾选要取消关注的会话') },
|
||||
options,
|
||||
behaviors: [{ type: 'callback', value: { action: 'watch_remove', kind: 'multi' } }],
|
||||
};
|
||||
}
|
||||
|
||||
/** The watch list for one conversation (multi-select add/remove + buttons). */
|
||||
export function watchListCard(entries, availableSessions) {
|
||||
const watching = Array.isArray(entries) ? entries : [];
|
||||
const addSelect = watchAddSelect(watching, availableSessions);
|
||||
const removeSelect = watchRemoveSelect(watching);
|
||||
|
||||
const elements = [];
|
||||
if (watching.length === 0) {
|
||||
elements.push({ tag: 'div', text: markdown(t('当前没有关注的会话。任务完成会自动推送结果。')) });
|
||||
} else {
|
||||
elements.push({ tag: 'div', text: markdown(t('当前关注 **{count}** 个会话:', { count: watching.length })) });
|
||||
}
|
||||
|
||||
if (addSelect) {
|
||||
elements.push({ tag: 'hr' }, { tag: 'div', text: markdown(t('**➕ 添加关注**(多选下拉勾选)')) }, addSelect);
|
||||
}
|
||||
if (removeSelect) {
|
||||
elements.push({ tag: 'hr' }, { tag: 'div', text: markdown(t('**➖ 取消关注**(多选下拉勾选)')) }, removeSelect);
|
||||
}
|
||||
elements.push({ tag: 'hr' }, buttonPair(t('📋 会话列表'), 'sessions', t('🔙 返回菜单'), 'back_to_menu'));
|
||||
return cardWith(t('👁 关注列表'), elements);
|
||||
}
|
||||
|
||||
// ── Completion push card ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The completion push card. `title` is the session title, `reason` the
|
||||
* turn-end kind (completed / stopped / aborted).
|
||||
|
|
@ -235,8 +862,73 @@ export function completionCard(sessionId, title, reason) {
|
|||
return cardWith(t('✅ 任务完成'), [
|
||||
{ tag: 'div', text: markdown(`**${safeTitle(title)}**\n\`${sessionId}\``) },
|
||||
{ tag: 'div', text: markdown(t('**状态**:{reason}', { reason: reasonText })) },
|
||||
button(t('打开会话列表'), 'sessions'),
|
||||
button(t('工作区'), 'workspaces'),
|
||||
{ tag: 'hr' },
|
||||
buttonPair(t('📋 会话列表'), 'sessions', t('🔙 返回菜单'), 'back_to_menu'),
|
||||
{ tag: 'div', text: markdown(t('绑定该会话后可继续追问,输入文字即可。')) },
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Steer card (补充指令) ─────────────────────────────────────────────────
|
||||
|
||||
/** 常用补充指令的快捷选项,供下拉秒选。 */
|
||||
const QUICK_STEER_OPTIONS = ['继续', '加速运行', '总结当前进展', '更简洁些', '更详细些'];
|
||||
|
||||
/** 快捷下拉的最后一项:切换到自定义输入卡片。 */
|
||||
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,
|
||||
}));
|
||||
options.push({ text: { tag: 'plain_text', content: '✏️ 更多 / 自定义…' }, 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,
|
||||
options,
|
||||
behaviors: [{ type: 'callback', value: { action: 'steer', source: 'quick' } }],
|
||||
},
|
||||
{ tag: 'hr' },
|
||||
button('🔙 返回菜单', 'back_to_menu'),
|
||||
];
|
||||
return cardWith('➕ 补充指令', elements);
|
||||
}
|
||||
|
||||
/** 自定义输入卡:输入框 + 提交(从下拉「更多/自定义」切过来)。 */
|
||||
export function customSteerCard() {
|
||||
const elements = [
|
||||
{ tag: 'div', text: markdown('输入补充指令后点「提交」,发送给当前运行的任务。') },
|
||||
{ tag: 'hr' },
|
||||
{
|
||||
tag: 'form',
|
||||
name: 'steer_form',
|
||||
elements: [
|
||||
{
|
||||
tag: 'input',
|
||||
name: 'steer_text',
|
||||
placeholder: { tag: 'plain_text', content: '输入你的补充指令' },
|
||||
},
|
||||
{
|
||||
tag: 'button',
|
||||
text: { tag: 'plain_text', content: '提交' },
|
||||
type: 'primary',
|
||||
width: 'fill',
|
||||
form_action_type: 'submit',
|
||||
behaviors: [{ type: 'callback', value: { action: 'steer', source: 'form' } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ tag: 'hr' },
|
||||
button('🔙 返回菜单', 'back_to_menu'),
|
||||
];
|
||||
return cardWith('➕ 自定义指令', elements);
|
||||
}
|
||||
|
|
@ -70,7 +70,7 @@ export async function runControlCommand(text, harness, state, key, {
|
|||
|
||||
const session = boundSession(harness, state, key);
|
||||
if (!session) {
|
||||
return commandResult(t('当前聊天没有正在运行的任务,请直接发送普通消息。'));
|
||||
return commandResult(t('当前聊天没有绑定会话,无法补充指令。请先绑定会话。'));
|
||||
}
|
||||
if (typeof session.steerActiveTurn !== 'function') {
|
||||
throw new TypeError('Harness session does not support steering active turns');
|
||||
|
|
@ -82,5 +82,5 @@ export async function runControlCommand(text, harness, state, key, {
|
|||
);
|
||||
return steered
|
||||
? commandResult(t('已提交补充指令,Agent 会在下一步读取。'))
|
||||
: commandResult(t('当前聊天没有正在运行的任务,请直接发送普通消息。'));
|
||||
: commandResult(t('任务已结束,没有正在运行的任务,无法补充指令。请直接发送消息开始新任务。'));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -182,6 +182,18 @@ export default {
|
|||
'任务完成会自动推送,回复数字或点按钮取消关注:':
|
||||
'Completion is pushed automatically. Reply with a number or tap a button to unwatch:',
|
||||
'👁 关注列表': '👁 Watch list',
|
||||
'⭐ 取消关注': '⭐ Unwatch',
|
||||
'☆ 关注': '☆ Watch',
|
||||
'🔍 关注列表': '🔍 Watch list',
|
||||
'勾选要关注的会话': 'Select sessions to watch',
|
||||
'勾选要取消关注的会话': 'Select sessions to unwatch',
|
||||
'当前没有关注的会话。任务完成会自动推送结果。':
|
||||
'No sessions are being watched. Results are pushed automatically when tasks complete.',
|
||||
'当前关注 **{count}** 个会话:': 'Currently watching **{count}** sessions:',
|
||||
'**➕ 添加关注**(多选下拉勾选)': '**➕ Add watch** (select from the multi-select dropdown)',
|
||||
'**➖ 取消关注**(多选下拉勾选)': '**➖ Remove watch** (select from the multi-select dropdown)',
|
||||
'📋 会话列表': '📋 Session list',
|
||||
'🔙 返回菜单': '🔙 Back to menu',
|
||||
'已完成': 'Completed',
|
||||
'已停止': 'Stopped',
|
||||
'已中止': 'Aborted',
|
||||
|
|
|
|||
|
|
@ -210,6 +210,10 @@ export default {
|
|||
'已请求停止当前任务。': 'Stop requested for the current task.',
|
||||
'当前聊天没有正在运行的任务,请直接发送普通消息。':
|
||||
'This chat has no running task; just send a regular message.',
|
||||
'当前聊天没有绑定会话,无法补充指令。请先绑定会话。':
|
||||
'No session is bound to this chat, so an additional instruction cannot be given. Bind a session first.',
|
||||
'任务已结束,没有正在运行的任务,无法补充指令。请直接发送消息开始新任务。':
|
||||
'The task has ended and there is no running task to steer. Send a message to start a new task.',
|
||||
'已提交补充指令,Agent 会在下一步读取。':
|
||||
'Additional instruction submitted; the Agent will read it at the next step.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2679,7 +2679,7 @@ test('card buttons from an allowed sender work', async () => {
|
|||
|
||||
await bridge.onCardAction(cardActionEvent('om_card_1', 'new', 'ou_owner'));
|
||||
await bridge.waitForIdle();
|
||||
assert.equal(sent.length, 2, 'allowed operator click should send a reply');
|
||||
assert.equal(sent.length, 3, 'allowed operator click should send a reply + menu card update');
|
||||
});
|
||||
|
||||
test('card buttons honor the wildcard sender allowlist', async () => {
|
||||
|
|
@ -2769,7 +2769,9 @@ test('number replies on a later session page use page-local labels', async () =>
|
|||
|
||||
await bridge.accept(event('sessions-number-pick', '1', { senderOpenId: 'ou_owner' }));
|
||||
await bridge.waitForIdle();
|
||||
assert.match(JSON.parse(sent.at(-1).content).text, /ID:session-21/);
|
||||
// #bindSession 先发文本确认,再原地更新/重发菜单卡;取最后一条文本消息验证绑定结果
|
||||
const texts = sent.filter((m) => m.msgType !== 'interactive');
|
||||
assert.match(JSON.parse(texts.at(-1).content).text, /ID:session-21/);
|
||||
});
|
||||
|
||||
test('session pagination preserves an explicitly selected workspace', async () => {
|
||||
|
|
@ -2929,17 +2931,17 @@ test('/repair status after a runtime restart never starts a duplicate authorizat
|
|||
assert.match(message, /不会启动新的授权/);
|
||||
});
|
||||
|
||||
test('menu repair entry is number-only and reply 6 starts the same repair flow', async () => {
|
||||
test('menu repair entry is number-only and reply 5 starts the same repair flow', async () => {
|
||||
const repair = repairCapability();
|
||||
const fx = repairBridge({ capability: repair.capability });
|
||||
|
||||
await fx.bridge.accept(event('repair-menu-open', '/m', { senderOpenId: 'ou_owner' }));
|
||||
await fx.bridge.waitForIdle();
|
||||
const menu = cards(fx.sent)[0].content;
|
||||
assert.match(JSON.stringify(menu), /6 · 修复卡片按钮/);
|
||||
assert.match(JSON.stringify(menu), /\*\*5\*\*🔧修复/);
|
||||
assert.equal(buttonsFromCard(menu).some((button) => callbackAction(button) === 'repair'), false);
|
||||
|
||||
await fx.bridge.accept(event('repair-menu-six', '6', { senderOpenId: 'ou_owner' }));
|
||||
await fx.bridge.accept(event('repair-menu-five', '5', { senderOpenId: 'ou_owner' }));
|
||||
await fx.bridge.waitForIdle();
|
||||
assert.equal(repair.calls.start.length, 1);
|
||||
assert.equal(fx.asks, 0);
|
||||
|
|
@ -3455,3 +3457,177 @@ test('archived sessions are hidden by default; /archived on reveals them', async
|
|||
const useOn = useActionsFromCard(cards.at(-1));
|
||||
assert.deepEqual(useOn, ['live-session', 'old-session'], '/archived on restores archived sessions');
|
||||
});
|
||||
|
||||
// ── stop / steer while a task is running (menu card interactions) ──────────
|
||||
|
||||
function activeTurnHarness({ stopped = true, steered = true } = {}) {
|
||||
const calls = { stop: [], steer: [], sessions: [] };
|
||||
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;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function steerDropdownEvent(messageId, option, operatorOpenId) {
|
||||
return {
|
||||
operator: { open_id: operatorOpenId },
|
||||
action: { value: { action: 'steer_pick' }, option },
|
||||
context: { open_message_id: messageId },
|
||||
};
|
||||
}
|
||||
|
||||
test('menu stop button stops the bound active turn without touching the model', async () => {
|
||||
const fixture = stateFixture([['p2p:ou_owner', 'session-active']]);
|
||||
const sent = [];
|
||||
const { calls, harness } = activeTurnHarness();
|
||||
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('menu-stop-open', '/m', { senderOpenId: 'ou_owner' }));
|
||||
await bridge.waitForIdle();
|
||||
assert.equal(sent.length, 1);
|
||||
|
||||
await bridge.onCardAction(cardActionEvent('om_card_1', 'stop', 'ou_owner'));
|
||||
await bridge.waitForIdle();
|
||||
|
||||
assert.equal(calls.sessions.some((id) => id === 'session-active'), true);
|
||||
assert.equal(calls.stop.length, 1);
|
||||
assert.equal(calls.stop[0].control.key, 'p2p:ou_owner');
|
||||
assert.match(JSON.parse(sent.at(-1).content).text, /已请求停止当前任务/);
|
||||
});
|
||||
|
||||
test('menu steer dropdown sends a quick instruction to the active turn', async () => {
|
||||
const fixture = stateFixture([['p2p:ou_owner', 'session-active']]);
|
||||
const sent = [];
|
||||
const { calls, harness } = activeTurnHarness();
|
||||
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('menu-steer-open', '/m', { senderOpenId: 'ou_owner' }));
|
||||
await bridge.waitForIdle();
|
||||
assert.equal(sent.length, 1);
|
||||
|
||||
await bridge.onCardAction(steerDropdownEvent('om_card_1', '继续', 'ou_owner'));
|
||||
await bridge.waitForIdle();
|
||||
|
||||
assert.equal(calls.steer.length, 1);
|
||||
assert.equal(calls.steer[0].text, '继续');
|
||||
assert.equal(calls.steer[0].control.key, 'p2p:ou_owner');
|
||||
assert.match(JSON.parse(sent.at(-1).content).text, /已提交补充指令/);
|
||||
});
|
||||
|
||||
test('menu steer custom option opens the form card without steering', async () => {
|
||||
const fixture = stateFixture([['p2p:ou_owner', 'session-active']]);
|
||||
const sent = [];
|
||||
const { calls, harness } = activeTurnHarness();
|
||||
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('menu-steer-custom-open', '/m', { senderOpenId: 'ou_owner' }));
|
||||
await bridge.waitForIdle();
|
||||
await bridge.onCardAction(steerDropdownEvent('om_card_1', 'custom', 'ou_owner'));
|
||||
await bridge.waitForIdle();
|
||||
|
||||
assert.equal(calls.steer.length, 0, 'custom option must not steer yet');
|
||||
const customCard = sent.at(-1);
|
||||
assert.equal(customCard.msgType, 'interactive');
|
||||
const form = customCard.content.body.elements.find((el) => el.tag === 'form');
|
||||
assert.ok(form, 'custom steer card must contain a form');
|
||||
const submit = form.elements.find((el) => el.tag === 'button');
|
||||
assert.equal(submit?.form_action_type, 'submit');
|
||||
});
|
||||
|
||||
test('custom steer form submission reads form_value and steers the active turn', async () => {
|
||||
const fixture = stateFixture([['p2p:ou_owner', 'session-active']]);
|
||||
const sent = [];
|
||||
const { calls, harness } = activeTurnHarness();
|
||||
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('steer-form-open', '/m', { senderOpenId: 'ou_owner' }));
|
||||
await bridge.waitForIdle();
|
||||
// 主菜单 steer 下拉选「更多/自定义」→ 发送 custom 表单卡(第二次 create → om_card_2)
|
||||
await bridge.onCardAction(steerDropdownEvent('om_card_1', 'custom', 'ou_owner'));
|
||||
await bridge.waitForIdle();
|
||||
assert.equal(sent.at(-1).msgType, 'interactive');
|
||||
|
||||
await bridge.onCardAction({
|
||||
operator: { open_id: 'ou_owner' },
|
||||
action: {
|
||||
value: { action: 'steer', source: 'form' },
|
||||
form_value: { steer_text: '更简洁些' },
|
||||
},
|
||||
context: { open_message_id: 'om_card_2' },
|
||||
});
|
||||
await bridge.waitForIdle();
|
||||
|
||||
assert.equal(calls.steer.length, 1);
|
||||
assert.equal(calls.steer[0].text, '更简洁些');
|
||||
assert.equal(calls.steer[0].control.key, 'p2p:ou_owner');
|
||||
assert.match(JSON.parse(sent.at(-1).content).text, /已提交补充指令/);
|
||||
});
|
||||
|
||||
test('menu stop and steer reply friendly when no session is bound', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
const { calls, harness } = activeTurnHarness();
|
||||
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('menu-nosession-open', '/m', { senderOpenId: 'ou_owner' }));
|
||||
await bridge.waitForIdle();
|
||||
await bridge.onCardAction(cardActionEvent('om_card_1', 'stop', 'ou_owner'));
|
||||
await bridge.waitForIdle();
|
||||
assert.equal(calls.stop.length, 0);
|
||||
assert.match(JSON.parse(sent.at(-1).content).text, /没有正在运行/);
|
||||
|
||||
await bridge.onCardAction(steerDropdownEvent('om_card_1', '继续', 'ou_owner'));
|
||||
await bridge.waitForIdle();
|
||||
assert.equal(calls.steer.length, 0);
|
||||
assert.match(JSON.parse(sent.at(-1).content).text, /没有绑定会话/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ import assert from 'node:assert/strict';
|
|||
import test from 'node:test';
|
||||
import {
|
||||
cardActionProbeCard,
|
||||
customSteerCard,
|
||||
menuCard,
|
||||
menuHelpText,
|
||||
modelCard,
|
||||
} from '../../../src/channels/feishu/feishu-cards.mjs';
|
||||
|
||||
function buttons(value, result = []) {
|
||||
|
|
@ -17,13 +19,39 @@ function buttons(value, result = []) {
|
|||
return result;
|
||||
}
|
||||
|
||||
test('menu appends watchlist and keeps repair number-only', () => {
|
||||
function selects(value, result = []) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) selects(item, result);
|
||||
return result;
|
||||
}
|
||||
if (!value || typeof value !== 'object') return result;
|
||||
if (value.tag === 'select_static') result.push(value);
|
||||
for (const child of Object.values(value)) selects(child, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function forms(value, result = []) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) forms(item, result);
|
||||
return result;
|
||||
}
|
||||
if (!value || typeof value !== 'object') return result;
|
||||
if (value.tag === 'form') result.push(value);
|
||||
for (const child of Object.values(value)) forms(child, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
test('menu exposes the increased command set and keeps repair number-only', () => {
|
||||
const card = JSON.parse(menuCard());
|
||||
assert.match(JSON.stringify(card), /6 · 修复卡片按钮/);
|
||||
assert.match(JSON.stringify(card), /\*\*5\*\*\S*修复/);
|
||||
const actions = buttons(card).flatMap((button) => (
|
||||
button.behaviors?.map((behavior) => behavior?.value?.action) ?? []
|
||||
));
|
||||
assert.deepEqual(actions, ['sessions', 'workspaces', 'new', 'status', 'help', 'watchlist']);
|
||||
assert.deepEqual(actions, [
|
||||
'presets', 'models', 'new', 'sessions', 'workspaces',
|
||||
'stop', 'compact', 'archive_toggle', 'status', 'help',
|
||||
]);
|
||||
// 修复不占位按钮:仅通过数字兜底「5🔧」触发(见 bridge)
|
||||
assert.equal(actions.includes('repair'), false);
|
||||
});
|
||||
|
||||
|
|
@ -45,3 +73,57 @@ test('card-action probe carries only its action and opaque nonce', () => {
|
|||
}]);
|
||||
assert.throws(() => cardActionProbeCard('{{client_id}}'), /safe card-action probe nonce/);
|
||||
});
|
||||
|
||||
test('custom steer card wraps input and submit in a form container', () => {
|
||||
const card = JSON.parse(customSteerCard());
|
||||
const form = forms(card)[0];
|
||||
assert.ok(form, 'custom steer card must contain a form container');
|
||||
assert.equal(form.name, 'steer_form');
|
||||
const inputs = buttons(form).filter((element) => element.tag === 'input');
|
||||
// inputs are not buttons; scan form.elements directly
|
||||
const input = form.elements.find((element) => element.tag === 'input');
|
||||
assert.equal(input?.name, 'steer_text');
|
||||
const submit = form.elements.find((element) => element.tag === 'button');
|
||||
assert.equal(submit?.form_action_type, 'submit');
|
||||
assert.deepEqual(submit?.behaviors, [{
|
||||
type: 'callback',
|
||||
value: { action: 'steer', source: 'form' },
|
||||
}]);
|
||||
// 表单外的返回菜单按钮仍保留
|
||||
const back = buttons(card).find((b) => b.behaviors?.[0]?.value?.action === 'back_to_menu');
|
||||
assert.ok(back);
|
||||
});
|
||||
|
||||
test('menu session dropdown highlights the currently bound session via initial_index', () => {
|
||||
const sessions = [
|
||||
{ id: 'session-1', title: 'First' },
|
||||
{ id: 'session-2', title: 'Second' },
|
||||
{ id: 'session-3', title: 'Third' },
|
||||
];
|
||||
const card = JSON.parse(menuCard({
|
||||
currentSession: { id: 'session-2', title: 'Second' },
|
||||
sessions,
|
||||
}));
|
||||
const pick = selects(card).find((s) => s.name === 'session_pick');
|
||||
assert.ok(pick, 'menu must render a session dropdown');
|
||||
// initial_index is 1-based; the currently bound session sits at index 2.
|
||||
assert.equal(pick.initial_index, 2);
|
||||
});
|
||||
|
||||
test('model card dropdown highlights the current model via initial_index', () => {
|
||||
const catalog = {
|
||||
groups: [
|
||||
{ id: 'openrouter', name: 'OpenRouter', models: [
|
||||
{ id: 'anthropic/claude-sonnet-4', name: 'Claude Sonnet 4' },
|
||||
{ id: 'deepseek/deepseek-chat', name: 'DeepSeek Chat' },
|
||||
] },
|
||||
],
|
||||
current: { provider: 'openrouter', model: 'deepseek/deepseek-chat' },
|
||||
};
|
||||
const card = JSON.parse(modelCard(catalog));
|
||||
const pick = selects(card).find((s) => s.name === 'model_pick');
|
||||
assert.ok(pick, 'model card must render a dropdown');
|
||||
// deepseek/deepseek-chat is the second option (1-based index 2), even
|
||||
// though the id itself contains a `/`.
|
||||
assert.equal(pick.initial_index, 2);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ test('/stop and /steer return friendly no-session messages without creating one'
|
|||
);
|
||||
assert.match(
|
||||
(await runControlCommand('/steer 补充', harness, state, 'direct:one')).message,
|
||||
/普通消息/,
|
||||
/没有绑定会话/,
|
||||
);
|
||||
assert.equal(calls.some(([method]) => method === 'workspaceSession'), false);
|
||||
});
|
||||
|
|
@ -135,7 +135,7 @@ test('/steer reports a lost active-turn race instead of starting new work', asyn
|
|||
const result = await runControlCommand('/steer continue', harness, state, 'direct:one', {
|
||||
control: { owner: {}, key: 'direct:one' },
|
||||
});
|
||||
assert.match(result.message, /普通消息/);
|
||||
assert.match(result.message, /没有正在运行/);
|
||||
assert.equal(calls.filter(([method]) => method === 'steerActiveTurn').length, 1);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue