import { create } from 'zustand' import { ApiError } from '../api/client' import { settingsApi } from '../api/settings' import { h5AccessApi } from '../api/h5Access' import { fetchProviderModels, clearProviderModelCache } from '../api/providerModels' import { getTuiConfig, saveTuiConfigPatch, clearConfigCache } from '../api/config' import { isThemeMode, type AppMode, type AppModeConfig, type DesktopTerminalSettings, type DesktopTerminalStartupShell, type H5AccessSettings, type NetworkSettings, type PermissionMode, type EffortLevel, type ModelInfo, type ThemeMode, type UpdateProxyMode, type UpdateProxySettings, type WebSearchSettings, } from '../types/settings' import { isTauriRuntime } from '../lib/desktopRuntime' 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 = 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[] activeProviderId: string | null activeProviderName: string | null locale: Locale theme: ThemeMode skipWebFetchPreflight: boolean desktopNotificationsEnabled: boolean desktopTerminal: DesktopTerminalSettings webSearch: WebSearchSettings updateProxy: UpdateProxySettings network: NetworkSettings h5Access: H5AccessSettings h5AccessError: string | null responseLanguage: string uiZoom: number isLoading: boolean error: string | null appMode: AppModeConfig appModeRequiresRestart: boolean fetchAll: () => Promise fetchH5Access: () => Promise setPermissionMode: (mode: PermissionMode) => Promise setModel: (modelId: string) => Promise setActiveProvider: (providerId: string | null, providerName: string | null, models: ModelInfo[]) => void syncFromConfig: () => Promise setEffort: (level: EffortLevel) => Promise setThinkingEnabled: (enabled: boolean) => Promise setLocale: (locale: Locale) => void setTheme: (theme: ThemeMode) => Promise setSkipWebFetchPreflight: (enabled: boolean) => Promise setDesktopNotificationsEnabled: (enabled: boolean) => Promise setDesktopTerminal: (settings: DesktopTerminalSettings) => Promise setWebSearch: (settings: WebSearchSettings) => Promise setUpdateProxy: (settings: UpdateProxySettings) => Promise setNetwork: (settings: NetworkSettings) => Promise enableH5Access: () => Promise disableH5Access: () => Promise regenerateH5AccessToken: () => Promise updateH5AccessSettings: (input: { allowedOrigins?: string[] publicBaseUrl?: string | null }) => Promise setResponseLanguage: (language: string) => Promise fetchAppMode: () => Promise setAppMode: (mode: AppMode, portableDir?: string | null) => Promise setUiZoom: (zoom: number) => void } type NetworkSettingsInput = Partial> & { proxy?: Partial } const DEFAULT_H5_ACCESS_SETTINGS: H5AccessSettings = { enabled: false, tokenPreview: null, allowedOrigins: [], publicBaseUrl: null, } const DEFAULT_DESKTOP_TERMINAL_SETTINGS: DesktopTerminalSettings = { startupShell: 'system', customShellPath: '', } const DEFAULT_UPDATE_PROXY_SETTINGS: UpdateProxySettings = { mode: 'system', url: '', } const DEFAULT_NETWORK_SETTINGS: NetworkSettings = { aiRequestTimeoutMs: 120_000, proxy: { mode: 'system', url: '', }, } export const useSettingsStore = create((set, get) => ({ permissionMode: 'default', currentModel: null, effortLevel: 'medium', thinkingEnabled: true, availableModels: [], activeProviderId: null, activeProviderName: null, locale: getStoredLocale(), theme: useUIStore.getState().theme, skipWebFetchPreflight: true, desktopNotificationsEnabled: false, desktopTerminal: DEFAULT_DESKTOP_TERMINAL_SETTINGS, webSearch: { mode: 'auto', tavilyApiKey: '', braveApiKey: '' }, updateProxy: DEFAULT_UPDATE_PROXY_SETTINGS, network: DEFAULT_NETWORK_SETTINGS, h5Access: DEFAULT_H5_ACCESS_SETTINGS, h5AccessError: null, responseLanguage: '', uiZoom: readStoredAppZoomLevel(), isLoading: false, error: null, appMode: { mode: 'default', portableDir: null, defaultPortableDir: null, activeConfigDir: null, configDirSource: 'system', }, appModeRequiresRestart: false, 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 [directModels, { mode }, config, userSettings, h5AccessResult] = await Promise.all([ // Fetch models directly from provider APIs (TUI-style) fetchProviderModels().catch(() => ({ models: [] as ModelInfo[], provider: null as { id: string; name: string } | null })), settingsApi.getPermissionMode(), // Read model + effort from ~/.claude.json (TUI-style), bypassing sidecar getTuiConfig(), settingsApi.getUser(), loadH5AccessSettings(previousH5Access), ]) // Use direct models if available (TUI-style), otherwise fall back to sidecar let availableModels: ModelInfo[] let activeProviderId: string | null let activeProviderName: string | null if (directModels.models.length > 0) { availableModels = directModels.models activeProviderId = directModels.provider?.id ?? null activeProviderName = directModels.provider?.name ?? null } else { // No direct models — always derive from ~/.claude.json authProvider. // Never use sidecar's provider name: it may be stale and corrupted // (e.g. "TUI: NVIDIA NIM" instead of "NVIDIA"). const authProvider = config.authProvider as string | undefined if (authProvider) { const CLI_PROVIDER_NAMES: Record = { nvidia: 'NVIDIA', openrouter: 'OpenRouter', opencode: 'OpenCode Zen', openai: 'OpenAI', local: 'Local', anthropic: 'Anthropic', } availableModels = [] activeProviderId = `cli-${authProvider}` activeProviderName = CLI_PROVIDER_NAMES[authProvider] ?? authProvider } else { availableModels = [] activeProviderId = null activeProviderName = null } } // Derive currentModel from config's model field, matched against availableModels const configModelId = config.model ?? null const currentModel: ModelInfo | null = configModelId ? availableModels.find((m) => m.id === configModelId) ?? { id: configModelId, name: configModelId, description: '', context: '', } : null const effortLevel: EffortLevel = (config.effortLevel as EffortLevel) ?? 'medium' const theme = isThemeMode(userSettings.theme) ? userSettings.theme : 'white' useUIStore.getState().setTheme(theme) set({ permissionMode: mode, availableModels, activeProviderId, activeProviderName, currentModel, effortLevel, thinkingEnabled: userSettings.alwaysThinkingEnabled !== false, theme, skipWebFetchPreflight: userSettings.skipWebFetchPreflight !== false, desktopNotificationsEnabled: userSettings.desktopNotificationsEnabled === true, desktopTerminal: normalizeDesktopTerminalSettings(userSettings.desktopTerminal), webSearch: normalizeWebSearchSettings(userSettings.webSearch), updateProxy: normalizeUpdateProxySettings(userSettings.updateProxy), network: normalizeNetworkSettings(userSettings.network), 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 saveTuiConfigPatch({ model: modelId }) const availableModels = get().availableModels const currentModel: ModelInfo | null = availableModels.find((m) => m.id === modelId) ?? { id: modelId, name: modelId, description: '', context: '', } set({ currentModel }) }, setActiveProvider: (providerId, providerName, models) => { set({ activeProviderId: providerId, activeProviderName: providerName, availableModels: models, currentModel: models.length > 0 ? models[0] : null, }) }, syncFromConfig: async () => { // Re-read ~/.claude.json and re-fetch the model list for the new provider. // This keeps settingsStore in sync when cliAuthStore writes to the config file. clearProviderModelCache() clearConfigCache() const config = await getTuiConfig().catch(() => ({})) const authProvider = config?.authProvider as string | undefined if (!authProvider) { set({ activeProviderId: null, activeProviderName: null, availableModels: [], currentModel: null }) return } const CLI_NAMES: Record = { nvidia: 'NVIDIA', openrouter: 'OpenRouter', opencode: 'OpenCode Zen', openai: 'OpenAI', local: 'Local', anthropic: 'Anthropic', } const cliProviderId = `cli-${authProvider}` const cliProviderName = CLI_NAMES[authProvider] ?? authProvider // Fetch fresh models for the new provider const { models } = await fetchProviderModels().catch(() => ({ models: [] as ModelInfo[], provider: null })) set({ activeProviderId: cliProviderId, activeProviderName: cliProviderName, availableModels: models, currentModel: models.length > 0 ? models[0] : null, }) }, setEffort: async (level) => { const prev = get().effortLevel set({ effortLevel: level }) try { await saveTuiConfigPatch({ effortLevel: 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 }) } } }, setDesktopTerminal: async (settings) => { const prev = get().desktopTerminal const next = normalizeDesktopTerminalSettings(settings) set({ desktopTerminal: next }) try { await settingsApi.updateUser({ desktopTerminal: next }) } catch (error) { set({ desktopTerminal: prev }) throw error } }, setWebSearch: async (webSearch) => { const prev = get().webSearch const next = normalizeWebSearchSettings(webSearch) set({ webSearch: next }) try { await settingsApi.updateUser({ webSearch: next }) } catch { set({ webSearch: prev }) } }, setUpdateProxy: async (settings) => { const prev = get().updateProxy const next = normalizeUpdateProxySettings(settings) set({ updateProxy: next }) try { await settingsApi.updateUser({ updateProxy: next }) } catch (error) { set({ updateProxy: prev }) throw error } }, setNetwork: async (settings) => { const prev = get().network const next = normalizeNetworkSettings(settings) set({ network: next }) try { await settingsApi.updateUser({ network: next }) } catch (error) { set({ network: prev }) throw error } }, 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 }) } }, fetchAppMode: async () => { if (!isTauriRuntime()) return try { const { invoke } = await import('@tauri-apps/api/core') const result: AppModeConfig = await invoke('get_app_mode') set({ appMode: result }) } catch { /* silently ignore - not in Tauri or command unavailable */ } }, setAppMode: async (mode, portableDir) => { if (!isTauriRuntime()) return const prev = get().appMode const newMode: AppModeConfig = { ...prev, mode, portableDir: mode === 'portable' ? portableDir ?? prev.defaultPortableDir ?? prev.portableDir : null, activeConfigDir: mode === 'portable' ? portableDir ?? prev.defaultPortableDir ?? prev.portableDir : null, configDirSource: mode === 'portable' ? 'portable' : 'system', } set({ appMode: newMode, appModeRequiresRestart: true }) try { const { invoke } = await import('@tauri-apps/api/core') await invoke('set_app_mode', { mode, portableDir: newMode.portableDir || null, }) } catch { set({ appMode: prev, appModeRequiresRestart: false }) } }, })) function normalizeWebSearchSettings(settings: WebSearchSettings | undefined): WebSearchSettings { return { mode: settings?.mode ?? 'auto', tavilyApiKey: settings?.tavilyApiKey ?? '', braveApiKey: settings?.braveApiKey ?? '', } } function isUpdateProxyMode(value: unknown): value is UpdateProxyMode { return value === 'system' || value === 'manual' } function normalizeUpdateProxySettings( settings: Partial | undefined, ): UpdateProxySettings { const mode = isUpdateProxyMode(settings?.mode) ? settings.mode : DEFAULT_UPDATE_PROXY_SETTINGS.mode return { mode, url: typeof settings?.url === 'string' ? settings.url.trim() : '', } } function normalizeNetworkSettings( settings: NetworkSettingsInput | undefined, ): NetworkSettings { const timeout = typeof settings?.aiRequestTimeoutMs === 'number' && Number.isFinite(settings.aiRequestTimeoutMs) ? Math.min(Math.max(Math.round(settings.aiRequestTimeoutMs), 5_000), 600_000) : DEFAULT_NETWORK_SETTINGS.aiRequestTimeoutMs const proxyMode = settings?.proxy?.mode === 'manual' ? 'manual' : 'system' return { aiRequestTimeoutMs: timeout, proxy: { mode: proxyMode, url: typeof settings?.proxy?.url === 'string' ? settings.proxy.url.trim() : '', }, } } function normalizeDesktopTerminalSettings( settings: Partial | undefined, ): DesktopTerminalSettings { const startupShell = isDesktopTerminalStartupShell(settings?.startupShell) ? settings.startupShell : DEFAULT_DESKTOP_TERMINAL_SETTINGS.startupShell return { startupShell, customShellPath: typeof settings?.customShellPath === 'string' ? settings.customShellPath : DEFAULT_DESKTOP_TERMINAL_SETTINGS.customShellPath, } } 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 } function isDesktopTerminalStartupShell(value: unknown): value is DesktopTerminalStartupShell { return value === 'system' || value === 'pwsh' || value === 'powershell' || value === 'cmd' || value === 'custom' }