cc-haha/desktop/src/stores/settingsStore.ts
DHX 8c124b29e9 feat(settings): add UI zoom slider to General Settings
- Add uiZoom field to UserSettings type and settingsStore with localStorage persistence
- Apply CSS zoom to AppShell root div for full-window scaling
- Add zoom slider in GeneralSettings (50%-200%, step 5%, centered at bottom-left)
- Export UI_ZOOM_MIN/MAX/STEP constants for consistent range control
- Add i18n support for UI zoom label and description (en/zh)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 22:02:38 +08:00

371 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 { useUIStore } from './uiStore'
const LOCALE_STORAGE_KEY = 'cc-haha-locale'
const UI_ZOOM_STORAGE_KEY = 'cc-haha-ui-zoom'
export const UI_ZOOM_MIN = 0.5
export const UI_ZOOM_MAX = 2.0
export const UI_ZOOM_STEP = 0.05
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'
}
function getStoredUiZoom(): number {
try {
const stored = localStorage.getItem(UI_ZOOM_STORAGE_KEY)
if (stored === null) return 1.0
const parsed = parseFloat(stored)
if (isNaN(parsed)) return 1.0
return Math.min(UI_ZOOM_MAX, Math.max(UI_ZOOM_MIN, parsed))
} catch { /* localStorage unavailable */ }
return 1.0
}
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: getStoredUiZoom(),
isLoading: false,
error: null,
setUiZoom: (zoom: number) => {
const clamped = Math.min(UI_ZOOM_MAX, Math.max(UI_ZOOM_MIN, zoom))
set({ uiZoom: clamped })
try { localStorage.setItem(UI_ZOOM_STORAGE_KEY, String(clamped)) } catch { /* noop */ }
},
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 : 'light'
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 ? undefined : false })
} 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
}