mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat(cursor): support model selection (#684)
This commit is contained in:
@@ -18,6 +18,7 @@ import type {
|
||||
} from '@/types/api'
|
||||
import type {
|
||||
CodexModelsResponse,
|
||||
CursorModelsResponse,
|
||||
DeleteUploadResponse,
|
||||
FileReadResponse,
|
||||
GitCommandResponse,
|
||||
@@ -494,6 +495,18 @@ export class ApiClient {
|
||||
)
|
||||
}
|
||||
|
||||
async getSessionCursorModels(sessionId: string): Promise<CursorModelsResponse> {
|
||||
return await this.request<CursorModelsResponse>(
|
||||
`/api/sessions/${encodeURIComponent(sessionId)}/cursor-models`
|
||||
)
|
||||
}
|
||||
|
||||
async getMachineCursorModels(machineId: string): Promise<CursorModelsResponse> {
|
||||
return await this.request<CursorModelsResponse>(
|
||||
`/api/machines/${encodeURIComponent(machineId)}/cursor-models`
|
||||
)
|
||||
}
|
||||
|
||||
async getMachineOpencodeModelsForCwd(machineId: string, cwd: string): Promise<OpencodeModelsResponse> {
|
||||
return await this.request<OpencodeModelsResponse>(
|
||||
`/api/machines/${encodeURIComponent(machineId)}/opencode-models?cwd=${encodeURIComponent(cwd)}`
|
||||
|
||||
@@ -53,6 +53,25 @@ describe('getModelOptionsForFlavor', () => {
|
||||
expect(options).toEqual([])
|
||||
})
|
||||
|
||||
it('returns only default/current for cursor before models are discovered (no claude fallback)', () => {
|
||||
const options = getModelOptionsForFlavor('cursor', 'composer-2.5')
|
||||
expect(options).toEqual([
|
||||
{ value: null, label: 'Default' },
|
||||
{ value: 'composer-2.5', label: 'composer-2.5' }
|
||||
])
|
||||
})
|
||||
|
||||
it('returns dynamic cursor options when supplied', () => {
|
||||
const options = getModelOptionsForFlavor('cursor', null, [
|
||||
{ value: 'composer-2.5', label: 'Composer 2.5' },
|
||||
{ value: 'gpt-5.5-high-fast', label: 'GPT-5.5 High Fast' }
|
||||
])
|
||||
expect(options).toEqual([
|
||||
{ value: 'composer-2.5', label: 'Composer 2.5' },
|
||||
{ value: 'gpt-5.5-high-fast', label: 'GPT-5.5 High Fast' }
|
||||
])
|
||||
})
|
||||
|
||||
it('includes the current opencode model when it is missing from explicit options', () => {
|
||||
const options = getModelOptionsForFlavor('opencode', 'ollama/legacy', [
|
||||
{ value: 'ollama/exaone:4.5-33b-q8', label: 'Ollama EXAONE' }
|
||||
@@ -105,4 +124,9 @@ describe('getNextModelForFlavor', () => {
|
||||
const next = getNextModelForFlavor('opencode', null, [])
|
||||
expect(next).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the current cursor model when the dynamic list has not loaded', () => {
|
||||
const next = getNextModelForFlavor('cursor', 'composer-2.5')
|
||||
expect(next).toBe('composer-2.5')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -62,6 +62,9 @@ export function getModelOptionsForFlavor(
|
||||
if (flavor === 'opencode') {
|
||||
return []
|
||||
}
|
||||
if (flavor === 'cursor') {
|
||||
return withCurrentModelOption([{ value: null, label: 'Default' }], currentModel)
|
||||
}
|
||||
// Kimi has no predefined model list — show just the auto/default option.
|
||||
if (flavor === 'kimi') {
|
||||
return withCurrentModelOption([{ value: null, label: 'Default' }], currentModel)
|
||||
@@ -93,6 +96,9 @@ export function getNextModelForFlavor(
|
||||
if (flavor === 'opencode') {
|
||||
return normalizeCurrentModel(currentModel)
|
||||
}
|
||||
if (flavor === 'cursor') {
|
||||
return normalizeCurrentModel(currentModel)
|
||||
}
|
||||
if (flavor === 'kimi') {
|
||||
return normalizeCurrentModel(currentModel)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { usePlatform } from '@/hooks/usePlatform'
|
||||
import { useMachinePathsExists } from '@/hooks/useMachinePathsExists'
|
||||
import { useSpawnSession } from '@/hooks/mutations/useSpawnSession'
|
||||
import { useCodexModels } from '@/hooks/queries/useCodexModels'
|
||||
import { useCursorModelsForMachine } from '@/hooks/queries/useCursorModelsForMachine'
|
||||
import { useOpencodeModelsForCwd } from '@/hooks/queries/useOpencodeModelsForCwd'
|
||||
import { useSessions } from '@/hooks/queries/useSessions'
|
||||
import { useActiveSuggestions, type Suggestion } from '@/hooks/useActiveSuggestions'
|
||||
@@ -127,6 +128,27 @@ export function NewSession(props: {
|
||||
}
|
||||
return options
|
||||
}, [codexModelsState.models, model])
|
||||
const cursorModelsState = useCursorModelsForMachine({
|
||||
api: props.api,
|
||||
machineId,
|
||||
enabled: agent === 'cursor' && Boolean(machineId)
|
||||
})
|
||||
const cursorModelOptions = useMemo(() => {
|
||||
const options = [{ value: 'auto', label: 'Default' }]
|
||||
for (const cursorModel of cursorModelsState.availableModels) {
|
||||
if (cursorModel.modelId === 'auto') {
|
||||
continue
|
||||
}
|
||||
options.push({
|
||||
value: cursorModel.modelId,
|
||||
label: cursorModel.name ?? cursorModel.modelId
|
||||
})
|
||||
}
|
||||
if (model !== 'auto' && !options.some((option) => option.value === model)) {
|
||||
options.splice(1, 0, { value: model, label: model })
|
||||
}
|
||||
return options
|
||||
}, [cursorModelsState.availableModels, model])
|
||||
|
||||
const recentPaths = useMemo(
|
||||
() => getRecentPaths(machineId),
|
||||
@@ -411,11 +433,26 @@ export function NewSession(props: {
|
||||
<ModelSelector
|
||||
agent={agent}
|
||||
model={model}
|
||||
options={agent === 'codex' ? codexModelOptions : undefined}
|
||||
isDisabled={isFormDisabled || (agent === 'codex' && Boolean(codexModelsState.error))}
|
||||
isLoading={agent === 'codex' && codexModelsState.isLoading}
|
||||
options={
|
||||
agent === 'codex'
|
||||
? codexModelOptions
|
||||
: agent === 'cursor'
|
||||
? cursorModelOptions
|
||||
: undefined
|
||||
}
|
||||
isDisabled={
|
||||
isFormDisabled
|
||||
|| (agent === 'codex' && Boolean(codexModelsState.error))
|
||||
|| (agent === 'cursor' && Boolean(cursorModelsState.error))
|
||||
}
|
||||
isLoading={
|
||||
(agent === 'codex' && codexModelsState.isLoading)
|
||||
|| (agent === 'cursor' && cursorModelsState.isLoading)
|
||||
}
|
||||
error={agent === 'codex' && codexModelsState.error
|
||||
? `${t('newSession.model.loadFailed')}: ${codexModelsState.error}`
|
||||
: agent === 'cursor' && cursorModelsState.error
|
||||
? `${t('newSession.model.loadFailed')}: ${cursorModelsState.error}`
|
||||
: null}
|
||||
onModelChange={setModel}
|
||||
/>
|
||||
|
||||
@@ -31,6 +31,7 @@ import { TeamPanel } from '@/components/TeamPanel'
|
||||
import { usePlatform } from '@/hooks/usePlatform'
|
||||
import { useSessionActions } from '@/hooks/mutations/useSessionActions'
|
||||
import { useCodexModels } from '@/hooks/queries/useCodexModels'
|
||||
import { useCursorModels } from '@/hooks/queries/useCursorModels'
|
||||
import { useOpencodeModels } from '@/hooks/queries/useOpencodeModels'
|
||||
import { useVoiceOptional } from '@/lib/voice-context'
|
||||
import { RealtimeVoiceSession, registerSessionStore, registerVoiceHooksStore, voiceHooks } from '@/realtime'
|
||||
@@ -167,6 +168,26 @@ export function SessionChat(props: {
|
||||
label: opencodeModel.name ?? opencodeModel.modelId
|
||||
}))
|
||||
}, [agentFlavor, opencodeModelsState.availableModels])
|
||||
const cursorModelsState = useCursorModels({
|
||||
api: props.api,
|
||||
sessionId: props.session.id,
|
||||
enabled: agentFlavor === 'cursor' && props.session.active
|
||||
})
|
||||
const cursorModelOptions = useMemo(() => {
|
||||
if (agentFlavor !== 'cursor') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return [
|
||||
{ value: null, label: 'Default' },
|
||||
...cursorModelsState.availableModels
|
||||
.filter((cursorModel) => cursorModel.modelId !== 'auto')
|
||||
.map((cursorModel) => ({
|
||||
value: cursorModel.modelId,
|
||||
label: cursorModel.name ?? cursorModel.modelId
|
||||
}))
|
||||
]
|
||||
}, [agentFlavor, cursorModelsState.availableModels])
|
||||
const {
|
||||
abortSession,
|
||||
switchSession,
|
||||
@@ -605,9 +626,11 @@ export function SessionChat(props: {
|
||||
availableModelOptions={
|
||||
agentFlavor === 'codex'
|
||||
? codexModelOptions
|
||||
: agentFlavor === 'opencode'
|
||||
? opencodeModelOptions
|
||||
: undefined
|
||||
: agentFlavor === 'cursor'
|
||||
? cursorModelOptions
|
||||
: agentFlavor === 'opencode'
|
||||
? opencodeModelOptions
|
||||
: undefined
|
||||
}
|
||||
active={props.session.active}
|
||||
allowSendWhenInactive
|
||||
@@ -627,7 +650,9 @@ export function SessionChat(props: {
|
||||
onModelChange={
|
||||
agentFlavor === 'codex'
|
||||
? (props.session.active && !controlledByUser && !codexModelsState.error ? handleModelChange : undefined)
|
||||
: handleModelChange
|
||||
: agentFlavor === 'cursor'
|
||||
? (props.session.active && !cursorModelsState.error ? handleModelChange : undefined)
|
||||
: handleModelChange
|
||||
}
|
||||
onModelReasoningEffortChange={
|
||||
agentFlavor === 'codex' && props.session.active && !controlledByUser
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { ApiClient } from '@/api/client'
|
||||
import type { CursorModelSummary } from '@/types/api'
|
||||
import { queryKeys } from '@/lib/query-keys'
|
||||
|
||||
export function useCursorModels(args: {
|
||||
api: ApiClient | null
|
||||
sessionId?: string | null
|
||||
enabled?: boolean
|
||||
}): {
|
||||
availableModels: CursorModelSummary[]
|
||||
currentModelId: string | null
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
} {
|
||||
const { api, sessionId } = args
|
||||
const enabled = Boolean(args.enabled && api && sessionId)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: sessionId
|
||||
? queryKeys.sessionCursorModels(sessionId)
|
||||
: ['session-cursor-models', 'unknown'] as const,
|
||||
queryFn: async () => {
|
||||
if (!api) {
|
||||
throw new Error('API unavailable')
|
||||
}
|
||||
if (!sessionId) {
|
||||
throw new Error('Cursor models target unavailable')
|
||||
}
|
||||
return await api.getSessionCursorModels(sessionId)
|
||||
},
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
return {
|
||||
availableModels: query.data?.availableModels ?? [],
|
||||
currentModelId: query.data?.currentModelId ?? null,
|
||||
isLoading: query.isLoading,
|
||||
error: query.data?.success === false
|
||||
? (query.data.error ?? 'Failed to load Cursor models')
|
||||
: query.error instanceof Error
|
||||
? query.error.message
|
||||
: query.error
|
||||
? 'Failed to load Cursor models'
|
||||
: null,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { ApiClient } from '@/api/client'
|
||||
import type { CursorModelSummary } from '@/types/api'
|
||||
import { queryKeys } from '@/lib/query-keys'
|
||||
|
||||
export function useCursorModelsForMachine(args: {
|
||||
api: ApiClient | null
|
||||
machineId?: string | null
|
||||
enabled?: boolean
|
||||
}): {
|
||||
availableModels: CursorModelSummary[]
|
||||
currentModelId: string | null
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
refetch: () => void
|
||||
} {
|
||||
const { api, machineId } = args
|
||||
const enabled = Boolean(args.enabled && api && machineId)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: machineId
|
||||
? queryKeys.machineCursorModels(machineId)
|
||||
: ['machine-cursor-models', 'unknown'] as const,
|
||||
queryFn: async () => {
|
||||
if (!api) {
|
||||
throw new Error('API unavailable')
|
||||
}
|
||||
if (!machineId) {
|
||||
throw new Error('Cursor models target unavailable')
|
||||
}
|
||||
return await api.getMachineCursorModels(machineId)
|
||||
},
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
return {
|
||||
availableModels: query.data?.availableModels ?? [],
|
||||
currentModelId: query.data?.currentModelId ?? null,
|
||||
isLoading: query.isLoading,
|
||||
error: query.data?.success === false
|
||||
? (query.data.error ?? 'Failed to load Cursor models')
|
||||
: query.error instanceof Error
|
||||
? query.error.message
|
||||
: query.error
|
||||
? 'Failed to load Cursor models'
|
||||
: null,
|
||||
refetch: () => {
|
||||
void query.refetch()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ export const queryKeys = {
|
||||
] as const,
|
||||
slashCommands: (sessionId: string) => ['slash-commands', sessionId] as const,
|
||||
sessionCodexModels: (sessionId: string) => ['session-codex-models', sessionId] as const,
|
||||
sessionCursorModels: (sessionId: string) => ['session-cursor-models', sessionId] as const,
|
||||
machineCursorModels: (machineId: string) => ['machine-cursor-models', machineId] as const,
|
||||
sessionOpencodeModels: (sessionId: string) => ['session-opencode-models', sessionId] as const,
|
||||
machineOpencodeModelsForCwd: (machineId: string, cwd: string) => ['machine-opencode-models', machineId, cwd] as const,
|
||||
skills: (sessionId: string) => ['skills', sessionId] as const,
|
||||
|
||||
@@ -12,6 +12,8 @@ export type {
|
||||
CodexModelsResponse,
|
||||
CodexModelSummary,
|
||||
CommandResponse,
|
||||
CursorModelsResponse,
|
||||
CursorModelSummary,
|
||||
DeleteUploadResponse,
|
||||
DirectoryEntry,
|
||||
FileReadResponse,
|
||||
|
||||
Reference in New Issue
Block a user