mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-17 01:56:18 +00:00
* feat(settings): persist account preferences across browsers * docs(settings): scope preference guidance to user persistence * fix(settings): preserve SSR and fence custom-agent defaults * test: include user persistence in scoped guidance inventory * fix(settings): sync explicit edits and preserve local tab updates
259 lines
6.5 KiB
TypeScript
259 lines
6.5 KiB
TypeScript
import {
|
|
DEFAULT_LOCAL_SETTINGS,
|
|
LOCAL_SETTINGS_KEY,
|
|
THREAD_MODEL_KEY_PREFIX,
|
|
getLocalSettings,
|
|
getThreadModelName,
|
|
saveLocalSettings,
|
|
saveThreadModelName,
|
|
type LocalSettings,
|
|
} from "./local";
|
|
import type { Preferences } from "./preferences-sync";
|
|
|
|
type Listener = () => void;
|
|
|
|
export type LocalSettingsSetter = <K extends keyof LocalSettings>(
|
|
key: K,
|
|
value: Partial<LocalSettings[K]>,
|
|
) => void;
|
|
|
|
const listeners = new Set<Listener>();
|
|
const threadModelNames = new Map<string, string | undefined>();
|
|
|
|
let baseSettings: LocalSettings = DEFAULT_LOCAL_SETTINGS;
|
|
let baseSettingsLoaded = false;
|
|
let storageListenerRegistered = false;
|
|
let preferenceEdit:
|
|
| ((before: LocalSettings, after: LocalSettings) => void)
|
|
| undefined;
|
|
|
|
/** Activate only after the workspace has identified the account. */
|
|
export function activatePreferences(
|
|
initial: Preferences,
|
|
edit: (before: LocalSettings, after: LocalSettings) => void,
|
|
) {
|
|
ensureBaseSettingsLoaded();
|
|
preferenceEdit = edit;
|
|
const apply = (value: Preferences) => {
|
|
if (preferenceEdit !== edit) return;
|
|
baseSettings = {
|
|
...baseSettings,
|
|
notification: { enabled: value.notification_enabled ?? true },
|
|
context: {
|
|
...baseSettings.context,
|
|
model_name: value.model_name ?? undefined,
|
|
mode: value.mode ?? undefined,
|
|
reasoning_effort: value.reasoning_effort ?? undefined,
|
|
},
|
|
};
|
|
emitChange();
|
|
};
|
|
apply(initial);
|
|
return {
|
|
apply,
|
|
stop: () => {
|
|
if (preferenceEdit === edit) {
|
|
apply({});
|
|
preferenceEdit = undefined;
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
function emitChange() {
|
|
for (const listener of listeners) {
|
|
listener();
|
|
}
|
|
}
|
|
|
|
function persistLocalOnlySettings() {
|
|
if (!preferenceEdit) {
|
|
saveLocalSettings(baseSettings);
|
|
return;
|
|
}
|
|
// Account preferences never leak back into the legacy shared-origin key.
|
|
const legacy = getLocalSettings();
|
|
saveLocalSettings({
|
|
...baseSettings,
|
|
notification: legacy.notification,
|
|
context: {
|
|
...baseSettings.context,
|
|
model_name: legacy.context.model_name,
|
|
mode: legacy.context.mode,
|
|
reasoning_effort: legacy.context.reasoning_effort,
|
|
},
|
|
});
|
|
}
|
|
|
|
function ensureBaseSettingsLoaded() {
|
|
if (baseSettingsLoaded || typeof window === "undefined") {
|
|
return;
|
|
}
|
|
|
|
baseSettings = getLocalSettings();
|
|
baseSettingsLoaded = true;
|
|
}
|
|
|
|
function ensureStorageListenerRegistered() {
|
|
if (storageListenerRegistered || typeof window === "undefined") {
|
|
return;
|
|
}
|
|
|
|
window.addEventListener("storage", handleStorage);
|
|
storageListenerRegistered = true;
|
|
}
|
|
|
|
function mergeSettingsSection<K extends keyof LocalSettings>(
|
|
settings: LocalSettings,
|
|
key: K,
|
|
value: Partial<LocalSettings[K]>,
|
|
): LocalSettings {
|
|
const current = settings[key];
|
|
if (
|
|
current !== null &&
|
|
typeof current === "object" &&
|
|
value !== null &&
|
|
typeof value === "object"
|
|
) {
|
|
return {
|
|
...settings,
|
|
[key]: {
|
|
...current,
|
|
...value,
|
|
},
|
|
} as LocalSettings;
|
|
}
|
|
return {
|
|
...settings,
|
|
[key]: value,
|
|
};
|
|
}
|
|
|
|
function readSharedSettings(): LocalSettings {
|
|
const local = getLocalSettings();
|
|
if (!preferenceEdit) return local;
|
|
// Device-local fields still follow other tabs, including key removal and
|
|
// storage.clear(). The legacy key must never replace account preferences.
|
|
return {
|
|
...local,
|
|
notification: baseSettings.notification,
|
|
context: {
|
|
...local.context,
|
|
model_name: baseSettings.context.model_name,
|
|
mode: baseSettings.context.mode,
|
|
reasoning_effort: baseSettings.context.reasoning_effort,
|
|
},
|
|
};
|
|
}
|
|
|
|
function handleStorage(event: StorageEvent) {
|
|
if (event.storageArea && event.storageArea !== localStorage) {
|
|
return;
|
|
}
|
|
|
|
ensureBaseSettingsLoaded();
|
|
|
|
if (event.key === null) {
|
|
baseSettings = readSharedSettings();
|
|
threadModelNames.clear();
|
|
emitChange();
|
|
return;
|
|
}
|
|
|
|
if (event.key === LOCAL_SETTINGS_KEY) {
|
|
baseSettings = readSharedSettings();
|
|
emitChange();
|
|
return;
|
|
}
|
|
|
|
if (!event.key.startsWith(THREAD_MODEL_KEY_PREFIX)) {
|
|
return;
|
|
}
|
|
|
|
const threadId = event.key.slice(THREAD_MODEL_KEY_PREFIX.length);
|
|
threadModelNames.set(threadId, getThreadModelName(threadId));
|
|
emitChange();
|
|
}
|
|
|
|
export function subscribe(listener: Listener): () => void {
|
|
ensureBaseSettingsLoaded();
|
|
ensureStorageListenerRegistered();
|
|
listeners.add(listener);
|
|
|
|
return () => {
|
|
listeners.delete(listener);
|
|
};
|
|
}
|
|
|
|
export function getBaseSettingsSnapshot(): LocalSettings {
|
|
ensureBaseSettingsLoaded();
|
|
return baseSettings;
|
|
}
|
|
|
|
export function getThreadModelSnapshot(threadId: string): string | undefined {
|
|
ensureBaseSettingsLoaded();
|
|
|
|
if (!threadModelNames.has(threadId)) {
|
|
threadModelNames.set(threadId, getThreadModelName(threadId));
|
|
}
|
|
|
|
return threadModelNames.get(threadId);
|
|
}
|
|
|
|
export const updateLocalSettings: LocalSettingsSetter = (key, value) => {
|
|
ensureBaseSettingsLoaded();
|
|
ensureStorageListenerRegistered();
|
|
|
|
const previous = baseSettings;
|
|
baseSettings = mergeSettingsSection(baseSettings, key, value);
|
|
persistLocalOnlySettings();
|
|
preferenceEdit?.(previous, baseSettings);
|
|
emitChange();
|
|
};
|
|
|
|
export function updateThreadSettings<K extends keyof LocalSettings>(
|
|
threadId: string,
|
|
key: K,
|
|
value: Partial<LocalSettings[K]>,
|
|
) {
|
|
ensureBaseSettingsLoaded();
|
|
ensureStorageListenerRegistered();
|
|
|
|
const previous = baseSettings;
|
|
const nextBaseSettings = mergeSettingsSection(baseSettings, key, value);
|
|
baseSettings = nextBaseSettings;
|
|
persistLocalOnlySettings();
|
|
preferenceEdit?.(previous, baseSettings);
|
|
|
|
if (
|
|
key === "context" &&
|
|
Object.prototype.hasOwnProperty.call(value, "model_name")
|
|
) {
|
|
const contextValue = value as Partial<LocalSettings["context"]>;
|
|
const threadModelName = contextValue.model_name;
|
|
threadModelNames.set(threadId, threadModelName);
|
|
saveThreadModelName(threadId, threadModelName);
|
|
}
|
|
|
|
emitChange();
|
|
}
|
|
|
|
/** Model availability/default resolution is not an explicit account edit. */
|
|
export function resolveThreadContext(
|
|
threadId: string,
|
|
context: Partial<LocalSettings["context"]>,
|
|
) {
|
|
if (!preferenceEdit) {
|
|
updateThreadSettings(threadId, "context", context);
|
|
return;
|
|
}
|
|
baseSettings = mergeSettingsSection(baseSettings, "context", context);
|
|
// Preserve explicit thread overrides, but do not create one from a temporary
|
|
// fallback: it would mask the account model when a slow GET finally arrives.
|
|
if (getThreadModelSnapshot(threadId) && "model_name" in context) {
|
|
threadModelNames.set(threadId, context.model_name);
|
|
saveThreadModelName(threadId, context.model_name);
|
|
}
|
|
emitChange();
|
|
}
|