mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 20:13:21 +08:00
fix(feishu): conform card form submission and model select to upstream
- read form data from action.form_value (Card 2.0) instead of formValue
- wrap custom steer input and submit in a form container with submit button
- reuse runModelCommand for dropdown model picks so IDs with multiple / keep working
- pass key when sending the help card so back-to-menu stays valid
- compute initial_index from {value} option arrays
- add in-flight tests for menu stop, steer dropdown, and steer form
This commit is contained in:
parent
7d2c93da86
commit
0241a32ec5
4 changed files with 297 additions and 59 deletions
|
|
@ -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 <id>` text
|
||||
* command) so model IDs containing `/` (e.g.
|
||||
* `openrouter/anthropic/claude-sonnet-4`) keep working, and all the
|
||||
* catalog validation, busy checks, pending-interaction checks and the
|
||||
* session binding lock stay in one place.
|
||||
*/
|
||||
async #handleModelSelect(key, chatId, modelId) {
|
||||
try {
|
||||
const 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);
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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, /普通消息/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue