diff --git a/data/menus.json b/data/menus.json new file mode 100644 index 0000000..654a278 --- /dev/null +++ b/data/menus.json @@ -0,0 +1,8 @@ +{ + "models": { + "claude": ["opus", "sonnet", "haiku"], + "codex": ["gpt-5.3-codex", "gpt-5.2-codex", "gpt-5-codex", "gpt-5.2", "gpt-5.4"], + "gemini": ["gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.5-flash-lite"] + }, + "efforts": ["low", "medium", "high", "max"] +} diff --git a/src/agents/claude-runner.ts b/src/agents/claude-runner.ts index 61a6aab..7997b5e 100644 --- a/src/agents/claude-runner.ts +++ b/src/agents/claude-runner.ts @@ -1,5 +1,5 @@ import { query, forkSession as sdkForkSession } from '@anthropic-ai/claude-agent-sdk'; -import { ensureDir, resolveAnthropicConfig } from '../config.js'; +import { ensureDir, resolveAnthropicConfig, loadMenus } from '../config.js'; import type { Config, ChannelAdapter, ReplyContext } from '../types.js'; import type { PermissionGateway, PermissionDecision } from '../core/permission.js'; import path from 'path'; @@ -282,7 +282,7 @@ export class AgentRunner { } listModels(): string[] { - return ['opus', 'sonnet', 'haiku']; + return loadMenus().models['claude'] ?? ['opus', 'sonnet', 'haiku']; } setEffort(effort: 'low' | 'medium' | 'high' | 'max' | undefined): void { diff --git a/src/agents/codex-runner.ts b/src/agents/codex-runner.ts index f97726e..13162ea 100644 --- a/src/agents/codex-runner.ts +++ b/src/agents/codex-runner.ts @@ -10,7 +10,7 @@ import { Codex, type ThreadEvent, type ThreadItem, type ThreadOptions, type Mode import type { Config } from '../types.js'; import type { AgentPlugin, AgentInstance, AgentCallbacks } from '../core/agent-loader.js'; import type { AgentEvent, AgentRunnerFull, ModelSwitcher, PermissionModeInfo } from './claude-runner.js'; -import { resolveOpenaiConfig } from '../config.js'; +import { resolveOpenaiConfig, loadMenus } from '../config.js'; import { logger } from '../utils/logger.js'; import fs from 'fs'; import path from 'path'; @@ -24,8 +24,8 @@ const MIME_EXT: Record = { 'image/webp': '.webp', }; -// ── Codex 模型列表 ── -const CODEX_MODELS = ['gpt-5.3-codex', 'gpt-5.2-codex', 'gpt-5-codex', 'gpt-5.2', 'gpt-5.4']; +// ── Codex 模型列表(回退默认值) ── +const CODEX_MODELS_DEFAULT = ['gpt-5.3-codex', 'gpt-5.2-codex', 'gpt-5-codex', 'gpt-5.2', 'gpt-5.4']; // ── Codex Runner ── @@ -55,7 +55,7 @@ export class CodexRunner implements AgentRunnerFull, ModelSwitcher { setModel(model: string): void { this.model = model; } getModel(): string { return this.model; } - listModels(): string[] { return CODEX_MODELS; } + listModels(): string[] { return loadMenus().models['codex'] ?? CODEX_MODELS_DEFAULT; } // ── Effort ── diff --git a/src/agents/gemini-runner.ts b/src/agents/gemini-runner.ts index 728288d..0c37775 100644 --- a/src/agents/gemini-runner.ts +++ b/src/agents/gemini-runner.ts @@ -17,7 +17,7 @@ import os from 'os'; import type { Config } from '../types.js'; import type { AgentPlugin, AgentInstance, AgentCallbacks } from '../core/agent-loader.js'; import type { AgentEvent, AgentRunnerFull, ModelSwitcher, PermissionModeInfo } from './claude-runner.js'; -import { resolveGoogleConfig, type GoogleResolved } from '../config.js'; +import { resolveGoogleConfig, loadMenus, type GoogleResolved } from '../config.js'; import { GeminiSessionFileAdapter } from '../core/session/adapters/gemini-session-file-adapter.js'; import { logger } from '../utils/logger.js'; @@ -38,8 +38,8 @@ const MIME_EXT: Record = { 'image/webp': '.webp', }; -// ── Gemini 模型列表 ── -const GEMINI_MODELS = [ +// ── Gemini 模型列表(回退默认值) ── +const GEMINI_MODELS_DEFAULT = [ 'gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.5-flash-lite', @@ -69,7 +69,7 @@ export class GeminiRunner implements AgentRunnerFull, ModelSwitcher { setModel(model: string): void { this.model = model; } getModel(): string { return this.model; } - listModels(): string[] { return GEMINI_MODELS; } + listModels(): string[] { return loadMenus().models['gemini'] ?? GEMINI_MODELS_DEFAULT; } // ── Effort (not applicable) ── diff --git a/src/config.ts b/src/config.ts index 3f6e94c..7a77954 100644 --- a/src/config.ts +++ b/src/config.ts @@ -9,6 +9,50 @@ import { commandExists } from './utils/cross-platform.js'; // Re-export path utilities for backward compatibility export { resolveRoot, resolvePaths, ensureDataDirs, getPackageRoot } from './paths.js'; +// ── Menus config (external model/effort lists) ── + +export interface MenusConfig { + models: Record; + efforts: string[]; +} + +const DEFAULT_MENUS: MenusConfig = { + models: { + claude: ['opus', 'sonnet', 'haiku'], + codex: ['gpt-5.3-codex', 'gpt-5.2-codex', 'gpt-5-codex', 'gpt-5.2', 'gpt-5.4'], + gemini: ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.5-flash-lite'], + }, + efforts: ['low', 'medium', 'high', 'max'], +}; + +let cachedMenus: MenusConfig | null = null; + +/** + * Load menus.json from data/ directory, falling back to hardcoded defaults. + * Result is cached after first successful load. + */ +export function loadMenus(): MenusConfig { + if (cachedMenus) return cachedMenus; + + try { + const menusPath = path.join(_getPackageRoot(), 'data', 'menus.json'); + if (fs.existsSync(menusPath)) { + const raw = JSON.parse(fs.readFileSync(menusPath, 'utf-8')); + if (raw.models && typeof raw.models === 'object' && Array.isArray(raw.efforts)) { + cachedMenus = raw as MenusConfig; + return cachedMenus; + } + logger.warn('menus.json has invalid structure, using defaults'); + } + } catch (e) { + logger.warn(`Failed to load menus.json: ${e}`); + } + + cachedMenus = DEFAULT_MENUS; + return cachedMenus; +} + + export interface AnthropicResolved { apiKey: string; baseUrl?: string; diff --git a/src/core/command-handler.ts b/src/core/command-handler.ts index c5e0d71..55b4293 100644 --- a/src/core/command-handler.ts +++ b/src/core/command-handler.ts @@ -8,7 +8,7 @@ import type { StatsCollector } from '../utils/stats-collector.js'; import { PermissionGateway, type PermissionDecision } from './permission.js'; import { InteractionRouter } from './interaction-router.js'; import { MessageQueue } from './message/message-queue.js'; -import { saveConfig, resolvePaths, getPackageRoot, getOwner } from '../config.js'; +import { saveConfig, resolvePaths, getPackageRoot, getOwner, loadMenus } from '../config.js'; import { logger } from '../utils/logger.js'; import crypto from 'crypto'; import path from 'path'; @@ -19,6 +19,19 @@ const allEfforts = ['low', 'medium', 'high', 'max'] as const; type Effort = typeof allEfforts[number]; const nonMaxEfforts = allEfforts.filter(e => e !== 'max') as readonly Effort[]; +// ── Numbered menu pending selection state ── +interface PendingSelection { + type: 'model' | 'effort'; + items: string[]; + createdAt: number; + channel: string; + channelId: string; + threadId?: string; +} + +const PENDING_SELECTION_EXPIRY_MS = 60_000; + + function getAvailableEfforts(agent: AgentRunnerFull, model: string): readonly Effort[] { if (agent.name === 'claude') { if (model.includes('opus')) return allEfforts; @@ -149,6 +162,7 @@ export class CommandHandler { private permissionGateway?: PermissionGateway; private interactionRouter?: InteractionRouter; private statsCollector?: StatsCollector; + private pendingSelections = new Map(); private agentMap: Map; private defaultAgentId: string; @@ -216,6 +230,43 @@ export class CommandHandler { return session.metadata?.replyContext; } + + /** 生成 pending selection 的 key */ + private pendingKey(channel: string, channelId: string, userId?: string): string { + return `${channel}:${channelId}:${userId || '_'}`; + } + + /** 设置 pending selection(60s 过期) */ + private setPending(channel: string, channelId: string, userId: string | undefined, pending: Omit): void { + const key = this.pendingKey(channel, channelId, userId); + this.pendingSelections.set(key, { ...pending, createdAt: Date.now() }); + } + + /** 获取未过期的 pending selection,过期则清除 */ + private getPending(channel: string, channelId: string, userId?: string): PendingSelection | undefined { + const key = this.pendingKey(channel, channelId, userId); + const pending = this.pendingSelections.get(key); + if (!pending) return undefined; + if (Date.now() - pending.createdAt > PENDING_SELECTION_EXPIRY_MS) { + this.pendingSelections.delete(key); + return undefined; + } + return pending; + } + + /** 清除 pending selection */ + private clearPending(channel: string, channelId: string, userId?: string): void { + this.pendingSelections.delete(this.pendingKey(channel, channelId, userId)); + } + + /** 格式化编号菜单列表 */ + private formatNumberedMenu(items: string[], currentValue?: string): string { + return items.map((item, i) => { + const marker = item === currentValue ? ' ✓' : ''; + return `${i + 1}. ${item}${marker}`; + }).join('\n'); + } + /** * 尝试通过渠道适配器发送交互卡片。 * 返回 message_id 表示卡片已发送,false 表示降级为文本。 @@ -456,7 +507,10 @@ export class CommandHandler { * 快速判断是否为命令(不进队列的命令) */ isCommand(content: string): boolean { - return content === '/p' || content === '/s' || quickCommandPrefixes.some(cmd => content.startsWith(cmd)); + if (content === '/p' || content === '/s' || quickCommandPrefixes.some(cmd => content.startsWith(cmd))) return true; + // 纯数字且有 pending 选择时也视为命令(快速路径) + if (/^\d+$/.test(content.trim()) && this.pendingSelections.size > 0) return true; + return false; } /** @@ -491,6 +545,30 @@ export class CommandHandler { logger.debug(`[CommandHandler] normalized: "${content}" -> "${normalizedContent}"`); } + + // ── 数字选择拦截:如果用户有 pending 的编号菜单,纯数字消息转为对应命令 ── + const pending = this.getPending(channel, channelId, userId); + if (pending) { + const trimmed = normalizedContent.trim(); + if (/^\d+$/.test(trimmed)) { + const idx = parseInt(trimmed, 10) - 1; + if (idx >= 0 && idx < pending.items.length) { + const selected = pending.items[idx]; + this.clearPending(channel, channelId, userId); + if (pending.type === 'model') { + return this.handle(`/model ${selected}`, channel, channelId, sendMessage, userId, threadId); + } else { + return this.handle(`/effort ${selected}`, channel, channelId, sendMessage, userId, threadId); + } + } + // 数字超出范围:清除 pending,提示 + this.clearPending(channel, channelId, userId); + return `❌ 无效选项: ${trimmed}(可选 1-${pending.items.length})`; + } + // 非数字消息:清除 pending,继续正常处理 + this.clearPending(channel, channelId, userId); + } + // 话题内禁用部分命令 if (threadId) { const threadBlocked = ['/new', '/slist', '/plist', '/bind', '/s', '/session', '/project', '/p', '/fork', '/del', '/agent']; @@ -866,12 +944,13 @@ export class CommandHandler { if (cardSent) return null; } - // 降级:文本 - const modelList = models.map((m: string) => `- ${m}`).join('\n'); + // 降级:编号菜单(支持数字选择) + const modelList = this.formatNumberedMenu(models, currentModel); const effortHint = efforts.length > 0 ? `\n推理强度: ${currentEffort === 'auto' ? 'auto (SDK默认)' : currentEffort} (使用 /effort 调整)` : ''; - return `当前模型: ${currentModel}${effortHint}\n\n可用模型:\n${modelList}\n\n${formatModelUsage(modelAgent, currentModel)}`; + this.setPending(channel, channelId, userId, { type: 'model', items: models, channel, channelId, threadId }); + return `当前模型: ${currentModel}${effortHint}\n\n可用模型:\n${modelList}\n\n回复数字切换模型(60秒内有效)`; } const parts = args.split(/\s+/); @@ -1046,10 +1125,12 @@ export class CommandHandler { if (cardSent) return null; } - // 降级:文本 + // 降级:编号菜单(支持数字选择) const effortDisplay = currentEffort === 'auto' ? 'auto (SDK默认)' : currentEffort; - const effortList = efforts.map(e => `${e === currentEffort ? ' ✓' : ' '} ${e}`).join('\n'); - return `⚡ 推理强度: ${effortDisplay}\n\n可选:\n${effortList}\n ${currentEffort === 'auto' ? ' ✓' : ' '} auto\n\n用法: /effort `; + const effortItems = [...efforts, 'auto'] as string[]; + const effortList = this.formatNumberedMenu(effortItems, currentEffort); + this.setPending(channel, channelId, userId, { type: 'effort', items: effortItems, channel, channelId, threadId }); + return `⚡ 推理强度: ${effortDisplay}\n\n可选:\n${effortList}\n\n回复数字切换推理强度(60秒内有效)`; } // /effort auto:恢复 SDK 默认