Merge pull request #257 from unslothai/feature/chat-model-switch-warning

feat: chat model switching toast and add image detection logic
This commit is contained in:
Wasim Yousef Said 2026-02-25 01:58:56 -08:00 committed by GitHub
commit c67da8f349
5 changed files with 86 additions and 41 deletions

View file

@ -10,7 +10,7 @@ export function AppProvider({ children }: AppProviderProps) {
return (
<ThemeProvider attribute="class" defaultTheme="light">
{children}
<Toaster position="top-right" />
<Toaster position="top-right" visibleToasts={2} expand={true} />
</ThemeProvider>
);
}

View file

@ -33,6 +33,7 @@ import {
useRef,
useState,
} from "react";
import { toast } from "sonner";
import { GuidedTour, useGuidedTourController } from "@/features/tour";
import { ChatSettingsPanel } from "./chat-settings-sheet";
import { db } from "./db";
@ -50,7 +51,7 @@ import {
SharedComposer,
} from "./shared-composer";
import { ThreadSidebar } from "./thread-sidebar";
import type { ChatView } from "./types";
import type { ChatView, MessageRecord } from "./types";
import { buildChatTourSteps } from "./tour";
type LoraCandidate = {
@ -90,6 +91,40 @@ function pickBestLoraForBase(
return partial ?? sorted[0];
}
function messageHasImage(message: MessageRecord): boolean {
const contentParts = Array.isArray(message.content) ? message.content : [];
if (contentParts.some((part) => part.type === "image")) {
return true;
}
const attachments = Array.isArray(message.attachments) ? message.attachments : [];
for (const attachment of attachments) {
const parts = Array.isArray(attachment.content) ? attachment.content : [];
for (const part of parts as Array<{ type?: string }>) {
if (part?.type === "image") {
return true;
}
}
}
return false;
}
async function resolveActiveSingleThreadId(view: ChatView): Promise<string | undefined> {
if (view.mode !== "single") {
return undefined;
}
if (view.threadId) {
return view.threadId;
}
// New-thread flow keeps threadId undefined in local view state.
// Fall back to most recent regular base thread.
const candidates = await db.threads.where("modelType").equals("base").toArray();
const latest = candidates
.filter((thread) => !thread.archived && !thread.pairId)
.sort((a, b) => b.createdAt - a.createdAt)[0];
return latest?.id;
}
const SingleContent = memo(function SingleContent({
threadId,
newThreadNonce,
@ -304,15 +339,42 @@ export function ChatPage(): ReactElement {
const currentCheckpoint =
useChatRuntimeStore.getState().params.checkpoint;
if (!value || value === currentCheckpoint) return;
setView({ mode: "single", newThreadNonce: crypto.randomUUID() });
void (async () => {
if (currentCheckpoint) {
await ejectModel();
let switchNote: string | undefined;
const activeThreadId = await resolveActiveSingleThreadId(view);
if (activeThreadId) {
const thread = await db.threads.get(activeThreadId);
if (thread?.modelId && thread.modelId !== value) {
const messages = await db.messages
.where("threadId")
.equals(activeThreadId)
.toArray();
const hasImage = messages.some(messageHasImage);
const targetModel = modelsFromStore.find((model) => model.id === value);
const nonVisionWithImages = hasImage && targetModel?.isVision === false;
switchNote = nonVisionWithImages
? "Full chat history will be sent to the new model. This chat has images; text-only models may fail."
: hasImage
? "Full chat history will be sent to the new model. This chat includes images."
: "Full chat history will be sent to the new model.";
}
}
await selectModel({ id: value, isLora: meta?.isLora });
if (switchNote) {
toast.warning("Model changed for this chat", {
description: switchNote,
duration: 6000,
});
}
await selectModel({
id: value,
isLora: meta?.isLora,
});
})();
},
[selectModel, ejectModel],
[modelsFromStore, selectModel, view],
);
const handleEject = useCallback(() => {
void ejectModel();
@ -361,36 +423,8 @@ export function ChatPage(): ReactElement {
const handleThreadSelect = useCallback(
(nextView: ChatView) => {
setView(nextView);
const threadId =
nextView.mode === "single" ? nextView.threadId : undefined;
const pairId =
nextView.mode === "compare" ? nextView.pairId : undefined;
void (async () => {
let thread: import("./types").ThreadRecord | undefined;
if (threadId) {
thread = await db.threads.get(threadId);
} else if (pairId) {
thread = await db.threads
.where("pairId")
.equals(pairId)
.first();
}
const threadModelId = thread?.modelId;
if (!threadModelId) return;
const currentCheckpoint =
useChatRuntimeStore.getState().params.checkpoint;
if (threadModelId === currentCheckpoint) return;
if (currentCheckpoint) {
await ejectModel();
}
await selectModel({ id: threadModelId });
})();
},
[ejectModel, selectModel],
[],
);
const models = useMemo<ModelOption[]>(

View file

@ -320,7 +320,7 @@ export function ChatSettingsPanel({
label="Max Tokens"
value={params.maxTokens}
min={64}
max={4096}
max={4092}
step={64}
onChange={set("maxTokens")}
/>

View file

@ -20,6 +20,7 @@ const DEFAULT_MODEL_MAX_SEQ_LENGTH = 2048;
type SelectedModelInput = {
id: string;
isLora?: boolean;
loadingDescription?: string;
};
const LORA_SUFFIX_RE = /_(\d{9,})$/;
@ -159,11 +160,22 @@ export function useChatModelRuntime() {
const explicitIsLora =
typeof selection === "string" ? undefined : selection.isLora;
const extraLoadingDescription =
typeof selection === "string" ? undefined : selection.loadingDescription;
const model = models.find((entry) => entry.id === modelId);
const lora = loras.find((entry) => entry.id === modelId);
const isLora =
explicitIsLora ?? model?.isLora ?? (lora ? true : false);
const displayName = model?.name || lora?.name || modelId;
const currentCheckpoint =
useChatRuntimeStore.getState().params.checkpoint;
const loadingDescription = [
currentCheckpoint ? "Unloading previous model first." : null,
extraLoadingDescription ?? null,
"This may include downloading. Large models can take a while.",
]
.filter(Boolean)
.join(" ");
setModelsError(null);
setLoadingModel({ id: modelId, displayName });
@ -197,8 +209,7 @@ export function useChatModelRuntime() {
success: `${displayName} loaded`,
error: (err) =>
err instanceof Error ? err.message : "Failed to load model",
description:
"This may include downloading. Large models can take a while.",
description: loadingDescription,
});
} catch (error) {
setLoadingModel(null);
@ -224,7 +235,7 @@ export function useChatModelRuntime() {
await toast.promise(performUnload(), {
loading: "Unloading model",
success: "Model unloaded",
success: { message: "Model unloaded", duration: 1200 },
error: (err) =>
err instanceof Error ? err.message : "Failed to unload model",
description: "Releases VRAM and resets inference state.",

View file

@ -15,7 +15,7 @@ export const DEFAULT_INFERENCE_PARAMS: InferenceParams = {
topK: 50,
minP: 0.01,
repetitionPenalty: 1.1,
maxTokens: 512,
maxTokens: 4092,
systemPrompt: "",
checkpoint: "",
};