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
This commit is contained in:
weishu
2026-01-23 11:55:29 +08:00
parent 29551478cc
commit e130dfd24b
13 changed files with 88 additions and 4 deletions
+2 -1
View File
@@ -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,
+1
View File
@@ -4,6 +4,7 @@ export interface SpawnSessionOptions {
sessionId?: string
approvedNewDirectoryCreation?: boolean
agent?: 'claude' | 'codex' | 'gemini'
model?: string
yolo?: boolean
token?: string
sessionType?: 'simple' | 'worktree'
+3
View File
@@ -333,6 +333,9 @@ export async function startRunner(): Promise<void> {
'--hapi-starting-mode', 'remote',
'--started-by', 'runner'
];
if (options.model) {
args.push('--model', options.model);
}
if (yolo) {
args.push('--yolo');
}
+2 -1
View File
@@ -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<string, unknown>
+2 -1
View File
@@ -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<Record<string, boolean>> {
+2
View File
@@ -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
+2 -1
View File
@@ -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<SpawnResponse> {
return await this.request<SpawnResponse>(`/api/machines/${encodeURIComponent(machineId)}/spawn`, {
method: 'POST',
body: JSON.stringify({ directory, agent, yolo, sessionType, worktreeName })
body: JSON.stringify({ directory, agent, model, yolo, sessionType, worktreeName })
})
}
@@ -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 (
<div className="flex flex-col gap-1.5 px-3 py-3">
<label className="text-xs font-medium text-[var(--app-hint)]">
{t('newSession.model')}{' '}
<span className="font-normal">({t('newSession.model.optional')})</span>
</label>
<select
value={props.model}
onChange={(e) => props.onModelChange(e.target.value)}
disabled={props.isDisabled}
className="w-full px-3 py-2 text-sm rounded-lg border border-[var(--app-divider)] bg-[var(--app-bg)] text-[var(--app-text)] focus:outline-none focus:ring-2 focus:ring-[var(--app-link)] disabled:opacity-50"
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
)
}
+13
View File
@@ -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<Record<string, boolean>>({})
const [agent, setAgent] = useState<AgentType>('claude')
const [model, setModel] = useState('auto')
const [yoloMode, setYoloMode] = useState(false)
const [sessionType, setSessionType] = useState<SessionType>('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}
/>
<ModelSelector
agent={agent}
model={model}
isDisabled={isFormDisabled}
onModelChange={setModel}
/>
<YoloToggle
yoloMode={yoloMode}
isDisabled={isFormDisabled}
+21
View File
@@ -1,2 +1,23 @@
export type AgentType = 'claude' | 'codex' | 'gemini'
export type SessionType = 'simple' | 'worktree'
export const MODEL_OPTIONS: Record<AgentType, { value: string; label: string }[]> = {
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' },
],
}
@@ -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
+2
View File
@@ -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.',
+2
View File
@@ -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': '启动时使用危险的代理标志。',