Skip to content
Open
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
8 changes: 8 additions & 0 deletions data/menus.json
Original file line number Diff line number Diff line change
@@ -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"]
}
4 changes: 2 additions & 2 deletions src/agents/claude-runner.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 {
Expand Down
8 changes: 4 additions & 4 deletions src/agents/codex-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -24,8 +24,8 @@ const MIME_EXT: Record<string, string> = {
'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 ──

Expand Down Expand Up @@ -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 ──

Expand Down
8 changes: 4 additions & 4 deletions src/agents/gemini-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -38,8 +38,8 @@ const MIME_EXT: Record<string, string> = {
'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',
Expand Down Expand Up @@ -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) ──

Expand Down
44 changes: 44 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string[]>;
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;
Expand Down
97 changes: 89 additions & 8 deletions src/core/command-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -149,6 +162,7 @@ export class CommandHandler {
private permissionGateway?: PermissionGateway;
private interactionRouter?: InteractionRouter;
private statsCollector?: StatsCollector;
private pendingSelections = new Map<string, PendingSelection>();
private agentMap: Map<string, AgentRunnerFull>;
private defaultAgentId: string;

Expand Down Expand Up @@ -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<PendingSelection, 'createdAt'>): 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 表示降级为文本。
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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'];
Expand Down Expand Up @@ -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+/);
Expand Down Expand Up @@ -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 <level>`;
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 默认
Expand Down