mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-22 06:38:14 +02:00
feat!: rebuild EveFlow as a JARVIS-style voice HUD for Hermes Agent (v2.0.0)
Complete rewrite of the application: - Toolchain: Electron 44, Vite 8, React 19, TypeScript 5.9, Zustand, Vitest; main process rewritten in TypeScript (electron/), typed IPC contract (shared/), sandboxed renderer with webSecurity on and an HTTP/SSE proxy in the main process. - Voice: AudioWorklet microphone capture at 16 kHz with adaptive energy VAD and auto-stop, WAV encoder, OpenAI-compatible STT, TTS queue with sentence-level streaming, prefetch and Web Audio playback feeding an analyser; hands-free mode, global push-to-talk hotkey, system-voice fallback. - Hermes: full API client (capabilities, health, models, skills, toolsets, sessions, jobs) with automatic transport selection: runs API (SSE lifecycle, approvals, steer, stop) > sessions stream > chat completions with local tools; tolerant event normalisation; webhook receiver hardened (secret, size limit). - UI: JARVIS arc-reactor core on Canvas 2D reacting to the real audio signal, holographic HUD layout (transcript, core, Hermes ops panel with tools/crons/ skills/sessions/link tabs, telemetry), settings drawer, approval modals, compact floating widget, four themes, tray icon and global shortcuts. - Removed the 3D Eve robot, three.js and legacy assets; migrated 1.x settings. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Wn5VX9HNbJ7N54hR24u9Y
This commit is contained in:
239 files changed
+11351
-86495
No files matched your search
@@ -0,0 +1,141 @@
|
||||
import { create } from 'zustand';
|
||||
import { uid } from '../lib/id';
|
||||
import type { HermesUsage } from '../services/hermes/types';
|
||||
|
||||
export type HudState = 'idle' | 'listening' | 'thinking' | 'speaking' | 'alert' | 'error' | 'success';
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
reasoning?: string;
|
||||
images?: string[];
|
||||
source?: string; // eveflow | telegram | cron | webhook ...
|
||||
timestamp: number;
|
||||
status?: 'streaming' | 'done' | 'error' | 'cancelled';
|
||||
usage?: HermesUsage;
|
||||
transport?: string;
|
||||
jobName?: string;
|
||||
}
|
||||
|
||||
export interface Activity {
|
||||
id: string;
|
||||
kind: 'tool' | 'subagent' | 'system' | 'job';
|
||||
name: string;
|
||||
status: 'running' | 'done' | 'error';
|
||||
detail?: string;
|
||||
output?: string;
|
||||
startedAt: number;
|
||||
endedAt?: number;
|
||||
}
|
||||
|
||||
export interface PendingRequest {
|
||||
id: string; // requestId or synthetic
|
||||
requestId: string;
|
||||
runId: string;
|
||||
kind: 'approval' | 'clarify' | 'input';
|
||||
title: string;
|
||||
description: string;
|
||||
options?: string[];
|
||||
tool?: string;
|
||||
args?: string;
|
||||
secret?: boolean;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
interface ChatStore {
|
||||
messages: ChatMessage[];
|
||||
activity: Activity[];
|
||||
pending: PendingRequest[];
|
||||
isSending: boolean;
|
||||
currentRunId: string | null;
|
||||
hud: HudState;
|
||||
hudOverride: HudState | null;
|
||||
error: string | null;
|
||||
draft: string;
|
||||
|
||||
addMessage: (message: Omit<ChatMessage, 'id' | 'timestamp'> & Partial<Pick<ChatMessage, 'id' | 'timestamp'>>) => string;
|
||||
updateMessage: (id: string, patch: Partial<ChatMessage> | ((m: ChatMessage) => Partial<ChatMessage>)) => void;
|
||||
appendToMessage: (id: string, delta: string, field?: 'content' | 'reasoning') => void;
|
||||
removeMessage: (id: string) => void;
|
||||
clear: () => void;
|
||||
pushActivity: (activity: Omit<Activity, 'id' | 'startedAt'> & Partial<Pick<Activity, 'id' | 'startedAt'>>) => string;
|
||||
finishActivity: (match: { id?: string; name?: string }, patch: Partial<Activity>) => void;
|
||||
addPending: (request: Omit<PendingRequest, 'id' | 'createdAt'>) => void;
|
||||
removePending: (id: string) => void;
|
||||
setSending: (sending: boolean, runId?: string | null) => void;
|
||||
setHud: (state: HudState) => void;
|
||||
setHudOverride: (state: HudState | null) => void;
|
||||
setError: (error: string | null) => void;
|
||||
setDraft: (draft: string) => void;
|
||||
}
|
||||
|
||||
const MAX_MESSAGES = 400;
|
||||
const MAX_ACTIVITY = 120;
|
||||
|
||||
export const useChat = create<ChatStore>((set, get) => ({
|
||||
messages: [],
|
||||
activity: [],
|
||||
pending: [],
|
||||
isSending: false,
|
||||
currentRunId: null,
|
||||
hud: 'idle',
|
||||
hudOverride: null,
|
||||
error: null,
|
||||
draft: '',
|
||||
|
||||
addMessage: (message) => {
|
||||
const id = message.id ?? uid('msg');
|
||||
const entry: ChatMessage = { ...message, id, timestamp: message.timestamp ?? Date.now() };
|
||||
set((s) => ({ messages: [...s.messages.slice(-MAX_MESSAGES), entry] }));
|
||||
return id;
|
||||
},
|
||||
updateMessage: (id, patch) =>
|
||||
set((s) => ({
|
||||
messages: s.messages.map((m) => (m.id === id ? { ...m, ...(typeof patch === 'function' ? patch(m) : patch) } : m))
|
||||
})),
|
||||
appendToMessage: (id, delta, field = 'content') =>
|
||||
set((s) => ({
|
||||
messages: s.messages.map((m) => (m.id === id ? { ...m, [field]: (m[field] ?? '') + delta } : m))
|
||||
})),
|
||||
removeMessage: (id) => set((s) => ({ messages: s.messages.filter((m) => m.id !== id) })),
|
||||
clear: () => set({ messages: [], activity: [], pending: [], error: null }),
|
||||
|
||||
pushActivity: (activity) => {
|
||||
const id = activity.id ?? uid('act');
|
||||
const entry: Activity = { ...activity, id, startedAt: activity.startedAt ?? Date.now() };
|
||||
set((s) => ({ activity: [...s.activity.slice(-MAX_ACTIVITY), entry] }));
|
||||
return id;
|
||||
},
|
||||
finishActivity: (match, patch) =>
|
||||
set((s) => {
|
||||
const list = [...s.activity];
|
||||
let index = -1;
|
||||
if (match.id) index = list.findIndex((a) => a.id === match.id);
|
||||
if (index === -1 && match.name) {
|
||||
for (let i = list.length - 1; i >= 0; i--) {
|
||||
if (list[i].name === match.name && list[i].status === 'running') {
|
||||
index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (index === -1) return {};
|
||||
list[index] = { ...list[index], endedAt: Date.now(), ...patch };
|
||||
return { activity: list };
|
||||
}),
|
||||
|
||||
addPending: (request) =>
|
||||
set((s) => ({
|
||||
pending: [...s.pending.filter((p) => p.requestId !== request.requestId || !request.requestId), { ...request, id: uid('req'), createdAt: Date.now() }]
|
||||
})),
|
||||
removePending: (id) => set((s) => ({ pending: s.pending.filter((p) => p.id !== id && p.requestId !== id) })),
|
||||
|
||||
setSending: (isSending, runId = null) => set({ isSending, currentRunId: isSending ? runId : null }),
|
||||
setHud: (hud) => {
|
||||
if (get().hud !== hud) set({ hud });
|
||||
},
|
||||
setHudOverride: (hudOverride) => set({ hudOverride }),
|
||||
setError: (error) => set({ error }),
|
||||
setDraft: (draft) => set({ draft })
|
||||
}));
|
||||
@@ -0,0 +1,217 @@
|
||||
import { create } from 'zustand';
|
||||
import type { WebhookStatus } from '../../shared/ipc';
|
||||
import { Log } from '../lib/log';
|
||||
import { persistGet, persistSet } from '../lib/persist';
|
||||
import { HermesClient, jobOutput, jobStatus, resolveTransport, type ResolvedTransport } from '../services/hermes/client';
|
||||
import type {
|
||||
HermesCapabilities,
|
||||
HermesHealth,
|
||||
HermesJob,
|
||||
HermesJobDraft,
|
||||
HermesModel,
|
||||
HermesSession,
|
||||
HermesSkill,
|
||||
HermesToolset
|
||||
} from '../services/hermes/types';
|
||||
import { useSettings } from './settings';
|
||||
|
||||
export type LinkState = 'unknown' | 'checking' | 'online' | 'degraded' | 'offline';
|
||||
|
||||
export interface JobRun {
|
||||
id: string;
|
||||
jobId: string;
|
||||
jobName: string;
|
||||
status: 'ok' | 'failed';
|
||||
output: string;
|
||||
at: string;
|
||||
read: boolean;
|
||||
}
|
||||
|
||||
interface HermesStore {
|
||||
link: LinkState;
|
||||
linkDetail: string;
|
||||
capabilities: HermesCapabilities | null;
|
||||
health: HermesHealth | null;
|
||||
models: HermesModel[];
|
||||
skills: HermesSkill[];
|
||||
toolsets: HermesToolset[];
|
||||
sessions: HermesSession[];
|
||||
jobs: HermesJob[];
|
||||
jobRuns: JobRun[];
|
||||
transport: ResolvedTransport;
|
||||
lastSyncAt: number | null;
|
||||
webhook: WebhookStatus | null;
|
||||
busy: boolean;
|
||||
|
||||
client: () => HermesClient;
|
||||
connect: () => Promise<void>;
|
||||
refreshJobs: () => Promise<void>;
|
||||
refreshSessions: () => Promise<void>;
|
||||
refreshCatalog: () => Promise<void>;
|
||||
refreshWebhook: () => Promise<void>;
|
||||
createJob: (draft: HermesJobDraft) => Promise<void>;
|
||||
updateJob: (id: string, patch: Partial<HermesJobDraft>) => Promise<void>;
|
||||
jobAction: (id: string, action: 'pause' | 'resume' | 'run' | 'delete') => Promise<void>;
|
||||
markRunRead: (id: string) => void;
|
||||
loadCache: () => Promise<void>;
|
||||
}
|
||||
|
||||
const CACHE_KEY = 'eveflow.hermes.cache.v2';
|
||||
const isTerminal = (status: string) => ['ok', 'failed', 'delivery_failed', 'completed', 'error'].includes(status.toLowerCase());
|
||||
|
||||
function runFromJob(job: HermesJob): JobRun | null {
|
||||
const output = jobOutput(job);
|
||||
const status = String(job.last_status ?? '').toLowerCase();
|
||||
if (!output || !job.last_run_at || !isTerminal(status || 'ok')) return null;
|
||||
const at = String(job.last_run_at);
|
||||
return {
|
||||
id: `${job.id}:${at}`,
|
||||
jobId: job.id,
|
||||
jobName: job.name || job.id,
|
||||
status: status.includes('fail') || status === 'error' ? 'failed' : 'ok',
|
||||
output,
|
||||
at,
|
||||
read: false
|
||||
};
|
||||
}
|
||||
|
||||
export const useHermes = create<HermesStore>((set, get) => ({
|
||||
link: 'unknown',
|
||||
linkDetail: '',
|
||||
capabilities: null,
|
||||
health: null,
|
||||
models: [],
|
||||
skills: [],
|
||||
toolsets: [],
|
||||
sessions: [],
|
||||
jobs: [],
|
||||
jobRuns: [],
|
||||
transport: 'completions',
|
||||
lastSyncAt: null,
|
||||
webhook: null,
|
||||
busy: false,
|
||||
|
||||
client: () => new HermesClient(useSettings.getState().settings.hermes),
|
||||
|
||||
connect: async () => {
|
||||
const config = useSettings.getState().settings.hermes;
|
||||
if (!config.url.trim()) {
|
||||
set({ link: 'offline', linkDetail: 'URL Hermes non configurée' });
|
||||
return;
|
||||
}
|
||||
set({ link: 'checking', linkDetail: '' });
|
||||
const client = get().client();
|
||||
try {
|
||||
const health = await client.health();
|
||||
let capabilities: HermesCapabilities | null = null;
|
||||
try {
|
||||
capabilities = await client.capabilities();
|
||||
} catch (err) {
|
||||
Log.warn('hermes', `capabilities unavailable: ${(err as Error).message}`);
|
||||
}
|
||||
const transport = resolveTransport(config, capabilities);
|
||||
const degraded = String(health.status ?? 'ok').toLowerCase() !== 'ok';
|
||||
set({ health, capabilities, transport, link: degraded ? 'degraded' : 'online', linkDetail: degraded ? `état ${health.status}` : '' });
|
||||
Log.info('hermes', `connected (${transport})`, { status: health.status, model: capabilities?.model });
|
||||
void get().refreshCatalog();
|
||||
void get().refreshJobs();
|
||||
void get().refreshSessions();
|
||||
} catch (err) {
|
||||
const message = (err as Error).message;
|
||||
set({ link: 'offline', linkDetail: message, transport: resolveTransport(config, null) });
|
||||
Log.warn('hermes', `connection failed: ${message}`);
|
||||
}
|
||||
},
|
||||
|
||||
refreshCatalog: async () => {
|
||||
const client = get().client();
|
||||
const [models, skills, toolsets] = await Promise.all([
|
||||
client.models().catch(() => [] as HermesModel[]),
|
||||
client.skills().catch(() => [] as HermesSkill[]),
|
||||
client.toolsets().catch(() => [] as HermesToolset[])
|
||||
]);
|
||||
set({ models, skills, toolsets });
|
||||
},
|
||||
|
||||
refreshSessions: async () => {
|
||||
try {
|
||||
const sessions = await get().client().listSessions();
|
||||
set({ sessions });
|
||||
} catch (err) {
|
||||
Log.debug('hermes', `sessions unavailable: ${(err as Error).message}`);
|
||||
}
|
||||
},
|
||||
|
||||
refreshJobs: async () => {
|
||||
if (!useSettings.getState().settings.hermes.url.trim()) return;
|
||||
try {
|
||||
const jobs = await get().client().listJobs();
|
||||
const previous = get().jobRuns;
|
||||
const known = new Map(previous.map((r) => [r.id, r]));
|
||||
const incoming = jobs.map(runFromJob).filter((r): r is JobRun => !!r).map((r) => ({ ...r, read: known.get(r.id)?.read ?? false }));
|
||||
const merged = [...incoming, ...previous.filter((r) => !incoming.some((i) => i.id === r.id))]
|
||||
.sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime())
|
||||
.slice(0, 100);
|
||||
set({ jobs, jobRuns: merged, lastSyncAt: Date.now(), link: get().link === 'offline' ? 'online' : get().link });
|
||||
persistSet(CACHE_KEY, { jobs, jobRuns: merged, syncedAt: Date.now() });
|
||||
} catch (err) {
|
||||
const message = (err as Error).message;
|
||||
Log.warn('hermes', `jobs sync failed: ${message}`);
|
||||
if (/HTTP 404/.test(message)) return; // jobs API disabled on this server
|
||||
set({ link: 'offline', linkDetail: message });
|
||||
}
|
||||
},
|
||||
|
||||
refreshWebhook: async () => {
|
||||
const api = window.eveflow;
|
||||
if (!api) return;
|
||||
try {
|
||||
set({ webhook: await api.hermes.webhookStatus() });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
|
||||
createJob: async (draft) => {
|
||||
set({ busy: true });
|
||||
try {
|
||||
await get().client().createJob(draft);
|
||||
await get().refreshJobs();
|
||||
} finally {
|
||||
set({ busy: false });
|
||||
}
|
||||
},
|
||||
updateJob: async (id, patch) => {
|
||||
set({ busy: true });
|
||||
try {
|
||||
await get().client().updateJob(id, patch);
|
||||
await get().refreshJobs();
|
||||
} finally {
|
||||
set({ busy: false });
|
||||
}
|
||||
},
|
||||
jobAction: async (id, action) => {
|
||||
set({ busy: true });
|
||||
const client = get().client();
|
||||
try {
|
||||
if (action === 'pause') await client.pauseJob(id);
|
||||
else if (action === 'resume') await client.resumeJob(id);
|
||||
else if (action === 'run') await client.runJob(id);
|
||||
else await client.deleteJob(id);
|
||||
await get().refreshJobs();
|
||||
} finally {
|
||||
set({ busy: false });
|
||||
}
|
||||
},
|
||||
markRunRead: (id) => {
|
||||
const jobRuns = get().jobRuns.map((r) => (r.id === id ? { ...r, read: true } : r));
|
||||
set({ jobRuns });
|
||||
persistSet(CACHE_KEY, { jobs: get().jobs, jobRuns, syncedAt: get().lastSyncAt });
|
||||
},
|
||||
loadCache: async () => {
|
||||
const cache = await persistGet<{ jobs?: HermesJob[]; jobRuns?: JobRun[]; syncedAt?: number }>(CACHE_KEY);
|
||||
if (cache) set({ jobs: cache.jobs ?? [], jobRuns: cache.jobRuns ?? [], lastSyncAt: cache.syncedAt ?? null });
|
||||
}
|
||||
}));
|
||||
|
||||
export { jobStatus };
|
||||
@@ -0,0 +1,192 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export interface SpeechSettings extends TtsConfig {
|
||||
autoSpeak: boolean;
|
||||
speakIncoming: boolean;
|
||||
}
|
||||
|
||||
export interface WebhookSettings {
|
||||
enabled: boolean;
|
||||
port: number;
|
||||
secret: string;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
version: 2;
|
||||
assistantName: string;
|
||||
userName: string;
|
||||
theme: HudTheme;
|
||||
language: string;
|
||||
hermes: HermesConfig;
|
||||
voice: VoiceSettings;
|
||||
speech: SpeechSettings;
|
||||
webhook: WebhookSettings;
|
||||
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:
|
||||
"Tu es l'interface vocale EveFlow (style JARVIS). 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
|
||||
},
|
||||
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
|
||||
},
|
||||
speech: {
|
||||
provider: 'openai-compatible',
|
||||
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,
|
||||
autoSpeak: true,
|
||||
speakIncoming: true
|
||||
},
|
||||
webhook: { enabled: true, port: 7842, secret: '' },
|
||||
ui: { showTelemetry: true, showReasoning: false, reduceMotion: false, compactOpacity: 0.92 },
|
||||
hermesSessionId: ''
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'eveflow.settings.v2';
|
||||
|
||||
type DeepPartial<T> = { [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K] };
|
||||
|
||||
function merge<T extends object>(base: T, patch: DeepPartial<T> | null | undefined): T {
|
||||
if (!patch) return base;
|
||||
const out: Record<string, unknown> = { ...(base as Record<string, unknown>) };
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
const current = (base as Record<string, unknown>)[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<object>);
|
||||
} 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<DeepPartial<Settings>> {
|
||||
const patch: DeepPartial<Settings> = { hermes: {}, voice: {}, speech: {} };
|
||||
const legacyConfig = await persistGet<string | Record<string, string>>('eveflow_agent_config');
|
||||
const parsed = typeof legacyConfig === 'string' ? (JSON.parse(legacyConfig) as Record<string, string>) : 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<string>(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<void>;
|
||||
update: (patch: DeepPartial<Settings>) => void;
|
||||
reset: () => void;
|
||||
setHermesSessionId: (id: string) => void;
|
||||
}
|
||||
|
||||
export const useSettings = create<SettingsStore>((set, get) => ({
|
||||
settings: DEFAULT_SETTINGS,
|
||||
loaded: false,
|
||||
load: async () => {
|
||||
let saved = await persistGet<DeepPartial<Settings>>(STORAGE_KEY);
|
||||
if (!saved) {
|
||||
try {
|
||||
saved = await migrateLegacy();
|
||||
} catch {
|
||||
saved = null;
|
||||
}
|
||||
}
|
||||
const merged = merge(DEFAULT_SETTINGS, saved);
|
||||
if (!merged.hermesSessionId) merged.hermesSessionId = uid('eveflow');
|
||||
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;
|
||||
@@ -0,0 +1,42 @@
|
||||
import { create } from 'zustand';
|
||||
import type { TtsState } from '../services/voice/tts';
|
||||
|
||||
export type ListenPhase = 'off' | 'arming' | 'listening' | 'speech' | 'transcribing';
|
||||
|
||||
interface VoiceStore {
|
||||
phase: ListenPhase;
|
||||
inputLevel: number;
|
||||
tts: TtsState;
|
||||
handsFree: boolean;
|
||||
lastTranscript: string;
|
||||
interim: string;
|
||||
error: string | null;
|
||||
micDevices: Array<{ deviceId: string; label: string }>;
|
||||
setPhase: (phase: ListenPhase) => void;
|
||||
setInputLevel: (level: number) => void;
|
||||
setTts: (state: TtsState) => void;
|
||||
setHandsFree: (on: boolean) => void;
|
||||
setTranscript: (text: string) => void;
|
||||
setInterim: (text: string) => void;
|
||||
setError: (error: string | null) => void;
|
||||
setMicDevices: (devices: Array<{ deviceId: string; label: string }>) => void;
|
||||
}
|
||||
|
||||
export const useVoice = create<VoiceStore>((set) => ({
|
||||
phase: 'off',
|
||||
inputLevel: 0,
|
||||
tts: 'idle',
|
||||
handsFree: false,
|
||||
lastTranscript: '',
|
||||
interim: '',
|
||||
error: null,
|
||||
micDevices: [],
|
||||
setPhase: (phase) => set({ phase }),
|
||||
setInputLevel: (inputLevel) => set({ inputLevel }),
|
||||
setTts: (tts) => set({ tts }),
|
||||
setHandsFree: (handsFree) => set({ handsFree }),
|
||||
setTranscript: (lastTranscript) => set({ lastTranscript }),
|
||||
setInterim: (interim) => set({ interim }),
|
||||
setError: (error) => set({ error }),
|
||||
setMicDevices: (micDevices) => set({ micDevices })
|
||||
}));
|
||||
Reference in new issue
Block a user