fix(frontend): persist model selection per thread (#1553)

* fix(frontend): persist model selection per thread

* fix(frontend): apply thread model override on fallback

* refactor(frontend): split thread settings hook

* fix frontend local storage guards
This commit is contained in:
Admire 2026-04-01 23:27:03 +08:00 committed by GitHub
parent 0a379602b8
commit 0eb6550cf4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 133 additions and 44 deletions

View File

@ -20,7 +20,7 @@ import { Tooltip } from "@/components/workspace/tooltip";
import { useAgent } from "@/core/agents"; import { useAgent } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { useNotification } from "@/core/notification/hooks"; import { useNotification } from "@/core/notification/hooks";
import { useLocalSettings } from "@/core/settings"; import { useThreadSettings } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks"; import { useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils"; import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env"; import { env } from "@/env";
@ -28,7 +28,6 @@ import { cn } from "@/lib/utils";
export default function AgentChatPage() { export default function AgentChatPage() {
const { t } = useI18n(); const { t } = useI18n();
const [settings, setSettings] = useLocalSettings();
const router = useRouter(); const router = useRouter();
const { agent_name } = useParams<{ const { agent_name } = useParams<{
@ -38,6 +37,7 @@ export default function AgentChatPage() {
const { agent } = useAgent(agent_name); const { agent } = useAgent(agent_name);
const { threadId, isNewThread, setIsNewThread } = useThreadChat(); const { threadId, isNewThread, setIsNewThread } = useThreadChat();
const [settings, setSettings] = useThreadSettings(threadId);
const { showNotification } = useNotification(); const { showNotification } = useNotification();
const [thread, sendMessage] = useThreadStream({ const [thread, sendMessage] = useThreadStream({

View File

@ -19,7 +19,7 @@ import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicato
import { Welcome } from "@/components/workspace/welcome"; import { Welcome } from "@/components/workspace/welcome";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { useNotification } from "@/core/notification/hooks"; import { useNotification } from "@/core/notification/hooks";
import { useLocalSettings } from "@/core/settings"; import { useThreadSettings } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks"; import { useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils"; import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env"; import { env } from "@/env";
@ -27,9 +27,8 @@ import { cn } from "@/lib/utils";
export default function ChatPage() { export default function ChatPage() {
const { t } = useI18n(); const { t } = useI18n();
const [settings, setSettings] = useLocalSettings();
const { threadId, isNewThread, setIsNewThread, isMock } = useThreadChat(); const { threadId, isNewThread, setIsNewThread, isMock } = useThreadChat();
const [settings, setSettings] = useThreadSettings(threadId);
useSpecificChatMode(); useSpecificChatMode();
const { showNotification } = useNotification(); const { showNotification } = useNotification();

View File

@ -3,44 +3,62 @@ import { useCallback, useLayoutEffect, useState } from "react";
import { import {
DEFAULT_LOCAL_SETTINGS, DEFAULT_LOCAL_SETTINGS,
getLocalSettings, getLocalSettings,
getThreadLocalSettings,
saveLocalSettings, saveLocalSettings,
saveThreadLocalSettings,
type LocalSettings, type LocalSettings,
} from "./local"; } from "./local";
export function useLocalSettings(): [ type LocalSettingsSetter = (
LocalSettings, key: keyof LocalSettings,
( value: Partial<LocalSettings[keyof LocalSettings]>,
key: keyof LocalSettings, ) => void;
value: Partial<LocalSettings[keyof LocalSettings]>,
) => void, function useSettingsState(
] { getSettings: () => LocalSettings,
const [mounted, setMounted] = useState(false); saveSettings: (settings: LocalSettings) => void,
): [LocalSettings, LocalSettingsSetter] {
const [state, setState] = useState<LocalSettings>(DEFAULT_LOCAL_SETTINGS); const [state, setState] = useState<LocalSettings>(DEFAULT_LOCAL_SETTINGS);
const [mounted, setMounted] = useState(false);
useLayoutEffect(() => { useLayoutEffect(() => {
if (!mounted) { setState(getSettings());
setState(getLocalSettings());
}
setMounted(true); setMounted(true);
}, [mounted]); }, [getSettings]);
const setter = useCallback(
( const setter = useCallback<LocalSettingsSetter>(
key: keyof LocalSettings, (key, value) => {
value: Partial<LocalSettings[keyof LocalSettings]>,
) => {
if (!mounted) return; if (!mounted) return;
setState((prev) => { setState((prev) => {
const newState = { const newState: LocalSettings = {
...prev, ...prev,
[key]: { [key]: {
...prev[key], ...prev[key],
...value, ...value,
}, },
}; };
saveLocalSettings(newState); saveSettings(newState);
return newState; return newState;
}); });
}, },
[mounted], [mounted, saveSettings],
); );
return [state, setter]; return [state, setter];
} }
export function useLocalSettings(): [LocalSettings, LocalSettingsSetter] {
return useSettingsState(getLocalSettings, saveLocalSettings);
}
export function useThreadSettings(
threadId: string,
): [LocalSettings, LocalSettingsSetter] {
return useSettingsState(
useCallback(() => getThreadLocalSettings(threadId), [threadId]),
useCallback(
(settings: LocalSettings) => saveThreadLocalSettings(threadId, settings),
[threadId],
),
);
}

View File

@ -15,6 +15,11 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
}; };
const LOCAL_SETTINGS_KEY = "deerflow.local-settings"; const LOCAL_SETTINGS_KEY = "deerflow.local-settings";
const THREAD_MODEL_KEY_PREFIX = "deerflow.thread-model.";
function isBrowser(): boolean {
return typeof window !== "undefined";
}
export interface LocalSettings { export interface LocalSettings {
notification: { notification: {
@ -22,8 +27,14 @@ export interface LocalSettings {
}; };
context: Omit< context: Omit<
AgentThreadContext, AgentThreadContext,
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled" | "thread_id"
| "is_plan_mode"
| "thinking_enabled"
| "subagent_enabled"
| "model_name"
| "reasoning_effort"
> & { > & {
model_name?: string | undefined;
mode: "flash" | "thinking" | "pro" | "ultra" | undefined; mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high"; reasoning_effort?: "minimal" | "low" | "medium" | "high";
}; };
@ -32,35 +43,96 @@ export interface LocalSettings {
}; };
} }
function mergeLocalSettings(settings?: Partial<LocalSettings>): LocalSettings {
return {
...DEFAULT_LOCAL_SETTINGS,
context: {
...DEFAULT_LOCAL_SETTINGS.context,
...settings?.context,
},
layout: {
...DEFAULT_LOCAL_SETTINGS.layout,
...settings?.layout,
},
notification: {
...DEFAULT_LOCAL_SETTINGS.notification,
...settings?.notification,
},
};
}
function getThreadModelStorageKey(threadId: string): string {
return `${THREAD_MODEL_KEY_PREFIX}${threadId}`;
}
export function getThreadModelName(threadId: string): string | undefined {
if (!isBrowser()) {
return undefined;
}
return localStorage.getItem(getThreadModelStorageKey(threadId)) ?? undefined;
}
export function saveThreadModelName(
threadId: string,
modelName: string | undefined,
) {
if (!isBrowser()) {
return;
}
const key = getThreadModelStorageKey(threadId);
if (!modelName) {
localStorage.removeItem(key);
return;
}
localStorage.setItem(key, modelName);
}
function applyThreadModelOverride(
settings: LocalSettings,
threadId?: string,
): LocalSettings {
const threadModelName = threadId ? getThreadModelName(threadId) : undefined;
if (!threadModelName) {
return settings;
}
return {
...settings,
context: {
...settings.context,
model_name: threadModelName,
},
};
}
export function getLocalSettings(): LocalSettings { export function getLocalSettings(): LocalSettings {
if (typeof window === "undefined") { if (!isBrowser()) {
return DEFAULT_LOCAL_SETTINGS; return DEFAULT_LOCAL_SETTINGS;
} }
const json = localStorage.getItem(LOCAL_SETTINGS_KEY); const json = localStorage.getItem(LOCAL_SETTINGS_KEY);
try { try {
if (json) { if (json) {
const settings = JSON.parse(json); const settings = JSON.parse(json) as Partial<LocalSettings>;
const mergedSettings = { return mergeLocalSettings(settings);
...DEFAULT_LOCAL_SETTINGS,
context: {
...DEFAULT_LOCAL_SETTINGS.context,
...settings.context,
},
layout: {
...DEFAULT_LOCAL_SETTINGS.layout,
...settings.layout,
},
notification: {
...DEFAULT_LOCAL_SETTINGS.notification,
...settings.notification,
},
};
return mergedSettings;
} }
} catch {} } catch {}
return DEFAULT_LOCAL_SETTINGS; return DEFAULT_LOCAL_SETTINGS;
} }
export function getThreadLocalSettings(threadId: string): LocalSettings {
return applyThreadModelOverride(getLocalSettings(), threadId);
}
export function saveLocalSettings(settings: LocalSettings) { export function saveLocalSettings(settings: LocalSettings) {
if (!isBrowser()) {
return;
}
localStorage.setItem(LOCAL_SETTINGS_KEY, JSON.stringify(settings)); localStorage.setItem(LOCAL_SETTINGS_KEY, JSON.stringify(settings));
} }
export function saveThreadLocalSettings(
threadId: string,
settings: LocalSettings,
) {
saveLocalSettings(settings);
saveThreadModelName(threadId, settings.context.model_name);
}