cc-haha/desktop/src/stores/settingsStore.ts
程序员阿江(Relakkes) fa5fda24d0 feat: notify users when desktop attention is needed
Add native system notifications as a desktop-wide attention channel for permission prompts and scheduled task completion. The implementation keeps notification presentation owned by the OS, adds a user-facing enable switch with permission handling, and lets scheduled tasks choose desktop notifications without routing that channel through IM adapters.

Constraint: Notifications must use OS-native APIs without custom sound playback.
Constraint: Desktop channel is local-only and must not be sent through IM adapter delivery.
Rejected: Browser Notification API | not reliable inside the packaged Tauri desktop runtime.
Rejected: Treat desktop as an IM channel | would leak a local-only channel into server-side adapter sending.
Confidence: high
Scope-risk: moderate
Directive: Keep notification styling at the OS layer; business code should only provide title, body, dedupe, and routing decisions.
Tested: bun run check:desktop
Tested: bun run quality:pr
Tested: Computer Use macOS debug app verification for settings toggle, permission prompt, scheduled task desktop channel, and task-run polling dedupe
Not-tested: Windows and Linux native runtime smoke tests on physical hosts
2026-05-03 16:45:32 +08:00

198 lines
5.9 KiB
TypeScript

import { create } from 'zustand'
import { settingsApi } from '../api/settings'
import { modelsApi } from '../api/models'
import type { PermissionMode, EffortLevel, ModelInfo, ThemeMode, WebSearchSettings } from '../types/settings'
import type { Locale } from '../i18n'
import { useUIStore } from './uiStore'
const LOCALE_STORAGE_KEY = 'cc-haha-locale'
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
isLoading: boolean
error: string | null
fetchAll: () => 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>
}
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: true,
webSearch: { mode: 'auto', tavilyApiKey: '', braveApiKey: '' },
isLoading: false,
error: null,
fetchAll: async () => {
set({ isLoading: true, error: null })
try {
const [{ mode }, modelsRes, { model }, { level }, userSettings] = await Promise.all([
settingsApi.getPermissionMode(),
modelsApi.list(),
modelsApi.getCurrent(),
modelsApi.getEffort(),
settingsApi.getUser(),
])
const theme = userSettings.theme === 'dark' ? 'dark' : '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 !== false,
webSearch: normalizeWebSearchSettings(userSettings.webSearch),
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
}
},
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 })
}
},
}))
function normalizeWebSearchSettings(settings: WebSearchSettings | undefined): WebSearchSettings {
return {
mode: settings?.mode ?? 'auto',
tavilyApiKey: settings?.tavilyApiKey ?? '',
braveApiKey: settings?.braveApiKey ?? '',
}
}