feat(feishu): redesign interactive menu with dropdowns, settings panel, and steer input

Replace the flat button-only command menu with an information-architected
assistant center: status summary header, session dropdown (current session
pre-selected), workspace/model/preset dropdowns with current value shown,
archive toggle, task control (stop/compact/steer), watch multi-select, and a
collapsed settings panel. All commands expose card or dropdown entry points.
This commit is contained in:
C3H3-AI 2026-08-22 21:52:31 +08:00
parent 85aaecf1f2
commit cf90ad95c3
3 changed files with 1339 additions and 253 deletions

File diff suppressed because one or more lines are too long

View file

@ -36,10 +36,18 @@ import { runWorkspaceCommand, resolveSessionListWorkspace, workspacePathSnapshot
import { askInWorkspaceSession } from '../shared/workspace-session.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';
@ -79,36 +87,9 @@ const REPAIR_URL_HOSTS = new Set([
'open.larksuite.com',
]);
const HELP_TEXT = [
'北汇星河 AIOS 已连接 DeepSeek Harness。',
'',
'直接发送文字或图片即可继续当前会话。',
'/new 开启一个全新会话',
'/compact 压缩当前会话的较早上下文',
'/workspace 工作区绝对路径 切换工作区',
'/workspacelist 列出工作区绝对路径',
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
'/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话',
'/models 按序号列出所有可用模型',
'/model [序号或完整模型ID] 查看或切换当前会话模型',
'示例:先发 /models,再发 /model 2',
'/presetlist 按序号列出可用 Agent Preset',
'/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset',
'纯数字 ID:/preset id:<ID>',
'/preset --default 跟随 Host 默认',
'/stop 停止当前任务',
'/steer 补充指令 纠偏当前任务',
'/status 检查连接状态',
'/repair 修复卡片按钮回调',
'/m(或 /menu) 打开交互卡片菜单',
'/watch [Session ID 或序号] 关注会话,任务完成自动推送',
'/unwatch [Session ID 或序号] 取消关注',
'/watchlist 查看关注列表',
'/archived on|off 会话列表是否包含归档会话',
'/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;
/** Safe user-facing text for bind/workspace failures (no raw messages). */
function safeErrorText(error) {
@ -279,6 +260,8 @@ export class FeishuHarnessBridge {
#eventTail = Promise.resolve();
/** Earliest completion that still needs delivery for each watch. */
#failedWatchSeqs = new Map();
/** 临时保存最近一次多选下拉(watch_add/watch_remove)的选中项,供批处理消费。 */
#lastMultiSelection = [];
constructor({
client,
@ -395,6 +378,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 = isControlCommand(commandText)
? runControlCommand
: (isModelCommand(commandText)
@ -642,23 +640,26 @@ export class FeishuHarnessBridge {
await this.#handleRepairCommand(event, commandText);
return;
}
if (commandText === '/help') {
await this.#send(event.message.chat_id, HELP_TEXT);
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 });
if (commandText === '/help' || MENU_COMMAND.test(commandText)) {
await this.#sendMenuCard(key, event.message.chat_id);
return;
}
if (commandText === '/new') {
await this.#state.clearSession(key);
await this.#send(event.message.chat_id, '已开启全新 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, '飞书机器人与 DeepSeek Harness 连接正常。');
await this.#showStatusCard(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);
}
await this.#sendMenuCard(key, event.message.chat_id);
return;
}
if (SESSION_LIST_PREFIX.test(commandText)) {
@ -1085,6 +1086,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) {
@ -1096,9 +1122,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?.formValue?.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);
});
}
@ -1117,18 +1167,123 @@ 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, '已开启全新 Harness 会话。');
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();
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, '飞书机器人与 DeepSeek Harness 连接正常。');
await this.#showStatusCard(key, chatId);
return;
}
if (action === 'help') {
await this.#send(chatId, menuHelpText());
await this.#showHelpCard(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:')) {
@ -1169,7 +1324,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=更多设置 7=帮助 8=修复
const actions = ['use:current', 'new', 'sessions', 'watchlist', 'status', 'settings', 'help', 'repair'];
const action = actions[number - 1];
if (!action) {
await this.#send(chatId, '菜单没有这个编号,回复 /m 重新打开。');
return;
@ -1283,6 +1441,7 @@ export class FeishuHarnessBridge {
try {
const current = await this.#harness.switchWorkspace(workspace);
await this.#send(chatId, `工作区已切换为:${current}`);
await this.#sendMenuCard(key, chatId);
} catch (error) {
await this.#send(chatId, `切换失败:${safeErrorText(error)}`);
}
@ -1313,6 +1472,386 @@ export class FeishuHarnessBridge {
return messageId;
}
async #sendMenuCard(key, chatId) {
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 watchCount = Array.isArray(this.#state.watchEntries?.(key)) ? this.#state.watchEntries(key).length : 0;
const archiveVisible = this.#state.includesArchivedSessions();
this.#rememberMenu(key, { kind: 'menu', chatId });
await this.#sendCard(
chatId,
menuCard({
workspaces, currentWorkspace,
currentSession: currentSessionId ? { id: currentSessionId, title: currentSessionTitle } : null,
sessions, watchCount, archiveVisible,
}),
{ key },
);
}
/**
* 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();
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 #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(chatId) {
await this.#sendCard(chatId, helpCard(), {});
}
/**
* 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);
await this.#send(chatId, '上下文压缩失败,请稍后重试。');
}
}
/**
* 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.#showSettingsCard(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.#showSettingsCard(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.
*/
async #handleModelSelect(key, chatId, modelId) {
try {
const parts = modelId.split('/');
if (parts.length !== 2) {
await this.#send(chatId, `模型 ID 格式无效:${modelId}`);
return;
}
const [provider, model] = parts;
const sessionId = this.#state?.sessionFor?.(key);
if (typeof sessionId === 'string' && sessionId) {
// Select model on the bound session
const session = this.#harness.workspaceSession(sessionId);
if (!session?.selectModel) {
await this.#send(chatId, '当前会话不支持切换模型。');
return;
}
await session.selectModel({ provider, model }, { signal: this.#signal });
} else {
// No session yet; select default model for future sessions
if (typeof this.#harness?.createSession !== 'function') {
throw new TypeError('Harness cannot create a session');
}
const newSessionId = await this.#harness.createSession({ signal: this.#signal });
if (typeof newSessionId !== 'string' || !newSessionId) {
await this.#send(chatId, '无法创建新会话来选择模型。');
return;
}
const session = this.#harness.workspaceSession(newSessionId);
if (!session?.selectModel) {
await this.#send(chatId, '新会话不支持切换模型。');
return;
}
await session.selectModel({ provider, model }, { signal: this.#signal });
await this.#state.setSession(key, newSessionId);
}
await this.#send(chatId, `模型已切换为:${modelId}`);
await this.#showSettingsCard(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() {
@ -1459,8 +1998,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. */

View file

@ -16,6 +16,9 @@
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) };
}
@ -24,6 +27,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',
@ -54,6 +58,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 || '暂无标题';
@ -67,23 +98,421 @@ function cardWith(headerText, elements) {
});
}
/** The main command menu (buttons + number-reply fallback). */
export function menuCard() {
return cardWith('🤖 助手菜单', [
{ tag: 'div', text: markdown('**点击按钮或直接回复数字**') },
button('1 · 会话列表', 'sessions'),
button('2 · 工作区', 'workspaces'),
button('3 · 新会话', 'new'),
button('4 · 状态', 'status'),
button('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('**6 · 修复卡片按钮**(请直接回复数字 **6**)') },
button('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. 任务追踪 → 关注任务(watch 引擎入口) · 状态
* 4. 系统 → 帮助 · 修复(数字 9)
* `ctx` bundle:
* - workspaces: string[] (工作区下拉选项)
* - currentWorkspace: string|null (当前工作区,下拉高亮)
* - currentSession: {id,title}|null (当前绑定会话,续写目标)
* - sessions: {id,title}[] (最近会话,供会话下拉切换)
* - watchCount: number (关注任务数)
* - archiveVisible: boolean (归档显隐开关当前值)
* Number fallback: 1=续写 2=新会话 3=会话列表 4=关注任务 5=模型 6=预设
* 7=状态 8=帮助 9=修复.
*/
export function menuCard(ctx) {
const {
workspaces = [],
currentWorkspace = null,
currentSession = null,
sessions = [],
watchCount = 0,
archiveVisible = false,
} = ctx || {};
// currentSession 提供 id/title;为兼容旧调用,也接受 currentSessionTitle
const currentSessionId = currentSession?.id ?? null;
const currentSessionTitle = currentSession?.title ?? ctx?.currentSessionTitle ?? null;
// 状态摘要:一眼看"当前是什么"
const wsLabel = currentWorkspace || '未设置工作区';
const sessLabel = currentSessionTitle ? `「${safeTitle(currentSessionTitle)}」` : '无会话';
const elements = [
{ tag: 'div', text: markdown(`**📂 ${wsLabel}** · **💬 ${sessLabel}**`) },
{ tag: 'hr' },
];
// ── 情境 / 会话(高频率)────────────────────────────────────
elements.push({ tag: 'div', text: markdown('**情境 · 会话**') });
// 会话下拉:列出最近会话,选择即切换(当前绑定会话带 ✓)
const sessionOptions = (Array.isArray(sessions) ? sessions : []).slice(0, 20);
if (sessionOptions.length > 0) {
elements.push({
tag: 'select_static',
name: 'session_pick',
placeholder: { tag: 'plain_text', content: '切换/续写会话' },
initial_index: initialIndex(sessionOptions, currentSessionId),
options: sessionOptions.map((s) => ({
text: { tag: 'plain_text', content: `${s.id === currentSessionId ? '✓ ' : ''}${safeTitle(s.title)}` },
value: s.id,
})),
behaviors: [{ type: 'callback', value: { action: 'session_pick' } }],
});
elements.push(buttonPair('🆕 新会话', 'new', '📋 全部会话', 'sessions'));
} else {
elements.push(buttonPair('🆕 新会话', 'new', '📋 会话列表', 'sessions'));
}
elements.push({ tag: 'hr' });
// ── 工作区(环境上下文)────────────────────────────────────
elements.push({ tag: 'div', text: markdown('**工作区**') });
if (Array.isArray(workspaces) && workspaces.length > 0) {
// 飞书 schema 2.0 的 select_static option 不支持 selected 字段,
// 默认展示用 initial_index;文本 ✓ 前缀仅作额外视觉高亮。
const wsOptions = workspaces.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(wsOptions, currentWorkspace),
options: wsOptions,
behaviors: [{ type: 'callback', value: { action: 'workspace_pick' } }],
});
} else {
elements.push(button('🗂 工作区列表', 'workspaces'));
}
elements.push({ tag: 'hr' });
// ── 任务控制(对运行中任务的操作)────────────────────────
elements.push({ tag: 'div', text: markdown('**任务控制**') });
elements.push(buttonPair('⏹ 停止', 'stop', '📐 压缩', 'compact'));
elements.push({ tag: 'div', text: markdown('**补充指令**(下拉选择,最后一项可自定义)') });
elements.push({
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' } }],
});
elements.push({ tag: 'hr' });
// ── 任务追踪 ───────────────────────────────────────────────
elements.push({ tag: 'div', text: markdown(`**任务追踪** · 👁 关注任务 ${watchCount}`) });
elements.push(buttonPair('👁 关注任务', 'watchlist', '📊 状态', 'status'));
elements.push({ tag: 'hr' });
// ── 底部操作(低频配置收敛到「更多设置」)──────────────────
elements.push(buttonPair('⚙ 更多设置', 'settings', '📖 帮助', 'help'));
elements.push(button('🔧 修复卡片', 'repair'));
// 命令与数字兜底说明(飞书 schema 2.0 不支持 collapse,用平铺文本)
elements.push({ tag: 'div', text: markdown(
'**数字兜底**\n'
+ '**1**续写 · **2**新会话 · **3**会话列表 · **4**关注 · **5**状态\n'
+ '**6**更多设置 · **7**帮助 · **8**修复',
) });
elements.push({ tag: 'div', text: markdown(
'**任务控制**(需先绑定会话)\n'
+ '`/stop` 停止 · `/steer <指令>` 补充指令',
) });
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({
id: `${group.id}/${model.id}`,
name: `${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.name}${opt.id === currentId ? ' ✓' : ''}` },
value: opt.id,
})),
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 ─────────────────────────────────────────────────────────────
/**
* Help card with all available commands and their descriptions.
*/
export function helpCard() {
const elements = [
{ tag: 'div', text: markdown('**回复数字或点击按钮使用功能**\n\n' +
'📋 会话列表 — 查看/绑定已有会话\n' +
'🆕 新会话 — 开启全新会话\n' +
'📊 状态 — 查看系统连接状态\n' +
'📐 压缩 — 压缩当前会话上下文\n' +
'🤖 预设 — 选择 Agent 预设\n' +
'🧠 模型 — 选择 AI 模型\n' +
'🔍 关注列表 — 管理关注会话\n' +
'🔄 修复 — 修复卡片按钮回调') },
{ tag: 'hr' },
{ tag: 'div', text: markdown('**文本命令**\n\n' +
'`/session ID` — 绑定已有会话\n' +
'`/workspace 路径` — 切换工作区\n' +
'`/watch ID` — 关注会话(完成后推送)\n' +
'`/stop` — 停止当前任务\n' +
'`/steer 指令` — 给 Agent 补充指令\n' +
'`/archived on/off` — 会话列表显示/隐藏归档\n' +
'`/compact` — 压缩上下文\n' +
'`/presetlist` — 列出预设\n' +
'`/models` — 列出模型') },
{ 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)) {
@ -116,6 +545,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
@ -153,63 +584,98 @@ export function sessionListCard(workspace, sessions, page, total, watchedSession
];
if (page > 0) elements.push(button('◀ 上一页', `sessions:${page - 1}`));
if (page + 1 < pageCount) elements.push(button('下一页 ▶', `sessions:${page + 1}`));
elements.push({ tag: 'div', text: markdown('回复数字(1~N)绑定本页会话。') });
elements.push(
{ tag: 'hr' },
buttonPair('🔙 返回菜单', 'back_to_menu', '🔍 关注列表', 'watchlist'),
{ tag: 'div', text: markdown('回复数字(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('当前 Host 上没有已登记的工作区。') }]
? [
{ tag: 'div', text: markdown('当前 Host 上没有已登记的工作区。') },
backButton(),
]
: [
{ tag: 'div', text: markdown(`回复数字切换工作区,或点击按钮:`) },
{ tag: 'div', text: markdown('回复数字切换工作区,或点击按钮:') },
...paths.map((path, index) => button(
`${index + 1}. ${path}${path === current ? '(当前)' : ''}`,
`workspace:${path}`,
)),
{ tag: 'hr' },
backButton(),
];
return cardWith('🗂 工作区', elements);
}
/** The card-menu help text (number-driven, no command memorization). */
export function menuHelpText() {
return [
'🤖 助手菜单(回复数字即可,无需记命令)',
'',
'1 · /sessionlist 列出会话(回复数字绑定)',
'2 · /workspacelist 列出工作区(回复数字切换)',
'3 · /new 开启新会话',
'4 · /status 连接状态',
'5 · /help 本帮助',
'6 · /repair 修复卡片按钮(请回复数字 6)',
'7 · /watchlist 关注列表',
'',
'直接发送文字/图片即继续当前会话。',
'/session ID 或序号 绑定已有会话',
'/watch ID 或序号 关注会话(完成后推送)',
'/compact 压缩上下文',
'/workspace 绝对路径 切换工作区',
'/presetlist 列出可用 Agent Preset',
'/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset',
'纯数字 ID:/preset id:<ID>',
'/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: '勾选要关注的会话' },
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('当前没有关注的会话。\n`/watch <ID|序号>` 关注后,任务完成会自动推送。') }]
: [
{ tag: 'div', text: markdown('任务完成会自动推送,回复数字或点按钮取消关注:') },
...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: '勾选要取消关注的会话' },
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('当前没有关注的会话。任务完成会自动推送结果。') });
} else {
elements.push({ tag: 'div', text: markdown(`当前关注 **${watching.length}** 个会话:`) });
}
if (addSelect) {
elements.push({ tag: 'hr' }, { tag: 'div', text: markdown('**➕ 添加关注**(多选下拉勾选)') }, addSelect);
}
if (removeSelect) {
elements.push({ tag: 'hr' }, { tag: 'div', text: markdown('**➖ 取消关注**(多选下拉勾选)') }, removeSelect);
}
elements.push({ tag: 'hr' }, buttonPair('📋 会话列表', 'sessions', '🔙 返回菜单', 'back_to_menu'));
return cardWith('👁 关注列表', elements);
}
// ── Completion push card ──────────────────────────────────────────────────
/**
* The completion push card. `title` is the session title, `reason` the
* turn-end kind (completed / stopped / aborted).
@ -227,8 +693,66 @@ export function completionCard(sessionId, title, reason) {
return cardWith('✅ 任务完成', [
{ tag: 'div', text: markdown(`**${safeTitle(title)}**\n\`${sessionId}\``) },
{ tag: 'div', text: markdown(`**状态**:${reasonText}`) },
button('打开会话列表', 'sessions'),
button('工作区', 'workspaces'),
{ tag: 'hr' },
buttonPair('📋 会话列表', 'sessions', '🔙 返回菜单', 'back_to_menu'),
{ tag: 'div', text: markdown('绑定该会话后可继续追问,输入文字即可。') },
]);
}
// ── 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: 'input',
name: 'steer_text',
placeholder: { tag: 'plain_text', content: '输入你的补充指令' },
},
{
tag: 'button',
text: { tag: 'plain_text', content: '提交' },
type: 'primary',
width: 'fill',
behaviors: [{ type: 'callback', value: { action: 'steer', source: 'form' } }],
},
{ tag: 'hr' },
button('🔙 返回菜单', 'back_to_menu'),
];
return cardWith('➕ 自定义指令', elements);
}