diff --git a/src/feishu/event-handler.ts b/src/feishu/event-handler.ts index 5fe2c02..409227d 100644 --- a/src/feishu/event-handler.ts +++ b/src/feishu/event-handler.ts @@ -2,6 +2,7 @@ import * as lark from '@larksuiteoapi/node-sdk'; import { logger } from '../utils/logger.js'; import { isUserAllowed, containsDangerousCommand, isOwner, autoDetectOwner } from '../utils/security.js'; import { sessionManager } from '../session/manager.js'; +import { parseFableCommand, resolveForcedModel } from '../session/model-override.js'; import { forkSession } from '../session/fork.js'; import { taskQueue } from '../session/queue.js'; import { claudeExecutor } from '../claude/executor.js'; @@ -1082,11 +1083,16 @@ async function handleSlashCommand( // /status - 查看状态 if (trimmed === '/status') { - const session = sessionManager.getOrCreate(chatId, userId); + const session = sessionManager.getOrCreate(chatId, userId, agentId); + const threadForcedModel = effectiveThreadId + ? sessionManager.getThreadSession(effectiveThreadId, agentId)?.forcedModel + : undefined; + const forcedModel = resolveForcedModel(threadForcedModel, session.forcedModel); const card = buildStatusCard( session.workingDir, session.status, taskQueue.pendingCountForChat(chatId), + forcedModel, ); if (threadReplyMsgId) { await feishuClient.replyCardInThread(threadReplyMsgId, card); @@ -1152,11 +1158,13 @@ async function handleSlashCommand( let conversationId: string | undefined; let convCwd: string | undefined; let workingDir: string; + let threadForcedModel: string | undefined; if (effectiveThreadId) { const ts = sessionManager.getThreadSession(effectiveThreadId, agentId); conversationId = ts?.conversationId; convCwd = ts?.conversationCwd; workingDir = ts?.workingDir ?? session.workingDir; + threadForcedModel = ts?.forcedModel; } else { conversationId = session.conversationId; convCwd = session.conversationCwd; @@ -1176,7 +1184,8 @@ async function handleSlashCommand( const { existsSync } = await import('node:fs'); const cwd = convCwd && existsSync(convCwd) ? convCwd : workingDir; const sessionKey = effectiveThreadId ? `${chatId}:${userId}:${effectiveThreadId}` : `${chatId}:${userId}`; - const model = agentRegistry.get(agentId)?.model; + // /fable 强制模型也应用于压缩查询,保持与正常执行一致 + const model = resolveForcedModel(threadForcedModel, session.forcedModel) ?? agentRegistry.get(agentId)?.model; await replyFn('🗜️ 正在压缩对话上下文,请稍候(约需 30–60 秒)…'); @@ -1480,6 +1489,57 @@ async function handleSlashCommand( return true; } + // /fable [1m|off] - 强制本会话使用 claude-fable-5 模型(OWNER only) + if (trimmed === '/fable' || trimmed.startsWith('/fable ')) { + const replyFn = async (msg: string) => { + if (threadReplyMsgId) await feishuClient.replyTextInThread(threadReplyMsgId, msg); + else await feishuClient.replyText(messageId, msg); + }; + + if (!isOwner(userId)) { + await replyFn('⚠️ 只有管理员可以使用 /fable 命令'); + return true; + } + + const rawArgs = trimmed === '/fable' ? '' : trimmed.slice('/fable '.length).trim(); + const parsed = parseFableCommand(rawArgs); + if (!parsed.ok) { + await replyFn(parsed.error!); + return true; + } + + // 确保 chat 级 session 存在 + sessionManager.getOrCreate(chatId, userId, agentId); + // 作用域:话题内且已有 thread session → 绑定该话题;否则绑定 chat 级 session + const threadSession = effectiveThreadId + ? sessionManager.getThreadSession(effectiveThreadId, agentId) + : undefined; + const bindToThread = Boolean(effectiveThreadId && threadSession); + + if (parsed.clear) { + // 取消强制:清掉两级作用域,确保彻底恢复默认 + if (bindToThread) sessionManager.setThreadForcedModel(effectiveThreadId!, null, agentId); + sessionManager.setForcedModel(chatId, userId, null, agentId); + await replyFn('↩️ 已取消强制模型,本会话恢复使用默认模型(从下一条消息生效)。'); + return true; + } + + const model = parsed.model!; + if (bindToThread) { + sessionManager.setThreadForcedModel(effectiveThreadId!, model, agentId); + } else { + sessionManager.setForcedModel(chatId, userId, model, agentId); + } + + const ctxLabel = parsed.context1m ? '1M 上下文' : '默认上下文'; + const scopeLabel = bindToThread ? '本话题' : '本会话'; + await replyFn([ + `🎯 已强制${scopeLabel}使用 **${model}**(${ctxLabel}),从下一条消息生效。`, + '发送 `/fable off` 恢复默认模型;`/fable 1m` 开启 1M 上下文。', + ].join('\n')); + return true; + } + // /help - 帮助 if (trimmed === '/help') { const helpLines: string[] = [ @@ -1493,6 +1553,7 @@ async function handleSlashCommand( '`/reset`(`/clear`)— 重置会话,清除对话历史', '`/stop` — 中断当前正在执行的任务', '`/config` — 查看当前 Agent 配置和人设 🔒', + '`/fable [1m|off]` — 强制本会话用 claude-fable-5 模型,`1m` 开启 1M 上下文 🔒', '`/help` — 显示此帮助', '', '**── 工作区 ──**', @@ -2775,6 +2836,8 @@ export async function executeClaudeTask( // 否则回退到 owner 检查(dev agent 中非 owner 也是只读) const agentCfg = agentRegistry.get(agentId); const readOnly = agentCfg?.readOnly ?? !isOwner(userId); + // /fable 强制模型:thread 级优先,其次 chat 级 session,都没有则用 agent 配置模型 + const forcedModel = resolveForcedModel(threadSession?.forcedModel, session.forcedModel); // 自定义 agent 支持 persona(dev agent 没配置时 → undefined → 使用默认 buildWorkspaceSystemPrompt) const customSystemPrompt = readPersonaFile(agentId); const knowledgeContent = loadKnowledgeContent(agentId); @@ -2797,7 +2860,7 @@ export async function executeClaudeTask( prompt: effectivePrompt, workingDir, readOnly, - model: agentCfg?.model, + model: forcedModel ?? agentCfg?.model, maxTurns: agentCfg?.maxTurns, ...(agentCfg ? { maxBudgetUsd: agentCfg.maxBudgetUsd } : {}), settingSources: agentCfg?.settingSources, @@ -2948,7 +3011,7 @@ export async function executeClaudeTask( prompt: restartPromptWithImageHints, workingDir: result.newWorkingDir, readOnly, - model: agentCfg?.model, + model: forcedModel ?? agentCfg?.model, maxTurns: agentCfg?.maxTurns, ...(agentCfg ? { maxBudgetUsd: agentCfg.maxBudgetUsd } : {}), settingSources: agentCfg?.settingSources, @@ -3359,12 +3422,15 @@ export async function executeDirectTask( // AskUserQuestion 回调(与 executeClaudeTask 共享逻辑) const onAskUserDirect = createAskUserHandler(chatId, () => threadReplyMsgId); + // /fable 强制模型:thread 级优先,其次 chat 级 session + const forcedModel = resolveForcedModel(threadSession?.forcedModel, session.forcedModel); + const result = await claudeExecutor.execute({ sessionKey, prompt: effectivePrompt, workingDir, readOnly: agentCfg.readOnly, - model: agentCfg.model, + model: forcedModel ?? agentCfg.model, maxTurns: agentCfg.maxTurns, maxBudgetUsd: agentCfg.maxBudgetUsd, toolAllow: agentCfg.toolAllow, diff --git a/src/feishu/message-builder.ts b/src/feishu/message-builder.ts index 3f5047d..8cfbe1d 100644 --- a/src/feishu/message-builder.ts +++ b/src/feishu/message-builder.ts @@ -282,7 +282,29 @@ export function buildStatusCard( workingDir: string, sessionStatus: string, pendingTasks: number, + forcedModel?: string, ): Record { + const fields: Array> = [ + { + is_short: true, + text: { tag: 'lark_md', content: `**工作目录:**\n${workingDir}` }, + }, + { + is_short: true, + text: { tag: 'lark_md', content: `**状态:**\n${sessionStatus}` }, + }, + { + is_short: true, + text: { tag: 'lark_md', content: `**排队任务:**\n${pendingTasks}` }, + }, + ]; + // /fable 强制模型(未设置则不展示,避免干扰) + if (forcedModel) { + fields.push({ + is_short: true, + text: { tag: 'lark_md', content: `**强制模型:**\n${forcedModel}` }, + }); + } return { config: { wide_screen_mode: true }, header: { @@ -292,20 +314,7 @@ export function buildStatusCard( elements: [ { tag: 'div', - fields: [ - { - is_short: true, - text: { tag: 'lark_md', content: `**工作目录:**\n${workingDir}` }, - }, - { - is_short: true, - text: { tag: 'lark_md', content: `**状态:**\n${sessionStatus}` }, - }, - { - is_short: true, - text: { tag: 'lark_md', content: `**排队任务:**\n${pendingTasks}` }, - }, - ], + fields, }, ], }; diff --git a/src/session/__tests__/database.test.ts b/src/session/__tests__/database.test.ts index 0822174..db65b5f 100644 --- a/src/session/__tests__/database.test.ts +++ b/src/session/__tests__/database.test.ts @@ -642,3 +642,117 @@ describe('SessionDatabase — user_tokens', () => { expect(db.getUserToken('ou_user2')!.accessToken).toBe('token-2'); }); }); + +// ============================================================ +// forced_model (/fable) — sessions + thread_sessions +// ============================================================ + +describe('SessionDatabase — forced_model', () => { + let db: SessionDatabase; + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'session-db-test-')); + db = new SessionDatabase(join(tempDir, 'test.db')); + }); + + afterEach(() => { + db.close(); + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should default forcedModel to undefined for a new session', () => { + db.upsert('chat-1:user-1', { + chatId: 'chat-1', + userId: 'user-1', + workingDir: '/tmp', + status: 'idle', + createdAt: new Date(), + lastActiveAt: new Date(), + }); + expect(db.get('chat-1:user-1')!.forcedModel).toBeUndefined(); + }); + + it('should set and read back a session-level forced model', () => { + db.upsert('chat-1:user-1', { + chatId: 'chat-1', + userId: 'user-1', + workingDir: '/tmp', + status: 'idle', + createdAt: new Date(), + lastActiveAt: new Date(), + }); + + db.updateForcedModel('chat-1:user-1', 'claude-fable-5[1m]'); + expect(db.get('chat-1:user-1')!.forcedModel).toBe('claude-fable-5[1m]'); + + // null clears it (revert to default) + db.updateForcedModel('chat-1:user-1', null); + expect(db.get('chat-1:user-1')!.forcedModel).toBeUndefined(); + }); + + it('should not wipe forcedModel when other columns are updated', () => { + db.upsert('chat-1:user-1', { + chatId: 'chat-1', + userId: 'user-1', + workingDir: '/tmp', + status: 'idle', + createdAt: new Date(), + lastActiveAt: new Date(), + }); + db.updateForcedModel('chat-1:user-1', 'claude-fable-5'); + + // A targeted update to a different column must preserve forced_model + db.updateWorkingDir('chat-1:user-1', '/projects/other'); + + const session = db.get('chat-1:user-1')!; + expect(session.workingDir).toBe('/projects/other'); + expect(session.forcedModel).toBe('claude-fable-5'); + }); + + it('should set and read back a thread-level forced model', () => { + const now = new Date(); + db.upsertThreadSession({ + threadId: 'thread-1', + chatId: 'chat-1', + userId: 'user-1', + workingDir: '/projects/repo-a', + createdAt: now, + updatedAt: now, + }); + expect(db.getThreadSession('thread-1')!.forcedModel).toBeUndefined(); + + db.setThreadForcedModel('thread-1', 'claude-fable-5[1m]'); + expect(db.getThreadSession('thread-1')!.forcedModel).toBe('claude-fable-5[1m]'); + + db.setThreadForcedModel('thread-1', null); + expect(db.getThreadSession('thread-1')!.forcedModel).toBeUndefined(); + }); + + it('should preserve thread forcedModel across an upsert conflict', () => { + const now = new Date(); + db.upsertThreadSession({ + threadId: 'thread-1', + chatId: 'chat-1', + userId: 'user-1', + workingDir: '/projects/repo-a', + createdAt: now, + updatedAt: now, + }); + db.setThreadForcedModel('thread-1', 'claude-fable-5'); + + // Re-upsert (e.g. workspace touch) must not clear forced_model — mirrors fork-field behavior + db.upsertThreadSession({ + threadId: 'thread-1', + chatId: 'chat-1', + userId: 'user-1', + workingDir: '/projects/repo-a', + conversationId: 'conv-1', + conversationCwd: '/projects/repo-a', + createdAt: now, + updatedAt: new Date(), + }); + + expect(db.getThreadSession('thread-1')!.forcedModel).toBe('claude-fable-5'); + }); +}); diff --git a/src/session/__tests__/model-override.test.ts b/src/session/__tests__/model-override.test.ts new file mode 100644 index 0000000..2da3f5a --- /dev/null +++ b/src/session/__tests__/model-override.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest'; +import { + parseFableCommand, + resolveForcedModel, + FABLE_MODEL, + FABLE_MODEL_1M, +} from '../model-override.js'; + +describe('parseFableCommand', () => { + it('empty args → claude-fable-5, default context', () => { + const r = parseFableCommand(''); + expect(r.ok).toBe(true); + expect(r.model).toBe(FABLE_MODEL); + expect(r.context1m).toBe(false); + expect(r.clear).toBeUndefined(); + }); + + it('"1m" → claude-fable-5[1m], 1M context', () => { + const r = parseFableCommand('1m'); + expect(r.ok).toBe(true); + expect(r.model).toBe(FABLE_MODEL_1M); + expect(r.context1m).toBe(true); + }); + + it('"1m" is case-insensitive', () => { + for (const arg of ['1M', ' 1m ', '1m']) { + const r = parseFableCommand(arg); + expect(r.ok).toBe(true); + expect(r.model).toBe(FABLE_MODEL_1M); + } + }); + + it('"off" / "default" / "reset" → clear', () => { + for (const arg of ['off', 'OFF', 'default', 'reset', ' off ']) { + const r = parseFableCommand(arg); + expect(r.ok).toBe(true); + expect(r.clear).toBe(true); + expect(r.model).toBeUndefined(); + } + }); + + it('unknown arg → usage error', () => { + const r = parseFableCommand('2m'); + expect(r.ok).toBe(false); + expect(r.error).toContain('2m'); + expect(r.error).toContain('/fable'); + expect(r.model).toBeUndefined(); + }); + + it('the 1M model string uses the CLI [1m] suffix convention', () => { + expect(FABLE_MODEL_1M).toBe(`${FABLE_MODEL}[1m]`); + }); +}); + +describe('resolveForcedModel', () => { + it('thread override takes precedence over session default', () => { + expect(resolveForcedModel('claude-fable-5[1m]', 'claude-fable-5')).toBe('claude-fable-5[1m]'); + }); + + it('falls back to session default when no thread override', () => { + expect(resolveForcedModel(undefined, 'claude-fable-5')).toBe('claude-fable-5'); + }); + + it('returns undefined when neither is set (caller falls back to agent model)', () => { + expect(resolveForcedModel(undefined, undefined)).toBeUndefined(); + }); + + it('thread override wins even when session is unset', () => { + expect(resolveForcedModel('claude-fable-5', undefined)).toBe('claude-fable-5'); + }); +}); diff --git a/src/session/database.ts b/src/session/database.ts index df3caf3..c161bb7 100644 --- a/src/session/database.ts +++ b/src/session/database.ts @@ -12,6 +12,7 @@ interface ThreadSessionRow { conversation_id: string | null; conversation_cwd: string | null; system_prompt_hash: string | null; + forced_model: string | null; routing_completed: number | null; routing_state: string | null; pipeline_context: string | null; @@ -33,6 +34,7 @@ interface SessionRow { conversation_id: string | null; conversation_cwd: string | null; system_prompt_hash: string | null; + forced_model: string | null; thread_id: string | null; thread_root_message_id: string | null; status: string; @@ -54,6 +56,7 @@ export class SessionDatabase { private stmtUpdateWorkingDir: Database.Statement; private stmtUpdateStatus: Database.Statement; private stmtUpdateConversationId: Database.Statement; + private stmtUpdateForcedModel: Database.Statement; private stmtUpdateThread: Database.Statement; private stmtUpdateLastActive: Database.Statement; private stmtResetBusy: Database.Statement; @@ -74,6 +77,7 @@ export class SessionDatabase { private stmtSetThreadPipelineContext: Database.Statement; private stmtSetThreadApproved: Database.Statement; private stmtSetThreadInplaceEdit: Database.Statement; + private stmtSetThreadForcedModel: Database.Statement; private stmtTouchThreadSession: Database.Statement; private stmtGetAllThreadSessions: Database.Statement; private stmtUpsertUserToken: Database.Statement; @@ -275,6 +279,19 @@ export class SessionDatabase { this.db.exec('UPDATE schema_version SET version = 15'); } + // Migration v15 → v16: add forced_model column to sessions + thread_sessions (/fable 命令) + if (version < 16) { + const sessionCols = this.db.prepare("PRAGMA table_info(sessions)").all() as Array<{ name: string }>; + if (!sessionCols.some((c) => c.name === 'forced_model')) { + this.db.exec('ALTER TABLE sessions ADD COLUMN forced_model TEXT'); + } + const threadCols = this.db.prepare("PRAGMA table_info(thread_sessions)").all() as Array<{ name: string }>; + if (!threadCols.some((c) => c.name === 'forced_model')) { + this.db.exec('ALTER TABLE thread_sessions ADD COLUMN forced_model TEXT'); + } + this.db.exec('UPDATE schema_version SET version = 16'); + } + this.stmtUpsert = this.db.prepare(` INSERT INTO sessions (key, chat_id, user_id, working_dir, conversation_id, conversation_cwd, thread_id, thread_root_message_id, status, created_at, last_active_at) VALUES (@key, @chat_id, @user_id, @working_dir, @conversation_id, @conversation_cwd, @thread_id, @thread_root_message_id, @status, @created_at, @last_active_at) @@ -309,6 +326,10 @@ export class SessionDatabase { 'UPDATE sessions SET conversation_id = ?, conversation_cwd = ?, system_prompt_hash = ?, last_active_at = ? WHERE key = ?', ); + this.stmtUpdateForcedModel = this.db.prepare( + 'UPDATE sessions SET forced_model = ?, last_active_at = ? WHERE key = ?', + ); + this.stmtUpdateThread = this.db.prepare( 'UPDATE sessions SET thread_id = ?, thread_root_message_id = ?, last_active_at = ? WHERE key = ?', ); @@ -411,6 +432,10 @@ export class SessionDatabase { 'UPDATE thread_sessions SET inplace_edit = ?, updated_at = ? WHERE thread_id = ?', ); + this.stmtSetThreadForcedModel = this.db.prepare( + 'UPDATE thread_sessions SET forced_model = ?, updated_at = ? WHERE thread_id = ?', + ); + this.stmtTouchThreadSession = this.db.prepare( 'UPDATE thread_sessions SET updated_at = ? WHERE thread_id = ?', ); @@ -485,6 +510,11 @@ export class SessionDatabase { this.stmtUpdateConversationId.run(conversationId, cwd ?? null, systemPromptHash ?? null, new Date().toISOString(), key); } + /** 设置/清除 session 级强制模型(null 清除,恢复默认模型) */ + updateForcedModel(key: string, model: string | null): void { + this.stmtUpdateForcedModel.run(model, new Date().toISOString(), key); + } + updateThread(key: string, threadId: string, rootMessageId: string): void { this.stmtUpdateThread.run(threadId, rootMessageId, new Date().toISOString(), key); } @@ -621,6 +651,11 @@ export class SessionDatabase { this.stmtSetThreadInplaceEdit.run(inplaceEdit ? 1 : 0, new Date().toISOString(), threadId); } + /** 设置/清除 thread 级强制模型(null 清除,恢复默认模型) */ + setThreadForcedModel(threadId: string, model: string | null): void { + this.stmtSetThreadForcedModel.run(model, new Date().toISOString(), threadId); + } + // ── User Token CRUD ── upsertUserToken(userId: string, accessToken: string, refreshToken: string, tokenExpiry: number, accountId: string = ''): void { @@ -687,6 +722,7 @@ export class SessionDatabase { conversationId: row.conversation_id ?? undefined, conversationCwd: row.conversation_cwd ?? undefined, systemPromptHash: row.system_prompt_hash ?? undefined, + forcedModel: row.forced_model ?? undefined, routingCompleted: !!row.routing_completed, routingState, pipelineContext, @@ -709,6 +745,7 @@ export class SessionDatabase { conversationId: row.conversation_id ?? undefined, conversationCwd: row.conversation_cwd ?? undefined, systemPromptHash: row.system_prompt_hash ?? undefined, + forcedModel: row.forced_model ?? undefined, threadId: row.thread_id ?? undefined, threadRootMessageId: row.thread_root_message_id ?? undefined, status: validStatus(row.status), diff --git a/src/session/manager.ts b/src/session/manager.ts index ea06685..37bf64c 100644 --- a/src/session/manager.ts +++ b/src/session/manager.ts @@ -104,6 +104,15 @@ export class SessionManager { this.db.updateConversationId(this.makeKey(chatId, userId, agentId), conversationId, cwd, systemPromptHash); } + /** + * 设置/清除 chat 级会话的强制模型(/fable 命令)。null 表示清除,恢复默认模型。 + */ + setForcedModel(chatId: string, userId: string, model: string | null, agentId: string = 'dev'): void { + const key = this.makeKey(chatId, userId, agentId); + this.db.updateForcedModel(key, model); + logger.info({ chatId, userId, agentId, model }, 'Session forced model updated'); + } + /** * 重置会话 */ @@ -175,6 +184,14 @@ export class SessionManager { this.db.setThreadInplaceEdit(this.makeThreadKey(threadId, agentId), inplaceEdit); } + /** + * 设置/清除 thread 级强制模型(/fable 命令)。null 表示清除,恢复默认模型。 + */ + setThreadForcedModel(threadId: string, model: string | null, agentId: string = 'dev'): void { + this.db.setThreadForcedModel(this.makeThreadKey(threadId, agentId), model); + logger.info({ threadId, agentId, model }, 'Thread forced model updated'); + } + /** * 保存 pipeline 上下文到 thread session(pipeline 完成后调用) */ diff --git a/src/session/model-override.ts b/src/session/model-override.ts new file mode 100644 index 0000000..153f16c --- /dev/null +++ b/src/session/model-override.ts @@ -0,0 +1,72 @@ +/** + * 本会话强制模型(/fable 命令)。 + * + * `/fable` 让 owner 把当前会话强制切到 claude-fable-5 模型,可选 `1m` 参数开启 1M 上下文。 + * 1M 上下文沿用 Claude Code CLI 的 `[1m]` 模型后缀约定(如 `claude-opus-4-8[1m]`), + * 该后缀由 SDK 透传给 CLI 解析,本层只负责拼接正确的模型字符串。 + * + * 作用域语义:thread 内设置 → 绑定该 thread;主面板/direct 设置 → 绑定 chat 级 session, + * 作为无 thread override 时的默认(见 resolveForcedModel)。 + */ + +/** /fable 强制的模型名称 */ +export const FABLE_MODEL = 'claude-fable-5'; + +/** 开启 1M 上下文的模型字符串(CLI `[1m]` 后缀约定) */ +export const FABLE_MODEL_1M = `${FABLE_MODEL}[1m]`; + +/** /fable 用法说明 */ +export const FABLE_USAGE = [ + '用法: `/fable [1m|off]`', + '`/fable` — 强制本会话使用 claude-fable-5(默认上下文)', + '`/fable 1m` — 强制 claude-fable-5 并开启 1M 上下文', + '`/fable off` — 取消强制,恢复默认模型', +].join('\n'); + +/** parseFableCommand 的解析结果 */ +export interface FableParseResult { + /** 是否解析成功 */ + ok: boolean; + /** ok=false 时的用法/错误提示 */ + error?: string; + /** true 表示取消强制(/fable off) */ + clear?: boolean; + /** 解析出的模型字符串(含 [1m] 后缀,clear=true 时为 undefined) */ + model?: string; + /** 是否开启 1M 上下文 */ + context1m?: boolean; +} + +/** + * 解析 `/fable` 命令参数。 + * - 空参数 → claude-fable-5(默认上下文) + * - `1m`(大小写不敏感)→ claude-fable-5[1m] + * - `off`/`default`/`reset`(大小写不敏感)→ 清除强制 + * - 其它 → 用法错误 + */ +export function parseFableCommand(rawArgs: string): FableParseResult { + const arg = rawArgs.trim().toLowerCase(); + + if (arg === '') { + return { ok: true, model: FABLE_MODEL, context1m: false }; + } + if (arg === '1m') { + return { ok: true, model: FABLE_MODEL_1M, context1m: true }; + } + if (arg === 'off' || arg === 'default' || arg === 'reset') { + return { ok: true, clear: true }; + } + return { ok: false, error: `⚠️ 无法识别参数 "${rawArgs.trim()}"。\n${FABLE_USAGE}` }; +} + +/** + * 解析本次执行应使用的强制模型。 + * thread 级 override 优先,其次回退到 chat 级 session 默认;两者都没有则返回 undefined + * (调用方回退到 agent 配置模型)。 + */ +export function resolveForcedModel( + threadForcedModel: string | undefined, + sessionForcedModel: string | undefined, +): string | undefined { + return threadForcedModel ?? sessionForcedModel; +} diff --git a/src/session/types.ts b/src/session/types.ts index 6dc8514..4bed333 100644 --- a/src/session/types.ts +++ b/src/session/types.ts @@ -15,6 +15,8 @@ export interface Session { conversationCwd?: string; /** 创建 conversationId 时的 system prompt hash(用于自动失效检测) */ systemPromptHash?: string; + /** 本会话强制模型(/fable 命令设置),覆盖 agent 配置模型 */ + forcedModel?: string; /** 飞书话题 ID (thread_id) - 每个新会话创建一个话题 */ threadId?: string; /** 话题根消息 ID (创建话题时的第一条消息) */ @@ -56,6 +58,8 @@ export interface ThreadSession { conversationCwd?: string; /** 创建 conversationId 时的 system prompt hash(用于自动失效检测) */ systemPromptHash?: string; + /** 本话题强制模型(/fable 命令设置),覆盖 agent 配置模型 */ + forcedModel?: string; /** 路由是否已完成(首条消息路由后设为 true) */ routingCompleted?: boolean; /** 路由状态(need_clarification 时非空) */