Handy/src/stores/settingsStore.ts
CJ Pais 5a3e6e33d1
add extra recording buffer (#1089)
* add extra recording buffer

* translations
2026-03-18 21:00:24 +08:00

590 lines
20 KiB
TypeScript

import { create } from "zustand";
import { subscribeWithSelector } from "zustand/middleware";
import { listen } from "@tauri-apps/api/event";
import type {
AppSettings as Settings,
AudioDevice,
WhisperAcceleratorSetting,
OrtAcceleratorSetting,
} from "@/bindings";
import { commands } from "@/bindings";
interface SettingsStore {
settings: Settings | null;
defaultSettings: Settings | null;
isLoading: boolean;
isUpdating: Record<string, boolean>;
audioDevices: AudioDevice[];
outputDevices: AudioDevice[];
customSounds: { start: boolean; stop: boolean };
postProcessModelOptions: Record<string, string[]>;
// Actions
initialize: () => Promise<void>;
loadDefaultSettings: () => Promise<void>;
updateSetting: <K extends keyof Settings>(
key: K,
value: Settings[K],
) => Promise<void>;
resetSetting: (key: keyof Settings) => Promise<void>;
refreshSettings: () => Promise<void>;
refreshAudioDevices: () => Promise<void>;
refreshOutputDevices: () => Promise<void>;
updateBinding: (id: string, binding: string) => Promise<void>;
resetBinding: (id: string) => Promise<void>;
getSetting: <K extends keyof Settings>(key: K) => Settings[K] | undefined;
isUpdatingKey: (key: string) => boolean;
playTestSound: (soundType: "start" | "stop") => Promise<void>;
checkCustomSounds: () => Promise<void>;
setPostProcessProvider: (providerId: string) => Promise<void>;
updatePostProcessSetting: (
settingType: "base_url" | "api_key" | "model",
providerId: string,
value: string,
) => Promise<void>;
updatePostProcessBaseUrl: (
providerId: string,
baseUrl: string,
) => Promise<void>;
updatePostProcessApiKey: (
providerId: string,
apiKey: string,
) => Promise<void>;
updatePostProcessModel: (providerId: string, model: string) => Promise<void>;
fetchPostProcessModels: (providerId: string) => Promise<string[]>;
setPostProcessModelOptions: (providerId: string, models: string[]) => void;
// Internal state setters
setSettings: (settings: Settings | null) => void;
setDefaultSettings: (defaultSettings: Settings | null) => void;
setLoading: (loading: boolean) => void;
setUpdating: (key: string, updating: boolean) => void;
setAudioDevices: (devices: AudioDevice[]) => void;
setOutputDevices: (devices: AudioDevice[]) => void;
setCustomSounds: (sounds: { start: boolean; stop: boolean }) => void;
}
// Note: Default settings are now fetched from Rust via commands.getDefaultSettings()
// This ensures platform-specific defaults (like overlay_position, shortcuts, paste_method) work correctly
const DEFAULT_AUDIO_DEVICE: AudioDevice = {
index: "default",
name: "Default",
is_default: true,
};
const settingUpdaters: {
[K in keyof Settings]?: (value: Settings[K]) => Promise<unknown>;
} = {
always_on_microphone: (value) =>
commands.updateMicrophoneMode(value as boolean),
audio_feedback: (value) =>
commands.changeAudioFeedbackSetting(value as boolean),
audio_feedback_volume: (value) =>
commands.changeAudioFeedbackVolumeSetting(value as number),
sound_theme: (value) => commands.changeSoundThemeSetting(value as string),
start_hidden: (value) => commands.changeStartHiddenSetting(value as boolean),
autostart_enabled: (value) =>
commands.changeAutostartSetting(value as boolean),
update_checks_enabled: (value) =>
commands.changeUpdateChecksSetting(value as boolean),
push_to_talk: (value) => commands.changePttSetting(value as boolean),
selected_microphone: (value) =>
commands.setSelectedMicrophone(
(value as string) === "Default" || value === null
? "default"
: (value as string),
),
clamshell_microphone: (value) =>
commands.setClamshellMicrophone(
(value as string) === "Default" ? "default" : (value as string),
),
selected_output_device: (value) =>
commands.setSelectedOutputDevice(
(value as string) === "Default" || value === null
? "default"
: (value as string),
),
recording_retention_period: (value) =>
commands.updateRecordingRetentionPeriod(value as string),
translate_to_english: (value) =>
commands.changeTranslateToEnglishSetting(value as boolean),
selected_language: (value) =>
commands.changeSelectedLanguageSetting(value as string),
overlay_position: (value) =>
commands.changeOverlayPositionSetting(value as string),
debug_mode: (value) => commands.changeDebugModeSetting(value as boolean),
custom_words: (value) => commands.updateCustomWords(value as string[]),
word_correction_threshold: (value) =>
commands.changeWordCorrectionThresholdSetting(value as number),
paste_method: (value) => commands.changePasteMethodSetting(value as string),
typing_tool: (value) => commands.changeTypingToolSetting(value as string),
external_script_path: (value) =>
commands.changeExternalScriptPathSetting(value as string | null),
clipboard_handling: (value) =>
commands.changeClipboardHandlingSetting(value as string),
auto_submit: (value) => commands.changeAutoSubmitSetting(value as boolean),
auto_submit_key: (value) =>
commands.changeAutoSubmitKeySetting(value as string),
history_limit: (value) => commands.updateHistoryLimit(value as number),
post_process_enabled: (value) =>
commands.changePostProcessEnabledSetting(value as boolean),
post_process_selected_prompt_id: (value) =>
commands.setPostProcessSelectedPrompt(value as string),
mute_while_recording: (value) =>
commands.changeMuteWhileRecordingSetting(value as boolean),
append_trailing_space: (value) =>
commands.changeAppendTrailingSpaceSetting(value as boolean),
log_level: (value) => commands.setLogLevel(value as any),
app_language: (value) => commands.changeAppLanguageSetting(value as string),
experimental_enabled: (value) =>
commands.changeExperimentalEnabledSetting(value as boolean),
show_tray_icon: (value) =>
commands.changeShowTrayIconSetting(value as boolean),
whisper_accelerator: (value) =>
commands.changeWhisperAcceleratorSetting(
value as WhisperAcceleratorSetting,
),
ort_accelerator: (value) =>
commands.changeOrtAcceleratorSetting(value as OrtAcceleratorSetting),
extra_recording_buffer_ms: (value) =>
commands.changeExtraRecordingBufferSetting(value as number),
};
export const useSettingsStore = create<SettingsStore>()(
subscribeWithSelector((set, get) => ({
settings: null,
defaultSettings: null,
isLoading: true,
isUpdating: {},
audioDevices: [],
outputDevices: [],
customSounds: { start: false, stop: false },
postProcessModelOptions: {},
// Internal setters
setSettings: (settings) => set({ settings }),
setDefaultSettings: (defaultSettings) => set({ defaultSettings }),
setLoading: (isLoading) => set({ isLoading }),
setUpdating: (key, updating) =>
set((state) => ({
isUpdating: { ...state.isUpdating, [key]: updating },
})),
setAudioDevices: (audioDevices) => set({ audioDevices }),
setOutputDevices: (outputDevices) => set({ outputDevices }),
setCustomSounds: (customSounds) => set({ customSounds }),
// Getters
getSetting: (key) => get().settings?.[key],
isUpdatingKey: (key) => get().isUpdating[key] || false,
// Load settings from store
refreshSettings: async () => {
try {
const result = await commands.getAppSettings();
if (result.status === "ok") {
const settings = result.data;
const normalizedSettings: Settings = {
...settings,
always_on_microphone: settings.always_on_microphone ?? false,
selected_microphone: settings.selected_microphone ?? "Default",
clamshell_microphone: settings.clamshell_microphone ?? "Default",
selected_output_device:
settings.selected_output_device ?? "Default",
};
set({ settings: normalizedSettings, isLoading: false });
} else {
console.error("Failed to load settings:", result.error);
set({ isLoading: false });
}
} catch (error) {
console.error("Failed to load settings:", error);
set({ isLoading: false });
}
},
// Load audio devices
refreshAudioDevices: async () => {
try {
const result = await commands.getAvailableMicrophones();
if (result.status === "ok") {
const devicesWithDefault = [
DEFAULT_AUDIO_DEVICE,
...result.data.filter(
(d) => d.name !== "Default" && d.name !== "default",
),
];
set({ audioDevices: devicesWithDefault });
} else {
set({ audioDevices: [DEFAULT_AUDIO_DEVICE] });
}
} catch (error) {
console.error("Failed to load audio devices:", error);
set({ audioDevices: [DEFAULT_AUDIO_DEVICE] });
}
},
// Load output devices
refreshOutputDevices: async () => {
try {
const result = await commands.getAvailableOutputDevices();
if (result.status === "ok") {
const devicesWithDefault = [
DEFAULT_AUDIO_DEVICE,
...result.data.filter(
(d) => d.name !== "Default" && d.name !== "default",
),
];
set({ outputDevices: devicesWithDefault });
} else {
set({ outputDevices: [DEFAULT_AUDIO_DEVICE] });
}
} catch (error) {
console.error("Failed to load output devices:", error);
set({ outputDevices: [DEFAULT_AUDIO_DEVICE] });
}
},
// Play a test sound
playTestSound: async (soundType: "start" | "stop") => {
try {
await commands.playTestSound(soundType);
} catch (error) {
console.error(`Failed to play test sound (${soundType}):`, error);
}
},
checkCustomSounds: async () => {
try {
const sounds = await commands.checkCustomSounds();
get().setCustomSounds(sounds);
} catch (error) {
console.error("Failed to check custom sounds:", error);
}
},
// Update a specific setting
updateSetting: async <K extends keyof Settings>(
key: K,
value: Settings[K],
) => {
const { settings, setUpdating } = get();
const updateKey = String(key);
const originalValue = settings?.[key];
setUpdating(updateKey, true);
try {
set((state) => ({
settings: state.settings ? { ...state.settings, [key]: value } : null,
}));
const updater = settingUpdaters[key];
if (updater) {
await updater(value);
} else if (key !== "bindings" && key !== "selected_model") {
console.warn(`No handler for setting: ${String(key)}`);
}
} catch (error) {
console.error(`Failed to update setting ${String(key)}:`, error);
if (settings) {
set({ settings: { ...settings, [key]: originalValue } });
}
} finally {
setUpdating(updateKey, false);
}
},
// Reset a setting to its default value
resetSetting: async (key) => {
const { defaultSettings } = get();
if (defaultSettings) {
const defaultValue = defaultSettings[key];
if (defaultValue !== undefined) {
await get().updateSetting(key, defaultValue as any);
}
}
},
// Update a specific binding
updateBinding: async (id, binding) => {
const { settings, setUpdating } = get();
const updateKey = `binding_${id}`;
const originalBinding = settings?.bindings?.[id]?.current_binding;
setUpdating(updateKey, true);
try {
// Optimistic update
set((state) => ({
settings: state.settings
? {
...state.settings,
bindings: {
...state.settings.bindings,
[id]: {
...state.settings.bindings[id]!,
current_binding: binding,
},
},
}
: null,
}));
const result = await commands.changeBinding(id, binding);
// Check if the command executed successfully
if (result.status === "error") {
throw new Error(result.error);
}
// Check if the binding change was successful
if (!result.data.success) {
throw new Error(result.data.error || "Failed to update binding");
}
} catch (error) {
console.error(`Failed to update binding ${id}:`, error);
// Rollback on error
if (originalBinding && get().settings) {
set((state) => ({
settings: state.settings
? {
...state.settings,
bindings: {
...state.settings.bindings,
[id]: {
...state.settings.bindings[id]!,
current_binding: originalBinding,
},
},
}
: null,
}));
}
// Re-throw to let the caller know it failed
throw error;
} finally {
setUpdating(updateKey, false);
}
},
// Reset a specific binding
resetBinding: async (id) => {
const { setUpdating, refreshSettings } = get();
const updateKey = `binding_${id}`;
setUpdating(updateKey, true);
try {
await commands.resetBinding(id);
await refreshSettings();
} catch (error) {
console.error(`Failed to reset binding ${id}:`, error);
} finally {
setUpdating(updateKey, false);
}
},
setPostProcessProvider: async (providerId) => {
const {
settings,
setUpdating,
refreshSettings,
setPostProcessModelOptions,
} = get();
const updateKey = "post_process_provider_id";
const previousId = settings?.post_process_provider_id ?? null;
setUpdating(updateKey, true);
if (settings) {
set((state) => ({
settings: state.settings
? { ...state.settings, post_process_provider_id: providerId }
: null,
}));
}
// Clear cached model options for the new provider so the dropdown
// doesn't show stale models from a previous fetch or base_url.
setPostProcessModelOptions(providerId, []);
try {
await commands.setPostProcessProvider(providerId);
await refreshSettings();
} catch (error) {
console.error("Failed to set post-process provider:", error);
if (previousId !== null) {
set((state) => ({
settings: state.settings
? { ...state.settings, post_process_provider_id: previousId }
: null,
}));
}
} finally {
setUpdating(updateKey, false);
}
},
// Generic updater for post-processing provider settings
updatePostProcessSetting: async (
settingType: "base_url" | "api_key" | "model",
providerId: string,
value: string,
) => {
const { setUpdating, refreshSettings } = get();
const updateKey = `post_process_${settingType}:${providerId}`;
setUpdating(updateKey, true);
try {
if (settingType === "base_url") {
await commands.changePostProcessBaseUrlSetting(providerId, value);
} else if (settingType === "api_key") {
await commands.changePostProcessApiKeySetting(providerId, value);
} else if (settingType === "model") {
await commands.changePostProcessModelSetting(providerId, value);
}
await refreshSettings();
} catch (error) {
console.error(
`Failed to update post-process ${settingType.replace("_", " ")}:`,
error,
);
} finally {
setUpdating(updateKey, false);
}
},
updatePostProcessBaseUrl: async (providerId, baseUrl) => {
const { setUpdating, refreshSettings } = get();
const updateKey = `post_process_base_url:${providerId}`;
setUpdating(updateKey, true);
try {
// Persist the new base URL first.
const urlResult = await commands.changePostProcessBaseUrlSetting(
providerId,
baseUrl,
);
if (urlResult.status === "error") {
console.error("Failed to persist base URL:", urlResult.error);
return;
}
// Reset the stored model since the previous value is almost certainly
// invalid for the new endpoint (e.g. switching Custom from Groq to
// Cerebras). Only proceed if the reset succeeds.
const modelResult = await commands.changePostProcessModelSetting(
providerId,
"",
);
if (modelResult.status === "error") {
console.error("Failed to reset model setting:", modelResult.error);
return;
}
// Clear cached model options only after both backend writes succeed.
set((state) => ({
postProcessModelOptions: {
...state.postProcessModelOptions,
[providerId]: [],
},
}));
// Single refresh after both backend writes.
await refreshSettings();
} catch (error) {
console.error("Failed to update post-process base URL:", error);
} finally {
setUpdating(updateKey, false);
}
},
updatePostProcessApiKey: async (providerId, apiKey) => {
// Clear cached models when API key changes - user should click refresh after
set((state) => ({
postProcessModelOptions: {
...state.postProcessModelOptions,
[providerId]: [],
},
}));
return get().updatePostProcessSetting("api_key", providerId, apiKey);
},
updatePostProcessModel: async (providerId, model) => {
return get().updatePostProcessSetting("model", providerId, model);
},
fetchPostProcessModels: async (providerId) => {
const updateKey = `post_process_models_fetch:${providerId}`;
const { setUpdating, setPostProcessModelOptions } = get();
setUpdating(updateKey, true);
try {
// Call Tauri backend command instead of fetch
const result = await commands.fetchPostProcessModels(providerId);
if (result.status === "ok") {
setPostProcessModelOptions(providerId, result.data);
return result.data;
} else {
console.error("Failed to fetch models:", result.error);
return [];
}
} catch (error) {
console.error("Failed to fetch models:", error);
// Don't cache empty array on error - let user retry
return [];
} finally {
setUpdating(updateKey, false);
}
},
setPostProcessModelOptions: (providerId, models) =>
set((state) => ({
postProcessModelOptions: {
...state.postProcessModelOptions,
[providerId]: models,
},
})),
// Load default settings from Rust
loadDefaultSettings: async () => {
try {
const result = await commands.getDefaultSettings();
if (result.status === "ok") {
set({ defaultSettings: result.data });
} else {
console.error("Failed to load default settings:", result.error);
}
} catch (error) {
console.error("Failed to load default settings:", error);
}
},
// Initialize everything
initialize: async () => {
const { refreshSettings, checkCustomSounds, loadDefaultSettings } = get();
// Note: Audio devices are NOT refreshed here. The frontend (App.tsx)
// is responsible for calling refreshAudioDevices/refreshOutputDevices
// after onboarding completes. This avoids triggering permission dialogs
// on macOS before the user is ready.
await Promise.all([
loadDefaultSettings(),
refreshSettings(),
checkCustomSounds(),
]);
// Re-fetch settings when the backend changes them (e.g. language
// reset during model switch). The backend is the source of truth.
listen("model-state-changed", () => {
get().refreshSettings();
});
},
})),
);