import { create } from 'zustand'; import { persistGet, persistSet } from '../lib/persist'; import { uid } from '../lib/id'; import type { HermesConfig } from '../services/hermes/types'; import type { SttConfig } from '../services/voice/stt'; import type { TtsConfig } from '../services/voice/tts'; export type HudTheme = 'arc' | 'gold' | 'crimson' | 'emerald'; export interface VoiceSettings extends SttConfig { /** auto = VAD auto-stop, manual = click to stop */ captureMode: 'auto' | 'manual'; handsFree: boolean; bargeIn: boolean; micDeviceId: string; silenceMs: number; sensitivity: number; // 1 (low) .. 5 (high) wakeChime: boolean; /** Hands-free: only react to utterances starting with this word (local STT recommended). */ wakeWordEnabled: boolean; wakeWord: string; /** off = push-to-talk / hands-free; transcript = filter after transcription; kws = always-on keyword spotting. */ wakeMode: 'off' | 'transcript' | 'kws'; kwsSensitivity: number; // 1..5 /** Use Silero VAD for end-of-speech in always-on mode when the model is installed. */ neuralVad: boolean; /** Execute short system intents locally (lock, volume, open app…) instead of asking Hermes. */ localCommands: boolean; /** Chromium mic processing (echo cancellation, noise suppression, auto gain). Off often transcribes better on a headset. */ micProcessing: boolean; } export interface SpeechSettings extends TtsConfig { autoSpeak: boolean; speakIncoming: boolean; } export interface WebhookSettings { enabled: boolean; port: number; secret: string; } export interface NotificationSettings { /** Quiet hours: no spoken pushes, no chime, dimmed HUD (24 h "HH:MM"). */ quietEnabled: boolean; quietStart: string; quietEnd: string; /** Pushes whose text or job name contains one of these words are spoken even during quiet hours. */ priorityKeywords: string; /** Speak only the first sentences of incoming pushes (cron reports can be long). */ summarizeIncoming: boolean; summarySentences: number; /** Dim the HUD (night theme) during quiet hours. */ nightTheme: boolean; } export interface Settings { version: 2; assistantName: string; userName: string; theme: HudTheme; language: string; hermes: HermesConfig; voice: VoiceSettings; speech: SpeechSettings; webhook: WebhookSettings; notifications: NotificationSettings; ui: { showTelemetry: boolean; showReasoning: boolean; reduceMotion: boolean; compactOpacity: number; }; hermesSessionId: string; } export const DEFAULT_SETTINGS: Settings = { version: 2, assistantName: 'JARVIS', userName: '', theme: 'arc', language: 'fr-FR', hermes: { url: 'http://127.0.0.1:8642', apiKey: '', model: '', sessionKey: 'eveflow-desktop', transport: 'auto', reasoningEffort: '', instructions: "Réponds en français, de façon concise et orale quand la question est simple; utilise le Markdown uniquement pour le contenu structuré (code, listes, tableaux). Les images doivent être des URL http(s) ou des fichiers du dossier partagé.", localTools: true, missionModel: '' }, voice: { provider: 'openai-compatible', apiUrl: 'http://127.0.0.1:8000/v1', apiKey: '', model: 'Qwen/Qwen3-ASR-0.6B', language: 'fr-FR', captureMode: 'auto', handsFree: false, bargeIn: false, micDeviceId: '', silenceMs: 900, sensitivity: 3, wakeChime: true, wakeWordEnabled: false, wakeWord: 'jarvis', wakeMode: 'off', kwsSensitivity: 3, neuralVad: true, localCommands: true, micProcessing: true, localModel: 'whisper-base' }, speech: { // Edge neural voices speak out of the box (no server, no key) with a real masculine/feminine choice. provider: 'edge', apiUrl: 'http://127.0.0.1:8000/v1', apiKey: '', model: 'tts-1', voice: 'alloy', speed: 1.05, format: 'mp3', systemVoice: '', language: 'fr-FR', volume: 1, localModel: 'supertonic-3', localSpeaker: 6, edgeVoice: '', voiceGender: 'male', timbre: 'jarvis', autoSpeak: true, speakIncoming: true }, webhook: { enabled: true, port: 7842, secret: '' }, notifications: { quietEnabled: false, quietStart: '22:30', quietEnd: '07:30', priorityKeywords: 'urgent, alerte, alarme, panne', summarizeIncoming: true, summarySentences: 2, nightTheme: true }, ui: { showTelemetry: true, showReasoning: false, reduceMotion: false, compactOpacity: 0.92 }, hermesSessionId: '' }; const STORAGE_KEY = 'eveflow.settings.v2'; type DeepPartial = { [K in keyof T]?: T[K] extends object ? DeepPartial : T[K] }; function merge(base: T, patch: DeepPartial | null | undefined): T { if (!patch) return base; const out: Record = { ...(base as Record) }; for (const [key, value] of Object.entries(patch)) { const current = (base as Record)[key]; if (value && typeof value === 'object' && !Array.isArray(value) && current && typeof current === 'object' && !Array.isArray(current)) { out[key] = merge(current as object, value as DeepPartial); } else if (value !== undefined) { out[key] = value; } } return out as T; } /** Import the settings of the 1.x releases (flat keys in the store). */ async function migrateLegacy(): Promise> { const patch: DeepPartial = { hermes: {}, voice: {}, speech: {} }; const legacyConfig = await persistGet>('eveflow_agent_config'); const parsed = typeof legacyConfig === 'string' ? (JSON.parse(legacyConfig) as Record) : legacyConfig; if (parsed) { if (parsed.hermesUrl) patch.hermes!.url = parsed.hermesUrl; if (parsed.hermesApiKey) patch.hermes!.apiKey = parsed.hermesApiKey; if (parsed.hermesModel && parsed.hermesModel !== 'hermes-agent') patch.hermes!.model = parsed.hermesModel; if (parsed.hermesSessionKey) patch.hermes!.sessionKey = parsed.hermesSessionKey; } const read = async (key: string) => { const v = await persistGet(key); return typeof v === 'string' ? v : null; }; const sttUrl = await read('eveflow_stt_api_url'); if (sttUrl) patch.voice!.apiUrl = sttUrl; const sttKey = await read('eveflow_stt_api_key'); if (sttKey) patch.voice!.apiKey = sttKey; const sttModel = await read('eveflow_stt_model'); if (sttModel) patch.voice!.model = sttModel; const ttsUrl = await read('eveflow_tts_api_url'); if (ttsUrl) patch.speech!.apiUrl = ttsUrl; const ttsKey = await read('eveflow_tts_api_key'); if (ttsKey) patch.speech!.apiKey = ttsKey; const ttsModel = await read('eveflow_tts_model'); if (ttsModel) patch.speech!.model = ttsModel; const ttsProvider = await read('eveflow_tts_provider'); if (ttsProvider === 'system' || ttsProvider === 'google-free') patch.speech!.provider = ttsProvider; const sessionId = await read('eveflow_hermes_session_id'); if (sessionId) patch.hermesSessionId = sessionId; return patch; } interface SettingsStore { settings: Settings; loaded: boolean; load: () => Promise; update: (patch: DeepPartial) => void; reset: () => void; setHermesSessionId: (id: string) => void; } export const useSettings = create((set, get) => ({ settings: DEFAULT_SETTINGS, loaded: false, load: async () => { let saved = await persistGet>(STORAGE_KEY); if (!saved) { try { saved = await migrateLegacy(); } catch { saved = null; } } const merged = merge(DEFAULT_SETTINGS, saved); if (!merged.hermesSessionId) merged.hermesSessionId = uid('eveflow'); if (saved && saved.voice && saved.voice.wakeMode === undefined && saved.voice.wakeWordEnabled) merged.voice.wakeMode = 'transcript'; set({ settings: merged, loaded: true }); persistSet(STORAGE_KEY, merged); }, update: (patch) => { const next = merge(get().settings, patch); set({ settings: next }); persistSet(STORAGE_KEY, next); }, reset: () => { const next = { ...DEFAULT_SETTINGS, hermesSessionId: uid('eveflow') }; set({ settings: next }); persistSet(STORAGE_KEY, next); }, setHermesSessionId: (id) => get().update({ hermesSessionId: id }) })); export const selectSettings = (s: SettingsStore) => s.settings;