Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 / 管理员)、诊断、重连,全在私聊菜单里。
Expand Down
91 changes: 88 additions & 3 deletions src/bot/handle-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
buildResumeDoneCard,
buildResumeErrorCard,
buildResumeLaunchingCard,
EFFORT_LABEL,
MC,
RES,
type HelpScope,
Expand Down Expand Up @@ -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<void>;
/** `comment` event handler: @bot in a cloud-doc comment → reply in-thread. */
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
}
Expand All @@ -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).
Expand Down Expand Up @@ -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<void> {
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<void> =>
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,
Expand Down
8 changes: 6 additions & 2 deletions src/card/command-cards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export const RES = {
pick: 'resume.pick',
} as const;

const EFFORT_LABEL: Record<ReasoningEffort, string> = {
export const EFFORT_LABEL: Record<ReasoningEffort, string> = {
none: '无',
minimal: '极简',
low: '低',
Expand Down Expand Up @@ -193,6 +193,7 @@ export function buildHelpCard(scope: HelpScope, noMention = true): CardObject {
md(
`${talkLine(noMention, '交给我处理')}\n` +
'· `/model` → 切换模型 / 推理强度\n' +
'· `/effort high` → 快速切换推理强度\n' +
'· `/settings` → 群设置(免@ 开关)\n' +
'· `/help` → 这张速查卡',
),
Expand All @@ -204,6 +205,7 @@ export function buildHelpCard(scope: HelpScope, noMention = true): CardObject {
md(
`${talkLine(noMention, '继续当前会话')}\n` +
'· `/model` → 切换模型 / 推理强度\n' +
'· `/effort high` → 快速切换推理强度\n' +
'· `/help` → 这张速查卡',
),
note('开新话题:回到主群区 @我 + 内容。'),
Expand All @@ -217,6 +219,7 @@ export function buildHelpCard(scope: HelpScope, noMention = true): CardObject {
'· `/resume` → 恢复历史会话\n' +
'· `/settings` → 群设置(免@ 开关)\n' +
'· `/model` → 需要在话题里用\n' +
'· `/effort high` → 需要在话题里用\n' +
'· `/help` → 这张速查卡',
),
);
Expand All @@ -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` → 命令速查卡',
),
Expand All @@ -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` 看当前可用命令。'),
);
}
Expand Down
11 changes: 10 additions & 1 deletion test/command-cards.test.ts
Original file line number Diff line number Diff line change
@@ -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[] {
Expand Down Expand Up @@ -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');
});
});