diff --git a/src/main/hermes.ts b/src/main/hermes.ts index 6f618af06..8ddd8c092 100644 --- a/src/main/hermes.ts +++ b/src/main/hermes.ts @@ -1109,6 +1109,7 @@ function sendMessageViaApi( history?: Array<{ role: string; content: string }>, attachments?: Attachment[], contextFolder?: string, + modelOverride?: string, ): ChatHandle { const mc = getModelConfig(profile); const controller = new AbortController(); @@ -1138,7 +1139,7 @@ function sendMessageViaApi( const reasoningEffort = reasoningEffortForProfile(profile); const bodyObj: Record = { - model: mc.model || "hermes-agent", + model: modelOverride || mc.model || "hermes-agent", messages, stream: true, ...(_resumeSessionId ? { session_id: _resumeSessionId } : {}), @@ -1224,7 +1225,7 @@ function sendMessageViaApi( function probeRealError(): void { // When streaming returns empty, make a non-streaming request to surface the real error const probeBodyObj: Record = { - model: mc.model || "hermes-agent", + model: modelOverride || mc.model || "hermes-agent", messages: [{ role: "user", content: userContent }], stream: false, }; @@ -1513,6 +1514,7 @@ function sendMessageViaRuns( history?: Array<{ role: string; content: string }>, attachments?: Attachment[], contextFolder?: string, + modelOverride?: string, ): ChatHandle { const mc = getModelConfig(profile); const controller = new AbortController(); @@ -1523,7 +1525,7 @@ function sendMessageViaRuns( (headersForAuth.Authorization ? `desk-${Date.now()}-${randomUUID()}` : ""); const ctxSystem = contextFolderSystemMessage(contextFolder); const bodyObj: Record = { - model: mc.model || "hermes-agent", + model: modelOverride || mc.model || "hermes-agent", input: message, conversation_history: apiHistory(history), }; @@ -1567,6 +1569,7 @@ function sendMessageViaRuns( history, attachments, contextFolder, + modelOverride, ); } @@ -2077,6 +2080,7 @@ function sendMessageViaCli( profile?: string, resumeSessionId?: string, attachments?: Attachment[], + modelOverride?: string, ): ChatHandle { // CLI fallback can't pipe multimodal content; inline text-file attachments // and ignore images. The gateway is the supported attachment path; this @@ -2108,8 +2112,8 @@ function sendMessageViaCli( args.push("--resume", resumeSessionId); } - if (mc.model) { - args.push("-m", mc.model); + if (modelOverride || mc.model) { + args.push("-m", modelOverride || mc.model); } const cliProvider = CLI_COMPAT_PROVIDER_OVERRIDE[mc.provider]; @@ -2404,6 +2408,7 @@ async function sendMessageViaNonGatewayApi( history?: Array<{ role: string; content: string }>, attachments?: Attachment[], contextFolder?: string, + modelOverride?: string, ): Promise { const approvalCommand = /^\/(?:approve|deny)\b/i.test(message.trim()); if (!attachments?.length && !approvalCommand) { @@ -2417,6 +2422,7 @@ async function sendMessageViaNonGatewayApi( history, attachments, contextFolder, + modelOverride, ); } } @@ -2429,6 +2435,7 @@ async function sendMessageViaNonGatewayApi( history, attachments, contextFolder, + modelOverride, ); } @@ -2440,13 +2447,18 @@ async function sendMessageViaBestApi( history?: Array<{ role: string; content: string }>, attachments?: Attachment[], contextFolder?: string, + modelOverride?: string, ): Promise { const approvalCommand = /^\/(?:approve|deny)\b/i.test(message.trim()); + // Skip the TUI gateway when a session-scoped model override is active — the + // TUI gateway reads its model from config.yaml and has no per-request + // override mechanism. The API path below already honours modelOverride. if ( shouldUseTuiGatewayClient() && !isRemoteMode() && !attachments?.length && - !approvalCommand + !approvalCommand && + !modelOverride ) { try { return await sendMessageViaTuiGateway( @@ -2473,6 +2485,7 @@ async function sendMessageViaBestApi( history, attachments, contextFolder, + modelOverride, ); } @@ -2484,6 +2497,7 @@ async function sendMessageViaBestApiWithLocalRecovery( history?: Array<{ role: string; content: string }>, attachments?: Attachment[], contextFolder?: string, + modelOverride?: string, ): Promise { let aborted = false; let retrying = false; @@ -2528,6 +2542,7 @@ async function sendMessageViaBestApiWithLocalRecovery( history, attachments, contextFolder, + modelOverride, ); return; } @@ -2538,6 +2553,7 @@ async function sendMessageViaBestApiWithLocalRecovery( profile, resumeSessionId, attachments, + modelOverride, ); }; @@ -2615,6 +2631,7 @@ async function sendMessageViaBestApiWithLocalRecovery( history, attachments, contextFolder, + modelOverride, ); return handle; @@ -2628,6 +2645,7 @@ export async function sendMessage( history?: Array<{ role: string; content: string }>, attachments?: Attachment[], contextFolder?: string, + modelOverride?: string, ): Promise { ensureInitialized(); @@ -2641,6 +2659,7 @@ export async function sendMessage( history, attachments, contextFolder, + modelOverride, ); } @@ -2652,6 +2671,7 @@ export async function sendMessage( profile, resumeSessionId, attachments, + modelOverride, ); } @@ -2675,11 +2695,12 @@ export async function sendMessage( history, attachments, contextFolder, + modelOverride, ); } // Fallback to CLI - return sendMessageViaCli(message, cb, profile, resumeSessionId, attachments); + return sendMessageViaCli(message, cb, profile, resumeSessionId, attachments, modelOverride); } // Lazy init — called on first sendMessage or gateway start diff --git a/src/main/index.ts b/src/main/index.ts index 88aa81b25..430ecb09e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1251,6 +1251,7 @@ function setupIPC(): void { attachments?: Attachment[], contextFolder?: string, runId?: string, + modelOverride?: string, ) => { // Each conversation has a stable runId minted by the renderer. Fall back // to a generated id for legacy callers so the run is still tracked. @@ -1395,6 +1396,7 @@ function setupIPC(): void { history, attachments, contextFolder, + modelOverride, ); activeRuns.set(chatRunId, handle.abort); diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index db98d45f8..be9b6bc27 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -368,6 +368,7 @@ interface HermesAPI { attachments?: Attachment[], contextFolder?: string, runId?: string, + modelOverride?: string, ) => Promise<{ response: string; sessionId?: string }>; abortChat: (runId?: string) => Promise; transcribeAudio: ( diff --git a/src/preload/index.ts b/src/preload/index.ts index 2a0b09f41..716d3dac6 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -368,6 +368,7 @@ const hermesAPI = { attachments?: Attachment[], contextFolder?: string, runId?: string, + modelOverride?: string, ): Promise<{ response: string; sessionId?: string }> => ipcRenderer.invoke( "send-message", @@ -378,6 +379,7 @@ const hermesAPI = { attachments, contextFolder, runId, + modelOverride, ), abortChat: (runId?: string): Promise => diff --git a/src/renderer/src/screens/Chat/Chat.tsx b/src/renderer/src/screens/Chat/Chat.tsx index 13a62dbb3..16a626f69 100644 --- a/src/renderer/src/screens/Chat/Chat.tsx +++ b/src/renderer/src/screens/Chat/Chat.tsx @@ -117,6 +117,13 @@ function Chat({ // Whether the worktree panel is visible (only applies when contextFolder is set) // Default false so the panel doesn't open automatically and interfere with scrolling const [worktreeVisible, setWorktreeVisible] = useState(false); + // Explicit session-scoped model override — set only when the user picks + // from the chat-screen picker (persist:false). Undefined until then so the + // TUI gateway bypass in sendMessageViaBestApi is not triggered for normal + // chats where the user never changed the model (issue #688). + const [sessionModelOverride, setSessionModelOverride] = useState< + string | undefined + >(undefined); const dragCounter = useRef(0); const chatInputRef = useRef(null); const queueRef = useRef([]); @@ -424,6 +431,7 @@ function Chat({ localCommands, activeTurnRef, contextFolder, + sessionModel: sessionModelOverride, sendViaDashboard: dashboardTransport.enabled ? dashboardTransport.sendMessage : undefined, @@ -634,7 +642,12 @@ function Chat({ modelGroups={modelConfig.modelGroups} displayModel={modelConfig.displayModel} onOpen={modelConfig.reload} - onSelectModel={modelConfig.selectModel} + onSelectModel={(provider, model, baseUrl) => { + void modelConfig.selectModel(provider, model, baseUrl, { + persist: false, + }); + setSessionModelOverride(model || undefined); + }} /> ; /** Working folder bound to this conversation (issue #27), or null. */ contextFolder: string | null; + /** Session-local model override — selected via the chat picker without + * persisting to config.yaml (issue #688). */ + sessionModel?: string; sendViaDashboard?: ( text: string, attachments?: Attachment[], @@ -106,6 +109,7 @@ export function useChatActions({ localCommands, activeTurnRef, contextFolder, + sessionModel, sendViaDashboard, execSlashViaDashboard, runBackgroundViaDashboard, @@ -115,9 +119,11 @@ export function useChatActions({ }: UseChatActionsArgs): UseChatActionsResult { const messagesRef = useRef(messages); const isLoadingRef = useRef(isLoading); + const sessionModelRef = useRef(sessionModel); useEffect(() => { messagesRef.current = messages; isLoadingRef.current = isLoading; + sessionModelRef.current = sessionModel; }); const pushUser = useCallback( @@ -156,6 +162,7 @@ export function useChatActions({ attachments, contextFolder ?? undefined, runId, + sessionModelRef.current || undefined, ); } catch { // onChatError IPC already surfaces this to the user diff --git a/src/renderer/src/screens/Chat/hooks/useModelConfig.ts b/src/renderer/src/screens/Chat/hooks/useModelConfig.ts index b5502185e..691ba832d 100644 --- a/src/renderer/src/screens/Chat/hooks/useModelConfig.ts +++ b/src/renderer/src/screens/Chat/hooks/useModelConfig.ts @@ -49,6 +49,7 @@ interface UseModelConfigResult { provider: string, model: string, baseUrl: string, + options?: { persist?: boolean }, ) => Promise; } @@ -134,8 +135,12 @@ export function useModelConfig(profile?: string): UseModelConfigResult { }, [reload]); const selectModel = useCallback( - async (provider: string, model: string, baseUrl: string): Promise => { - const seq = ++loadSeqRef.current; + async ( + provider: string, + model: string, + baseUrl: string, + { persist = true }: { persist?: boolean } = {}, + ): Promise => { // Named providers (deepseek, groq, anthropic, …) have a hardcoded // canonical base_url in `hermes-agent`'s PROVIDER_REGISTRY. A stored // model entry that carries a stale `baseUrl` from an earlier confused @@ -148,6 +153,16 @@ export function useModelConfig(profile?: string): UseModelConfigResult { setCurrentModel(model); setCurrentProvider(provider); setCurrentBaseUrl(effectiveBaseUrl); + // Session-only selection: update local state only, do not write to + // config.yaml so the global default model is preserved (issue #688). + // Advance the sequence counter so any in-flight reload() triggered by + // onConnectionConfigChanged / onModelLibraryChanged cannot clobber the + // session-scoped selection with the persisted value. + if (!persist) { + ++loadSeqRef.current; + return; + } + const seq = ++loadSeqRef.current; try { await window.hermesAPI.setModelConfig( provider,