fix(feishu): streamline menu card layout and update help card

- Move preset/model/archive controls from settings card to main menu
- Arrange 4 dropdowns in 2x2 grid with external icons
- Align steer and archive side by side
- Replace #showSettingsCard with #sendMenuCard for preset/model/archive updates
- Add #sendMenuCard after 'new' button action for consistency
- Update help card to reflect current menu layout (12 items)
- Clean up duplicate sessionlist entries in menuHelpText
- Add missing English dictionary entries
This commit is contained in:
C3H3-AI 2026-08-24 09:46:32 +08:00
parent 044ab74c28
commit 45f55f73eb
5 changed files with 300 additions and 194 deletions

File diff suppressed because one or more lines are too long

View file

@ -1259,6 +1259,7 @@ export class FeishuHarnessBridge {
if (action === 'new') {
await this.#state.clearSession(key);
await this.#send(chatId, t('已开启全新 Harness 会话。'));
await this.#sendMenuCard(key, chatId);
return;
}
if (action === 'use:current') {
@ -1385,8 +1386,8 @@ export class FeishuHarnessBridge {
async #handleMenuPick(menu, number, { chatId, key, event }) {
if (menu.kind === 'menu') {
// Number fallback for the total menu:
// 1=续写 2=新会话 3=会话列表 4=状态 5=修复 6=更多设置 7=帮助
const actions = ['use:current', 'new', 'sessions', 'status', 'repair', 'settings', 'help'];
// 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 重新打开。'));
@ -1605,13 +1606,32 @@ export class FeishuHarnessBridge {
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,
sessions, archiveVisible, presetCatalog, modelCatalog,
}),
{ key, update: !newCard },
);
@ -1883,7 +1903,7 @@ export class FeishuHarnessBridge {
}
await this.#harness.updateAgentPreset(null, { signal: this.#signal });
await this.#send(chatId, '已恢复跟随 Host 默认预设。');
await this.#showSettingsCard(key, chatId);
await this.#sendMenuCard(key, chatId);
} catch (error) {
this.#logger.warn?.('[dsh-feishu] preset default failed:', error.message);
await this.#send(chatId, '预设重置失败,请稍后重试。');
@ -1901,7 +1921,7 @@ export class FeishuHarnessBridge {
}
await this.#harness.updateAgentPreset(presetId, { signal: this.#signal });
await this.#send(chatId, `预设已切换为:${presetId}`);
await this.#showSettingsCard(key, chatId);
await this.#sendMenuCard(key, chatId);
} catch (error) {
this.#logger.warn?.('[dsh-feishu] preset select failed:', error.message);
await this.#send(chatId, `预设切换失败:${error.message}`);
@ -1927,7 +1947,7 @@ export class FeishuHarnessBridge {
);
const message = result?.message || '模型切换失败,请稍后重试。';
await this.#send(chatId, message);
await this.#showSettingsCard(key, chatId);
await this.#sendMenuCard(key, chatId);
} catch (error) {
this.#logger.warn?.('[dsh-feishu] model select failed:', error.message);
await this.#send(chatId, `模型切换失败:${error.message}`);

View file

@ -113,15 +113,18 @@ function backButton() {
* by operating flow:
* 1. 会话 · 工作区(高频率)→ 会话下拉 · 工作区下拉 · 新会话 · 全部会话
* 2. 任务控制 → 停止 · 压缩 · 补充指令下拉
* 3. 系统 → 状态 · 帮助 · 更多设置
* 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=更多设置 7=帮助.
* 5=修复 6=帮助.
*/
export function menuCard(ctx) {
const {
@ -130,26 +133,25 @@ export function menuCard(ctx) {
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 wsLabel = currentWorkspace || '未设置工作区';
const sessLabel = currentSessionTitle ? `「${safeTitle(currentSessionTitle)}」` : '无会话';
const elements = [
{ tag: 'div', text: markdown(`**📂 ${wsLabel}** · **💬 ${sessLabel}**`) },
{ tag: 'hr' },
];
// ── 会话 · 工作区(高频率,左右并排)────────────────────────
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) => ({
@ -175,7 +177,6 @@ export function menuCard(ctx) {
};
}
// 构建工作区下拉
let workspaceDropdown = null;
if (hasWorkspaces) {
const wsOptions = workspaces.slice(0, 20).map((path) => ({
@ -192,25 +193,87 @@ export function menuCard(ctx) {
};
}
// 左右并排:column_set 两列,左会话右工作区
if (sessionDropdown && workspaceDropdown) {
elements.push({
tag: 'column_set',
flex_mode: 'none',
columns: [
{ tag: 'column', width: 'weighted', weight: 1, elements: [sessionDropdown] },
{ tag: 'column', width: 'weighted', weight: 1, elements: [workspaceDropdown] },
],
// 第 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,
});
} else {
if (sessionDropdown) elements.push(sessionDropdown);
if (workspaceDropdown) elements.push(workspaceDropdown);
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' } }],
};
}
// 新会话 + 全部会话按钮
elements.push(buttonPair('🆕 新会话', 'new', '📋 全部会话', 'sessions'));
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'));
}
@ -219,32 +282,51 @@ export function menuCard(ctx) {
// ── 任务控制(对运行中任务的操作)────────────────────────
elements.push({ tag: 'div', text: markdown('**任务控制**') });
elements.push(buttonPair('⏹ 停止', 'stop', '📐 压缩', 'compact'));
elements.push({ tag: 'div', text: markdown('**补充指令**(下拉选择,最后一项可自定义)') });
elements.push({ tag: 'hr' });
// ── 补充指令 + 归档切换(并列)────────────────────────────
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' },
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'),
],
},
],
behaviors: [{ type: 'callback', value: { action: 'steer_pick' } }],
});
elements.push({ tag: 'hr' });
// ── 底部操作(系统功能)────────────────────────────────────
elements.push(buttonPair('📊 状态', 'status', '📖 帮助', 'help'));
elements.push(button('⚙ 更多设置', 'settings'));
// 命令与数字兜底说明
elements.push({ tag: 'div', text: markdown(
'**数字兜底**\n'
+ '**1**续写 · **2**新会话 · **3**会话列表 · **4**状态\n'
+ '**5**🔧修复 · **6**更多设置 · **7**帮助',
+ '**1**工作区列表 · **2**新会话 · **3**会话列表 · **4**状态\n'
+ '**5**🔧修复 · **6**帮助',
) });
return cardWith('🤖 助手中心', elements);
}
@ -507,11 +589,11 @@ export function menuHelpText() {
return [
'🤖 助手菜单(回复数字即可,无需记命令)',
'',
'📋 会话',
'/sessionlist / session N 查看/绑定当前工作区会话',
'📋 会话 / 工作区',
'/sessionlist 列出工作区会话',
'/session ID 绑定已有会话',
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
'/workspacelist 列出工作区绝对路径',
'/workspacelist 列出工作区',
'/workspace 路径 切换工作区',
'/new 开启全新会话',
'',
'📊 状态 / 压缩',
@ -550,14 +632,16 @@ export function helpCard(extraTextLines = []) {
{ tag: 'div', text: markdown('**📋 卡片功能**\n\n' +
'1. 会话下拉 — 切换当前绑定会话\n' +
'2. 工作区下拉 — 切换工作区\n' +
'3. 🆕 新会话 — 开启全新会话\n' +
'4. 📋 全部会话 — 查看/绑定会话\n' +
'5. ⏹ 停止 — 停止当前任务\n' +
'6. 📐 压缩 — 压缩当前会话上下文\n' +
'7. 补充指令 — 给 Agent 发送指令\n' +
'8. 📊 状态 — 查看系统连接状态\n' +
'9. 📖 帮助 — 查看本帮助\n' +
'10. ⚙ 更多设置 — 预设/模型/归档设置') },
'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' +
@ -582,8 +666,8 @@ export function helpCard(extraTextLines = []) {
'`/repair` — 修复卡片按钮') },
{ tag: 'hr' },
{ tag: 'div', text: markdown('**💡 数字兜底**\n回复数字快速操作:\n' +
'**1**续写 · **2**新会话 · **3**全部会话\n' +
'**4**状态 · **5**修复 · **6**更多设置 · **7**帮助') },
'**1**工作区列表 · **2**新会话 · **3**会话/关注\n' +
'**4**状态 · **5**修复 · **6**帮助') },
{ tag: 'hr' },
backButton(),
];

View file

@ -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 () => {

View file

@ -48,8 +48,8 @@ test('menu exposes the increased command set and keeps repair number-only', () =
button.behaviors?.map((behavior) => behavior?.value?.action) ?? []
));
assert.deepEqual(actions, [
'new', 'sessions', 'workspaces', 'stop', 'compact',
'status', 'help', 'settings',
'presets', 'models', 'new', 'sessions', 'workspaces',
'stop', 'compact', 'archive_toggle', 'status', 'help',
]);
// 修复不占位按钮:仅通过数字兜底「5🔧」触发(见 bridge)
assert.equal(actions.includes('repair'), false);