diff --git a/src/channels/feishu/bridge.mjs b/src/channels/feishu/bridge.mjs index 333bf37..d46ec9b 100644 --- a/src/channels/feishu/bridge.mjs +++ b/src/channels/feishu/bridge.mjs @@ -1147,7 +1147,7 @@ export class FeishuHarnessBridge { }); } if (source === 'form') { - const text = nonEmptyString(event?.action?.formValue?.steer_text); + const text = nonEmptyString(event?.action?.form_value?.steer_text); if (text) { return this.#sendSteer(entry, text).catch((error) => { this.#logger.warn?.('[dsh-feishu] steer (form) failed:', error.message); @@ -1271,7 +1271,7 @@ export class FeishuHarnessBridge { return; } if (action === 'help') { - await this.#showHelpCard(chatId); + await this.#showHelpCard(key, chatId); return; } if (action === 'back_to_menu') { @@ -1739,8 +1739,8 @@ export class FeishuHarnessBridge { /** * Show the help card with all command descriptions. */ - async #showHelpCard(chatId) { - await this.#sendCard(chatId, helpCard(WORKSPACE_HELP_LINES), {}); + async #showHelpCard(key, chatId) { + await this.#sendCard(chatId, helpCard(WORKSPACE_HELP_LINES), { key }); } /** @@ -1839,45 +1839,23 @@ export class FeishuHarnessBridge { /** * Handle model selection from the model dropdown. + * + * Reuses `runModelCommand` (the same path as the `/model ` text + * command) so model IDs containing `/` (e.g. + * `openrouter/anthropic/claude-sonnet-4`) keep working, and all the + * catalog validation, busy checks, pending-interaction checks and the + * session binding lock stay in one place. */ async #handleModelSelect(key, chatId, modelId) { try { - const 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}`); + const result = await runModelCommand( + `/model ${modelId}`, this.#harness, this.#state, key, { + signal: this.#signal, + control: { owner: this, key }, + }, + ); + const message = result?.message || '模型切换失败,请稍后重试。'; + await this.#send(chatId, message); await this.#showSettingsCard(key, chatId); } catch (error) { this.#logger.warn?.('[dsh-feishu] model select failed:', error.message); diff --git a/src/channels/feishu/feishu-cards.mjs b/src/channels/feishu/feishu-cards.mjs index 5b11568..4651d08 100644 --- a/src/channels/feishu/feishu-cards.mjs +++ b/src/channels/feishu/feishu-cards.mjs @@ -150,15 +150,16 @@ export function menuCard(ctx) { // 会话下拉:列出最近会话,选择即切换(当前绑定会话带 ✓) const sessionOptions = (Array.isArray(sessions) ? sessions : []).slice(0, 20); if (sessionOptions.length > 0) { + const sessionPickOptions = sessionOptions.map((s) => ({ + text: { tag: 'plain_text', content: `${s.id === currentSessionId ? '✓ ' : ''}${safeTitle(s.title)}` }, + value: s.id, + })); 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, - })), + initial_index: initialIndex(sessionPickOptions, currentSessionId), + options: sessionPickOptions, behaviors: [{ type: 'callback', value: { action: 'session_pick' } }], }); elements.push(buttonPair('🆕 新会话', 'new', '📋 全部会话', 'sessions')); @@ -427,8 +428,8 @@ export function modelCard(catalog) { for (const group of groups) { for (const model of group.models) { allOptions.push({ - id: `${group.id}/${model.id}`, - name: `${group.name} - ${model.name}`, + value: `${group.id}/${model.id}`, + text: { tag: 'plain_text', content: `${group.name} - ${model.name}` }, }); } } @@ -441,8 +442,8 @@ export function modelCard(catalog) { 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, + text: { tag: 'plain_text', content: `${opt.text.content}${opt.value === currentId ? ' ✓' : ''}` }, + value: opt.value, })), behaviors: [{ type: 'callback', value: { action: 'model_pick' } }], }, @@ -782,16 +783,23 @@ export function customSteerCard() { { 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: 'form', + name: 'steer_form', + elements: [ + { + tag: 'input', + name: 'steer_text', + placeholder: { tag: 'plain_text', content: '输入你的补充指令' }, + }, + { + tag: 'button', + text: { tag: 'plain_text', content: '提交' }, + type: 'primary', + width: 'fill', + form_action_type: 'submit', + behaviors: [{ type: 'callback', value: { action: 'steer', source: 'form' } }], + }, + ], }, { tag: 'hr' }, button('🔙 返回菜单', 'back_to_menu'), diff --git a/test/channels/feishu/bridge.test.mjs b/test/channels/feishu/bridge.test.mjs index 96c228a..3031868 100644 --- a/test/channels/feishu/bridge.test.mjs +++ b/test/channels/feishu/bridge.test.mjs @@ -2868,3 +2868,177 @@ test('archived sessions are hidden by default; /archived on reveals them', async const useOn = useActionsFromCard(cards.at(-1)); assert.deepEqual(useOn, ['live-session', 'old-session'], '/archived on restores archived sessions'); }); + +// ── stop / steer while a task is running (menu card interactions) ────────── + +function activeTurnHarness({ stopped = true, steered = true } = {}) { + const calls = { stop: [], steer: [], sessions: [] }; + return { + calls, + harness: { + ensureRunning: async () => true, + workspaceSession: (id) => { + calls.sessions.push(id); + return { + async stopActiveTurn(control, options) { + calls.stop.push({ control, options }); + return stopped; + }, + async steerActiveTurn(text, control, options) { + calls.steer.push({ text, control, options }); + return steered; + }, + }; + }, + }, + }; +} + +function steerDropdownEvent(messageId, option, operatorOpenId) { + return { + operator: { open_id: operatorOpenId }, + action: { value: { action: 'steer_pick' }, option }, + context: { open_message_id: messageId }, + }; +} + +test('menu stop button stops the bound active turn without touching the model', async () => { + const fixture = stateFixture([['p2p:ou_owner', 'session-active']]); + const sent = []; + const { calls, harness } = activeTurnHarness(); + const bridge = new FeishuHarnessBridge({ + client: cardClient(async (outgoing) => sent.push(outgoing)), + channel: {}, + harness, + state: fixture.state, + status: bridgeStatus(), + allowedSenderOpenIds: new Set(['ou_owner']), + }); + + await bridge.accept(event('menu-stop-open', '/m', { senderOpenId: 'ou_owner' })); + await bridge.waitForIdle(); + assert.equal(sent.length, 1); + + await bridge.onCardAction(cardActionEvent('om_card_1', 'stop', 'ou_owner')); + await bridge.waitForIdle(); + + assert.equal(calls.sessions.some((id) => id === 'session-active'), true); + assert.equal(calls.stop.length, 1); + assert.equal(calls.stop[0].control.key, 'p2p:ou_owner'); + assert.match(JSON.parse(sent.at(-1).content).text, /已请求停止当前任务/); +}); + +test('menu steer dropdown sends a quick instruction to the active turn', async () => { + const fixture = stateFixture([['p2p:ou_owner', 'session-active']]); + const sent = []; + const { calls, harness } = activeTurnHarness(); + const bridge = new FeishuHarnessBridge({ + client: cardClient(async (outgoing) => sent.push(outgoing)), + channel: {}, + harness, + state: fixture.state, + status: bridgeStatus(), + allowedSenderOpenIds: new Set(['ou_owner']), + }); + + await bridge.accept(event('menu-steer-open', '/m', { senderOpenId: 'ou_owner' })); + await bridge.waitForIdle(); + assert.equal(sent.length, 1); + + await bridge.onCardAction(steerDropdownEvent('om_card_1', '继续', 'ou_owner')); + await bridge.waitForIdle(); + + assert.equal(calls.steer.length, 1); + assert.equal(calls.steer[0].text, '继续'); + assert.equal(calls.steer[0].control.key, 'p2p:ou_owner'); + assert.match(JSON.parse(sent.at(-1).content).text, /已提交补充指令/); +}); + +test('menu steer custom option opens the form card without steering', async () => { + const fixture = stateFixture([['p2p:ou_owner', 'session-active']]); + const sent = []; + const { calls, harness } = activeTurnHarness(); + const bridge = new FeishuHarnessBridge({ + client: cardClient(async (outgoing) => sent.push(outgoing)), + channel: {}, + harness, + state: fixture.state, + status: bridgeStatus(), + allowedSenderOpenIds: new Set(['ou_owner']), + }); + + await bridge.accept(event('menu-steer-custom-open', '/m', { senderOpenId: 'ou_owner' })); + await bridge.waitForIdle(); + await bridge.onCardAction(steerDropdownEvent('om_card_1', 'custom', 'ou_owner')); + await bridge.waitForIdle(); + + assert.equal(calls.steer.length, 0, 'custom option must not steer yet'); + const customCard = sent.at(-1); + assert.equal(customCard.msgType, 'interactive'); + const form = customCard.content.body.elements.find((el) => el.tag === 'form'); + assert.ok(form, 'custom steer card must contain a form'); + const submit = form.elements.find((el) => el.tag === 'button'); + assert.equal(submit?.form_action_type, 'submit'); +}); + +test('custom steer form submission reads form_value and steers the active turn', async () => { + const fixture = stateFixture([['p2p:ou_owner', 'session-active']]); + const sent = []; + const { calls, harness } = activeTurnHarness(); + const bridge = new FeishuHarnessBridge({ + client: cardClient(async (outgoing) => sent.push(outgoing)), + channel: {}, + harness, + state: fixture.state, + status: bridgeStatus(), + allowedSenderOpenIds: new Set(['ou_owner']), + }); + + await bridge.accept(event('steer-form-open', '/m', { senderOpenId: 'ou_owner' })); + await bridge.waitForIdle(); + // 主菜单 steer 下拉选「更多/自定义」→ 发送 custom 表单卡(第二次 create → om_card_2) + await bridge.onCardAction(steerDropdownEvent('om_card_1', 'custom', 'ou_owner')); + await bridge.waitForIdle(); + assert.equal(sent.at(-1).msgType, 'interactive'); + + await bridge.onCardAction({ + operator: { open_id: 'ou_owner' }, + action: { + value: { action: 'steer', source: 'form' }, + form_value: { steer_text: '更简洁些' }, + }, + context: { open_message_id: 'om_card_2' }, + }); + await bridge.waitForIdle(); + + assert.equal(calls.steer.length, 1); + assert.equal(calls.steer[0].text, '更简洁些'); + assert.equal(calls.steer[0].control.key, 'p2p:ou_owner'); + assert.match(JSON.parse(sent.at(-1).content).text, /已提交补充指令/); +}); + +test('menu stop and steer reply friendly when no session is bound', async () => { + const fixture = stateFixture(); + const sent = []; + const { calls, harness } = activeTurnHarness(); + const bridge = new FeishuHarnessBridge({ + client: cardClient(async (outgoing) => sent.push(outgoing)), + channel: {}, + harness, + state: fixture.state, + status: bridgeStatus(), + allowedSenderOpenIds: new Set(['ou_owner']), + }); + + await bridge.accept(event('menu-nosession-open', '/m', { senderOpenId: 'ou_owner' })); + await bridge.waitForIdle(); + await bridge.onCardAction(cardActionEvent('om_card_1', 'stop', 'ou_owner')); + await bridge.waitForIdle(); + assert.equal(calls.stop.length, 0); + assert.match(JSON.parse(sent.at(-1).content).text, /没有正在运行/); + + await bridge.onCardAction(steerDropdownEvent('om_card_1', '继续', 'ou_owner')); + await bridge.waitForIdle(); + assert.equal(calls.steer.length, 0); + assert.match(JSON.parse(sent.at(-1).content).text, /普通消息/); +}); diff --git a/test/channels/feishu/feishu-cards.test.mjs b/test/channels/feishu/feishu-cards.test.mjs index 662f20c..a342a01 100644 --- a/test/channels/feishu/feishu-cards.test.mjs +++ b/test/channels/feishu/feishu-cards.test.mjs @@ -2,8 +2,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { cardActionProbeCard, + customSteerCard, menuCard, menuHelpText, + modelCard, } from '../../../src/channels/feishu/feishu-cards.mjs'; function buttons(value, result = []) { @@ -17,6 +19,28 @@ function buttons(value, result = []) { return result; } +function selects(value, result = []) { + if (Array.isArray(value)) { + for (const item of value) selects(item, result); + return result; + } + if (!value || typeof value !== 'object') return result; + if (value.tag === 'select_static') result.push(value); + for (const child of Object.values(value)) selects(child, result); + return result; +} + +function forms(value, result = []) { + if (Array.isArray(value)) { + for (const item of value) forms(item, result); + return result; + } + if (!value || typeof value !== 'object') return result; + if (value.tag === 'form') result.push(value); + for (const child of Object.values(value)) forms(child, result); + return result; +} + test('menu exposes the increased command set and keeps repair number-only', () => { const card = JSON.parse(menuCard()); assert.match(JSON.stringify(card), /6 · 修复卡片按钮/); @@ -49,3 +73,57 @@ test('card-action probe carries only its action and opaque nonce', () => { }]); assert.throws(() => cardActionProbeCard('{{client_id}}'), /safe card-action probe nonce/); }); + +test('custom steer card wraps input and submit in a form container', () => { + const card = JSON.parse(customSteerCard()); + const form = forms(card)[0]; + assert.ok(form, 'custom steer card must contain a form container'); + assert.equal(form.name, 'steer_form'); + const inputs = buttons(form).filter((element) => element.tag === 'input'); + // inputs are not buttons; scan form.elements directly + const input = form.elements.find((element) => element.tag === 'input'); + assert.equal(input?.name, 'steer_text'); + const submit = form.elements.find((element) => element.tag === 'button'); + assert.equal(submit?.form_action_type, 'submit'); + assert.deepEqual(submit?.behaviors, [{ + type: 'callback', + value: { action: 'steer', source: 'form' }, + }]); + // 表单外的返回菜单按钮仍保留 + const back = buttons(card).find((b) => b.behaviors?.[0]?.value?.action === 'back_to_menu'); + assert.ok(back); +}); + +test('menu session dropdown highlights the currently bound session via initial_index', () => { + const sessions = [ + { id: 'session-1', title: 'First' }, + { id: 'session-2', title: 'Second' }, + { id: 'session-3', title: 'Third' }, + ]; + const card = JSON.parse(menuCard({ + currentSession: { id: 'session-2', title: 'Second' }, + sessions, + })); + const pick = selects(card).find((s) => s.name === 'session_pick'); + assert.ok(pick, 'menu must render a session dropdown'); + // initial_index is 1-based; the currently bound session sits at index 2. + assert.equal(pick.initial_index, 2); +}); + +test('model card dropdown highlights the current model via initial_index', () => { + const catalog = { + groups: [ + { id: 'openrouter', name: 'OpenRouter', models: [ + { id: 'anthropic/claude-sonnet-4', name: 'Claude Sonnet 4' }, + { id: 'deepseek/deepseek-chat', name: 'DeepSeek Chat' }, + ] }, + ], + current: { provider: 'openrouter', model: 'deepseek/deepseek-chat' }, + }; + const card = JSON.parse(modelCard(catalog)); + const pick = selects(card).find((s) => s.name === 'model_pick'); + assert.ok(pick, 'model card must render a dropdown'); + // deepseek/deepseek-chat is the second option (1-based index 2), even + // though the id itself contains a `/`. + assert.equal(pick.initial_index, 2); +});