mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-11 07:53:17 +08:00
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:
parent
85aaecf1f2
commit
cf90ad95c3
3 changed files with 1339 additions and 253 deletions
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue