mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
Settings General now owns AI request timeout and proxy policy so Anthropic-native and OpenAI-compatible provider paths share one user-visible control. The desktop UI persists the network settings, the server proxy and provider checks read them directly, and CLI sessions receive the same timeout/proxy environment. Constraint: Provider request behavior must be consistent across Anthropic, OpenAI Chat, and OpenAI Responses formats Rejected: Keep preset API_TIMEOUT_MS precedence | it would make the General timeout control unreliable for some providers Confidence: high Scope-risk: moderate Directive: Do not add protocol-specific AI request timeout controls without preserving General as the final override Tested: bun test src/server/__tests__/conversation-service.test.ts src/server/__tests__/network-settings.test.ts src/server/__tests__/providers.test.ts src/server/__tests__/proxy-network-settings.test.ts Tested: cd desktop && bun run test -- --run src/stores/settingsStore.test.ts src/__tests__/generalSettings.test.tsx Tested: bun run check:desktop Tested: bun run check:server Not-tested: socks5 manual proxy support; manual proxy validation currently accepts HTTP/HTTPS URLs only
559 lines
16 KiB
TypeScript
559 lines
16 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 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<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
|
|
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<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>
|
|
setDesktopTerminal: (settings: DesktopTerminalSettings) => Promise<void>
|
|
setWebSearch: (settings: WebSearchSettings) => Promise<void>
|
|
setUpdateProxy: (settings: UpdateProxySettings) => Promise<void>
|
|
setNetwork: (settings: NetworkSettings) => 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>
|
|
fetchAppMode: () => Promise<void>
|
|
setAppMode: (mode: AppMode, portableDir?: string | null) => Promise<void>
|
|
setUiZoom: (zoom: number) => void
|
|
}
|
|
|
|
type NetworkSettingsInput = Partial<Omit<NetworkSettings, 'proxy'>> & {
|
|
proxy?: Partial<NetworkSettings['proxy']>
|
|
}
|
|
|
|
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<SettingsStore>((set, get) => ({
|
|
permissionMode: 'default',
|
|
currentModel: null,
|
|
effortLevel: 'medium',
|
|
thinkingEnabled: true,
|
|
availableModels: [],
|
|
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 [{ 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,
|
|
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 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 })
|
|
}
|
|
}
|
|
},
|
|
|
|
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<UpdateProxySettings> | 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<DesktopTerminalSettings> | 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'
|
|
}
|