mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
- 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>
371 lines
11 KiB
TypeScript
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
|
|
}
|