From e130dfd24b420e9308a2d1984f0cfb408ed7ae8c Mon Sep 17 00:00:00 2001 From: weishu Date: Fri, 23 Jan 2026 11:41:02 +0800 Subject: [PATCH] feat: add model selection for AI agents (claude, codex, gemini) Add a new ModelSelector component that allows users to choose specific model variants for different AI agents. This includes: - ModelSelector UI component with agent-specific model options - Model parameter propagation through API, RPC, and spawning layers - Support for auto model selection (default behavior) - Localization strings for English and Chinese - Type definitions for model options per agent --- cli/src/api/apiMachine.ts | 3 +- cli/src/modules/common/rpcTypes.ts | 1 + cli/src/runner/run.ts | 3 ++ server/src/sync/rpcGateway.ts | 3 +- server/src/sync/syncEngine.ts | 3 +- server/src/web/routes/machines.ts | 2 ++ web/src/api/client.ts | 3 +- .../components/NewSession/ModelSelector.tsx | 34 +++++++++++++++++++ web/src/components/NewSession/index.tsx | 13 +++++++ web/src/components/NewSession/types.ts | 21 ++++++++++++ web/src/hooks/mutations/useSpawnSession.ts | 2 ++ web/src/lib/locales/en.ts | 2 ++ web/src/lib/locales/zh-CN.ts | 2 ++ 13 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 web/src/components/NewSession/ModelSelector.tsx diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts index b96b494e..23991b89 100644 --- a/cli/src/api/apiMachine.ts +++ b/cli/src/api/apiMachine.ts @@ -100,7 +100,7 @@ export class ApiMachineClient { setRPCHandlers({ spawnSession, stopSession, requestShutdown }: MachineRpcHandlers): void { this.rpcHandlerManager.registerHandler('spawn-happy-session', async (params: any) => { - const { directory, sessionId, machineId, approvedNewDirectoryCreation, agent, yolo, token, sessionType, worktreeName } = params || {} + const { directory, sessionId, machineId, approvedNewDirectoryCreation, agent, model, yolo, token, sessionType, worktreeName } = params || {} if (!directory) { throw new Error('Directory is required') @@ -112,6 +112,7 @@ export class ApiMachineClient { machineId, approvedNewDirectoryCreation, agent, + model, yolo, token, sessionType, diff --git a/cli/src/modules/common/rpcTypes.ts b/cli/src/modules/common/rpcTypes.ts index e7e7fd14..31c24262 100644 --- a/cli/src/modules/common/rpcTypes.ts +++ b/cli/src/modules/common/rpcTypes.ts @@ -4,6 +4,7 @@ export interface SpawnSessionOptions { sessionId?: string approvedNewDirectoryCreation?: boolean agent?: 'claude' | 'codex' | 'gemini' + model?: string yolo?: boolean token?: string sessionType?: 'simple' | 'worktree' diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index bf1f0953..ab9ec28e 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -333,6 +333,9 @@ export async function startRunner(): Promise { '--hapi-starting-mode', 'remote', '--started-by', 'runner' ]; + if (options.model) { + args.push('--model', options.model); + } if (yolo) { args.push('--yolo'); } diff --git a/server/src/sync/rpcGateway.ts b/server/src/sync/rpcGateway.ts index 097125d8..08edbc09 100644 --- a/server/src/sync/rpcGateway.ts +++ b/server/src/sync/rpcGateway.ts @@ -94,6 +94,7 @@ export class RpcGateway { machineId: string, directory: string, agent: 'claude' | 'codex' | 'gemini' = 'claude', + model?: string, yolo?: boolean, sessionType?: 'simple' | 'worktree', worktreeName?: string @@ -102,7 +103,7 @@ export class RpcGateway { const result = await this.machineRpc( machineId, 'spawn-happy-session', - { type: 'spawn-in-directory', directory, agent, yolo, sessionType, worktreeName } + { type: 'spawn-in-directory', directory, agent, model, yolo, sessionType, worktreeName } ) if (result && typeof result === 'object') { const obj = result as Record diff --git a/server/src/sync/syncEngine.ts b/server/src/sync/syncEngine.ts index 505660bb..e9d25763 100644 --- a/server/src/sync/syncEngine.ts +++ b/server/src/sync/syncEngine.ts @@ -270,11 +270,12 @@ export class SyncEngine { machineId: string, directory: string, agent: 'claude' | 'codex' | 'gemini' = 'claude', + model?: string, yolo?: boolean, sessionType?: 'simple' | 'worktree', worktreeName?: string ): Promise<{ type: 'success'; sessionId: string } | { type: 'error'; message: string }> { - return await this.rpcGateway.spawnSession(machineId, directory, agent, yolo, sessionType, worktreeName) + return await this.rpcGateway.spawnSession(machineId, directory, agent, model, yolo, sessionType, worktreeName) } async checkPathsExist(machineId: string, paths: string[]): Promise> { diff --git a/server/src/web/routes/machines.ts b/server/src/web/routes/machines.ts index 62436c1d..e7b80c02 100644 --- a/server/src/web/routes/machines.ts +++ b/server/src/web/routes/machines.ts @@ -7,6 +7,7 @@ import { requireMachine } from './guards' const spawnBodySchema = z.object({ directory: z.string().min(1), agent: z.enum(['claude', 'codex', 'gemini']).optional(), + model: z.string().optional(), yolo: z.boolean().optional(), sessionType: z.enum(['simple', 'worktree']).optional(), worktreeName: z.string().optional() @@ -52,6 +53,7 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho machineId, parsed.data.directory, parsed.data.agent, + parsed.data.model, parsed.data.yolo, parsed.data.sessionType, parsed.data.worktreeName diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 545ba0c9..f5c78242 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -352,13 +352,14 @@ export class ApiClient { machineId: string, directory: string, agent?: 'claude' | 'codex' | 'gemini', + model?: string, yolo?: boolean, sessionType?: 'simple' | 'worktree', worktreeName?: string ): Promise { return await this.request(`/api/machines/${encodeURIComponent(machineId)}/spawn`, { method: 'POST', - body: JSON.stringify({ directory, agent, yolo, sessionType, worktreeName }) + body: JSON.stringify({ directory, agent, model, yolo, sessionType, worktreeName }) }) } diff --git a/web/src/components/NewSession/ModelSelector.tsx b/web/src/components/NewSession/ModelSelector.tsx new file mode 100644 index 00000000..733d9aac --- /dev/null +++ b/web/src/components/NewSession/ModelSelector.tsx @@ -0,0 +1,34 @@ +import type { AgentType } from './types' +import { MODEL_OPTIONS } from './types' +import { useTranslation } from '@/lib/use-translation' + +export function ModelSelector(props: { + agent: AgentType + model: string + isDisabled: boolean + onModelChange: (value: string) => void +}) { + const { t } = useTranslation() + const options = MODEL_OPTIONS[props.agent] + + return ( +
+ + +
+ ) +} diff --git a/web/src/components/NewSession/index.tsx b/web/src/components/NewSession/index.tsx index 907e4ee5..fd779e0b 100644 --- a/web/src/components/NewSession/index.tsx +++ b/web/src/components/NewSession/index.tsx @@ -12,6 +12,7 @@ import { ActionButtons } from './ActionButtons' import { AgentSelector } from './AgentSelector' import { DirectorySection } from './DirectorySection' import { MachineSelector } from './MachineSelector' +import { ModelSelector } from './ModelSelector' import { SessionTypeSelector } from './SessionTypeSelector' import { YoloToggle } from './YoloToggle' @@ -34,6 +35,7 @@ export function NewSession(props: { const [isDirectoryFocused, setIsDirectoryFocused] = useState(false) const [pathExistence, setPathExistence] = useState>({}) const [agent, setAgent] = useState('claude') + const [model, setModel] = useState('auto') const [yoloMode, setYoloMode] = useState(false) const [sessionType, setSessionType] = useState('simple') const [worktreeName, setWorktreeName] = useState('') @@ -46,6 +48,10 @@ export function NewSession(props: { } }, [sessionType]) + useEffect(() => { + setModel('auto') + }, [agent]) + useEffect(() => { if (props.machines.length === 0) return if (machineId && props.machines.find((m) => m.id === machineId)) return @@ -193,6 +199,7 @@ export function NewSession(props: { machineId, directory: directory.trim(), agent, + model: model !== 'auto' ? model : undefined, yolo: yoloMode, sessionType, worktreeName: sessionType === 'worktree' ? (worktreeName.trim() || undefined) : undefined @@ -251,6 +258,12 @@ export function NewSession(props: { isDisabled={isFormDisabled} onAgentChange={setAgent} /> + = { + claude: [ + { value: 'auto', label: 'Auto' }, + { value: 'opus', label: 'Opus' }, + { value: 'sonnet', label: 'Sonnet' }, + ], + codex: [ + { value: 'auto', label: 'Auto' }, + { value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' }, + { value: 'gpt-5.2', label: 'GPT-5.2' }, + { value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' }, + { value: 'gpt-5.1-codex-mini', label: 'GPT-5.1 Codex Mini' }, + ], + gemini: [ + { value: 'auto', label: 'Auto' }, + { value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview' }, + { value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' }, + { value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' }, + ], +} diff --git a/web/src/hooks/mutations/useSpawnSession.ts b/web/src/hooks/mutations/useSpawnSession.ts index 73d8c7e9..0a595239 100644 --- a/web/src/hooks/mutations/useSpawnSession.ts +++ b/web/src/hooks/mutations/useSpawnSession.ts @@ -7,6 +7,7 @@ type SpawnInput = { machineId: string directory: string agent?: 'claude' | 'codex' | 'gemini' + model?: string yolo?: boolean sessionType?: 'simple' | 'worktree' worktreeName?: string @@ -28,6 +29,7 @@ export function useSpawnSession(api: ApiClient | null): { input.machineId, input.directory, input.agent, + input.model, input.yolo, input.sessionType, input.worktreeName diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index d68f5562..5934ce8a 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -100,6 +100,8 @@ export default { 'newSession.type.worktree.desc': 'Create a new worktree next to repo', 'newSession.type.worktree.placeholder': 'feature-x (default 1228-xxxx)', 'newSession.agent': 'Agent', + 'newSession.model': 'Model', + 'newSession.model.optional': 'optional', 'newSession.yolo': 'YOLO mode', 'newSession.yolo.title': 'Bypass approvals and sandbox', 'newSession.yolo.desc': 'Uses dangerous agent flags when spawning.', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index ae75adea..0089b79a 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -102,6 +102,8 @@ export default { 'newSession.type.worktree.desc': '在仓库旁创建新工作树', 'newSession.type.worktree.placeholder': 'feature-x (默认 1228-xxxx)', 'newSession.agent': '代理', + 'newSession.model': '模型', + 'newSession.model.optional': '可选', 'newSession.yolo': 'YOLO 模式', 'newSession.yolo.title': '跳过审批和沙箱', 'newSession.yolo.desc': '启动时使用危险的代理标志。',