From b67b42bda934f17beb1d3f88e7eeea3754bba64e Mon Sep 17 00:00:00 2001 From: zhou_tianjian <7484366+zhou_tianjian@user.noreply.gitee.com> Date: Thu, 4 Jun 2026 21:53:23 +0800 Subject: [PATCH] Add effort slash command --- README.md | 1 + src/bot/handle-message.ts | 91 ++++++++++++++++++++++++++++++++++++-- src/card/command-cards.ts | 8 +++- test/command-cards.test.ts | 11 ++++- 4 files changed, 105 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 4d43bf3..37b9d47 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,7 @@ feishu-codex-bridge bot rm <名> # 移除一个机器人配置 - **💬 单会话群**:整群就是**一条连续会话**(全程**免 @**、消息按序排队、无 `/resume`)。适合**个人单线深入**、像私聊一样直接聊。 - **干活**:在项目群里 **@机器人** 描述需求;机器人在该群绑定的目录里跑 Codex,流式卡片回结果。 - **话题 = 会话**:对某条消息开话题后,话题内可**免 @** 连续对话,是一条连贯的 Codex 会话。 +- **模型 / 推理强度**:在单会话群或话题内发 `/model` 打开选择卡;只想快速改推理强度可发 `/effort high`(可用 level 以当前 Codex 模型为准,下一轮生效)。 - **文档评论 @机器人**:在飞书文档评论里 @ 它就回(前提:已开通文档评论权限 + 订阅 `drive.notice.comment_add_v1`,且机器人对该文档有访问权限)。只支持 doc/docx/sheet/file;评论框不渲染 markdown,回复为纯文本,超长会截断。 - **终止**:卡片上的 **⏹** 随时终止当前轮;卡死超过 watchdog 阈值(默认 120s)自动中止并回收进程。 - **私聊控制台**:项目列表、设置(模型 / 推理强度 / 免 @ / watchdog / 管理员)、诊断、重连,全在私聊菜单里。 diff --git a/src/bot/handle-message.ts b/src/bot/handle-message.ts index 058f7c2..49cd9e3 100644 --- a/src/bot/handle-message.ts +++ b/src/bot/handle-message.ts @@ -26,6 +26,7 @@ import { buildResumeDoneCard, buildResumeErrorCard, buildResumeLaunchingCard, + EFFORT_LABEL, MC, RES, type HelpScope, @@ -117,6 +118,11 @@ interface RunReaction { done: () => void; } +type SlashCommand = 'resume' | 'model' | 'effort' | 'settings' | 'help'; + +const FALLBACK_EFFORTS: ReasoningEffort[] = ['low', 'medium', 'high', 'xhigh']; +const ALL_EFFORTS: ReasoningEffort[] = ['none', 'minimal', ...FALLBACK_EFFORTS]; + export interface Orchestrator { onMessage: (msg: NormalizedMessage) => Promise; /** `comment` event handler: @bot in a cloud-doc comment → reply in-thread. */ @@ -190,6 +196,24 @@ export function createOrchestrator( return { model: def?.id ?? 'gpt-5.5', effort: def?.defaultEffort ?? 'medium' }; } + function effortOptionsForModel(models: ModelInfo[], modelId: string): ReasoningEffort[] { + const model = models.find((m) => m.id === modelId); + return model?.supportedEfforts.length ? model.supportedEfforts : FALLBACK_EFFORTS; + } + + function formatEfforts(efforts: ReasoningEffort[]): string { + return efforts.map((e) => `\`${e}\`${EFFORT_LABEL[e] ? `(${EFFORT_LABEL[e]})` : ''}`).join(' / '); + } + + function parseEffortArg(text: string): string | undefined { + return /^\/effort(?:\s+(\S+))?/i.exec(text.trim())?.[1]?.toLowerCase(); + } + + function normalizeEffort(raw: string | undefined): ReasoningEffort | undefined { + if (!raw) return undefined; + return ALL_EFFORTS.includes(raw as ReasoningEffort) ? (raw as ReasoningEffort) : undefined; + } + // Feishu gives bots no way to mark a message "已读" (read receipts are a // human-client signal), so a reaction stands in for one. Best-effort — a // missing im:message.reactions:write_only scope just means no reaction appears. @@ -305,6 +329,10 @@ export function createOrchestrator( await postModelCard(msg, msg.chatId); return; } + if (cmd === 'effort') { + await postEffortCommand(msg, msg.chatId, false); + return; + } handleTurn(msg, text, msg.chatId, true, project); return; } @@ -321,6 +349,10 @@ export function createOrchestrator( await postModelCard(msg, msg.threadId); return; } + if (cmd === 'effort') { + await postEffortCommand(msg, msg.threadId, true); + return; + } handleTurn(msg, text, msg.threadId, false, project); return; } @@ -345,14 +377,22 @@ export function createOrchestrator( .catch(() => undefined); return; } + if (cmd === 'effort') { + await channel + .send(msg.chatId, { markdown: '`/effort` 需要在话题里使用(先 @我 开个话题)。' }, { replyTo: msg.messageId }) + .catch(() => undefined); + return; + } startTopicDirectly(msg, text, project); }; - /** Parse a leading slash command (`/resume`, `/model`, `/settings`); null otherwise. */ - function parseCommand(text: string): 'resume' | 'model' | 'settings' | 'help' | null { + /** Parse a leading slash command (`/resume`, `/model`, `/effort`, `/settings`); null otherwise. */ + function parseCommand(text: string): SlashCommand | null { const m = /^\/(\w+)/.exec(text); const name = m?.[1]?.toLowerCase(); - return name === 'resume' || name === 'model' || name === 'settings' || name === 'help' ? name : null; + return name === 'resume' || name === 'model' || name === 'effort' || name === 'settings' || name === 'help' + ? name + : null; } /** Whether to respond to a non-@ message in a project group (免@ default on). @@ -632,6 +672,51 @@ export function createOrchestrator( }); } + /** `/effort [level]`: text shortcut for changing only the current session's reasoning effort. */ + async function postEffortCommand(msg: NormalizedMessage, sessionKey: string, inThread: boolean): Promise { + await withTrace({ chatId: msg.chatId, msgId: msg.messageId }, async () => { + const [models, rec] = await Promise.all([listModels(), getSession(sessionKey)]); + const def = pickDefault(models); + const model = rec?.model ?? def.model; + const current = rec?.effort ?? def.effort; + const supported = effortOptionsForModel(models, model); + const raw = parseEffortArg(msg.content); + const reply = (markdown: string): Promise => + channel + .send(msg.chatId, { markdown }, { replyTo: msg.messageId, replyInThread: inThread }) + .then(() => undefined) + .catch(() => undefined); + + if (!raw) { + await reply( + `当前模型:\`${model}\`\n` + + `当前 effort:\`${current}\`${EFFORT_LABEL[current] ? `(${EFFORT_LABEL[current]})` : ''}\n` + + `可用:${formatEfforts(supported)}\n` + + '用法:`/effort high`', + ); + return; + } + + const requested = raw === 'default' || raw === 'reset' ? def.effort : normalizeEffort(raw); + if (!requested) { + await reply(`未知 effort:\`${raw}\`。\n可用:${formatEfforts(supported)}`); + return; + } + if (!supported.includes(requested)) { + await reply(`当前模型 \`${model}\` 不支持 effort \`${requested}\`。\n可用:${formatEfforts(supported)}`); + return; + } + if (!rec) { + await reply('当前会话还没有完成初始化;请先让会话完成第一轮回复,再设置 `/effort`。'); + return; + } + + await patchSession(sessionKey, { effort: requested }); + await reply(`✅ 已设置 effort:\`${requested}\`${EFFORT_LABEL[requested] ? `(${EFFORT_LABEL[requested]})` : ''},下一轮生效。`); + log.info('card', 'effort', { threadId: sessionKey, effort: requested }); + }); + } + /** `/help`: post the command cheat-sheet for the caller's current scope. */ async function postHelpCard( msg: NormalizedMessage, diff --git a/src/card/command-cards.ts b/src/card/command-cards.ts index 78fa89a..560170e 100644 --- a/src/card/command-cards.ts +++ b/src/card/command-cards.ts @@ -12,7 +12,7 @@ export const RES = { pick: 'resume.pick', } as const; -const EFFORT_LABEL: Record = { +export const EFFORT_LABEL: Record = { none: '无', minimal: '极简', low: '低', @@ -193,6 +193,7 @@ export function buildHelpCard(scope: HelpScope, noMention = true): CardObject { md( `${talkLine(noMention, '交给我处理')}\n` + '· `/model` → 切换模型 / 推理强度\n' + + '· `/effort high` → 快速切换推理强度\n' + '· `/settings` → 群设置(免@ 开关)\n' + '· `/help` → 这张速查卡', ), @@ -204,6 +205,7 @@ export function buildHelpCard(scope: HelpScope, noMention = true): CardObject { md( `${talkLine(noMention, '继续当前会话')}\n` + '· `/model` → 切换模型 / 推理强度\n' + + '· `/effort high` → 快速切换推理强度\n' + '· `/help` → 这张速查卡', ), note('开新话题:回到主群区 @我 + 内容。'), @@ -217,6 +219,7 @@ export function buildHelpCard(scope: HelpScope, noMention = true): CardObject { '· `/resume` → 恢复历史会话\n' + '· `/settings` → 群设置(免@ 开关)\n' + '· `/model` → 需要在话题里用\n' + + '· `/effort high` → 需要在话题里用\n' + '· `/help` → 这张速查卡', ), ); @@ -242,6 +245,7 @@ export function buildWelcomeCard(kind: 'multi' | 'single', docUrl?: string, noMe md( `${talkLine(noMention, '交给我处理')}\n` + '· `/model` → 切换模型 / 推理强度\n' + + '· `/effort high` → 快速切换推理强度\n' + '· `/settings` → 群设置(免@ 开关)\n' + '· `/help` → 命令速查卡', ), @@ -255,7 +259,7 @@ export function buildWelcomeCard(kind: 'multi' | 'single', docUrl?: string, noMe '· `/settings` → 群设置(免@ 开关)', ), md('🧵 **话题内**'), - md('· 直接发消息(免@)→ 继续当前会话\n· `/model` → 切换模型 / 推理强度'), + md('· 直接发消息(免@)→ 继续当前会话\n· `/model` → 切换模型 / 推理强度\n· `/effort high` → 快速切换推理强度'), note('任意场景发 `/help` 看当前可用命令。'), ); } diff --git a/test/command-cards.test.ts b/test/command-cards.test.ts index 9999d66..64c3acb 100644 --- a/test/command-cards.test.ts +++ b/test/command-cards.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { buildResumeCard, type ResumeCardState } from '../src/card/command-cards'; +import { buildHelpCard, buildResumeCard, buildWelcomeCard, type ResumeCardState } from '../src/card/command-cards'; import type { ThreadSummary } from '../src/agent/types'; function buttons(node: unknown, acc: any[] = []): any[] { @@ -51,3 +51,12 @@ describe('buildResumeCard', () => { expect(JSON.stringify(card)).toContain('还没有历史会话'); }); }); + +describe('help cards', () => { + it('documents the /effort shortcut in session scopes', () => { + expect(JSON.stringify(buildHelpCard('single'))).toContain('/effort high'); + expect(JSON.stringify(buildHelpCard('topic'))).toContain('/effort high'); + expect(JSON.stringify(buildWelcomeCard('single'))).toContain('/effort high'); + expect(JSON.stringify(buildWelcomeCard('multi'))).toContain('/effort high'); + }); +});