Skip to content
Merged
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
35 changes: 28 additions & 7 deletions src/main/hermes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -1138,7 +1139,7 @@ function sendMessageViaApi(

const reasoningEffort = reasoningEffortForProfile(profile);
const bodyObj: Record<string, unknown> = {
model: mc.model || "hermes-agent",
model: modelOverride || mc.model || "hermes-agent",
messages,
stream: true,
...(_resumeSessionId ? { session_id: _resumeSessionId } : {}),
Expand Down Expand Up @@ -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<string, unknown> = {
model: mc.model || "hermes-agent",
model: modelOverride || mc.model || "hermes-agent",
messages: [{ role: "user", content: userContent }],
stream: false,
};
Expand Down Expand Up @@ -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();
Expand All @@ -1523,7 +1525,7 @@ function sendMessageViaRuns(
(headersForAuth.Authorization ? `desk-${Date.now()}-${randomUUID()}` : "");
const ctxSystem = contextFolderSystemMessage(contextFolder);
const bodyObj: Record<string, unknown> = {
model: mc.model || "hermes-agent",
model: modelOverride || mc.model || "hermes-agent",
input: message,
conversation_history: apiHistory(history),
};
Expand Down Expand Up @@ -1567,6 +1569,7 @@ function sendMessageViaRuns(
history,
attachments,
contextFolder,
modelOverride,
);
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -2404,6 +2408,7 @@ async function sendMessageViaNonGatewayApi(
history?: Array<{ role: string; content: string }>,
attachments?: Attachment[],
contextFolder?: string,
modelOverride?: string,
): Promise<ChatHandle> {
const approvalCommand = /^\/(?:approve|deny)\b/i.test(message.trim());
if (!attachments?.length && !approvalCommand) {
Expand All @@ -2417,6 +2422,7 @@ async function sendMessageViaNonGatewayApi(
history,
attachments,
contextFolder,
modelOverride,
);
}
}
Expand All @@ -2429,6 +2435,7 @@ async function sendMessageViaNonGatewayApi(
history,
attachments,
contextFolder,
modelOverride,
);
}

Expand All @@ -2440,13 +2447,18 @@ async function sendMessageViaBestApi(
history?: Array<{ role: string; content: string }>,
attachments?: Attachment[],
contextFolder?: string,
modelOverride?: string,
): Promise<ChatHandle> {
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(
Expand All @@ -2473,6 +2485,7 @@ async function sendMessageViaBestApi(
history,
attachments,
contextFolder,
modelOverride,
);
}

Expand All @@ -2484,6 +2497,7 @@ async function sendMessageViaBestApiWithLocalRecovery(
history?: Array<{ role: string; content: string }>,
attachments?: Attachment[],
contextFolder?: string,
modelOverride?: string,
): Promise<ChatHandle> {
let aborted = false;
let retrying = false;
Expand Down Expand Up @@ -2528,6 +2542,7 @@ async function sendMessageViaBestApiWithLocalRecovery(
history,
attachments,
contextFolder,
modelOverride,
);
return;
}
Expand All @@ -2538,6 +2553,7 @@ async function sendMessageViaBestApiWithLocalRecovery(
profile,
resumeSessionId,
attachments,
modelOverride,
);
};

Expand Down Expand Up @@ -2615,6 +2631,7 @@ async function sendMessageViaBestApiWithLocalRecovery(
history,
attachments,
contextFolder,
modelOverride,
);

return handle;
Expand All @@ -2628,6 +2645,7 @@ export async function sendMessage(
history?: Array<{ role: string; content: string }>,
attachments?: Attachment[],
contextFolder?: string,
modelOverride?: string,
): Promise<ChatHandle> {
ensureInitialized();

Expand All @@ -2641,6 +2659,7 @@ export async function sendMessage(
history,
attachments,
contextFolder,
modelOverride,
);
}

Expand All @@ -2652,6 +2671,7 @@ export async function sendMessage(
profile,
resumeSessionId,
attachments,
modelOverride,
);
}

Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -1395,6 +1396,7 @@ function setupIPC(): void {
history,
attachments,
contextFolder,
modelOverride,
);

activeRuns.set(chatRunId, handle.abort);
Expand Down
1 change: 1 addition & 0 deletions src/preload/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,7 @@ interface HermesAPI {
attachments?: Attachment[],
contextFolder?: string,
runId?: string,
modelOverride?: string,
) => Promise<{ response: string; sessionId?: string }>;
abortChat: (runId?: string) => Promise<void>;
transcribeAudio: (
Expand Down
2 changes: 2 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,7 @@ const hermesAPI = {
attachments?: Attachment[],
contextFolder?: string,
runId?: string,
modelOverride?: string,
): Promise<{ response: string; sessionId?: string }> =>
ipcRenderer.invoke(
"send-message",
Expand All @@ -378,6 +379,7 @@ const hermesAPI = {
attachments,
contextFolder,
runId,
modelOverride,
),

abortChat: (runId?: string): Promise<void> =>
Expand Down
15 changes: 14 additions & 1 deletion src/renderer/src/screens/Chat/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>(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<ChatInputHandle>(null);
const queueRef = useRef<QueuedMessage[]>([]);
Expand Down Expand Up @@ -424,6 +431,7 @@ function Chat({
localCommands,
activeTurnRef,
contextFolder,
sessionModel: sessionModelOverride,
sendViaDashboard: dashboardTransport.enabled
? dashboardTransport.sendMessage
: undefined,
Expand Down Expand Up @@ -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);
}}
/>
<ReasoningEffortPicker
value={reasoningEffort}
Expand Down
7 changes: 7 additions & 0 deletions src/renderer/src/screens/Chat/hooks/useChatActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ interface UseChatActionsArgs {
activeTurnRef: React.MutableRefObject<ActiveTurn | null>;
/** 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[],
Expand Down Expand Up @@ -106,6 +109,7 @@ export function useChatActions({
localCommands,
activeTurnRef,
contextFolder,
sessionModel,
sendViaDashboard,
execSlashViaDashboard,
runBackgroundViaDashboard,
Expand All @@ -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(
Expand Down Expand Up @@ -156,6 +162,7 @@ export function useChatActions({
attachments,
contextFolder ?? undefined,
runId,
sessionModelRef.current || undefined,
);
} catch {
// onChatError IPC already surfaces this to the user
Expand Down
19 changes: 17 additions & 2 deletions src/renderer/src/screens/Chat/hooks/useModelConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ interface UseModelConfigResult {
provider: string,
model: string,
baseUrl: string,
options?: { persist?: boolean },
) => Promise<void>;
}

Expand Down Expand Up @@ -134,8 +135,12 @@ export function useModelConfig(profile?: string): UseModelConfigResult {
}, [reload]);

const selectModel = useCallback(
async (provider: string, model: string, baseUrl: string): Promise<void> => {
const seq = ++loadSeqRef.current;
async (
provider: string,
model: string,
baseUrl: string,
{ persist = true }: { persist?: boolean } = {},
): Promise<void> => {
// 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
Expand All @@ -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,
Expand Down
Loading