mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
New desktop installs and settings payloads without an explicit theme now hydrate to the pure white workspace. The General settings selector keeps the other two themes available, but presents pure white first so the default and visible ordering agree. Constraint: Existing persisted theme choices must continue to win over the default. Rejected: Remove the warm classic theme | users still need the alternate light appearance. Confidence: high Scope-risk: narrow Directive: Keep the localStorage and user-settings theme fallbacks aligned when changing desktop theme defaults. Tested: cd desktop && bun run test src/stores/uiStore.test.ts src/stores/settingsStore.test.ts src/__tests__/generalSettings.test.tsx Tested: cd desktop && bun run lint Tested: bun run check:desktop Tested: git diff --check Not-tested: Full root bun run verify.
369 lines
11 KiB
TypeScript
369 lines
11 KiB
TypeScript
import { create } from 'zustand'
|
|
import { ApiError } from '../api/client'
|
|
import { settingsApi } from '../api/settings'
|
|
import { modelsApi } from '../api/models'
|
|
import { h5AccessApi } from '../api/h5Access'
|
|
import { isThemeMode, type H5AccessSettings, type PermissionMode, type EffortLevel, type ModelInfo, type ThemeMode, type WebSearchSettings } from '../types/settings'
|
|
import type { Locale } from '../i18n'
|
|
import {
|
|
APP_ZOOM_CONTROL_STEP,
|
|
DEFAULT_APP_ZOOM,
|
|
MAX_APP_ZOOM,
|
|
MIN_APP_ZOOM,
|
|
applyAppZoomLevel,
|
|
normalizeAppZoomLevel,
|
|
readStoredAppZoomLevel,
|
|
} from '../lib/appZoom'
|
|
import { useUIStore } from './uiStore'
|
|
|
|
const LOCALE_STORAGE_KEY = 'cc-haha-locale'
|
|
export const UI_ZOOM_MIN = MIN_APP_ZOOM
|
|
export const UI_ZOOM_MAX = MAX_APP_ZOOM
|
|
export const UI_ZOOM_STEP = APP_ZOOM_CONTROL_STEP
|
|
export const UI_ZOOM_DEFAULT = DEFAULT_APP_ZOOM
|
|
let desktopNotificationsSaveQueue: Promise<void> = Promise.resolve()
|
|
|
|
function getStoredLocale(): Locale {
|
|
try {
|
|
const stored = localStorage.getItem(LOCALE_STORAGE_KEY)
|
|
if (stored === 'en' || stored === 'zh') return stored
|
|
} catch { /* localStorage unavailable */ }
|
|
return 'zh'
|
|
}
|
|
|
|
type SettingsStore = {
|
|
permissionMode: PermissionMode
|
|
currentModel: ModelInfo | null
|
|
effortLevel: EffortLevel
|
|
thinkingEnabled: boolean
|
|
availableModels: ModelInfo[]
|
|
activeProviderName: string | null
|
|
locale: Locale
|
|
theme: ThemeMode
|
|
skipWebFetchPreflight: boolean
|
|
desktopNotificationsEnabled: boolean
|
|
webSearch: WebSearchSettings
|
|
h5Access: H5AccessSettings
|
|
h5AccessError: string | null
|
|
responseLanguage: string
|
|
uiZoom: number
|
|
isLoading: boolean
|
|
error: string | null
|
|
|
|
fetchAll: () => Promise<void>
|
|
fetchH5Access: () => Promise<void>
|
|
setPermissionMode: (mode: PermissionMode) => Promise<void>
|
|
setModel: (modelId: string) => Promise<void>
|
|
setEffort: (level: EffortLevel) => Promise<void>
|
|
setThinkingEnabled: (enabled: boolean) => Promise<void>
|
|
setLocale: (locale: Locale) => void
|
|
setTheme: (theme: ThemeMode) => Promise<void>
|
|
setSkipWebFetchPreflight: (enabled: boolean) => Promise<void>
|
|
setDesktopNotificationsEnabled: (enabled: boolean) => Promise<void>
|
|
setWebSearch: (settings: WebSearchSettings) => Promise<void>
|
|
enableH5Access: () => Promise<string>
|
|
disableH5Access: () => Promise<void>
|
|
regenerateH5AccessToken: () => Promise<string>
|
|
updateH5AccessSettings: (input: {
|
|
allowedOrigins?: string[]
|
|
publicBaseUrl?: string | null
|
|
}) => Promise<void>
|
|
setResponseLanguage: (language: string) => Promise<void>
|
|
setUiZoom: (zoom: number) => void
|
|
}
|
|
|
|
const DEFAULT_H5_ACCESS_SETTINGS: H5AccessSettings = {
|
|
enabled: false,
|
|
tokenPreview: null,
|
|
allowedOrigins: [],
|
|
publicBaseUrl: null,
|
|
}
|
|
|
|
export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
|
permissionMode: 'default',
|
|
currentModel: null,
|
|
effortLevel: 'medium',
|
|
thinkingEnabled: true,
|
|
availableModels: [],
|
|
activeProviderName: null,
|
|
locale: getStoredLocale(),
|
|
theme: useUIStore.getState().theme,
|
|
skipWebFetchPreflight: true,
|
|
desktopNotificationsEnabled: false,
|
|
webSearch: { mode: 'auto', tavilyApiKey: '', braveApiKey: '' },
|
|
h5Access: DEFAULT_H5_ACCESS_SETTINGS,
|
|
h5AccessError: null,
|
|
responseLanguage: '',
|
|
uiZoom: readStoredAppZoomLevel(),
|
|
isLoading: false,
|
|
error: null,
|
|
|
|
setUiZoom: (zoom: number) => {
|
|
const level = normalizeAppZoomLevel(zoom)
|
|
set({ uiZoom: level })
|
|
void applyAppZoomLevel(level)
|
|
},
|
|
|
|
fetchAll: async () => {
|
|
set({ isLoading: true, error: null })
|
|
try {
|
|
const previousH5Access = get().h5Access
|
|
const [{ mode }, modelsRes, { model }, { level }, userSettings, h5AccessResult] = await Promise.all([
|
|
settingsApi.getPermissionMode(),
|
|
modelsApi.list(),
|
|
modelsApi.getCurrent(),
|
|
modelsApi.getEffort(),
|
|
settingsApi.getUser(),
|
|
loadH5AccessSettings(previousH5Access),
|
|
])
|
|
const theme = isThemeMode(userSettings.theme) ? userSettings.theme : 'white'
|
|
useUIStore.getState().setTheme(theme)
|
|
set({
|
|
permissionMode: mode,
|
|
availableModels: modelsRes.models,
|
|
activeProviderName: modelsRes.provider?.name ?? null,
|
|
currentModel: model,
|
|
effortLevel: level,
|
|
thinkingEnabled: userSettings.alwaysThinkingEnabled !== false,
|
|
theme,
|
|
skipWebFetchPreflight: userSettings.skipWebFetchPreflight !== false,
|
|
desktopNotificationsEnabled: userSettings.desktopNotificationsEnabled === true,
|
|
webSearch: normalizeWebSearchSettings(userSettings.webSearch),
|
|
h5Access: h5AccessResult.settings,
|
|
h5AccessError: h5AccessResult.error,
|
|
responseLanguage: typeof userSettings.language === 'string' ? userSettings.language : '',
|
|
isLoading: false,
|
|
error: null,
|
|
})
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof Error ? error.message : 'Failed to load desktop settings'
|
|
set({ isLoading: false, error: message })
|
|
throw error
|
|
}
|
|
},
|
|
|
|
fetchH5Access: async () => {
|
|
const result = await loadH5AccessSettings(get().h5Access)
|
|
set({ h5Access: result.settings, h5AccessError: result.error })
|
|
},
|
|
|
|
setPermissionMode: async (mode) => {
|
|
const prev = get().permissionMode
|
|
set({ permissionMode: mode })
|
|
try {
|
|
await settingsApi.setPermissionMode(mode)
|
|
} catch {
|
|
set({ permissionMode: prev })
|
|
}
|
|
},
|
|
|
|
setModel: async (modelId) => {
|
|
await modelsApi.setCurrent(modelId)
|
|
const { model } = await modelsApi.getCurrent()
|
|
set({ currentModel: model })
|
|
},
|
|
|
|
setEffort: async (level) => {
|
|
const prev = get().effortLevel
|
|
set({ effortLevel: level })
|
|
try {
|
|
await modelsApi.setEffort(level)
|
|
} catch {
|
|
set({ effortLevel: prev })
|
|
}
|
|
},
|
|
|
|
setThinkingEnabled: async (enabled) => {
|
|
const prev = get().thinkingEnabled
|
|
set({ thinkingEnabled: enabled })
|
|
try {
|
|
await settingsApi.updateUser({ alwaysThinkingEnabled: enabled })
|
|
} catch {
|
|
set({ thinkingEnabled: prev })
|
|
}
|
|
},
|
|
|
|
setLocale: (locale) => {
|
|
set({ locale })
|
|
try { localStorage.setItem(LOCALE_STORAGE_KEY, locale) } catch { /* noop */ }
|
|
},
|
|
|
|
setTheme: async (theme) => {
|
|
const prev = get().theme
|
|
set({ theme })
|
|
useUIStore.getState().setTheme(theme)
|
|
try {
|
|
await settingsApi.updateUser({ theme })
|
|
} catch {
|
|
set({ theme: prev })
|
|
useUIStore.getState().setTheme(prev)
|
|
}
|
|
},
|
|
|
|
setSkipWebFetchPreflight: async (enabled) => {
|
|
const prev = get().skipWebFetchPreflight
|
|
set({ skipWebFetchPreflight: enabled })
|
|
try {
|
|
await settingsApi.updateUser({ skipWebFetchPreflight: enabled })
|
|
} catch {
|
|
set({ skipWebFetchPreflight: prev })
|
|
}
|
|
},
|
|
|
|
setDesktopNotificationsEnabled: async (enabled) => {
|
|
const prev = get().desktopNotificationsEnabled
|
|
set({ desktopNotificationsEnabled: enabled })
|
|
const save = desktopNotificationsSaveQueue
|
|
.catch(() => undefined)
|
|
.then(async () => {
|
|
if (get().desktopNotificationsEnabled !== enabled) return
|
|
await settingsApi.updateUser({ desktopNotificationsEnabled: enabled })
|
|
})
|
|
|
|
desktopNotificationsSaveQueue = save
|
|
|
|
try {
|
|
await save
|
|
} catch {
|
|
if (get().desktopNotificationsEnabled === enabled) {
|
|
set({ desktopNotificationsEnabled: prev })
|
|
}
|
|
}
|
|
},
|
|
|
|
setWebSearch: async (webSearch) => {
|
|
const prev = get().webSearch
|
|
const next = normalizeWebSearchSettings(webSearch)
|
|
set({ webSearch: next })
|
|
try {
|
|
await settingsApi.updateUser({ webSearch: next })
|
|
} catch {
|
|
set({ webSearch: prev })
|
|
}
|
|
},
|
|
|
|
enableH5Access: async () => {
|
|
set({ h5AccessError: null })
|
|
try {
|
|
const { settings, token } = await h5AccessApi.enable()
|
|
set({
|
|
h5Access: normalizeH5AccessSettings(settings),
|
|
h5AccessError: null,
|
|
})
|
|
return token
|
|
} catch (error) {
|
|
set({ h5AccessError: getErrorMessage(error, 'Failed to enable H5 access.') })
|
|
throw error
|
|
}
|
|
},
|
|
|
|
disableH5Access: async () => {
|
|
set({ h5AccessError: null })
|
|
try {
|
|
const { settings } = await h5AccessApi.disable()
|
|
set({
|
|
h5Access: normalizeH5AccessSettings(settings),
|
|
h5AccessError: null,
|
|
})
|
|
} catch (error) {
|
|
set({ h5AccessError: getErrorMessage(error, 'Failed to disable H5 access.') })
|
|
throw error
|
|
}
|
|
},
|
|
|
|
regenerateH5AccessToken: async () => {
|
|
set({ h5AccessError: null })
|
|
try {
|
|
const { settings, token } = await h5AccessApi.regenerate()
|
|
set({
|
|
h5Access: normalizeH5AccessSettings(settings),
|
|
h5AccessError: null,
|
|
})
|
|
return token
|
|
} catch (error) {
|
|
set({ h5AccessError: getErrorMessage(error, 'Failed to regenerate the H5 token.') })
|
|
throw error
|
|
}
|
|
},
|
|
|
|
updateH5AccessSettings: async (input) => {
|
|
set({ h5AccessError: null })
|
|
try {
|
|
const { settings } = await h5AccessApi.update(input)
|
|
set({
|
|
h5Access: normalizeH5AccessSettings(settings),
|
|
h5AccessError: null,
|
|
})
|
|
} catch (error) {
|
|
set({ h5AccessError: getErrorMessage(error, 'Failed to update H5 access settings.') })
|
|
throw error
|
|
}
|
|
},
|
|
|
|
setResponseLanguage: async (language) => {
|
|
const prev = get().responseLanguage
|
|
set({ responseLanguage: language })
|
|
try {
|
|
await settingsApi.updateUser({ language: language || undefined })
|
|
} catch {
|
|
set({ responseLanguage: prev })
|
|
}
|
|
},
|
|
}))
|
|
|
|
function normalizeWebSearchSettings(settings: WebSearchSettings | undefined): WebSearchSettings {
|
|
return {
|
|
mode: settings?.mode ?? 'auto',
|
|
tavilyApiKey: settings?.tavilyApiKey ?? '',
|
|
braveApiKey: settings?.braveApiKey ?? '',
|
|
}
|
|
}
|
|
|
|
function normalizeH5AccessSettings(settings: H5AccessSettings | undefined): H5AccessSettings {
|
|
return {
|
|
enabled: settings?.enabled === true,
|
|
tokenPreview: settings?.tokenPreview ?? null,
|
|
allowedOrigins: Array.isArray(settings?.allowedOrigins) ? settings.allowedOrigins : [],
|
|
publicBaseUrl: settings?.publicBaseUrl ?? null,
|
|
}
|
|
}
|
|
|
|
async function loadH5AccessSettings(previousH5Access: H5AccessSettings): Promise<{
|
|
settings: H5AccessSettings
|
|
error: string | null
|
|
}> {
|
|
try {
|
|
const { settings } = await h5AccessApi.get()
|
|
return {
|
|
settings: normalizeH5AccessSettings(settings),
|
|
error: null,
|
|
}
|
|
} catch (error) {
|
|
if (isLegacyH5EndpointError(error)) {
|
|
return {
|
|
settings: DEFAULT_H5_ACCESS_SETTINGS,
|
|
error: null,
|
|
}
|
|
}
|
|
|
|
return {
|
|
settings: previousH5Access,
|
|
error: getErrorMessage(error, 'Failed to load H5 access settings.'),
|
|
}
|
|
}
|
|
}
|
|
|
|
function isLegacyH5EndpointError(error: unknown) {
|
|
const status = error instanceof ApiError
|
|
? error.status
|
|
: typeof error === 'object' && error !== null && 'status' in error && typeof error.status === 'number'
|
|
? error.status
|
|
: null
|
|
return status === 404 || status === 405
|
|
}
|
|
|
|
function getErrorMessage(error: unknown, fallback: string) {
|
|
return error instanceof Error && error.message.trim().length > 0 ? error.message : fallback
|
|
}
|