Compare commits

..
34 Commits
Author SHA1 Message Date
LogiFlow b8073c0be6 feat: voix Edge neuronales, Supertonic 3 en local et genre respecté par tous les moteurs (v2.5.0) (#21)
feat: voix Edge neuronales, Supertonic 3 en local et genre respecté par tous les moteurs (v2.5.0)
2026-09-04 17:40:11 +02:00
Claude 32551ccc0d feat: voix Edge neuronales, Supertonic 3 en local et genre respecté par tous les moteurs (v2.5.0)
- Nouveau moteur « Microsoft Edge » (voix neuronales gratuites, sans clé) : Henri / Denise
  par défaut selon le genre, liste des voix fr-FR / fr-CA / fr-CH / fr-BE, WebSocket signé
  (Sec-MS-GEC) dans le processus principal, MP3 24 kHz. Moteur par défaut des nouvelles
  installations.
- Supertonic 3 ajouté au catalogue local (31 langues, 5 voix masculines + 5 féminines,
  44 kHz, 129 Mo) : langue transmise au worker, genres des voix vérifiés par mesure de F0.
- Kokoro déclassé pour le français (une seule voix féminine, accent) ; le choix
  masculin/féminin bascule sur le meilleur modèle installé et conserve le genre quand on
  change de modèle ; Google Translate signalé comme voix féminine uniquement.
- Deux préréglages JARVIS : en ligne (Edge Henri) et hors ligne (Supertonic 3).
- Traitement du micro par Chromium (écho, bruit, gain) débrayable pour de meilleures
  transcriptions au casque.
- Tests : protocole Edge (jeton, SSML, trames), sélection de voix ; README et feuille de
  route (mesures Supertonic, Parakeet vs Qwen3-ASR).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMpgFriwxiBgurUVb21oCE
2026-09-04 15:37:00 +00:00
LogiFlow a95f91185f Merge pull request #20 from R0m1k3/claude/refonte-app-vocale-v91uz7
Claude/refonte app vocale v91uz7
2026-09-04 13:22:03 +02:00
LogiFlowandClaude Fable 5.1 5ef440e8cf feat: voix JARVIS, Parakeet v3 pour le français, worklets audio sous CSP stricte (v2.4.1) (#19)
* feat: voix JARVIS, Parakeet v3 pour le français, worklets audio sous CSP stricte (v2.4.1)

Voix
- Timbre « JARVIS » (Web Audio) : hauteur légèrement abaissée, chaleur dans
  les basses, présence, compression douce, courte réverbération d'intercom.
  Activé par défaut, réglable dans Paramètres → Voix.
- Préréglage « Voix JARVIS » en un clic : moteur local, voix masculine
  française (Piper Tom téléchargé automatiquement, Kokoro n'ayant pas de voix
  française masculine), débit calme. Affichage de la voix active.
- Boutons Masculine / Féminine désormais lisibles (style du sélecteur ajouté).

Reconnaissance
- Parakeet TDT 0.6B v3 (NVIDIA NeMo, int8, 25 langues européennes dont le
  français) ajouté au catalogue et recommandé : plus précis et plus rapide que
  Whisper sur processeur, ponctuation incluse.
- 0,4 s de silence ajoutées avant et après chaque énoncé avant la
  reconnaissance (syllabes coupées, hallucinations de Whisper sur les clips
  courts).

Écoute permanente
- Les modules AudioWorklet sont livrés en fichiers statiques (public/worklets)
  chargés depuis l'application : en version installée, la CSP stricte
  (script-src 'self') refusait les URL blob et l'écoute permanente échouait
  avec « Unable to load a worklet's module ». Repli blob conservé pour le dev.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Wn5VX9HNbJ7N54hR24u9Y

* docs: mesures Parakeet vs Whisper et worklets dans la feuille de route

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Wn5VX9HNbJ7N54hR24u9Y

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-04 13:02:32 +02:00
Claude e55d70bd22 docs: mesures Parakeet vs Whisper et worklets dans la feuille de route
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Wn5VX9HNbJ7N54hR24u9Y
2026-09-04 11:02:08 +00:00
Claude 813425b622 feat: voix JARVIS, Parakeet v3 pour le français, worklets audio sous CSP stricte (v2.4.1)
Voix
- Timbre « JARVIS » (Web Audio) : hauteur légèrement abaissée, chaleur dans
  les basses, présence, compression douce, courte réverbération d'intercom.
  Activé par défaut, réglable dans Paramètres → Voix.
- Préréglage « Voix JARVIS » en un clic : moteur local, voix masculine
  française (Piper Tom téléchargé automatiquement, Kokoro n'ayant pas de voix
  française masculine), débit calme. Affichage de la voix active.
- Boutons Masculine / Féminine désormais lisibles (style du sélecteur ajouté).

Reconnaissance
- Parakeet TDT 0.6B v3 (NVIDIA NeMo, int8, 25 langues européennes dont le
  français) ajouté au catalogue et recommandé : plus précis et plus rapide que
  Whisper sur processeur, ponctuation incluse.
- 0,4 s de silence ajoutées avant et après chaque énoncé avant la
  reconnaissance (syllabes coupées, hallucinations de Whisper sur les clips
  courts).

Écoute permanente
- Les modules AudioWorklet sont livrés en fichiers statiques (public/worklets)
  chargés depuis l'application : en version installée, la CSP stricte
  (script-src 'self') refusait les URL blob et l'écoute permanente échouait
  avec « Unable to load a worklet's module ». Repli blob conservé pour le dev.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Wn5VX9HNbJ7N54hR24u9Y
2026-09-04 10:57:55 +00:00
LogiFlowandClaude d1b12c16c5 fix: portail laissant passer /health mais bloquant /v1 traité comme liaison en échec (v2.4.0.3) (#18)
- Une page web reçue sur /v1/capabilities fait échouer la connexion et le test
  de liaison même si /health répond, ce qui déclenche la recherche de l'API.
- La découverte sonde /v1/capabilities puis /v1/models (JSON exigé, 401/403
  accepté) avant /health, pour ne pas prendre un portail pour l'API.


Claude-Session: https://claude.ai/code/session_017Wn5VX9HNbJ7N54hR24u9Y

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-04 08:22:19 +02:00
LogiFlowandClaude df25dd591c fix: page web reçue à la place de l'API Hermes détectée, URL découverte automatiquement (v2.4.0.2) (#17)
- Toute réponse HTML (portail de connexion, tableau de bord, erreur de proxy)
  est reconnue et expliquée avec le titre de la page, au lieu d'être prise
  pour un état « ok » ou de produire une réponse vide.
- Découverte automatique : quand la liaison échoue, EveFlow essaie l'API sur le
  même hôte (port 8642, /api, /v1, sous-domaines api. et hermes.) et corrige
  l'URL si un serveur Hermes répond. Le bouton « Tester la liaison » fait de
  même et affiche les essais.

Tests : 47 tests unitaires ; e2e Electron vert.


Claude-Session: https://claude.ai/code/session_017Wn5VX9HNbJ7N54hR24u9Y

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-04 08:06:05 +02:00
LogiFlow c75c1d674c Merge pull request #16 from R0m1k3/claude/refonte-app-vocale-v91uz7
fix: liaison « dégradé », réponses vides, transcriptions parasites, v…
2026-09-04 07:53:40 +02:00
LogiFlowandClaude 6d3073b083 fix: liaison « dégradé », réponses vides, transcriptions parasites, voix masculine (v2.4.0.1) (#15)
- Liaison Hermes : l'échec de l'API des crons (/api/jobs absente ou refusée)
  ne fait plus passer la liaison en « dégradé » ; l'onglet Crons affiche la
  raison. Les états « healthy / ready / ok » sont reconnus et un état
  « degraded » détaille les contrôles en échec.
- Chat completions : une réponse sans fragment n'affiche plus « … » ; le corps
  est relu (JSON non streamé, erreur renvoyée en HTTP 200) et sinon l'erreur
  explicite « Réponse vide de Hermes » est affichée avec le début du corps.
- Voix : transcriptions parasites de Whisper (« (cliquant) », « *Claire* »,
  « [Musique] », génériques de sous-titres) ignorées au lieu d'être envoyées.
- Préférence de voix masculine / féminine (Paramètres → Voix), appliquée à
  tous les moteurs : Piper Tom / Pierre en local (téléchargement automatique),
  onyx / nova en API OpenAI, Paul / Hortense en voix Windows. Genre des voix
  du catalogue déduit des libellés.

Tests : 42 tests unitaires (santé, récupération de réponse, filtre de bruit,
préférence de voix) ; e2e Electron inchangé et vert.


Claude-Session: https://claude.ai/code/session_017Wn5VX9HNbJ7N54hR24u9Y

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-04 07:44:07 +02:00
Claude 31659500ab fix: liaison « dégradé », réponses vides, transcriptions parasites, voix masculine (v2.4.0.1)
- Liaison Hermes : l'échec de l'API des crons (/api/jobs absente ou refusée)
  ne fait plus passer la liaison en « dégradé » ; l'onglet Crons affiche la
  raison. Les états « healthy / ready / ok » sont reconnus et un état
  « degraded » détaille les contrôles en échec.
- Chat completions : une réponse sans fragment n'affiche plus « … » ; le corps
  est relu (JSON non streamé, erreur renvoyée en HTTP 200) et sinon l'erreur
  explicite « Réponse vide de Hermes » est affichée avec le début du corps.
- Voix : transcriptions parasites de Whisper (« (cliquant) », « *Claire* »,
  « [Musique] », génériques de sous-titres) ignorées au lieu d'être envoyées.
- Préférence de voix masculine / féminine (Paramètres → Voix), appliquée à
  tous les moteurs : Piper Tom / Pierre en local (téléchargement automatique),
  onyx / nova en API OpenAI, Paul / Hortense en voix Windows. Genre des voix
  du catalogue déduit des libellés.

Tests : 42 tests unitaires (santé, récupération de réponse, filtre de bruit,
préférence de voix) ; e2e Electron inchangé et vert.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Wn5VX9HNbJ7N54hR24u9Y
2026-09-04 05:43:41 +00:00
LogiFlowandClaude 6fc38d335b feat: serveur MCP, heures calmes, mode mission, widget glanceable, barge-in (v2.4.0) (#14)
Serveur MCP local
- Endpoint /mcp (JSON-RPC 2.0, Streamable HTTP, réponses JSON) sur le serveur
  webhook : initialize, tools/list, tools/call, ping. Même secret que le
  webhook. 14 outils : capture_screen (image MCP), lock_session, open_app,
  open_url, media_key, clipboard_get/set, find_files, speak_text,
  notify_user, set_hud_state, get_app_status, get_conversation_history,
  show_message. Les outils UI transitent par IPC vers le renderer.
- Les mêmes actions système sont proposées au modèle en chat completions.

Notifications
- Heures calmes (plage horaire, franchissement de minuit), mots prioritaires,
  échecs de crons toujours lus, résumé vocal des messages entrants (n premières
  phrases), thème nuit automatique, compteur de non-lus.

Conversation
- Mode mission : bouton dans la barre de commande, second modèle Hermes
  (hermes.missionModel) pour les tâches longues.
- Barge-in : la parole de l'utilisateur coupe la voix ; seuil d'énergie et
  ratio relevés pendant la synthèse pour ignorer l'écho.

Widget compact
- Bandeau glanceable : état, dernière phrase, non-lus, indicateurs heures
  calmes et mission.

Tests : 28 tests unitaires (heures calmes, priorité, résumé), e2e Electron
sous Xvfb : MCP initialize/tools/list/tools/call (presse-papiers, capture en
image, état et message via le renderer), bandeau compact, aucune erreur.


Claude-Session: https://claude.ai/code/session_017Wn5VX9HNbJ7N54hR24u9Y

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-04 00:47:34 +02:00
LogiFlowandClaude af8bdc5e9d feat: fin de phrase Silero, vision d'écran et actions locales (v2.3.0) (#13)
Écoute permanente
- Silero VAD dans le worker sherpa-onnx (vad.start/audio/stop) ; le renderer
  envoie des trames de 128 ms pendant la commande et reçoit le segment WAV
  complet (speech-start / segment). Repli sur le VAD énergétique si le modèle
  n'est pas installé ou si l'option est désactivée.
- Modèle « silero-vad » (0,6 Mo) dans le catalogue, section « Fin de phrase »
  dans Modèles locaux, réglage dans Paramètres → Micro.

Vision d'écran
- IPC system:screen-capture (desktopCapturer, JPEG 1600 px) ; bouton dans la
  barre de commande ; « regarde mon écran… » joint la capture à la question
  envoyée à Hermes (transport chat completions pour les images).

Actions locales
- IPC system:action à liste blanche : verrouiller la session, touches média
  (volume, mute, lecture, piste), ouvrir une application connue ou une URL
  http(s), presse-papiers, recherche de fichiers dans les dossiers utilisateur.
- Routeur d'intentions FR/EN exécuté avant Hermes (« coupe le son », « ouvre
  Spotify », « verrouille la session »…), résultat affiché et lu ; désactivable.

Correctif
- Chat completions : les premiers fragments SSE arrivés avant l'événement de
  démarrage étaient perdus (premier mot manquant) ; ils sont maintenant rejoués.

Tests : 26 tests unitaires (intentions locales ajoutées), e2e Electron sous
Xvfb avec les vrais modèles (Silero : un segment par phrase, 6,7 s d'audio
traités en 190 ms ; capture 109 ko reçue par Hermes ; intention locale
traitée sans Hermes).


Claude-Session: https://claude.ai/code/session_017Wn5VX9HNbJ7N54hR24u9Y

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-03 20:46:26 +02:00
LogiFlow 4f6074183f feat: écoute permanente par détection de mot-clé (v2.2.0) (#12)
feat: écoute permanente par détection de mot-clé (v2.2.0)
2026-09-03 20:22:00 +02:00
Claude 0d284fea9e feat: always-on wake word with sherpa-onnx keyword spotting (v2.2.0)
- Catalog: 3.3 MB zipformer keyword-spotting model (kws-en).
- shared/keywords: BPE encoding of wake phrases (SentencePiece table for common words,
  greedy longest-match fallback over the model vocabulary) and keywords file builder.
- Worker: KeywordSpotter stream fed with 16-bit PCM, detections pushed as unsolicited
  messages; engine derives the keywords file, maps sensitivity to threshold/score,
  forwards detections to the renderer and re-arms after a worker restart.
- Renderer: WakeListener keeps one microphone stream, batches 256 ms frames to the
  spotter and captures the command on the same stream after detection (pre-roll, VAD),
  then resumes spotting; the wake word also interrupts speech. Settings: wake mode
  (off / always-on / transcript filter), keyword, sensitivity, status and one-click
  model download; HUD caption shows the active keyword.
- Validated: detection in the worker (fork) and through the real Electron IPC path on
  Kokoro audio, no false positive on an English recording; 23 unit tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Wn5VX9HNbJ7N54hR24u9Y
2026-09-03 18:21:35 +00:00
LogiFlow 6d2f8f4ec9 fix: passe de revue complète, test Electron réel et feuille de route JARVIS (v2.1.0.1) (#11)
fix: passe de revue complète, test Electron réel et feuille de route JARVIS (v2.1.0.1)
2026-09-03 20:08:36 +02:00
Claude 5f0fa7a1e2 fix: full review pass (main process, voice pipeline, Hermes client, HUD) and e2e-validated local voice (v2.1.0.1)
Electron main
- utility-process channel: sherpa output copied into V8 buffers (Electron rejects external
  buffers), audio exchanged as base64; worker restart on timeout, identity-safe exit handling,
  deterministic native unload by restart
- webhook: loopback by default without secret, UTF-8-safe body assembly, clean restart, port validation
- files IPC: realpath-based root check, openPath allow-list (reveal-only outside document folders),
  Windows reserved names; store: debounced async atomic writes with backup of corrupt files;
  logger: streaming writes with rotation; log message size cap
- HTTP stream proxy: socket released on idle timeout / renderer destroyed, id validation
- model manager: inactivity timeout, retrying rm/rename (Windows locks), engine stopped before
  replacing a model; WAV decoder handles float/24-bit; IPC payload validation
- window: opaque rounded window on Windows, navigation lock-down, visibility events;
  Ctrl+Alt+Escape instead of the Task Manager shortcut; single-instance guard; EVEFLOW_USER_DATA

Voice pipeline
- abort semantics (SendHandle.aborted), abort before the stream opens, session id prefixes per transport
- hands-free re-arm after replies without speech, start/stop race, no chime on auto re-arm,
  no silence shipped to STT (400 ms pre-roll), no transcription of empty manual stops
- TTS: bounded prefetch, cancellable segments, non-interrupting notices, volume applied at play time
- SSE CRLF split, usage in chat completions, finish_reason length, phonetic regex hoisted

HUD
- core renderer: no canvas shadows, cached colours, reusable spectrum buffer, theme read on change,
  30 fps idle, stops when the window is hidden; ping flashes on send / tool / speech
- deltas coalesced per animation frame; stable auto-scroll; narrow selectors everywhere
- bundled fonts (offline), reduce-motion fix, error toasts, ops drawer below 1180 px,
  interim transcript and first-token latency in the core caption, Ctrl+K, dialog semantics,
  switch/aria roles, compact widget cleanup, Whisper small recommended for French
- docs/ROADMAP.md: audit results, e2e results and the plan towards a real JARVIS

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Wn5VX9HNbJ7N54hR24u9Y
2026-09-03 18:08:09 +00:00
LogiFlow b1b8d0d5d4 feat: modèles vocaux intégrés hors ligne (sherpa-onnx) et mot d'activation (v2.1.0) (#10)
feat: modèles vocaux intégrés hors ligne (sherpa-onnx), gestionnaire de modèles et mot d'activation (v2.1.0)
2026-09-03 19:44:22 +02:00
Claude eefc36aca8 feat: local offline voice models (sherpa-onnx) with model manager and wake word (v2.1.0)
- electron/voice: model catalog (Whisper base/small/turbo, SenseVoice, Kokoro v1.0,
  Piper fr), streaming tar.bz2 downloader with progress, sherpa-onnx worker running in
  an Electron utilityProcess (transcribe / synthesize / status / unload), IPC + bridge.
- Renderer: 'local' providers for STT and TTS, Settings → Modèles locaux (download,
  progress, delete, activate), speaker selection, hands-free wake word with a tolerant
  matcher (accents, punctuation, edit distance) and attention window after a bare wake word.
- Packaging: native addon and worker unpacked from the asar; release 2.1.0.

Validated on Linux: Kokoro (fr) → Whisper base round trip, Piper (fr) → Whisper round trip.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Wn5VX9HNbJ7N54hR24u9Y
2026-09-03 17:43:52 +00:00
LogiFlow af1e3d8aae build: numéro de release 2.0.0.1 injecté au packaging (#9)
build: numéro de release 2.0.0.1 injecté au packaging (electron-builder exige un semver)
2026-09-03 17:47:50 +02:00
Claude 2471add00b build: four-part releaseVersion (2.0.0.1) injected at packaging time
electron-builder refuses a non-semver package.json version, so the published
number lives in package.json releaseVersion and scripts/dist.mjs passes it as
extraMetadata.version (plus the hotfix digit as Windows build number). The
workflow tags and names the release from the same field.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Wn5VX9HNbJ7N54hR24u9Y
2026-09-03 15:47:32 +00:00
LogiFlow 35437e2d51 chore: version 2.0.0.1 et télémétrie sans fausses valeurs (#8)
chore: version 2.0.0.1 (schéma correctif à quatre chiffres) et télémétrie sans fausses valeurs
2026-09-03 17:43:37 +02:00
Claude e2c67a5770 chore: hotfix versioning 2.0.0.1 and honest telemetry placeholders when the bridge is missing
- Version numbers gain a fourth hotfix component (2.0.0.1); the workflow maps it to the
  Windows file build number.
- The system panel shows dashes instead of 0% until real measurements arrive from the
  main process.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Wn5VX9HNbJ7N54hR24u9Y
2026-09-03 15:43:19 +00:00
LogiFlow 050ae20773 fix: modèle Hermes par défaut et repli sans outils client (v2.0.2) (#7)
fix: modèle Hermes par défaut et repli sans outils client (v2.0.2)
2026-09-03 17:40:44 +02:00
Claude 2a471e11a4 fix: default Hermes model from /v1/models and retry chat completions without client tools on HTTP 400 (v2.0.2)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Wn5VX9HNbJ7N54hR24u9Y
2026-09-03 15:40:21 +00:00
LogiFlow b5357c5abd fix: charger le pont Electron dans l'application installée (v2.0.1) (#6)
fix: charger le pont Electron dans l'application installée (v2.0.1)
2026-09-03 17:38:58 +02:00
Claude c73114eb0d fix: bundle the Electron preload so the sandboxed bridge loads in packaged builds (v2.0.1)
The preload script imported ../shared/ipc at runtime; sandboxed preloads cannot
require local modules, so window.eveflow was never exposed in the installed app
and every request fell back to browser fetch (Failed to fetch, LOCALHOST metrics).
Main and preload are now bundled with esbuild into single files in dist-electron/.

Also: explicit error banner when the bridge is missing inside Electron, and a more
precise connection test message (transport path, capabilities status).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Wn5VX9HNbJ7N54hR24u9Y
2026-09-03 15:38:36 +00:00
LogiFlow e3d5ead652 ci: release automatique sur master quand la version n'est pas encore taguée (#5)
ci: release automatique sur master quand la version de package.json n'est pas encore taguée
2026-09-03 15:56:51 +02:00
Claude eec6d62c20 ci: release automatically when master carries an unreleased package.json version
On push to master the workflow now checks whether tag v<version> exists and,
if not, creates the tag and the GitHub release with the Windows installer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Wn5VX9HNbJ7N54hR24u9Y
2026-09-03 13:56:32 +00:00
LogiFlow 8ee34b9932 ci: publier la release Windows depuis un lancement manuel du workflow (#4)
ci: publier la release Windows depuis un lancement manuel du workflow
2026-09-03 15:55:56 +02:00
Claude acc50da44b ci: publish a GitHub release from workflow_dispatch using the package.json version
Tag pushes are not possible from every environment; a manual run of the
workflow now creates the v<version> tag and release itself.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Wn5VX9HNbJ7N54hR24u9Y
2026-09-03 13:55:17 +00:00
LogiFlow 3690dedeb3 feat!: EveFlow 2.0.0 — HUD vocal JARVIS pour Hermes Agent (#3)
feat!: refonte complète — HUD vocal JARVIS pour Hermes Agent (v2.0.0)
2026-09-03 15:53:32 +02:00
Claude 127a2083a1 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
2026-09-03 13:40:59 +00:00
Michael 5217eee53a fix: downsample audio to 16kHz to prevent ASR 500 error, add permission check handler, and render errors in compact view (v1.0.8) 2026-06-08 20:44:31 +02:00
272 changed files with 17067 additions and 86104 deletions

No files matched your search

+47 -28
View File
@@ -1,59 +1,78 @@
name: Windows EXE
name: Build
on:
push:
branches:
- main
- master
tags:
- "v*"
branches: [main, master]
tags: ["v*"]
pull_request:
workflow_dispatch:
permissions:
contents: write
jobs:
check:
name: Typecheck & tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run typecheck
- run: npm test
- run: npm run build:renderer
build-windows:
name: Build Windows installer
needs: check
if: github.event_name != 'pull_request'
runs-on: windows-latest
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Build app
run: npm run build
- name: Build Windows EXE
run: npx electron-builder --win --publish never
- run: npm ci
- name: Read version
id: ver
shell: bash
run: |
version=$(node -p "const p=require('./package.json'); p.releaseVersion || p.version")
echo "version=$version" >> "$GITHUB_OUTPUT"
# Fourth component (hotfix number, e.g. 2.0.0.1) becomes the Windows file build number.
echo "build=$(echo "$version" | cut -d. -f4)" >> "$GITHUB_OUTPUT"
if git ls-remote --exit-code --tags origin "refs/tags/v$version" >/dev/null 2>&1; then
echo "released=true" >> "$GITHUB_OUTPUT"
else
echo "released=false" >> "$GITHUB_OUTPUT"
fi
- run: npm run build
- name: Package Windows EXE
run: npm run dist -- --win --publish never
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload Windows artifact
uses: actions/upload-artifact@v4
BUILD_NUMBER: ${{ steps.ver.outputs.build }}
- uses: actions/upload-artifact@v4
with:
name: EveFlow-Windows-${{ github.ref_name }}
name: EveFlow-Windows-v${{ steps.ver.outputs.version }}
path: |
out/*.exe
out/*.exe.blockmap
out/latest.yml
if-no-files-found: error
- name: Publish GitHub release
if: startsWith(github.ref, 'refs/tags/v')
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/master' && steps.ver.outputs.released == 'false')
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ steps.ver.outputs.version }}
name: EveFlow ${{ steps.ver.outputs.version }}
generate_release_notes: true
files: |
out/*.exe
out/*.exe.blockmap
+2
View File
@@ -1,6 +1,8 @@
node_modules/
dist/
dist-electron/
out/
.env
.env.local
*.log
.vite/
-72
View File
@@ -1,72 +0,0 @@
# Blender 4.5.5 LTS MTL File: 'None'
# www.blender.org
newmtl ArmLight
Ns 0.000000
Ka 1.000000 1.000000 1.000000
Ks 0.000000 0.000000 0.000000
Ke 0.000000 0.000000 0.000000
Ni 1.500000
illum 1
map_Kd tex1_128x128_m_4a4a505db864ac4a_14.png
map_d tex1_128x128_m_2f869d05e999c94a_0.png
newmtl Eve
Ns 0.000000
Ka 1.000000 1.000000 1.000000
Ks 0.000000 0.000000 0.000000
Ke 0.000000 0.000000 0.000000
Ni 1.500000
d 1.000000
illum 1
map_Kd tex1_16x16_m_60d58f4141724929_14.png
newmtl Eye
Ns 0.000000
Ka 1.000000 1.000000 1.000000
Ks 0.000000 0.000000 0.000000
Ke 0.000000 0.000000 0.000000
Ni 1.500000
illum 1
map_Kd tex1_32x32_m_cad406fa8385be70_14.png
map_d tex1_32x32_m_c8162c34352a09e2_0.png
newmtl HeadGun
Ns 0.000000
Ka 1.000000 1.000000 1.000000
Ks 0.000000 0.000000 0.000000
Ke 0.000000 0.000000 0.000000
Ni 1.500000
d 1.000000
illum 1
map_Kd tex1_128x128_m_87b2e2053969c2c1_14.png
newmtl Lights
Ns 0.000000
Ka 1.000000 1.000000 1.000000
Ks 0.000000 0.000000 0.000000
Ke 0.000000 0.000000 0.000000
Ni 1.500000
illum 1
map_Kd tex1_128x128_b3ab82716f6eb03b_14.png
map_d tex1_128x128_e0ee4f7cd514a91a_0.png
newmtl UpperBody
Ns 0.000000
Ka 1.000000 1.000000 1.000000
Ks 0.000000 0.000000 0.000000
Ke 0.000000 0.000000 0.000000
Ni 1.500000
illum 1
map_Kd tex1_128x128_m_7149b995a42d2f50_14.png
map_d tex1_128x128_m_164b500fe0940dee_0.png
newmtl Visor
Ns 0.000000
Ka 1.000000 1.000000 1.000000
Ks 0.000000 0.000000 0.000000
Ke 0.000000 0.000000 0.000000
Ni 1.500000
illum 1
map_Kd tex1_128x128_m_499edc885bc97572_14.png
map_d tex1_128x128_m_f2552de8e9085d25_0.png
-16262
View File
File diff suppressed because it is too large. Load diff
-72
View File
@@ -1,72 +0,0 @@
# Blender 4.5.5 LTS MTL File: 'None'
# www.blender.org
newmtl ArmLight
Ns 0.000000
Ka 1.000000 1.000000 1.000000
Ks 0.000000 0.000000 0.000000
Ke 0.000000 0.000000 0.000000
Ni 1.500000
illum 1
map_Kd tex1_128x128_m_4a4a505db864ac4a_14.png
map_d tex1_128x128_m_2f869d05e999c94a_0.png
newmtl Eve
Ns 0.000000
Ka 1.000000 1.000000 1.000000
Ks 0.000000 0.000000 0.000000
Ke 0.000000 0.000000 0.000000
Ni 1.500000
d 1.000000
illum 1
map_Kd tex1_16x16_m_60d58f4141724929_14.png
newmtl Eye
Ns 0.000000
Ka 1.000000 1.000000 1.000000
Ks 0.000000 0.000000 0.000000
Ke 0.000000 0.000000 0.000000
Ni 1.500000
illum 1
map_Kd tex1_32x32_m_cad406fa8385be70_14.png
map_d tex1_32x32_m_c8162c34352a09e2_0.png
newmtl HeadGun
Ns 0.000000
Ka 1.000000 1.000000 1.000000
Ks 0.000000 0.000000 0.000000
Ke 0.000000 0.000000 0.000000
Ni 1.500000
d 1.000000
illum 1
map_Kd tex1_128x128_m_87b2e2053969c2c1_14.png
newmtl Lights
Ns 0.000000
Ka 1.000000 1.000000 1.000000
Ks 0.000000 0.000000 0.000000
Ke 0.000000 0.000000 0.000000
Ni 1.500000
illum 1
map_Kd tex1_128x128_b3ab82716f6eb03b_14.png
map_d tex1_128x128_e0ee4f7cd514a91a_0.png
newmtl UpperBody
Ns 0.000000
Ka 1.000000 1.000000 1.000000
Ks 0.000000 0.000000 0.000000
Ke 0.000000 0.000000 0.000000
Ni 1.500000
illum 1
map_Kd tex1_128x128_m_7149b995a42d2f50_14.png
map_d tex1_128x128_m_164b500fe0940dee_0.png
newmtl Visor
Ns 0.000000
Ka 1.000000 1.000000 1.000000
Ks 0.000000 0.000000 0.000000
Ke 0.000000 0.000000 0.000000
Ni 1.500000
illum 1
map_Kd tex1_128x128_m_499edc885bc97572_14.png
map_d tex1_128x128_m_f2552de8e9085d25_0.png
-16262
View File
File diff suppressed because it is too large. Load diff
-12
View File
@@ -1,12 +0,0 @@
# Blender 4.5.5 LTS MTL File: 'None'
# www.blender.org
newmtl EVE_Diver
Ns 0.000000
Ka 1.000000 1.000000 1.000000
Ks 0.000000 0.000000 0.000000
Ke 0.000000 0.000000 0.000000
Ni 1.500000
d 1.000000
illum 1
map_Kd tex1_256x256_m_7f667e040dcfe4ee_14.png
-897
View File
@@ -1,897 +0,0 @@
# Blender 4.5.5 LTS
# www.blender.org
mtllib EVE_Diver.mtl
o EVE_Diver
v -0.452637 10.603516 0.262695
v -0.391113 11.027832 0.227050
v -0.453613 10.603516 -0.260255
v -0.392090 11.027832 -0.225098
v -0.000977 11.027832 -0.452149
v -0.000977 10.603516 -0.522950
v 0.452637 10.603516 -0.262696
v 0.391113 11.027832 -0.227052
v 0.453613 10.603516 0.260253
v 0.392090 11.027832 0.225097
v 0.000977 11.027832 0.452148
v 0.000977 10.603516 0.522948
v -0.452637 10.603516 0.262695
v -0.391113 11.027832 0.227050
v -0.391113 11.027832 0.227050
v -0.558594 11.027832 0.324218
v -0.392090 11.027832 -0.225098
v -0.560059 11.027832 -0.321778
v -0.001465 11.027832 -0.645997
v -0.000977 11.027832 -0.452149
v 0.558594 11.027832 -0.324220
v 0.391113 11.027832 -0.227052
v 0.392090 11.027832 0.225097
v 0.560059 11.027832 0.321777
v 0.000977 11.027832 0.452148
v 0.001465 11.027832 0.645995
v -0.391113 11.027832 0.227050
v -0.558594 11.027832 0.324218
v -0.646484 10.603516 0.374999
v -0.647949 10.603516 -0.372071
v -0.558594 11.027832 0.324218
v -0.560059 11.027832 -0.321778
v -0.001465 10.603516 -0.747559
v -0.001465 11.027832 -0.645997
v 0.646484 10.603516 -0.375001
v 0.558594 11.027832 -0.324220
v 0.647949 10.603516 0.372070
v 0.560059 11.027832 0.321777
v 0.001465 10.603516 0.747558
v 0.001465 11.027832 0.645995
v -0.646484 10.603516 0.374999
v -0.558594 11.027832 0.324218
v 0.679199 7.251953 2.741210
v 0.545410 7.251953 2.741210
v 0.679199 9.599609 2.741210
v 0.545410 9.599609 2.741210
v 0.545410 7.251953 2.741210
v 0.545410 7.104004 2.485351
v 0.545410 9.599609 2.741210
v 0.545410 9.747559 2.485351
v 0.679199 7.251953 2.741210
v 0.679199 9.599609 2.741210
v 0.679199 7.104004 2.485351
v 0.679199 9.747559 2.485351
v -0.551758 7.251953 2.741210
v -0.685547 7.251953 2.741210
v -0.551758 9.599609 2.741210
v -0.685547 9.599609 2.741210
v -0.685547 7.251953 2.741210
v -0.685547 7.104004 2.485351
v -0.685547 9.599609 2.741210
v -0.685547 9.747559 2.485351
v -0.551758 7.251953 2.741210
v -0.551758 9.599609 2.741210
v -0.551758 7.104004 2.485351
v -0.551758 9.747559 2.485351
v -2.088867 7.956543 2.741210
v -2.088867 8.090332 2.741210
v 2.083496 7.956543 2.741210
v 2.083496 8.090332 2.741210
v -2.088867 8.090332 2.741210
v -2.236816 8.090332 2.485351
v 2.083496 8.090332 2.741210
v 2.230957 8.090332 2.485351
v -2.236816 7.956543 2.485351
v -2.088867 7.956543 2.741210
v 2.230957 7.956543 2.485351
v 2.083496 7.956543 2.741210
v -1.899902 8.792969 2.741210
v -1.899902 8.926758 2.741210
v 1.867676 8.792969 2.741210
v 1.867676 8.926758 2.741210
v -2.043457 8.926758 2.485351
v 2.011719 8.926758 2.485351
v -1.899902 8.926758 2.741210
v 1.867676 8.926758 2.741210
v -2.043457 8.792969 2.485351
v -1.899902 8.792969 2.741210
v 2.011719 8.792969 2.485351
v 1.867676 8.792969 2.741210
v 0.258789 11.023438 0.413085
v 0.208984 11.023438 0.413085
v 0.258789 11.023438 -0.433106
v 0.208984 11.023438 -0.433106
v 0.208984 11.023438 0.413085
v 0.208984 10.927734 0.468261
v 0.208984 11.023438 -0.433106
v 0.208984 10.927734 -0.488282
v 0.258789 10.927734 0.468261
v 0.258789 11.023438 0.413085
v 0.258789 10.927734 -0.488282
v 0.258789 11.023438 -0.433106
v -0.208984 11.023438 0.413085
v -0.258789 11.023438 0.413085
v -0.208984 11.023438 -0.433106
v -0.258789 11.023438 -0.433106
v -0.258789 11.023438 0.413085
v -0.258789 10.927734 0.468261
v -0.258789 11.023438 -0.433106
v -0.258789 10.927734 -0.488282
v -0.208984 10.927734 0.468261
v -0.208984 11.023438 0.413085
v -0.208984 10.927734 -0.488282
v -0.208984 11.023438 -0.433106
v -0.423340 11.023438 0.249023
v -0.423340 11.023438 0.198730
v 0.423340 11.023438 0.249023
v 0.423340 11.023438 0.198730
v -0.423340 11.023438 0.198730
v -0.478516 10.927734 0.198730
v 0.423340 11.023438 0.198730
v 0.478516 10.927734 0.198730
v -0.478516 10.927734 0.249023
v -0.423340 11.023438 0.249023
v 0.478516 10.927734 0.249023
v 0.423340 11.023438 0.249023
v -0.423340 11.023438 -0.218751
v -0.423340 11.023438 -0.269044
v 0.423340 11.023438 -0.218751
v 0.423340 11.023438 -0.269044
v -0.423340 11.023438 -0.269044
v -0.478516 10.927734 -0.269044
v 0.423340 11.023438 -0.269044
v 0.478516 10.927734 -0.269044
v -0.478516 10.927734 -0.218751
v -0.423340 11.023438 -0.218751
v 0.478516 10.927734 -0.218751
v 0.423340 11.023438 -0.218751
v -2.616699 9.216797 -0.743653
v -2.589355 9.301758 -0.743653
v -2.616699 9.216797 0.763671
v -2.589355 9.301758 0.763671
v -2.427246 9.249023 -0.842286
v -2.427246 9.249023 0.862304
v -2.589355 9.301758 -0.743653
v -2.589355 9.301758 0.763671
v -2.454590 9.164062 -0.842286
v -2.616699 9.216797 -0.743653
v -2.454590 9.164062 0.862304
v -2.616699 9.216797 0.763671
v -2.498535 9.580078 -0.451661
v -2.498535 9.580078 -0.362305
v -2.965820 8.145996 -0.451661
v -2.965820 8.145996 -0.362305
v -2.498535 9.580078 -0.362305
v -2.306152 9.620605 -0.362305
v -2.965820 8.145996 -0.362305
v -2.834473 7.999512 -0.362305
v -2.498535 9.580078 -0.451661
v -2.965820 8.145996 -0.451661
v -2.306152 9.620605 -0.451661
v -2.834473 7.999512 -0.451661
v -2.498535 9.580078 0.381835
v -2.498535 9.580078 0.471191
v -2.965820 8.145996 0.381835
v -2.965820 8.145996 0.471191
v -2.498535 9.580078 0.471191
v -2.306152 9.620605 0.471191
v -2.965820 8.145996 0.471191
v -2.834473 7.999512 0.471191
v -2.498535 9.580078 0.381835
v -2.965820 8.145996 0.381835
v -2.306152 9.620605 0.381835
v -2.834473 7.999512 0.381835
v -2.875488 8.423828 -0.743653
v -2.847656 8.508789 -0.743653
v -2.875488 8.423828 0.763671
v -2.847656 8.508789 0.763671
v -2.685547 8.456055 -0.842286
v -2.685547 8.456055 0.862304
v -2.847656 8.508789 -0.743653
v -2.847656 8.508789 0.763671
v -2.713379 8.371094 -0.842286
v -2.875488 8.423828 -0.743653
v -2.713379 8.371094 0.862304
v -2.875488 8.423828 0.763671
v -2.073242 9.500488 -0.000001
v -2.496582 9.638184 -0.000001
v -2.203613 9.099609 0.729980
v -2.626953 9.237305 0.729980
v -2.464844 8.297363 0.729980
v -2.888672 8.435547 0.729980
v -3.019043 8.034668 -0.000001
v -2.595703 7.896484 -0.000001
v -2.464844 8.297363 -0.729981
v -2.888672 8.435547 -0.729981
v -2.626953 9.237305 -0.729981
v -2.203613 9.099609 -0.729981
v -2.496582 9.638184 -0.000001
v -2.073242 9.500488 -0.000001
v -2.384277 9.981934 -0.000001
v -2.571289 9.409180 1.042968
v -2.496582 9.638184 -0.000001
v -2.626953 9.237305 0.729980
v -2.944336 8.263672 1.042968
v -2.888672 8.435547 0.729980
v -3.019043 8.034668 -0.000001
v -3.130859 7.690918 -0.000001
v -2.944336 8.263672 -1.042969
v -2.888672 8.435547 -0.729981
v -2.626953 9.237305 -0.729981
v -2.571289 9.409180 -1.042969
v -2.496582 9.638184 -0.000001
v -2.384277 9.981934 -0.000001
v -2.384277 9.981934 -0.000001
v -1.960938 9.843750 -0.000001
v -2.571289 9.409180 1.042968
v -2.147949 9.270996 1.042968
v -2.944336 8.263672 1.042968
v -2.520996 8.125977 1.042968
v -3.130859 7.690918 -0.000001
v -2.707520 7.553223 -0.000001
v -2.520996 8.125977 -1.042969
v -2.944336 8.263672 -1.042969
v -2.147949 9.270996 -1.042969
v -2.571289 9.409180 -1.042969
v -1.960938 9.843750 -0.000001
v -2.384277 9.981934 -0.000001
v -0.002441 7.286621 2.763183
v 1.350586 7.427246 2.763183
v -0.002930 6.862793 2.763183
v 1.464355 7.033203 2.763183
v 1.913086 7.782227 2.763183
v 2.230957 7.576660 2.763183
v 2.329590 8.142578 2.763183
v 1.983398 8.178223 2.763183
v 1.899902 9.187988 2.763183
v 1.656738 8.949707 2.763183
v 0.970215 9.465332 2.763183
v 1.095703 9.812988 2.763183
v -0.011230 9.631836 2.763183
v -0.012695 10.007324 2.763183
v -1.093262 9.817383 2.763183
v -0.974609 9.467285 2.763183
v -1.904297 9.189941 2.763183
v -1.662109 8.950195 2.763183
v -2.018066 8.201172 2.763183
v -2.361328 8.206543 2.763183
v -1.955566 7.784668 2.763183
v -2.265137 7.564453 2.763183
v -1.471191 7.021484 2.763183
v -1.362793 7.415527 2.763183
v -0.002930 6.862793 2.763183
v -0.002441 7.286621 2.763183
v -0.002441 7.286621 2.763183
v -0.002441 7.286621 2.636718
v 1.350586 7.427246 2.763183
v 1.350586 7.427246 2.296386
v 1.913086 7.782227 2.763183
v 1.913086 7.782227 1.984374
v 1.983398 8.178223 2.763183
v 1.983398 8.178223 1.858886
v 1.656738 8.949707 1.864745
v 1.656738 8.949707 2.763183
v 0.970215 9.465332 1.988769
v 0.970215 9.465332 2.763183
v -0.011230 9.631836 2.031738
v -0.011230 9.631836 2.763183
v -0.974609 9.467285 2.763183
v -0.974609 9.467285 1.987792
v -1.662109 8.950195 2.763183
v -1.662109 8.950195 1.864745
v -2.018066 8.201172 2.763183
v -2.018066 8.201172 1.840820
v -1.955566 7.784668 1.954101
v -1.955566 7.784668 2.763183
v -1.362793 7.415527 2.291015
v -1.362793 7.415527 2.763183
v -0.002441 7.286621 2.636718
v -0.002441 7.286621 2.763183
v -0.002930 6.862793 2.763183
v 1.464355 7.033203 2.763183
v -0.002930 6.862793 2.444824
v 1.464355 7.033203 2.053710
v 2.230957 7.576660 2.763183
v 2.230957 7.576660 1.608886
v 2.329590 8.142578 2.763183
v 2.329590 8.142578 1.434081
v 1.899902 9.187988 1.435058
v 1.899902 9.187988 2.763183
v 1.095703 9.812988 1.594726
v 1.095703 9.812988 2.763183
v -0.012695 10.007324 1.650390
v -0.012695 10.007324 2.763183
v -1.093262 9.817383 2.763183
v -1.093262 9.817383 1.594238
v -1.904297 9.189941 2.763183
v -1.904297 9.189941 1.434081
v -2.361328 8.206543 2.763183
v -2.361328 8.206543 1.407226
v -2.265137 7.564453 1.582031
v -2.265137 7.564453 2.763183
v -1.471191 7.021484 2.047363
v -1.471191 7.021484 2.763183
v -0.002930 6.862793 2.444824
v -0.002930 6.862793 2.763183
v 2.621582 9.216797 0.743652
v 2.594238 9.301758 0.743652
v 2.621582 9.216797 -0.763673
v 2.594238 9.301758 -0.763673
v 2.432129 9.249023 0.842284
v 2.432129 9.249023 -0.862305
v 2.594238 9.301758 0.743652
v 2.594238 9.301758 -0.763673
v 2.459473 9.164062 0.842284
v 2.621582 9.216797 0.743652
v 2.459473 9.164062 -0.862305
v 2.621582 9.216797 -0.763673
v 2.503418 9.580078 0.451659
v 2.503418 9.580078 0.362304
v 2.970703 8.145996 0.451660
v 2.970703 8.145996 0.362304
v 2.503418 9.580078 0.362304
v 2.311035 9.620605 0.362304
v 2.970703 8.145996 0.362304
v 2.839355 7.999512 0.362304
v 2.503418 9.580078 0.451659
v 2.970703 8.145996 0.451660
v 2.311035 9.620605 0.451659
v 2.839355 7.999512 0.451660
v 2.503418 9.580078 -0.381837
v 2.503418 9.580078 -0.471192
v 2.970703 8.145996 -0.381837
v 2.970703 8.145996 -0.471192
v 2.503418 9.580078 -0.471192
v 2.311035 9.620605 -0.471192
v 2.970703 8.145996 -0.471192
v 2.839355 7.999512 -0.471192
v 2.503418 9.580078 -0.381837
v 2.970703 8.145996 -0.381837
v 2.311035 9.620605 -0.381837
v 2.839355 7.999512 -0.381837
v 2.880371 8.423828 0.743652
v 2.852539 8.508789 0.743652
v 2.880371 8.423828 -0.763673
v 2.852539 8.508789 -0.763673
v 2.690430 8.456055 0.842285
v 2.690430 8.456055 -0.862305
v 2.852539 8.508789 0.743652
v 2.852539 8.508789 -0.763673
v 2.718262 8.371094 0.842285
v 2.880371 8.423828 0.743652
v 2.718262 8.371094 -0.862305
v 2.880371 8.423828 -0.763673
v 2.073242 9.500488 -0.000001
v 2.496582 9.638184 -0.000001
v 2.203613 9.099609 -0.729981
v 2.626953 9.237305 -0.729981
v 2.464844 8.297363 -0.729981
v 2.888672 8.435547 -0.729981
v 3.019043 8.034668 -0.000001
v 2.595703 7.896484 -0.000001
v 2.464844 8.297363 0.729980
v 2.888672 8.435547 0.729980
v 2.626953 9.237305 0.729980
v 2.203613 9.099609 0.729980
v 2.496582 9.638184 -0.000001
v 2.073242 9.500488 -0.000001
v 2.384277 9.981934 -0.000001
v 2.571289 9.409180 -1.042969
v 2.496582 9.638184 -0.000001
v 2.626953 9.237305 -0.729981
v 2.944336 8.263672 -1.042969
v 2.888672 8.435547 -0.729981
v 3.019043 8.034668 -0.000001
v 3.130859 7.690918 -0.000001
v 2.944336 8.263672 1.042968
v 2.888672 8.435547 0.729980
v 2.626953 9.237305 0.729980
v 2.571289 9.409180 1.042968
v 2.496582 9.638184 -0.000001
v 2.384277 9.981934 -0.000001
v 2.384277 9.981934 -0.000001
v 1.960938 9.843750 -0.000001
v 2.571289 9.409180 -1.042969
v 2.147949 9.270996 -1.042969
v 2.944336 8.263672 -1.042969
v 2.520996 8.125977 -1.042969
v 3.130859 7.690918 -0.000001
v 2.707520 7.553223 -0.000001
v 2.520996 8.125977 1.042968
v 2.944336 8.263672 1.042968
v 2.147949 9.270996 1.042968
v 2.571289 9.409180 1.042968
v 1.960938 9.843750 -0.000001
v 2.384277 9.981934 -0.000001
vn 0.8519 -0.1656 -0.4969
vn 0.8573 -0.1652 0.4876
vn -0.1499 0.4734 0.8680
vn -0.3068 0.9518 -0.0000
vn -0.0000 -0.1657 0.9862
vn -0.8519 -0.1656 0.4969
vn -0.8518 -0.1656 0.4969
vn -0.8573 -0.1652 -0.4876
vn 0.1499 -0.4734 0.8680
vn -0.0000 -0.1657 -0.9862
vn 0.8518 -0.1656 -0.4969
vn -0.0000 1.0000 -0.0000
vn 0.3068 -0.9518 -0.0000
vn -0.8424 0.2283 0.4881
vn -0.8456 0.2292 -0.4821
vn 0.1500 -0.4735 -0.8680
vn -0.0000 0.2277 -0.9737
vn -0.0000 0.2278 -0.9737
vn 0.8424 0.2283 -0.4881
vn 0.8456 0.2292 0.4821
vn -0.0000 0.2277 0.9737
vn -0.0000 0.2278 0.9737
vn -0.0000 -0.0000 1.0000
vn -1.0000 -0.0000 -0.0000
vn 1.0000 -0.0000 -0.0000
vn -0.0000 -1.0000 -0.0000
vn -0.0000 -0.0000 -1.0000
vn -0.9518 0.3067 -0.0000
vn 0.3068 0.9518 -0.0000
vn 0.3067 0.9518 -0.0000
vn -0.3068 -0.9518 -0.0000
vn -0.3067 -0.9518 -0.0000
vn -0.9518 0.3068 -0.0000
vn -0.3093 -0.9510 -0.0000
vn -0.1499 -0.4734 -0.8680
vn 0.1499 0.4734 -0.8680
vn -0.1499 0.4734 -0.8680
vn 0.1499 0.4734 0.8680
vn -0.1499 -0.4734 0.8680
vn 0.9518 0.3068 -0.0000
vn -0.1500 -0.4735 0.8680
vn -0.3233 0.9463 -0.0000
vn -0.8284 0.5601 -0.0000
vn -0.9939 -0.1104 -0.0000
vn -0.7885 -0.6150 -0.0001
vn -0.7884 -0.6152 -0.0000
vn -0.3930 -0.9196 -0.0000
vn -0.0001 -1.0000 -0.0000
vn 0.3930 -0.9196 -0.0000
vn 0.3930 -0.9195 -0.0000
vn 0.7746 -0.6325 -0.0000
vn 0.7747 -0.6324 -0.0000
vn 0.9899 -0.1414 -0.0000
vn 0.9900 -0.1414 -0.0000
vn 0.8345 0.5510 -0.0000
vn 0.8344 0.5511 -0.0000
vn 0.3162 0.9487 -0.0000
vn 0.3163 0.9487 -0.0000
vn 0.3537 -0.9354 -0.0000
vn 0.1499 -0.4734 -0.8680
vn 0.3093 -0.9510 -0.0000
vn 0.8440 -0.5363 -0.0000
vn 0.9947 0.1026 -0.0000
vn 0.7953 0.6063 -0.0000
vn 0.7953 0.6062 -0.0000
vn 0.4025 0.9154 -0.0000
vn -0.0003 1.0000 -0.0000
vn -0.0002 1.0000 -0.0000
vn -0.4025 0.9154 -0.0000
vn -0.7817 0.6236 -0.0000
vn -0.7816 0.6237 -0.0000
vn -0.9899 0.1414 -0.0000
vn -0.8498 -0.5271 -0.0000
vn -0.8498 -0.5272 -0.0000
vn -0.3468 -0.9379 -0.0000
vn -0.3464 -0.9381 -0.0000
vn 0.9518 0.3067 -0.0000
vn -0.3067 0.9518 -0.0000
vn 0.3067 -0.9518 -0.0000
vt 0.669678 0.523682
vt 0.706604 0.486755
vt 0.679138 0.533142
vt 0.716064 0.496216
vt 0.935120 0.295105
vt 0.984192 0.245972
vt 0.992126 0.253906
vt 0.765320 0.447021
vt 0.755859 0.437500
vt 0.942993 0.302979
vt 0.805054 0.388306
vt 0.814575 0.397766
vt 0.854309 0.339050
vt 0.863770 0.348511
vt 0.886047 0.344177
vt 0.913025 0.299316
vt 0.903503 0.289795
vt 0.893921 0.352051
vt 0.952759 0.240601
vt 0.962280 0.250061
vt 0.688660 0.542664
vt 0.725586 0.505737
vt 0.836914 0.393250
vt 0.774780 0.456482
vt 0.824036 0.407227
vt 0.844849 0.401184
vt 0.873291 0.358032
vt 0.922485 0.308777
vt 0.971741 0.259583
vt 0.698120 0.552124
vt 0.735046 0.515198
vt 0.795776 0.450256
vt 0.784302 0.466003
vt 0.833557 0.416748
vt 0.882751 0.367493
vt 0.932007 0.318298
vt 0.981201 0.269043
vt 0.680115 0.534973
vt 0.974792 0.240295
vt 0.687317 0.542236
vt 0.982056 0.247498
vt 0.686523 0.552673
vt 0.992615 0.246643
vt 0.669617 0.535767
vt 0.975647 0.229736
vt 0.687134 0.541992
vt 0.981812 0.247253
vt 0.669067 0.535217
vt 0.975098 0.229187
vt 0.680359 0.535095
vt 0.975037 0.240417
vt 0.687012 0.541687
vt 0.981689 0.247070
vt 0.683594 0.533508
vt 0.970947 0.246094
vt 0.690063 0.539978
vt 0.977417 0.252563
vt 0.686218 0.551758
vt 0.989258 0.248718
vt 0.671753 0.537354
vt 0.974792 0.234314
vt 0.971130 0.267822
vt 0.698792 0.540161
vt 0.960083 0.256714
vt 0.687683 0.529053
vt 0.963684 0.239807
vt 0.670776 0.532715
vt 0.988037 0.264160
vt 0.695129 0.557068
vt 0.673828 0.524658
vt 0.722961 0.475586
vt 0.681763 0.532593
vt 0.730835 0.483521
vt 0.772034 0.426514
vt 0.779907 0.434448
vt 0.787842 0.442322
vt 0.829041 0.385315
vt 0.821106 0.377441
vt 0.746704 0.499329
vt 0.870178 0.328308
vt 0.878113 0.336243
vt 0.738770 0.491394
vt 0.927185 0.287170
vt 0.919312 0.279236
vt 0.976257 0.238098
vt 0.968384 0.230164
vt 0.689697 0.540527
vt 0.697571 0.548401
vt 0.705444 0.554871
vt 0.695496 0.544800
vt 0.731445 0.528870
vt 0.721497 0.518799
vt 0.757446 0.502869
vt 0.747498 0.492737
vt 0.764832 0.475403
vt 0.774780 0.485535
vt 0.790894 0.449402
vt 0.800842 0.459534
vt 0.826843 0.433472
vt 0.816895 0.423401
vt 0.852844 0.407471
vt 0.842896 0.397400
vt 0.868896 0.371399
vt 0.878845 0.381470
vt 0.894897 0.345337
vt 0.904846 0.355469
vt 0.930847 0.329468
vt 0.920959 0.319336
vt 0.948242 0.312073
vt 0.938293 0.302002
vt 0.964294 0.276001
vt 0.974243 0.286072
vt 0.990295 0.250000
vt 1.000244 0.260071
vt 0.696533 0.546326
vt 0.722595 0.520264
vt 0.748596 0.494263
vt 0.765991 0.476868
vt 0.791992 0.450867
vt 0.818054 0.424805
vt 0.844055 0.398804
vt 0.870117 0.372742
vt 0.896179 0.346741
vt 0.922180 0.320679
vt 0.939514 0.303345
vt 0.965576 0.277283
vt 0.991638 0.251282
vt 0.661072 0.510559
vt 0.687134 0.484497
vt 0.713135 0.458496
vt 0.730530 0.441101
vt 0.756531 0.415100
vt 0.782593 0.389038
vt 0.808594 0.363037
vt 0.834656 0.336975
vt 0.860657 0.310913
vt 0.886719 0.284912
vt 0.904053 0.267578
vt 0.930115 0.241516
vt 0.956116 0.215454
s 1
usemtl EVE_Diver
f 1/1/1 3/2/2 2/3/1
f 2/3/1 3/2/2 4/4/2
f 394/5/3 396/6/4 395/7/4
f 4/4/2 3/2/2 5/8/5
f 3/2/2 6/9/5 5/8/5
f 393/10/3 394/5/3 395/7/4
f 6/9/5 7/11/6 5/8/5
f 5/8/5 7/11/6 8/12/7
f 7/11/6 9/13/8 8/12/7
f 8/12/7 9/13/8 10/14/8
f 392/15/9 394/5/3 393/10/3
f 10/14/8 9/13/8 11/16/10
f 9/13/8 12/17/10 11/16/10
f 391/18/9 392/15/9 393/10/3
f 12/17/10 13/19/1 11/16/10
f 11/16/10 13/19/1 14/20/11
f 15/3/12 17/4/12 16/21/12
f 16/21/12 17/4/12 18/22/12
f 389/23/13 392/15/9 391/18/9
f 18/22/12 17/4/12 19/24/12
f 17/4/12 20/8/12 19/24/12
f 19/24/12 20/8/12 21/25/12
f 20/8/12 22/12/12 21/25/12
f 390/26/13 389/23/13 391/18/9
f 22/12/12 23/14/12 21/25/12
f 21/25/12 23/14/12 24/27/12
f 23/14/12 25/16/12 24/27/12
f 24/27/12 25/16/12 26/28/12
f 25/16/12 27/20/12 26/28/12
f 26/28/12 27/20/12 28/29/12
f 29/30/14 31/21/14 30/31/15
f 388/32/16 389/23/13 390/26/13
f 31/21/14 32/22/15 30/31/15
f 30/31/15 32/22/15 33/33/17
f 32/22/15 34/24/18 33/33/17
f 33/33/17 34/24/18 35/34/19
f 34/24/18 36/25/19 35/34/19
f 35/34/19 36/25/19 37/35/20
f 36/25/19 38/27/20 37/35/20
f 37/35/20 38/27/20 39/36/21
f 38/27/20 40/28/22 39/36/21
f 39/36/21 40/28/22 41/37/14
f 40/28/22 42/29/14 41/37/14
f 43/38/23 45/39/23 44/40/23
f 44/40/23 45/39/23 46/41/23
f 47/40/24 49/41/24 48/42/24
f 48/42/24 49/41/24 50/43/24
f 51/38/25 53/44/25 52/39/25
f 52/39/25 53/44/25 54/45/25
f 55/38/23 57/39/23 56/46/23
f 56/46/23 57/39/23 58/47/23
f 59/46/24 61/47/24 60/42/24
f 60/42/24 61/47/24 62/43/24
f 63/38/25 65/48/25 64/39/25
f 64/39/25 65/48/25 66/49/25
f 67/50/23 69/51/23 68/52/23
f 68/52/23 69/51/23 70/53/23
f 71/52/12 73/53/12 72/42/12
f 72/42/12 73/53/12 74/43/12
f 75/48/26 77/49/26 76/50/26
f 76/50/26 77/49/26 78/51/26
f 79/50/23 81/51/23 80/52/23
f 80/52/23 81/51/23 82/53/23
f 83/42/12 85/52/12 84/43/12
f 84/43/12 85/52/12 86/53/12
f 87/48/26 89/49/26 88/50/26
f 88/50/26 89/49/26 90/51/26
f 91/54/12 93/55/12 92/56/12
f 92/56/12 93/55/12 94/57/12
f 95/56/24 97/57/24 96/58/24
f 96/58/24 97/57/24 98/59/24
f 99/60/25 101/61/25 100/54/25
f 100/54/25 101/61/25 102/55/25
f 103/54/12 105/55/12 104/56/12
f 104/56/12 105/55/12 106/57/12
f 107/56/24 109/57/24 108/58/24
f 108/58/24 109/57/24 110/59/24
f 111/60/25 113/61/25 112/54/25
f 112/54/25 113/61/25 114/55/25
f 115/54/12 117/55/12 116/56/12
f 116/56/12 117/55/12 118/57/12
f 119/56/27 121/57/27 120/58/27
f 120/58/27 121/57/27 122/59/27
f 123/60/23 125/61/23 124/54/23
f 124/54/23 125/61/23 126/55/23
f 127/54/12 129/55/12 128/56/12
f 128/56/12 129/55/12 130/57/12
f 131/56/27 133/57/27 132/58/27
f 132/58/27 133/57/27 134/59/27
f 135/60/23 137/61/23 136/54/23
f 136/54/23 137/61/23 138/55/23
f 139/62/28 141/63/28 140/64/28
f 140/64/28 141/63/28 142/65/28
f 143/66/29 145/64/30 144/67/29
f 144/67/29 145/64/30 146/65/30
f 147/68/31 149/69/31 148/62/32
f 148/62/32 149/69/31 150/63/32
f 151/62/33 153/63/33 152/64/33
f 152/64/33 153/63/33 154/65/33
f 155/64/23 157/65/23 156/66/23
f 156/66/23 157/65/23 158/67/23
f 159/62/27 161/68/27 160/63/27
f 160/63/27 161/68/27 162/69/27
f 163/62/33 165/63/33 164/64/33
f 164/64/33 165/63/33 166/65/33
f 167/64/23 169/65/23 168/66/23
f 168/66/23 169/65/23 170/67/23
f 171/62/27 173/68/27 172/63/27
f 172/63/27 173/68/27 174/69/27
f 175/62/33 177/63/33 176/64/33
f 176/64/33 177/63/33 178/65/33
f 179/66/29 181/64/30 180/67/30
f 180/67/30 181/64/30 182/65/30
f 183/68/31 185/69/32 184/62/32
f 184/62/32 185/69/32 186/63/32
f 187/70/34 189/71/35 188/72/31
f 188/72/31 189/71/35 190/73/35
f 189/71/35 191/74/36 190/73/35
f 190/73/35 191/74/36 192/75/36
f 387/76/16 389/23/13 388/32/16
f 192/75/36 191/74/36 193/77/29
f 191/74/36 194/78/29 193/77/29
f 386/79/37 387/76/16 388/32/16
f 194/78/29 195/80/38 193/77/29
f 193/77/29 195/80/38 196/81/38
f 385/82/37 387/76/16 386/79/37
f 196/81/38 195/80/38 197/83/39
f 195/80/38 198/84/39 197/83/39
f 197/83/39 198/84/39 199/85/34
f 198/84/39 200/86/31 199/85/34
f 201/87/33 203/72/33 202/82/33
f 384/88/4 385/82/37 386/79/37
f 203/72/33 204/73/33 202/82/33
f 202/82/33 204/73/33 205/76/33
f 204/73/33 206/75/33 205/76/33
f 383/87/4 385/82/37 384/88/4
f 206/75/33 207/77/33 205/76/33
f 205/76/33 207/77/33 208/23/33
f 380/5/40 381/85/40 382/6/40
f 208/23/33 207/77/33 209/15/33
f 207/77/33 210/81/33 209/15/33
f 379/83/40 381/85/40 380/5/40
f 210/81/33 211/83/33 209/15/33
f 209/15/33 211/83/33 212/5/33
f 211/83/33 213/85/33 212/5/33
f 212/5/33 213/85/33 214/6/33
f 215/87/29 217/82/38 216/88/29
f 216/88/29 217/82/38 218/79/38
f 217/82/38 219/76/41 218/79/38
f 218/79/38 219/76/41 220/32/41
f 219/76/41 221/23/31 220/32/41
f 220/32/41 221/23/31 222/26/31
f 377/15/40 379/83/40 380/5/40
f 222/26/31 221/23/31 223/18/35
f 221/23/31 224/15/35 223/18/35
f 223/18/35 224/15/35 225/10/36
f 224/15/35 226/5/36 225/10/36
f 225/10/36 226/5/36 227/7/29
f 226/5/36 228/6/29 227/7/29
f 229/89/23 231/90/23 230/91/23
f 378/81/40 379/83/40 377/15/40
f 231/90/23 232/92/23 230/91/23
f 230/91/23 232/92/23 233/93/23
f 232/92/23 234/94/23 233/93/23
f 375/77/40 378/81/40 377/15/40
f 234/94/23 235/95/23 233/93/23
f 233/93/23 235/95/23 236/96/23
f 235/95/23 237/97/23 236/96/23
f 236/96/23 237/97/23 238/98/23
f 376/23/40 375/77/40 377/15/40
f 238/98/23 237/97/23 239/99/23
f 237/97/23 240/100/23 239/99/23
f 239/99/23 240/100/23 241/101/23
f 240/100/23 242/102/23 241/101/23
f 373/76/40 375/77/40 376/23/40
f 242/102/23 243/103/23 241/101/23
f 241/101/23 243/103/23 244/104/23
f 243/103/23 245/105/23 244/104/23
f 244/104/23 245/105/23 246/106/23
f 374/75/40 375/77/40 373/76/40
f 246/106/23 245/105/23 247/107/23
f 245/105/23 248/108/23 247/107/23
f 247/107/23 248/108/23 249/109/23
f 248/108/23 250/110/23 249/109/23
f 372/73/40 374/75/40 373/76/40
f 250/110/23 251/111/23 249/109/23
f 249/109/23 251/111/23 252/112/23
f 251/111/23 253/113/23 252/112/23
f 252/112/23 253/113/23 254/114/23
f 255/89/12 257/91/42 256/115/12
f 256/115/12 257/91/42 258/116/42
f 257/91/42 259/93/43 258/116/42
f 258/116/42 259/93/43 260/117/43
f 259/93/43 261/96/44 260/117/43
f 260/117/43 261/96/44 262/118/44
f 370/82/40 372/73/40 373/76/40
f 262/118/44 261/96/44 263/119/45
f 261/96/44 264/98/46 263/119/45
f 263/119/45 264/98/46 265/120/47
f 264/98/46 266/99/47 265/120/47
f 265/120/47 266/99/47 267/121/48
f 266/99/47 268/101/26 267/121/48
f 371/72/40 372/73/40 370/82/40
f 268/101/26 269/104/49 267/121/48
f 267/121/48 269/104/49 270/122/50
f 269/104/49 271/106/51 270/122/50
f 270/122/50 271/106/51 272/123/52
f 271/106/51 273/107/53 272/123/52
f 272/123/52 273/107/53 274/124/54
f 369/87/40 371/72/40 370/82/40
f 274/124/54 273/107/53 275/125/55
f 273/107/53 276/109/56 275/125/55
f 275/125/55 276/109/56 277/126/57
f 276/109/56 278/112/58 277/126/57
f 277/126/57 278/112/58 279/127/12
f 278/112/58 280/114/12 279/127/12
f 281/90/26 283/128/26 282/92/59
f 366/84/60 368/86/13 367/85/61
f 283/128/26 284/129/59 282/92/59
f 282/92/59 284/129/59 285/94/62
f 284/129/59 286/130/62 285/94/62
f 285/94/62 286/130/62 287/95/63
f 286/130/62 288/131/63 287/95/63
f 365/83/60 366/84/60 367/85/61
f 288/131/63 289/132/64 287/95/63
f 287/95/63 289/132/64 290/97/65
f 289/132/64 291/133/66 290/97/65
f 290/97/65 291/133/66 292/100/66
f 291/133/66 293/134/67 292/100/66
f 292/100/66 293/134/67 294/102/68
f 363/80/37 366/84/60 365/83/60
f 294/102/68 293/134/67 295/103/69
f 293/134/67 296/135/69 295/103/69
f 295/103/69 296/135/69 297/105/70
f 296/135/69 298/136/71 297/105/70
f 297/105/70 298/136/71 299/108/72
f 298/136/71 300/137/72 299/108/72
f 364/81/37 363/80/37 365/83/60
f 300/137/72 301/138/73 299/108/72
f 299/108/72 301/138/73 302/110/74
f 301/138/73 303/139/75 302/110/74
f 302/110/74 303/139/75 304/111/76
f 303/139/75 305/140/26 304/111/76
f 304/111/76 305/140/26 306/113/26
f 307/62/77 309/63/77 308/64/77
f 308/64/77 309/63/77 310/65/77
f 311/66/4 313/64/78 312/67/78
f 312/67/78 313/64/78 314/65/4
f 315/68/13 317/69/79 316/62/79
f 316/62/79 317/69/79 318/63/13
f 319/62/40 321/63/40 320/64/40
f 320/64/40 321/63/40 322/65/40
f 323/64/27 325/65/27 324/66/27
f 324/66/27 325/65/27 326/67/27
f 327/62/23 329/68/23 328/63/23
f 328/63/23 329/68/23 330/69/23
f 331/62/40 333/63/40 332/64/40
f 332/64/40 333/63/40 334/65/40
f 335/64/27 337/65/27 336/66/27
f 336/66/27 337/65/27 338/67/27
f 339/62/23 341/68/23 340/63/23
f 340/63/23 341/68/23 342/69/23
f 343/62/40 345/63/40 344/64/77
f 344/64/77 345/63/40 346/65/40
f 347/66/4 349/64/78 348/67/78
f 348/67/78 349/64/78 350/65/78
f 351/68/13 353/69/79 352/62/79
f 352/62/79 353/69/79 354/63/79
f 355/70/61 357/71/9 356/72/13
f 356/72/13 357/71/9 358/73/9
f 357/71/9 359/74/3 358/73/9
f 358/73/9 359/74/3 360/75/3
f 361/77/4 363/80/37 364/81/37
f 360/75/3 359/74/3 361/77/4
f 359/74/3 362/78/4 361/77/4
f 362/78/4 363/80/37 361/77/4
-12
View File
@@ -1,12 +0,0 @@
# Blender 4.5.5 LTS MTL File: 'None'
# www.blender.org
newmtl EVE_Xmas
Ns 0.000000
Ka 1.000000 1.000000 1.000000
Ks 0.000000 0.000000 0.000000
Ke 0.000000 0.000000 0.000000
Ni 1.500000
illum 1
map_Kd tex1_128x128_m_f5b8cc0e1394df76_14.png
map_d tex1_128x128_m_47fba0aeb06eb3d7_0.png
File diff suppressed because it is too large. Load diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 651 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 592 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 428 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

-12
View File
@@ -1,12 +0,0 @@
# Blender 4.5.5 LTS MTL File: 'None'
# www.blender.org
newmtl EVE_Diver
Ns 0.000000
Ka 1.000000 1.000000 1.000000
Ks 0.000000 0.000000 0.000000
Ke 0.000000 0.000000 0.000000
Ni 1.500000
d 1.000000
illum 1
map_Kd tex1_256x256_m_7f667e040dcfe4ee_14.png
-897
View File
@@ -1,897 +0,0 @@
# Blender 4.5.5 LTS
# www.blender.org
mtllib EVE_Diver.mtl
o EVE_Diver
v -0.452637 10.603516 0.262695
v -0.391113 11.027832 0.227050
v -0.453613 10.603516 -0.260255
v -0.392090 11.027832 -0.225098
v -0.000977 11.027832 -0.452149
v -0.000977 10.603516 -0.522950
v 0.452637 10.603516 -0.262696
v 0.391113 11.027832 -0.227052
v 0.453613 10.603516 0.260253
v 0.392090 11.027832 0.225097
v 0.000977 11.027832 0.452148
v 0.000977 10.603516 0.522948
v -0.452637 10.603516 0.262695
v -0.391113 11.027832 0.227050
v -0.391113 11.027832 0.227050
v -0.558594 11.027832 0.324218
v -0.392090 11.027832 -0.225098
v -0.560059 11.027832 -0.321778
v -0.001465 11.027832 -0.645997
v -0.000977 11.027832 -0.452149
v 0.558594 11.027832 -0.324220
v 0.391113 11.027832 -0.227052
v 0.392090 11.027832 0.225097
v 0.560059 11.027832 0.321777
v 0.000977 11.027832 0.452148
v 0.001465 11.027832 0.645995
v -0.391113 11.027832 0.227050
v -0.558594 11.027832 0.324218
v -0.646484 10.603516 0.374999
v -0.647949 10.603516 -0.372071
v -0.558594 11.027832 0.324218
v -0.560059 11.027832 -0.321778
v -0.001465 10.603516 -0.747559
v -0.001465 11.027832 -0.645997
v 0.646484 10.603516 -0.375001
v 0.558594 11.027832 -0.324220
v 0.647949 10.603516 0.372070
v 0.560059 11.027832 0.321777
v 0.001465 10.603516 0.747558
v 0.001465 11.027832 0.645995
v -0.646484 10.603516 0.374999
v -0.558594 11.027832 0.324218
v 0.679199 7.251953 2.741210
v 0.545410 7.251953 2.741210
v 0.679199 9.599609 2.741210
v 0.545410 9.599609 2.741210
v 0.545410 7.251953 2.741210
v 0.545410 7.104004 2.485351
v 0.545410 9.599609 2.741210
v 0.545410 9.747559 2.485351
v 0.679199 7.251953 2.741210
v 0.679199 9.599609 2.741210
v 0.679199 7.104004 2.485351
v 0.679199 9.747559 2.485351
v -0.551758 7.251953 2.741210
v -0.685547 7.251953 2.741210
v -0.551758 9.599609 2.741210
v -0.685547 9.599609 2.741210
v -0.685547 7.251953 2.741210
v -0.685547 7.104004 2.485351
v -0.685547 9.599609 2.741210
v -0.685547 9.747559 2.485351
v -0.551758 7.251953 2.741210
v -0.551758 9.599609 2.741210
v -0.551758 7.104004 2.485351
v -0.551758 9.747559 2.485351
v -2.088867 7.956543 2.741210
v -2.088867 8.090332 2.741210
v 2.083496 7.956543 2.741210
v 2.083496 8.090332 2.741210
v -2.088867 8.090332 2.741210
v -2.236816 8.090332 2.485351
v 2.083496 8.090332 2.741210
v 2.230957 8.090332 2.485351
v -2.236816 7.956543 2.485351
v -2.088867 7.956543 2.741210
v 2.230957 7.956543 2.485351
v 2.083496 7.956543 2.741210
v -1.899902 8.792969 2.741210
v -1.899902 8.926758 2.741210
v 1.867676 8.792969 2.741210
v 1.867676 8.926758 2.741210
v -2.043457 8.926758 2.485351
v 2.011719 8.926758 2.485351
v -1.899902 8.926758 2.741210
v 1.867676 8.926758 2.741210
v -2.043457 8.792969 2.485351
v -1.899902 8.792969 2.741210
v 2.011719 8.792969 2.485351
v 1.867676 8.792969 2.741210
v 0.258789 11.023438 0.413085
v 0.208984 11.023438 0.413085
v 0.258789 11.023438 -0.433106
v 0.208984 11.023438 -0.433106
v 0.208984 11.023438 0.413085
v 0.208984 10.927734 0.468261
v 0.208984 11.023438 -0.433106
v 0.208984 10.927734 -0.488282
v 0.258789 10.927734 0.468261
v 0.258789 11.023438 0.413085
v 0.258789 10.927734 -0.488282
v 0.258789 11.023438 -0.433106
v -0.208984 11.023438 0.413085
v -0.258789 11.023438 0.413085
v -0.208984 11.023438 -0.433106
v -0.258789 11.023438 -0.433106
v -0.258789 11.023438 0.413085
v -0.258789 10.927734 0.468261
v -0.258789 11.023438 -0.433106
v -0.258789 10.927734 -0.488282
v -0.208984 10.927734 0.468261
v -0.208984 11.023438 0.413085
v -0.208984 10.927734 -0.488282
v -0.208984 11.023438 -0.433106
v -0.423340 11.023438 0.249023
v -0.423340 11.023438 0.198730
v 0.423340 11.023438 0.249023
v 0.423340 11.023438 0.198730
v -0.423340 11.023438 0.198730
v -0.478516 10.927734 0.198730
v 0.423340 11.023438 0.198730
v 0.478516 10.927734 0.198730
v -0.478516 10.927734 0.249023
v -0.423340 11.023438 0.249023
v 0.478516 10.927734 0.249023
v 0.423340 11.023438 0.249023
v -0.423340 11.023438 -0.218751
v -0.423340 11.023438 -0.269044
v 0.423340 11.023438 -0.218751
v 0.423340 11.023438 -0.269044
v -0.423340 11.023438 -0.269044
v -0.478516 10.927734 -0.269044
v 0.423340 11.023438 -0.269044
v 0.478516 10.927734 -0.269044
v -0.478516 10.927734 -0.218751
v -0.423340 11.023438 -0.218751
v 0.478516 10.927734 -0.218751
v 0.423340 11.023438 -0.218751
v -2.616699 9.216797 -0.743653
v -2.589355 9.301758 -0.743653
v -2.616699 9.216797 0.763671
v -2.589355 9.301758 0.763671
v -2.427246 9.249023 -0.842286
v -2.427246 9.249023 0.862304
v -2.589355 9.301758 -0.743653
v -2.589355 9.301758 0.763671
v -2.454590 9.164062 -0.842286
v -2.616699 9.216797 -0.743653
v -2.454590 9.164062 0.862304
v -2.616699 9.216797 0.763671
v -2.498535 9.580078 -0.451661
v -2.498535 9.580078 -0.362305
v -2.965820 8.145996 -0.451661
v -2.965820 8.145996 -0.362305
v -2.498535 9.580078 -0.362305
v -2.306152 9.620605 -0.362305
v -2.965820 8.145996 -0.362305
v -2.834473 7.999512 -0.362305
v -2.498535 9.580078 -0.451661
v -2.965820 8.145996 -0.451661
v -2.306152 9.620605 -0.451661
v -2.834473 7.999512 -0.451661
v -2.498535 9.580078 0.381835
v -2.498535 9.580078 0.471191
v -2.965820 8.145996 0.381835
v -2.965820 8.145996 0.471191
v -2.498535 9.580078 0.471191
v -2.306152 9.620605 0.471191
v -2.965820 8.145996 0.471191
v -2.834473 7.999512 0.471191
v -2.498535 9.580078 0.381835
v -2.965820 8.145996 0.381835
v -2.306152 9.620605 0.381835
v -2.834473 7.999512 0.381835
v -2.875488 8.423828 -0.743653
v -2.847656 8.508789 -0.743653
v -2.875488 8.423828 0.763671
v -2.847656 8.508789 0.763671
v -2.685547 8.456055 -0.842286
v -2.685547 8.456055 0.862304
v -2.847656 8.508789 -0.743653
v -2.847656 8.508789 0.763671
v -2.713379 8.371094 -0.842286
v -2.875488 8.423828 -0.743653
v -2.713379 8.371094 0.862304
v -2.875488 8.423828 0.763671
v -2.073242 9.500488 -0.000001
v -2.496582 9.638184 -0.000001
v -2.203613 9.099609 0.729980
v -2.626953 9.237305 0.729980
v -2.464844 8.297363 0.729980
v -2.888672 8.435547 0.729980
v -3.019043 8.034668 -0.000001
v -2.595703 7.896484 -0.000001
v -2.464844 8.297363 -0.729981
v -2.888672 8.435547 -0.729981
v -2.626953 9.237305 -0.729981
v -2.203613 9.099609 -0.729981
v -2.496582 9.638184 -0.000001
v -2.073242 9.500488 -0.000001
v -2.384277 9.981934 -0.000001
v -2.571289 9.409180 1.042968
v -2.496582 9.638184 -0.000001
v -2.626953 9.237305 0.729980
v -2.944336 8.263672 1.042968
v -2.888672 8.435547 0.729980
v -3.019043 8.034668 -0.000001
v -3.130859 7.690918 -0.000001
v -2.944336 8.263672 -1.042969
v -2.888672 8.435547 -0.729981
v -2.626953 9.237305 -0.729981
v -2.571289 9.409180 -1.042969
v -2.496582 9.638184 -0.000001
v -2.384277 9.981934 -0.000001
v -2.384277 9.981934 -0.000001
v -1.960938 9.843750 -0.000001
v -2.571289 9.409180 1.042968
v -2.147949 9.270996 1.042968
v -2.944336 8.263672 1.042968
v -2.520996 8.125977 1.042968
v -3.130859 7.690918 -0.000001
v -2.707520 7.553223 -0.000001
v -2.520996 8.125977 -1.042969
v -2.944336 8.263672 -1.042969
v -2.147949 9.270996 -1.042969
v -2.571289 9.409180 -1.042969
v -1.960938 9.843750 -0.000001
v -2.384277 9.981934 -0.000001
v -0.002441 7.286621 2.763183
v 1.350586 7.427246 2.763183
v -0.002930 6.862793 2.763183
v 1.464355 7.033203 2.763183
v 1.913086 7.782227 2.763183
v 2.230957 7.576660 2.763183
v 2.329590 8.142578 2.763183
v 1.983398 8.178223 2.763183
v 1.899902 9.187988 2.763183
v 1.656738 8.949707 2.763183
v 0.970215 9.465332 2.763183
v 1.095703 9.812988 2.763183
v -0.011230 9.631836 2.763183
v -0.012695 10.007324 2.763183
v -1.093262 9.817383 2.763183
v -0.974609 9.467285 2.763183
v -1.904297 9.189941 2.763183
v -1.662109 8.950195 2.763183
v -2.018066 8.201172 2.763183
v -2.361328 8.206543 2.763183
v -1.955566 7.784668 2.763183
v -2.265137 7.564453 2.763183
v -1.471191 7.021484 2.763183
v -1.362793 7.415527 2.763183
v -0.002930 6.862793 2.763183
v -0.002441 7.286621 2.763183
v -0.002441 7.286621 2.763183
v -0.002441 7.286621 2.636718
v 1.350586 7.427246 2.763183
v 1.350586 7.427246 2.296386
v 1.913086 7.782227 2.763183
v 1.913086 7.782227 1.984374
v 1.983398 8.178223 2.763183
v 1.983398 8.178223 1.858886
v 1.656738 8.949707 1.864745
v 1.656738 8.949707 2.763183
v 0.970215 9.465332 1.988769
v 0.970215 9.465332 2.763183
v -0.011230 9.631836 2.031738
v -0.011230 9.631836 2.763183
v -0.974609 9.467285 2.763183
v -0.974609 9.467285 1.987792
v -1.662109 8.950195 2.763183
v -1.662109 8.950195 1.864745
v -2.018066 8.201172 2.763183
v -2.018066 8.201172 1.840820
v -1.955566 7.784668 1.954101
v -1.955566 7.784668 2.763183
v -1.362793 7.415527 2.291015
v -1.362793 7.415527 2.763183
v -0.002441 7.286621 2.636718
v -0.002441 7.286621 2.763183
v -0.002930 6.862793 2.763183
v 1.464355 7.033203 2.763183
v -0.002930 6.862793 2.444824
v 1.464355 7.033203 2.053710
v 2.230957 7.576660 2.763183
v 2.230957 7.576660 1.608886
v 2.329590 8.142578 2.763183
v 2.329590 8.142578 1.434081
v 1.899902 9.187988 1.435058
v 1.899902 9.187988 2.763183
v 1.095703 9.812988 1.594726
v 1.095703 9.812988 2.763183
v -0.012695 10.007324 1.650390
v -0.012695 10.007324 2.763183
v -1.093262 9.817383 2.763183
v -1.093262 9.817383 1.594238
v -1.904297 9.189941 2.763183
v -1.904297 9.189941 1.434081
v -2.361328 8.206543 2.763183
v -2.361328 8.206543 1.407226
v -2.265137 7.564453 1.582031
v -2.265137 7.564453 2.763183
v -1.471191 7.021484 2.047363
v -1.471191 7.021484 2.763183
v -0.002930 6.862793 2.444824
v -0.002930 6.862793 2.763183
v 2.621582 9.216797 0.743652
v 2.594238 9.301758 0.743652
v 2.621582 9.216797 -0.763673
v 2.594238 9.301758 -0.763673
v 2.432129 9.249023 0.842284
v 2.432129 9.249023 -0.862305
v 2.594238 9.301758 0.743652
v 2.594238 9.301758 -0.763673
v 2.459473 9.164062 0.842284
v 2.621582 9.216797 0.743652
v 2.459473 9.164062 -0.862305
v 2.621582 9.216797 -0.763673
v 2.503418 9.580078 0.451659
v 2.503418 9.580078 0.362304
v 2.970703 8.145996 0.451660
v 2.970703 8.145996 0.362304
v 2.503418 9.580078 0.362304
v 2.311035 9.620605 0.362304
v 2.970703 8.145996 0.362304
v 2.839355 7.999512 0.362304
v 2.503418 9.580078 0.451659
v 2.970703 8.145996 0.451660
v 2.311035 9.620605 0.451659
v 2.839355 7.999512 0.451660
v 2.503418 9.580078 -0.381837
v 2.503418 9.580078 -0.471192
v 2.970703 8.145996 -0.381837
v 2.970703 8.145996 -0.471192
v 2.503418 9.580078 -0.471192
v 2.311035 9.620605 -0.471192
v 2.970703 8.145996 -0.471192
v 2.839355 7.999512 -0.471192
v 2.503418 9.580078 -0.381837
v 2.970703 8.145996 -0.381837
v 2.311035 9.620605 -0.381837
v 2.839355 7.999512 -0.381837
v 2.880371 8.423828 0.743652
v 2.852539 8.508789 0.743652
v 2.880371 8.423828 -0.763673
v 2.852539 8.508789 -0.763673
v 2.690430 8.456055 0.842285
v 2.690430 8.456055 -0.862305
v 2.852539 8.508789 0.743652
v 2.852539 8.508789 -0.763673
v 2.718262 8.371094 0.842285
v 2.880371 8.423828 0.743652
v 2.718262 8.371094 -0.862305
v 2.880371 8.423828 -0.763673
v 2.073242 9.500488 -0.000001
v 2.496582 9.638184 -0.000001
v 2.203613 9.099609 -0.729981
v 2.626953 9.237305 -0.729981
v 2.464844 8.297363 -0.729981
v 2.888672 8.435547 -0.729981
v 3.019043 8.034668 -0.000001
v 2.595703 7.896484 -0.000001
v 2.464844 8.297363 0.729980
v 2.888672 8.435547 0.729980
v 2.626953 9.237305 0.729980
v 2.203613 9.099609 0.729980
v 2.496582 9.638184 -0.000001
v 2.073242 9.500488 -0.000001
v 2.384277 9.981934 -0.000001
v 2.571289 9.409180 -1.042969
v 2.496582 9.638184 -0.000001
v 2.626953 9.237305 -0.729981
v 2.944336 8.263672 -1.042969
v 2.888672 8.435547 -0.729981
v 3.019043 8.034668 -0.000001
v 3.130859 7.690918 -0.000001
v 2.944336 8.263672 1.042968
v 2.888672 8.435547 0.729980
v 2.626953 9.237305 0.729980
v 2.571289 9.409180 1.042968
v 2.496582 9.638184 -0.000001
v 2.384277 9.981934 -0.000001
v 2.384277 9.981934 -0.000001
v 1.960938 9.843750 -0.000001
v 2.571289 9.409180 -1.042969
v 2.147949 9.270996 -1.042969
v 2.944336 8.263672 -1.042969
v 2.520996 8.125977 -1.042969
v 3.130859 7.690918 -0.000001
v 2.707520 7.553223 -0.000001
v 2.520996 8.125977 1.042968
v 2.944336 8.263672 1.042968
v 2.147949 9.270996 1.042968
v 2.571289 9.409180 1.042968
v 1.960938 9.843750 -0.000001
v 2.384277 9.981934 -0.000001
vn 0.8519 -0.1656 -0.4969
vn 0.8573 -0.1652 0.4876
vn -0.1499 0.4734 0.8680
vn -0.3068 0.9518 -0.0000
vn -0.0000 -0.1657 0.9862
vn -0.8519 -0.1656 0.4969
vn -0.8518 -0.1656 0.4969
vn -0.8573 -0.1652 -0.4876
vn 0.1499 -0.4734 0.8680
vn -0.0000 -0.1657 -0.9862
vn 0.8518 -0.1656 -0.4969
vn -0.0000 1.0000 -0.0000
vn 0.3068 -0.9518 -0.0000
vn -0.8424 0.2283 0.4881
vn -0.8456 0.2292 -0.4821
vn 0.1500 -0.4735 -0.8680
vn -0.0000 0.2277 -0.9737
vn -0.0000 0.2278 -0.9737
vn 0.8424 0.2283 -0.4881
vn 0.8456 0.2292 0.4821
vn -0.0000 0.2277 0.9737
vn -0.0000 0.2278 0.9737
vn -0.0000 -0.0000 1.0000
vn -1.0000 -0.0000 -0.0000
vn 1.0000 -0.0000 -0.0000
vn -0.0000 -1.0000 -0.0000
vn -0.0000 -0.0000 -1.0000
vn -0.9518 0.3067 -0.0000
vn 0.3068 0.9518 -0.0000
vn 0.3067 0.9518 -0.0000
vn -0.3068 -0.9518 -0.0000
vn -0.3067 -0.9518 -0.0000
vn -0.9518 0.3068 -0.0000
vn -0.3093 -0.9510 -0.0000
vn -0.1499 -0.4734 -0.8680
vn 0.1499 0.4734 -0.8680
vn -0.1499 0.4734 -0.8680
vn 0.1499 0.4734 0.8680
vn -0.1499 -0.4734 0.8680
vn 0.9518 0.3068 -0.0000
vn -0.1500 -0.4735 0.8680
vn -0.3233 0.9463 -0.0000
vn -0.8284 0.5601 -0.0000
vn -0.9939 -0.1104 -0.0000
vn -0.7885 -0.6150 -0.0001
vn -0.7884 -0.6152 -0.0000
vn -0.3930 -0.9196 -0.0000
vn -0.0001 -1.0000 -0.0000
vn 0.3930 -0.9196 -0.0000
vn 0.3930 -0.9195 -0.0000
vn 0.7746 -0.6325 -0.0000
vn 0.7747 -0.6324 -0.0000
vn 0.9899 -0.1414 -0.0000
vn 0.9900 -0.1414 -0.0000
vn 0.8345 0.5510 -0.0000
vn 0.8344 0.5511 -0.0000
vn 0.3162 0.9487 -0.0000
vn 0.3163 0.9487 -0.0000
vn 0.3537 -0.9354 -0.0000
vn 0.1499 -0.4734 -0.8680
vn 0.3093 -0.9510 -0.0000
vn 0.8440 -0.5363 -0.0000
vn 0.9947 0.1026 -0.0000
vn 0.7953 0.6063 -0.0000
vn 0.7953 0.6062 -0.0000
vn 0.4025 0.9154 -0.0000
vn -0.0003 1.0000 -0.0000
vn -0.0002 1.0000 -0.0000
vn -0.4025 0.9154 -0.0000
vn -0.7817 0.6236 -0.0000
vn -0.7816 0.6237 -0.0000
vn -0.9899 0.1414 -0.0000
vn -0.8498 -0.5271 -0.0000
vn -0.8498 -0.5272 -0.0000
vn -0.3468 -0.9379 -0.0000
vn -0.3464 -0.9381 -0.0000
vn 0.9518 0.3067 -0.0000
vn -0.3067 0.9518 -0.0000
vn 0.3067 -0.9518 -0.0000
vt 0.669678 0.523682
vt 0.706604 0.486755
vt 0.679138 0.533142
vt 0.716064 0.496216
vt 0.935120 0.295105
vt 0.984192 0.245972
vt 0.992126 0.253906
vt 0.765320 0.447021
vt 0.755859 0.437500
vt 0.942993 0.302979
vt 0.805054 0.388306
vt 0.814575 0.397766
vt 0.854309 0.339050
vt 0.863770 0.348511
vt 0.886047 0.344177
vt 0.913025 0.299316
vt 0.903503 0.289795
vt 0.893921 0.352051
vt 0.952759 0.240601
vt 0.962280 0.250061
vt 0.688660 0.542664
vt 0.725586 0.505737
vt 0.836914 0.393250
vt 0.774780 0.456482
vt 0.824036 0.407227
vt 0.844849 0.401184
vt 0.873291 0.358032
vt 0.922485 0.308777
vt 0.971741 0.259583
vt 0.698120 0.552124
vt 0.735046 0.515198
vt 0.795776 0.450256
vt 0.784302 0.466003
vt 0.833557 0.416748
vt 0.882751 0.367493
vt 0.932007 0.318298
vt 0.981201 0.269043
vt 0.680115 0.534973
vt 0.974792 0.240295
vt 0.687317 0.542236
vt 0.982056 0.247498
vt 0.686523 0.552673
vt 0.992615 0.246643
vt 0.669617 0.535767
vt 0.975647 0.229736
vt 0.687134 0.541992
vt 0.981812 0.247253
vt 0.669067 0.535217
vt 0.975098 0.229187
vt 0.680359 0.535095
vt 0.975037 0.240417
vt 0.687012 0.541687
vt 0.981689 0.247070
vt 0.683594 0.533508
vt 0.970947 0.246094
vt 0.690063 0.539978
vt 0.977417 0.252563
vt 0.686218 0.551758
vt 0.989258 0.248718
vt 0.671753 0.537354
vt 0.974792 0.234314
vt 0.971130 0.267822
vt 0.698792 0.540161
vt 0.960083 0.256714
vt 0.687683 0.529053
vt 0.963684 0.239807
vt 0.670776 0.532715
vt 0.988037 0.264160
vt 0.695129 0.557068
vt 0.673828 0.524658
vt 0.722961 0.475586
vt 0.681763 0.532593
vt 0.730835 0.483521
vt 0.772034 0.426514
vt 0.779907 0.434448
vt 0.787842 0.442322
vt 0.829041 0.385315
vt 0.821106 0.377441
vt 0.746704 0.499329
vt 0.870178 0.328308
vt 0.878113 0.336243
vt 0.738770 0.491394
vt 0.927185 0.287170
vt 0.919312 0.279236
vt 0.976257 0.238098
vt 0.968384 0.230164
vt 0.689697 0.540527
vt 0.697571 0.548401
vt 0.705444 0.554871
vt 0.695496 0.544800
vt 0.731445 0.528870
vt 0.721497 0.518799
vt 0.757446 0.502869
vt 0.747498 0.492737
vt 0.764832 0.475403
vt 0.774780 0.485535
vt 0.790894 0.449402
vt 0.800842 0.459534
vt 0.826843 0.433472
vt 0.816895 0.423401
vt 0.852844 0.407471
vt 0.842896 0.397400
vt 0.868896 0.371399
vt 0.878845 0.381470
vt 0.894897 0.345337
vt 0.904846 0.355469
vt 0.930847 0.329468
vt 0.920959 0.319336
vt 0.948242 0.312073
vt 0.938293 0.302002
vt 0.964294 0.276001
vt 0.974243 0.286072
vt 0.990295 0.250000
vt 1.000244 0.260071
vt 0.696533 0.546326
vt 0.722595 0.520264
vt 0.748596 0.494263
vt 0.765991 0.476868
vt 0.791992 0.450867
vt 0.818054 0.424805
vt 0.844055 0.398804
vt 0.870117 0.372742
vt 0.896179 0.346741
vt 0.922180 0.320679
vt 0.939514 0.303345
vt 0.965576 0.277283
vt 0.991638 0.251282
vt 0.661072 0.510559
vt 0.687134 0.484497
vt 0.713135 0.458496
vt 0.730530 0.441101
vt 0.756531 0.415100
vt 0.782593 0.389038
vt 0.808594 0.363037
vt 0.834656 0.336975
vt 0.860657 0.310913
vt 0.886719 0.284912
vt 0.904053 0.267578
vt 0.930115 0.241516
vt 0.956116 0.215454
s 1
usemtl EVE_Diver
f 1/1/1 3/2/2 2/3/1
f 2/3/1 3/2/2 4/4/2
f 394/5/3 396/6/4 395/7/4
f 4/4/2 3/2/2 5/8/5
f 3/2/2 6/9/5 5/8/5
f 393/10/3 394/5/3 395/7/4
f 6/9/5 7/11/6 5/8/5
f 5/8/5 7/11/6 8/12/7
f 7/11/6 9/13/8 8/12/7
f 8/12/7 9/13/8 10/14/8
f 392/15/9 394/5/3 393/10/3
f 10/14/8 9/13/8 11/16/10
f 9/13/8 12/17/10 11/16/10
f 391/18/9 392/15/9 393/10/3
f 12/17/10 13/19/1 11/16/10
f 11/16/10 13/19/1 14/20/11
f 15/3/12 17/4/12 16/21/12
f 16/21/12 17/4/12 18/22/12
f 389/23/13 392/15/9 391/18/9
f 18/22/12 17/4/12 19/24/12
f 17/4/12 20/8/12 19/24/12
f 19/24/12 20/8/12 21/25/12
f 20/8/12 22/12/12 21/25/12
f 390/26/13 389/23/13 391/18/9
f 22/12/12 23/14/12 21/25/12
f 21/25/12 23/14/12 24/27/12
f 23/14/12 25/16/12 24/27/12
f 24/27/12 25/16/12 26/28/12
f 25/16/12 27/20/12 26/28/12
f 26/28/12 27/20/12 28/29/12
f 29/30/14 31/21/14 30/31/15
f 388/32/16 389/23/13 390/26/13
f 31/21/14 32/22/15 30/31/15
f 30/31/15 32/22/15 33/33/17
f 32/22/15 34/24/18 33/33/17
f 33/33/17 34/24/18 35/34/19
f 34/24/18 36/25/19 35/34/19
f 35/34/19 36/25/19 37/35/20
f 36/25/19 38/27/20 37/35/20
f 37/35/20 38/27/20 39/36/21
f 38/27/20 40/28/22 39/36/21
f 39/36/21 40/28/22 41/37/14
f 40/28/22 42/29/14 41/37/14
f 43/38/23 45/39/23 44/40/23
f 44/40/23 45/39/23 46/41/23
f 47/40/24 49/41/24 48/42/24
f 48/42/24 49/41/24 50/43/24
f 51/38/25 53/44/25 52/39/25
f 52/39/25 53/44/25 54/45/25
f 55/38/23 57/39/23 56/46/23
f 56/46/23 57/39/23 58/47/23
f 59/46/24 61/47/24 60/42/24
f 60/42/24 61/47/24 62/43/24
f 63/38/25 65/48/25 64/39/25
f 64/39/25 65/48/25 66/49/25
f 67/50/23 69/51/23 68/52/23
f 68/52/23 69/51/23 70/53/23
f 71/52/12 73/53/12 72/42/12
f 72/42/12 73/53/12 74/43/12
f 75/48/26 77/49/26 76/50/26
f 76/50/26 77/49/26 78/51/26
f 79/50/23 81/51/23 80/52/23
f 80/52/23 81/51/23 82/53/23
f 83/42/12 85/52/12 84/43/12
f 84/43/12 85/52/12 86/53/12
f 87/48/26 89/49/26 88/50/26
f 88/50/26 89/49/26 90/51/26
f 91/54/12 93/55/12 92/56/12
f 92/56/12 93/55/12 94/57/12
f 95/56/24 97/57/24 96/58/24
f 96/58/24 97/57/24 98/59/24
f 99/60/25 101/61/25 100/54/25
f 100/54/25 101/61/25 102/55/25
f 103/54/12 105/55/12 104/56/12
f 104/56/12 105/55/12 106/57/12
f 107/56/24 109/57/24 108/58/24
f 108/58/24 109/57/24 110/59/24
f 111/60/25 113/61/25 112/54/25
f 112/54/25 113/61/25 114/55/25
f 115/54/12 117/55/12 116/56/12
f 116/56/12 117/55/12 118/57/12
f 119/56/27 121/57/27 120/58/27
f 120/58/27 121/57/27 122/59/27
f 123/60/23 125/61/23 124/54/23
f 124/54/23 125/61/23 126/55/23
f 127/54/12 129/55/12 128/56/12
f 128/56/12 129/55/12 130/57/12
f 131/56/27 133/57/27 132/58/27
f 132/58/27 133/57/27 134/59/27
f 135/60/23 137/61/23 136/54/23
f 136/54/23 137/61/23 138/55/23
f 139/62/28 141/63/28 140/64/28
f 140/64/28 141/63/28 142/65/28
f 143/66/29 145/64/30 144/67/29
f 144/67/29 145/64/30 146/65/30
f 147/68/31 149/69/31 148/62/32
f 148/62/32 149/69/31 150/63/32
f 151/62/33 153/63/33 152/64/33
f 152/64/33 153/63/33 154/65/33
f 155/64/23 157/65/23 156/66/23
f 156/66/23 157/65/23 158/67/23
f 159/62/27 161/68/27 160/63/27
f 160/63/27 161/68/27 162/69/27
f 163/62/33 165/63/33 164/64/33
f 164/64/33 165/63/33 166/65/33
f 167/64/23 169/65/23 168/66/23
f 168/66/23 169/65/23 170/67/23
f 171/62/27 173/68/27 172/63/27
f 172/63/27 173/68/27 174/69/27
f 175/62/33 177/63/33 176/64/33
f 176/64/33 177/63/33 178/65/33
f 179/66/29 181/64/30 180/67/30
f 180/67/30 181/64/30 182/65/30
f 183/68/31 185/69/32 184/62/32
f 184/62/32 185/69/32 186/63/32
f 187/70/34 189/71/35 188/72/31
f 188/72/31 189/71/35 190/73/35
f 189/71/35 191/74/36 190/73/35
f 190/73/35 191/74/36 192/75/36
f 387/76/16 389/23/13 388/32/16
f 192/75/36 191/74/36 193/77/29
f 191/74/36 194/78/29 193/77/29
f 386/79/37 387/76/16 388/32/16
f 194/78/29 195/80/38 193/77/29
f 193/77/29 195/80/38 196/81/38
f 385/82/37 387/76/16 386/79/37
f 196/81/38 195/80/38 197/83/39
f 195/80/38 198/84/39 197/83/39
f 197/83/39 198/84/39 199/85/34
f 198/84/39 200/86/31 199/85/34
f 201/87/33 203/72/33 202/82/33
f 384/88/4 385/82/37 386/79/37
f 203/72/33 204/73/33 202/82/33
f 202/82/33 204/73/33 205/76/33
f 204/73/33 206/75/33 205/76/33
f 383/87/4 385/82/37 384/88/4
f 206/75/33 207/77/33 205/76/33
f 205/76/33 207/77/33 208/23/33
f 380/5/40 381/85/40 382/6/40
f 208/23/33 207/77/33 209/15/33
f 207/77/33 210/81/33 209/15/33
f 379/83/40 381/85/40 380/5/40
f 210/81/33 211/83/33 209/15/33
f 209/15/33 211/83/33 212/5/33
f 211/83/33 213/85/33 212/5/33
f 212/5/33 213/85/33 214/6/33
f 215/87/29 217/82/38 216/88/29
f 216/88/29 217/82/38 218/79/38
f 217/82/38 219/76/41 218/79/38
f 218/79/38 219/76/41 220/32/41
f 219/76/41 221/23/31 220/32/41
f 220/32/41 221/23/31 222/26/31
f 377/15/40 379/83/40 380/5/40
f 222/26/31 221/23/31 223/18/35
f 221/23/31 224/15/35 223/18/35
f 223/18/35 224/15/35 225/10/36
f 224/15/35 226/5/36 225/10/36
f 225/10/36 226/5/36 227/7/29
f 226/5/36 228/6/29 227/7/29
f 229/89/23 231/90/23 230/91/23
f 378/81/40 379/83/40 377/15/40
f 231/90/23 232/92/23 230/91/23
f 230/91/23 232/92/23 233/93/23
f 232/92/23 234/94/23 233/93/23
f 375/77/40 378/81/40 377/15/40
f 234/94/23 235/95/23 233/93/23
f 233/93/23 235/95/23 236/96/23
f 235/95/23 237/97/23 236/96/23
f 236/96/23 237/97/23 238/98/23
f 376/23/40 375/77/40 377/15/40
f 238/98/23 237/97/23 239/99/23
f 237/97/23 240/100/23 239/99/23
f 239/99/23 240/100/23 241/101/23
f 240/100/23 242/102/23 241/101/23
f 373/76/40 375/77/40 376/23/40
f 242/102/23 243/103/23 241/101/23
f 241/101/23 243/103/23 244/104/23
f 243/103/23 245/105/23 244/104/23
f 244/104/23 245/105/23 246/106/23
f 374/75/40 375/77/40 373/76/40
f 246/106/23 245/105/23 247/107/23
f 245/105/23 248/108/23 247/107/23
f 247/107/23 248/108/23 249/109/23
f 248/108/23 250/110/23 249/109/23
f 372/73/40 374/75/40 373/76/40
f 250/110/23 251/111/23 249/109/23
f 249/109/23 251/111/23 252/112/23
f 251/111/23 253/113/23 252/112/23
f 252/112/23 253/113/23 254/114/23
f 255/89/12 257/91/42 256/115/12
f 256/115/12 257/91/42 258/116/42
f 257/91/42 259/93/43 258/116/42
f 258/116/42 259/93/43 260/117/43
f 259/93/43 261/96/44 260/117/43
f 260/117/43 261/96/44 262/118/44
f 370/82/40 372/73/40 373/76/40
f 262/118/44 261/96/44 263/119/45
f 261/96/44 264/98/46 263/119/45
f 263/119/45 264/98/46 265/120/47
f 264/98/46 266/99/47 265/120/47
f 265/120/47 266/99/47 267/121/48
f 266/99/47 268/101/26 267/121/48
f 371/72/40 372/73/40 370/82/40
f 268/101/26 269/104/49 267/121/48
f 267/121/48 269/104/49 270/122/50
f 269/104/49 271/106/51 270/122/50
f 270/122/50 271/106/51 272/123/52
f 271/106/51 273/107/53 272/123/52
f 272/123/52 273/107/53 274/124/54
f 369/87/40 371/72/40 370/82/40
f 274/124/54 273/107/53 275/125/55
f 273/107/53 276/109/56 275/125/55
f 275/125/55 276/109/56 277/126/57
f 276/109/56 278/112/58 277/126/57
f 277/126/57 278/112/58 279/127/12
f 278/112/58 280/114/12 279/127/12
f 281/90/26 283/128/26 282/92/59
f 366/84/60 368/86/13 367/85/61
f 283/128/26 284/129/59 282/92/59
f 282/92/59 284/129/59 285/94/62
f 284/129/59 286/130/62 285/94/62
f 285/94/62 286/130/62 287/95/63
f 286/130/62 288/131/63 287/95/63
f 365/83/60 366/84/60 367/85/61
f 288/131/63 289/132/64 287/95/63
f 287/95/63 289/132/64 290/97/65
f 289/132/64 291/133/66 290/97/65
f 290/97/65 291/133/66 292/100/66
f 291/133/66 293/134/67 292/100/66
f 292/100/66 293/134/67 294/102/68
f 363/80/37 366/84/60 365/83/60
f 294/102/68 293/134/67 295/103/69
f 293/134/67 296/135/69 295/103/69
f 295/103/69 296/135/69 297/105/70
f 296/135/69 298/136/71 297/105/70
f 297/105/70 298/136/71 299/108/72
f 298/136/71 300/137/72 299/108/72
f 364/81/37 363/80/37 365/83/60
f 300/137/72 301/138/73 299/108/72
f 299/108/72 301/138/73 302/110/74
f 301/138/73 303/139/75 302/110/74
f 302/110/74 303/139/75 304/111/76
f 303/139/75 305/140/26 304/111/76
f 304/111/76 305/140/26 306/113/26
f 307/62/77 309/63/77 308/64/77
f 308/64/77 309/63/77 310/65/77
f 311/66/4 313/64/78 312/67/78
f 312/67/78 313/64/78 314/65/4
f 315/68/13 317/69/79 316/62/79
f 316/62/79 317/69/79 318/63/13
f 319/62/40 321/63/40 320/64/40
f 320/64/40 321/63/40 322/65/40
f 323/64/27 325/65/27 324/66/27
f 324/66/27 325/65/27 326/67/27
f 327/62/23 329/68/23 328/63/23
f 328/63/23 329/68/23 330/69/23
f 331/62/40 333/63/40 332/64/40
f 332/64/40 333/63/40 334/65/40
f 335/64/27 337/65/27 336/66/27
f 336/66/27 337/65/27 338/67/27
f 339/62/23 341/68/23 340/63/23
f 340/63/23 341/68/23 342/69/23
f 343/62/40 345/63/40 344/64/77
f 344/64/77 345/63/40 346/65/40
f 347/66/4 349/64/78 348/67/78
f 348/67/78 349/64/78 350/65/78
f 351/68/13 353/69/79 352/62/79
f 352/62/79 353/69/79 354/63/79
f 355/70/61 357/71/9 356/72/13
f 356/72/13 357/71/9 358/73/9
f 357/71/9 359/74/3 358/73/9
f 358/73/9 359/74/3 360/75/3
f 361/77/4 363/80/37 364/81/37
f 360/75/3 359/74/3 361/77/4
f 359/74/3 362/78/4 361/77/4
f 362/78/4 363/80/37 361/77/4
-12
View File
@@ -1,12 +0,0 @@
# Blender 4.5.5 LTS MTL File: 'None'
# www.blender.org
newmtl EVE_Xmas
Ns 0.000000
Ka 1.000000 1.000000 1.000000
Ks 0.000000 0.000000 0.000000
Ke 0.000000 0.000000 0.000000
Ni 1.500000
illum 1
map_Kd tex1_128x128_m_f5b8cc0e1394df76_14.png
map_d tex1_128x128_m_47fba0aeb06eb3d7_0.png
File diff suppressed because it is too large. Load diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 651 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 592 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 428 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

+120 -57
View File
@@ -1,83 +1,146 @@
# 🚀 EveFlow — Compagnon de Bureau Rétro-Futuriste 3D
# EveFlow 2 — Interface vocale JARVIS pour Hermes Agent
[![Windows Version](https://img.shields.io/badge/OS-Windows-blue.svg?style=flat-square&logo=windows)](https://github.com/R0m1k3/EveFlow)
[![Version](https://img.shields.io/badge/version-1.0.7-brightgreen.svg?style=flat-square)](https://github.com/R0m1k3/EveFlow/releases/tag/v1.0.7)
[![Build](https://img.shields.io/github/actions/workflow/status/R0m1k3/EveFlow/windows-release.yml?style=flat-square)](https://github.com/R0m1k3/EveFlow/actions)
[![Version](https://img.shields.io/badge/version-2.5.0-brightgreen.svg?style=flat-square)](https://github.com/R0m1k3/EveFlow/releases)
[![License](https://img.shields.io/badge/license-MIT-lightgrey.svg?style=flat-square)](LICENSE)
**EveFlow** est un compagnon de bureau Windows immersif haut de gamme, combinant une esthétique cyberpunk rétro-futuriste soignée et des technologies d'intelligence artificielle avancées. Il intègre un assistant virtuel en 3D nommé **Eve**, animé en temps réel avec des expressions émotionnelles dynamiques et synchronisé avec des services de synthèse vocale (TTS/STT) locaux et des architectures multi-agents.
**EveFlow** est un compagnon de bureau Windows qui transforme [Hermes Agent](https://hermes-agent.nousresearch.com/) en assistant vocal à la JARVIS : un noyau holographique réactif au son, une conversation en streaming, les outils, sous-agents, approbations, crons, skills et sessions d'Hermes pilotés depuis un seul HUD.
La version 2 est une réécriture complète : plus de robot 3D, un pipeline vocal fiable (AudioWorklet + détection d'activité vocale), un client Hermes qui exploite l'API serveur complète (runs SSE, sessions, jobs, capabilities) et une architecture Electron sécurisée (sandbox, `webSecurity` actif, réseau proxifié par le processus principal).
---
## 📸 Aperçu de l'Interface
## Fonctionnalités
Découvrez le cockpit de contrôle immersif et le mode widget compact d'EveFlow :
### Noyau JARVIS
* Arc reactor rendu en Canvas 2D : anneaux gradués, arcs segmentés et spectre radial calculé sur le **vrai signal audio** (voix synthétisée ou microphone).
* États visuels : veille, écoute, analyse (outils en cours), transmission, attention (approbation), anomalie, succès.
* Quatre thèmes (arc cyan, gold, crimson, emerald), mode animations réduites.
* Mode compact flottant toujours au premier plan, opacité réglable.
### Voix
* **Capture micro** via AudioWorklet à 16 kHz, sans monitoring du micro dans les haut-parleurs, avec annulation d'écho et réduction de bruit.
* **Détection d'activité vocale** (seuil adaptatif, sensibilité et silence de fin réglables) : l'enregistrement s'arrête tout seul quand vous avez fini de parler.
* **Mains libres** : le micro se réactive après chaque réponse.
* **Modèles intégrés, hors ligne** (sherpa-onnx dans un processus séparé) : reconnaissance Parakeet v3, Whisper (base, small, large-v3 turbo) ou SenseVoice, synthèse **Supertonic 3** (31 langues dont le français, cinq voix masculines et cinq féminines, 44 kHz, 129 Mo, environ 7× plus rapide que le temps réel sur 4 cœurs), Kokoro v1.0 (excellent en anglais ; en français une seule voix féminine avec accent) ou Piper (Siwis, Tom, UPMC). Les modèles se téléchargent depuis **Paramètres → Modèles locaux** et tournent sur le processeur.
* **Fin de phrase neuronale** : en écoute permanente, Silero VAD (0,6 Mo, sherpa-onnx) décide du début et de la fin de la commande à la place du seuil d'énergie ; moins de faux départs sur le bruit, coupure plus nette. Repli automatique sur le VAD énergétique si le modèle n'est pas installé.
* **Vision d'écran** : « Jarvis, regarde mon écran » (ou le bouton de la barre de commande) joint une capture de l'écran principal à la question envoyée à Hermes.
* **Actions locales instantanées** : « verrouille la session », « monte le son », « coupe le son », « piste suivante », « ouvre Spotify », « ouvre github.com »… exécutées sur le PC sans passer par Hermes, résultat lu à voix haute. Liste blanche d'actions dans le processus principal, désactivable dans les paramètres.
* **Serveur MCP intégré** : Hermes se connecte à `http://<pc>:7842/mcp` et obtient les outils du PC (capture d'écran renvoyée en image, verrouillage, applications, URL, touches média, presse-papiers, recherche de fichiers, voix, notifications, état du HUD, affichage dans le fil). Même port et même secret que le webhook ; en mode chat completions, les mêmes outils sont proposés directement au modèle.
* **Heures calmes et priorités** : plage horaire pendant laquelle les messages poussés s'affichent sans être lus ni faire clignoter le noyau (badge « non lus » à la place), thème nuit automatique, mots prioritaires lus quand même, résumé vocal des rapports longs (les premières phrases seulement).
* **Mode mission** : un bouton dans la barre de commande bascule sur un second modèle Hermes (plus puissant) pour les tâches longues ; le modèle rapide reste utilisé pour la conversation courante.
* **Widget compact « glanceable »** : état (veille, écoute, réflexion, parle), dernière phrase de l'assistant, badge de non-lus, indicateurs heures calmes et mission.
* **Voix Microsoft Edge** (moteur par défaut) : les voix neuronales de la lecture à voix haute d'Edge, gratuites, sans clé ni installation : Henri, Denise, Rémy, Vivienne, Éloise (fr-FR) et les voix fr-CA, fr-CH, fr-BE, plus de 300 voix dans 74 langues. Le rendu le plus naturel disponible ; nécessite une connexion.
* **Voix JARVIS** : deux préréglages en un clic (Paramètres → Voix) : en ligne (Edge Henri) ou hors ligne (Supertonic 3, voix masculine grave, téléchargé automatiquement), timbre « JARVIS » (légèrement plus grave et posé, chaleur, présence, courte réverbération d'intercom), débit calme.
* **Reconnaissance française de référence** : Parakeet TDT 0.6B v3 (NVIDIA NeMo, 25 langues européennes) dans le catalogue, plus précis et bien plus rapide que Whisper sur processeur, avec ponctuation. Whisper base/small/turbo restent disponibles.
* **Voix masculine ou féminine** : un réglage unique (Paramètres → Voix) appliqué à tous les moteurs. Henri / Denise sur Edge ; en local Supertonic 3 (ou Piper Tom / Pierre) pour le masculin, téléchargé automatiquement si aucune voix masculine n'est installée, et le genre est conservé quand on change de modèle ; onyx / nova pour les API compatibles OpenAI ; Paul / Hortense pour les voix Windows. Google Translate n'a qu'une voix féminine par langue : le réglage l'indique.
* **Barge-in** : en mains libres, parler par-dessus l'assistant coupe sa voix ; le seuil est relevé pendant qu'il parle pour ignorer l'écho du haut-parleur.
* **Écoute permanente** : un détecteur de mot-clé de 3 Mo (sherpa-onnx, keyword spotting) tourne en continu sur le micro, quasi gratuit en CPU. « Jarvis » (ou n'importe quel mot-clé) ouvre l'écoute, « Jarvis, allume… » envoie directement la commande, et le mot coupe la voix en cours. Alternative : filtre du mot après transcription en mains libres.
* **STT externe** : n'importe quelle API `/v1/audio/transcriptions` compatible OpenAI (Qwen3-ASR, Whisper, Speaches, faster-whisper-server, LocalAI, OpenAI). Repli sur la reconnaissance Chromium.
* **TTS externe** : API `/v1/audio/speech` compatible OpenAI (Qwen3-TTS via un serveur compatible, Kokoro-FastAPI, OpenAI, LocalAI…), voix système Windows ou Google Translate. Lecture phrase par phrase pendant le streaming, préchargement du segment suivant, coupure instantanée.
* **Traitement du micro débrayable** : l'annulation d'écho, la réduction de bruit et le gain automatique de Chromium peuvent être coupés (Paramètres → Reconnaissance) ; avec un casque, le signal brut est souvent mieux transcrit.
* Raccourcis globaux : `Ctrl+Shift+Espace` (micro), `Ctrl+Shift+J` (afficher/masquer), `Ctrl+Shift+Échap` (couper la voix).
### Hermes, toute la puissance
* Découverte automatique via `GET /v1/capabilities` et `GET /health/detailed`, choix du transport le plus riche :
1. **Runs API** (`POST /v1/runs` + `GET /v1/runs/{id}/events`) : deltas, outils, sous-agents, `approval.request`, `run.completed`, arrêt (`/stop`) et injection de consignes en cours de run (`/steer`).
2. **Sessions API** (`/api/sessions/{id}/chat/stream`) : mémoire côté serveur, fork, suppression, relecture de l'historique.
3. **Chat completions** OpenAI (`/v1/chat/completions`) avec `hermes.tool.progress`, continuité de session (`X-Hermes-Session-Id`) et outils EveFlow côté client (état du HUD, fichiers partagés, notifications).
* Mémoire longue durée via `X-Hermes-Session-Key`.
* **Approbations** d'outils affichées dans le HUD : une fois, pour la session, toujours, refuser.
* **Crons** : création en langage naturel (`every 1h`, `weekdays at 9am`, `in 30m`, expression cron), pause/reprise, exécution immédiate, édition, historique des résultats lus à voix haute.
* **Skills et toolsets** exposés par le serveur, **sessions** navigables.
* **Webhook local** (`POST http://<pc>:7842/eveflow/hook`) pour recevoir les livraisons de crons, le miroir Telegram ou n'importe quel script, avec secret optionnel.
* Images inline (URL, data URL, fichiers du dossier partagé `Documents/EveFlow_Shared`).
### Système
* Télémétrie réelle : charge CPU, mémoire, fréquence, FPS, uptime.
* Journal sur disque (`%APPDATA%/eveflow/eveflow.log`), icône de zone de notification, instance unique.
---
## ✨ Fonctionnalités Clés
## Stack
* **🤖 Avatar 3D Eve Interactif** : Un modèle 3D animé sous WebGL avec des shaders personnalisés pour ses yeux LED. Ses expressions (joie, tristesse, réflexion, colère, surprise, neutre), son clignotement de paupières et ses mouvements de bras s'adaptent de manière organique selon le contexte et la parole.
* **🌌 Interface Transparent Glassmorphic Premium** : Fenêtre sans bordure avec un effet de verre dépoli haut de gamme, s'intégrant magnifiquement à votre bureau Windows sans perturber votre espace de travail.
* **🔄 Deux Modes d'Affichage Dynamiques** :
* **Mode Cockpit** : Interface complète de discussion avec panneau de commande, historique des messages, affichage des logs et télémétrie matérielle avancée.
* **Mode Flottant (Widget)** : Une carte compacte translucide toujours au premier plan, plaçant l'avatar d'Eve directement dans le coin de votre écran pour une compagnie discrète.
* **🔌 Serveur Webhook Intégré (Hermes)** : Port d'écoute webhook local (`7842`) permettant la réception d'événements et de notifications poussés en temps réel par des agents tiers (comme Telegram ou des scripts d'automatisation).
* **🖥️ Télémétrie CPU en Temps Réel** : Suivi des performances de votre ordinateur (fréquence CPU et température thermique calculée selon la charge réelle) directement intégré sous forme d'afficheurs analogiques virtuels futuristes.
* **🔒 Sécurité Zero Trust** : Isolation du contexte Chromium activée (`contextIsolation`), interdiction de l'intégration directe de Node dans le renderer et vérification stricte des extensions de fichiers lors des opérations de lecture/écriture de fichiers partagés via IPC.
* Electron 44 (sandbox, contextIsolation, `webSecurity` actif, proxy HTTP en streaming dans le main process)
* React 19 + Vite 8 + TypeScript 5.9 + Zustand
* Canvas 2D, Web Audio (AudioWorklet, AnalyserNode)
* sherpa-onnx (ONNX Runtime) pour la voix locale, exécuté dans un `utilityProcess` Electron
* Vitest pour les tests unitaires (SSE, VAD, WAV, normalisation d'événements Hermes)
```
electron/ processus principal (fenêtre, tray, raccourcis, IPC, webhook, proxy HTTP)
electron/voice catalogue de modèles, téléchargement, worker sherpa-onnx (STT/TTS)
shared/ contrat IPC + normalisation des pushs webhook (main + renderer)
src/lib transport, SSE, persistance, utilitaires texte
src/services hermes/ (client, événements, outils locaux) voice/ (capture, VAD, STT, TTS)
src/state stores Zustand (settings, chat, hermes, voice)
src/components hud/ chat/ panels/ settings/ compact/
tests/ vitest
```
---
## 🛠️ Stack Technique
## Prérequis côté Hermes
* **Framework Applicatif** : [Electron](https://www.electronjs.org/) (Zéro Trust)
* **Frontend** : [React](https://react.dev/) + [Vite](https://vitejs.dev/) + [TypeScript](https://www.typescriptlang.org/)
* **Moteur 3D** : [Three.js](https://threejs.org/) via [@react-three/fiber](https://github.com/pmndrs/react-three-fiber) et [@react-three/drei](https://github.com/pmndrs/drei)
* **Serveur Webhook** : Node.js `http` (Port `7842`)
Activez le serveur API dans la configuration Hermes (`~/.hermes/config.yaml`) ou via l'environnement :
```
API_SERVER_ENABLED=true
API_SERVER_PORT=8642
API_SERVER_KEY=<votre clé>
```
Puis lancez `hermes gateway`. Renseignez l'URL (`http://<hôte>:8642`) et la clé dans **Paramètres → Hermes** et cliquez **Tester la liaison**. Le transport choisi apparaît dans la barre supérieure.
Pour recevoir les résultats de crons ou le miroir d'autres canaux dans EveFlow, faites pointer une livraison Hermes (script, webhook, `deliver`) vers `http://<ip-du-pc>:7842/eveflow/hook` avec un JSON tel que :
```json
{ "role": "assistant", "text": "Rapport terminé", "source": "telegram" }
{ "event": "run.completed", "input": "question", "output": "réponse" }
{ "event": "job.completed", "job": { "name": "Rapport" }, "output": "…", "status": "ok" }
```
---
## 🚀 Installation & Lancement en Développement
### Donner à Hermes les outils du PC (MCP)
### Prérequis
* [Node.js](https://nodejs.org/) (Version 20 recommandée)
* [Git](https://git-scm.com/)
Dans `~/.hermes/config.yaml` côté Hermes :
### Étapes d'installation
1. **Cloner le dépôt** :
```bash
git clone https://github.com/R0m1k3/EveFlow.git
cd EveFlow
```
2. **Installer les dépendances** :
```bash
npm install
```
3. **Lancer l'application en mode développement** :
```bash
npm start
```
*Cette commande démarre le serveur de développement Vite à l'adresse `http://localhost:5173` puis lance Electron en mode connecté.*
```yaml
mcp_servers:
eveflow:
url: "http://<ip-du-pc>:7842/mcp"
headers:
Authorization: "Bearer <secret du webhook EveFlow>"
```
Sans secret, EveFlow n'écoute qu'en local (`127.0.0.1`) ; définissez un secret dans Paramètres → Webhook pour un Hermes distant. Outils exposés : `capture_screen`, `lock_session`, `open_app`, `open_url`, `media_key`, `clipboard_get`, `clipboard_set`, `find_files`, `speak_text`, `notify_user`, `set_hud_state`, `get_app_status`, `get_conversation_history`, `show_message`.
### L'URL répond par une page web ?
Si « Tester la liaison » signale « Le serveur renvoie une page web … au lieu de l'API Hermes », l'URL saisie pointe vers un portail (page de connexion, tableau de bord) et non vers le serveur API. EveFlow cherche alors automatiquement l'API sur le même hôte (port 8642, chemins `/api` et `/v1`, sous-domaines `api.` ou `hermes.`) et corrige l'URL s'il la trouve. Sinon, ouvrez le port 8642 du serveur Hermes ou exposez-le sur un chemin dédié de votre proxy, sans authentification web devant lui (la clé `API_SERVER_KEY` suffit).
## Développement
```bash
npm install
npm start # Vite + Electron (rechargement à chaud du renderer)
npm test # tests unitaires
npm run typecheck # renderer + main process
```
Sans Electron, `npm run dev` puis `http://127.0.0.1:5173/` (ou `?mode=compact`) permet de travailler l'interface dans un navigateur ; les appels réseau passent alors directement par `fetch` (CORS requis côté serveur).
## Packaging Windows et versions
```bash
npm run dist
```
L'installateur NSIS est produit dans `out/`. Le numéro de version publié est `releaseVersion` dans `package.json` (quatre chiffres, ex. `2.0.0.1` : le quatrième chiffre sert aux petits correctifs). `version` reste la base à trois chiffres exigée par electron-builder. À chaque fusion sur master, le workflow GitHub Actions crée le tag et la release Windows si ce numéro n'a pas encore été publié.
---
## 📦 Compilation & Packaging (Production)
## Licence
Pour générer l'exécutable d'installation Windows (`.exe`) autonome :
1. **Compiler le bundle frontend** :
```bash
npm run build
```
2. **Générer le package d'installation Windows** :
```bash
npm run dist
```
*L'exécutable d'installation NSIS (`EveFlow Setup 1.0.7.exe`) et la build décompressée seront générés dans le dossier `./out/`.*
---
## 📜 Licence
Ce projet est sous licence MIT. Consultez le fichier [LICENSE](LICENSE) pour plus de détails.
MIT.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

+91
View File
@@ -0,0 +1,91 @@
# Feuille de route : vers un vrai JARVIS
État au 3 septembre 2026, après la revue complète du code (trois audits : processus principal, services/état, interface) et le test de bout en bout de l'application Electron sous Xvfb avec les vrais modèles.
## Ce qui existe déjà
| Capacité | État | Notes |
|---|---|---|
| HUD arc-reactor réactif au son | Fait | Canvas 2D optimisé (pas d'ombres, couleurs en cache, 30 fps en veille, arrêt fenêtre masquée) |
| Reconnaissance vocale locale | Fait | Whisper base/small/turbo via sherpa-onnx dans un processus utilitaire |
| Synthèse vocale locale | Fait (2.5.0) | Supertonic 3 (31 langues, 5 voix masculines + 5 féminines, 44 kHz), Kokoro v1.0 (anglais ; français féminin avec accent) et Piper fr |
| Synthèse vocale en ligne | Fait (2.5.0) | Voix neuronales Microsoft Edge (Henri, Denise, Rémy, Vivienne…), gratuites, sans clé, via WebSocket signé dans le processus principal |
| Mot d'activation permanent | Fait (2.2.0) | Keyword spotting sherpa-onnx en continu ; mot-clé libre encodé en BPE ; validé sur audio réel (détection, zéro faux positif sur le test anglais) |
| Mot d'activation après transcription | Fait | Filtre « Jarvis … » en mains libres, tolérant aux erreurs de transcription |
| Détection de fin de phrase | Fait (2.3.0) | Silero VAD neuronal dans le worker (segment renvoyé au renderer), repli sur le VAD énergétique si le modèle manque |
| Vision d'écran | Fait (2.3.0) | Capture `desktopCapturer` jointe à la requête Hermes (bouton, ou « regarde mon écran ») |
| Serveur MCP local (outils du PC pour Hermes) | Fait (2.4.0) | `/mcp` sur le serveur webhook, JSON-RPC Streamable HTTP, 14 outils dont la capture d'écran renvoyée en image |
| Heures calmes, priorités, résumé vocal | Fait (2.4.0) | Paramètres → Notifications ; thème nuit automatique ; badge non-lus |
| Mode mission (second modèle) | Fait (2.4.0) | Bouton dans la barre de commande ; modèle dédié aux tâches longues |
| Widget compact glanceable | Fait (2.4.0) | État, dernière phrase, non-lus, indicateurs |
| Barge-in | Fait (2.4.0) | Coupe la voix dès que l'utilisateur parle ; seuil relevé pendant la synthèse (à valider avec l'annulation d'écho Windows) |
| Actions système locales | Fait (2.3.0) | Verrouillage, volume et touches média, ouvrir une application ou une URL, presse-papiers, recherche de fichiers ; intentions courtes exécutées sans passer par Hermes |
| Hermes : runs, sessions, chat completions | Fait | Transport choisi selon `/v1/capabilities` |
| Approbations, steer, stop | Fait | Modales, injection de consigne en cours de run |
| Crons, skills, toolsets, sessions | Fait | Panneau Hermes Ops |
| Webhook local (Telegram, crons) | Fait | Loopback par défaut, secret pour le LAN |
| Télémétrie réelle | Fait | CPU, RAM, fréquence, FPS, uptime |
## Ce que les meilleurs projets « Jarvis » de 2026 font en plus
Sources : [jarvis-desktop-ai](https://github.com/ccarloshenri/jarvis-desktop-ai), [JarvisAi](https://github.com/PanPenek/JarvisAi), [bertrandmbanwi/Jarvis](https://github.com/bertrandmbanwi/Jarvis), [InterGenJLU/jarvis](https://github.com/InterGenJLU/jarvis), [livekit-wakeword](https://livekit.com/blog/livekit-wakeword), [sherpa-onnx keyword spotting](https://k2-fsa.github.io/sherpa/onnx/kws/index.html), [Hermes Agent features](https://hermes-agent.nousresearch.com/docs/user-guide/features/overview).
1. **Mot d'activation permanent, quasi gratuit en CPU.** Les projets de référence utilisent openWakeWord (« hey jarvis ») ou un modèle de keyword spotting qui écoute en continu, au lieu de transcrire chaque phrase. sherpa-onnx fournit un modèle KWS anglais de 3,3 Mo qui accepte n'importe quel mot-clé sans réentraînement ; « JARVIS » s'encode `▁JA R VI S` avec son modèle BPE (vérifié). Livré en 2.2.0 (voir l'étape 1 ci-dessous).
2. **VAD neuronal (Silero) au lieu du seuil d'énergie.** Fin de phrase plus nette (environ 500 ms gagnés) et beaucoup moins de faux départs sur le bruit ambiant. Silero est déjà livré dans sherpa-onnx (`silero_vad.onnx`, 0,6 Mo). Livré en 2.3.0.
3. **Latence perçue sous la seconde.** Les références visent 1 s entre la fin de parole et le premier mot prononcé : STT rapide, premier token en streaming, TTS phrase par phrase (déjà en place), et un modèle Hermes rapide pour la conversation courante.
4. **Vision d'écran.** Capture d'écran à la demande (« Jarvis, qu'est-ce que je regarde ? ») envoyée à Hermes comme image, ou lecture d'une fenêtre. Hermes accepte déjà les images inline. Livré en 2.3.0 (transport chat completions pour les images).
5. **Actions système locales.** Ouvrir une application, régler le volume, verrouiller la session, chercher un fichier. Livré en 2.3.0 sous forme d'intentions courtes exécutées localement avant Hermes ; reste à exposer les mêmes actions à Hermes via un serveur MCP local.
6. **Proactivité.** Notifications parlées à l'arrivée d'un cron, rappel, événement webhook, avec un résumé plutôt que la lecture intégrale ; c'est en partie fait via le webhook, à enrichir avec des règles (heures calmes, priorité).
7. **Mémoire et personnalisation.** Hermes gère la mémoire longue durée (`X-Hermes-Session-Key`) ; côté EveFlow, un profil (nom, préférences de voix, style de réponse) déjà transmis dans les instructions.
## Plan proposé
### Étape 1 : écoute permanente — livrée en 2.2.0
- Le renderer garde un seul flux micro (AudioWorklet 16 kHz) et envoie des blocs de 256 ms au processus principal, qui alimente le `KeywordSpotter` sherpa-onnx dans le worker.
- Mots-clés encodés en BPE (table SentencePiece pour les mots courants, repli glouton sur le vocabulaire du modèle), sensibilité réglable (seuil 0,45 → 0,12).
- À la détection : chime, capture de la commande sur le même flux (VAD énergétique, pré-roll 400 ms), transcription locale ou API, envoi à Hermes ; retour automatique à l'écoute.
- Fin de phrase Silero livrée en 2.3.0 : le renderer envoie des trames de 128 ms au worker pendant la commande, le worker renvoie le segment WAV complet ; VAD énergétique en repli.
### Étape 2 : vision et actions locales — livrée en 2.3.0
- Capture d'écran (`desktopCapturer`, JPEG 1600 px) jointe à la requête Hermes : bouton dans la barre de commande, ou phrase « regarde mon écran… » à l'oral comme à l'écrit.
- Actions locales (processus principal, liste blanche) : verrouiller, volume/mute/lecture/piste, ouvrir une application connue ou une URL http(s), presse-papiers, recherche de fichiers dans Documents/Bureau/Téléchargements/Images.
- Routeur d'intentions FR/EN (`src/services/localCommands.ts`) exécuté avant l'envoi à Hermes ; désactivable dans Paramètres → Micro. Les phrases composées (« ouvre X puis… ») partent à Hermes.
- Exposition à Hermes via le serveur MCP local livrée en 2.4.0.
### Étape 3 : conversation plus naturelle — livrée en 2.4.0
- Barge-in : l'utilisateur qui parle par-dessus coupe la voix ; seuil d'énergie relevé pendant la synthèse pour ignorer l'écho (à valider sur Windows avec l'annulation d'écho du micro).
- Mode mission : second modèle Hermes choisi d'un clic pour les tâches longues.
- Serveur MCP local : Hermes enchaîne lui-même les actions du PC dans ses runs.
### Étape 4 : présence — livrée en 2.4.0
- Widget compact glanceable : état, dernière phrase, non-lus, indicateurs heures calmes / mission.
- Heures calmes, mots prioritaires, résumé vocal des messages entrants, thème nuit.
### Pistes suivantes
- Voix différente par contexte (nuit, travail) et profils de réponse.
- Mémoire locale des préférences transmise à Hermes (`X-Hermes-Session-Key` déjà en place).
- Validation du barge-in et des touches média sur Windows réel (retours utilisateurs).
## Résultats du test de bout en bout (Linux, Xvfb, 4 cœurs lents)
| Étape | Résultat |
|---|---|
| Pont Electron, télémétrie, webhook | OK |
| Conversation via runs (streaming, outils, sous-agent) | OK |
| Kokoro (fr) → Whisper base, phrase courte de 2 s | synthèse 2,9 s, transcription 2,5 s, texte approximatif |
| Kokoro (fr) → Whisper base, phrase de 7 s | transcription correcte à un mot près |
| Piper (fr) → Whisper base | transcription exacte |
| Silero VAD sur phrase Kokoro de 2,3 s (2.3.0) | un seul segment, début et fin détectés, 6,7 s d'audio traités en 190 ms |
| Capture d'écran → Hermes (2.3.0) | JPEG de 107 ko reçu côté Hermes (mock chat completions) |
| Intention locale « coupe le son » (2.3.0) | traitée sans Hermes, résultat affiché dans le fil |
| Voix JARVIS et Parakeet (2.4.1) | timbre JARVIS (Web Audio : pitch, EQ, compression, réverbération courte), préréglage voix masculine française, Parakeet TDT v3 pour le français, worklets audio livrés en fichiers statiques (l'écoute permanente échouait sous la CSP stricte en version installée), rembourrage de silence avant la reconnaissance |
| Correctif 2.4.0.3 | un portail qui laisse passer /health mais renvoie une page web sur /v1 est traité comme une liaison en échec et déclenche la recherche de l'API (sondes /v1/capabilities, /v1/models) |
| Correctif 2.4.0.2 | une page web (portail de connexion) reçue à la place de l'API Hermes est reconnue et expliquée ; l'API est recherchée automatiquement sur le même hôte (port 8642, /api, hermes.…) et l'URL corrigée |
| Correctifs 2.4.0.1 | réponse vide en chat completions désormais expliquée (JSON non streamé ou erreur HTTP 200), la liaison ne passe plus en « dégradé » quand seule l'API des crons échoue, transcriptions parasites (« (cliquant) », « *Claire* ») ignorées, préférence de voix masculine/féminine |
| Parakeet v3 vs Whisper base sur trois phrases Piper (fr) (2.4.1) | Parakeet : 3/3 exactes avec ponctuation, 0,4 à 0,5 s à chaud (6,7 s au premier appel) ; Whisper base : erreurs sur « Jarvis », « Peux-tu », 0,7 à 0,9 s |
| Worklets audio sous CSP stricte (2.4.1) | chargement des modules statiques OK dans l'application empaquetée |
| Voix françaises (2.5.0) | Edge Henri : MP3 reçu de bout en bout (32 ko pour 4 s). Supertonic 3 en français : 10 voix, RTF 0,14 sur 4 cœurs (8,7 s d'audio en 1,3 s), genres déterminés par mesure de la fréquence fondamentale (voix 0-4 : 170-210 Hz, voix 5-9 : 92-137 Hz) ; le worker compilé accepte `language` et bascule sur l'anglais pour une langue inconnue |
| Reconnaissance française : Parakeet v3 vs Qwen3-ASR 0.6B int8 (2.5.0) | Six phrases Supertonic (3,8 s) : Parakeet WER 8,5 % (erreurs surtout de forme : « 14h30 »), 384 ms par phrase ; Qwen3-ASR WER 15,3 % (« mémoires vivres »), 1 477 ms, 940 Mo. Qwen3-ASR n'est pas ajouté au catalogue |
| Serveur MCP (2.4.0) | initialize, tools/list (14 outils), tools/call côté principal (presse-papiers, capture image) et côté renderer (état, message dans le fil) |
Sur un PC à 28 cœurs les temps sont nettement plus courts. Whisper small est maintenant recommandé pour le français.
+169
View File
@@ -0,0 +1,169 @@
import { app, ipcMain, shell } from 'electron';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { IPC } from '../../shared/ipc';
import { log } from '../logger';
const READABLE_EXT: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.bmp': 'image/bmp',
'.txt': 'text/plain',
'.log': 'text/plain',
'.csv': 'text/csv',
'.json': 'application/json',
'.md': 'text/markdown',
'.xml': 'application/xml',
'.html': 'text/html'
};
/** Documents and media the assistant may open with the default application. */
const OPENABLE_EXT = new Set([
...Object.keys(READABLE_EXT),
'.pdf', '.docx', '.doc', '.xlsx', '.xls', '.pptx', '.ppt', '.odt', '.ods', '.odp', '.rtf',
'.mp3', '.wav', '.ogg', '.flac', '.mp4', '.mkv', '.webm', '.zip', '.7z', '.py', '.ts', '.tsx', '.yaml', '.yml', '.toml', '.ini'
]);
const FORBIDDEN_EXT = new Set([
'.exe', '.bat', '.cmd', '.ps1', '.vbs', '.msi', '.scr', '.pif', '.reg', '.sh', '.com', '.hta', '.vbe', '.wsf', '.lnk',
'.jar', '.dll', '.js', '.jse', '.pyw', '.msc', '.cpl', '.inf', '.url', '.scf', '.website', '.appref-ms', '.application',
'.gadget', '.psm1', '.psd1', '.ps1xml', '.wsh', '.wsc', '.sct', '.desktop', '.app', '.command', '.appimage', '.run'
]);
const WIN_RESERVED = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\..*)?$/i;
let sharedDir: string | null = null;
export function getSharedDirectory(): string {
if (sharedDir && fs.existsSync(sharedDir)) return sharedDir;
const candidates = [
path.join(os.homedir(), 'Documents', 'EveFlow_Shared'),
path.join(app.getPath('userData'), 'shared')
];
for (const dir of candidates) {
try {
fs.mkdirSync(dir, { recursive: true });
sharedDir = dir;
return dir;
} catch {
/* try next candidate */
}
}
sharedDir = app.getPath('temp');
return sharedDir;
}
function normalizeSafe(filePath: string): string {
if (!filePath || typeof filePath !== 'string' || filePath.length > 4096) throw new Error('Chemin invalide');
const normalized = path.normalize(filePath);
if (normalized.split(/[\\/]/).includes('..')) throw new Error('Traversee de repertoire refusee');
return normalized;
}
function allowedRoots(): string[] {
return [
getSharedDirectory(),
path.join(os.homedir(), 'Documents'),
path.join(os.homedir(), 'Pictures'),
path.join(os.homedir(), 'Downloads'),
path.join(os.homedir(), 'Desktop'),
app.getPath('temp')
].map((r) => path.resolve(r));
}
function isInside(root: string, target: string): boolean {
const rel = path.relative(root, target);
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
}
/** Resolves symlinks and checks the real path against the allowed roots (prefix tricks like "bob-backup" are rejected). */
function isInsideAllowedRoots(target: string): boolean {
let resolved: string;
try {
resolved = fs.realpathSync.native(target);
} catch {
return false;
}
return allowedRoots().some((root) => isInside(root, resolved));
}
function stripFileScheme(value: string): string {
if (!/^file:\/\//i.test(value)) return value;
let out = value.replace(/^file:\/\//i, '');
// file:///C:/x -> /C:/x on Windows-style paths
if (/^\/[a-zA-Z]:/.test(out)) out = out.slice(1);
try {
return decodeURIComponent(out);
} catch {
return out;
}
}
export function registerFilesIpc(): void {
ipcMain.handle(IPC.readLocalFile, async (_e, filePath: string) => {
const target = normalizeSafe(stripFileScheme(filePath));
const ext = path.extname(target).toLowerCase();
const mime = READABLE_EXT[ext];
if (!mime) throw new Error(`Type de fichier non autorise : ${ext || '(aucune extension)'}`);
if (!isInsideAllowedRoots(target)) throw new Error('Chemin hors des dossiers autorises');
const stat = await fs.promises.stat(target);
if (stat.size > 25 * 1024 * 1024) throw new Error('Fichier trop volumineux (25 Mo max)');
const content = await fs.promises.readFile(target);
return `data:${mime};base64,${content.toString('base64')}`;
});
ipcMain.handle(IPC.writeSharedFile, async (_e, filename: string, content: string, isBase64 = false) => {
if (!filename || typeof filename !== 'string') throw new Error('Nom de fichier invalide');
if (typeof content !== 'string' || content.length > 50 * 1024 * 1024) throw new Error('Contenu invalide');
const safeName = path.basename(filename);
if (
safeName !== filename ||
safeName.startsWith('.') ||
/[<>:"|?*\u0000-\u001f]/.test(safeName) ||
WIN_RESERVED.test(safeName) ||
/[. ]$/.test(safeName) ||
safeName.length > 200
) {
throw new Error('Nom de fichier non securise');
}
const ext = path.extname(safeName).toLowerCase();
if (FORBIDDEN_EXT.has(ext)) throw new Error(`Extension interdite : ${ext}`);
const target = path.join(getSharedDirectory(), safeName);
if (isBase64) {
const payload = content.replace(/^data:[^;]+;base64,/, '');
await fs.promises.writeFile(target, Buffer.from(payload, 'base64'));
} else {
await fs.promises.writeFile(target, content, 'utf8');
}
log('INFO', 'files', `shared file written: ${target}`);
return { path: target, url: `file:///${target.replace(/\\/g, '/')}` };
});
ipcMain.handle(IPC.openPath, async (_e, filePath: string) => {
const target = normalizeSafe(stripFileScheme(filePath));
if (!fs.existsSync(target)) throw new Error('Fichier introuvable');
const real = await fs.promises.realpath(target);
const stat = await fs.promises.stat(real);
const ext = path.extname(real).toLowerCase();
if (!stat.isDirectory() && (FORBIDDEN_EXT.has(ext) || !OPENABLE_EXT.has(ext))) throw new Error(`Ouverture refusee pour ${ext || 'ce fichier'}`);
// Outside the user's document folders we only reveal the file, never execute its handler.
if (!isInsideAllowedRoots(real)) {
shell.showItemInFolder(real);
return true;
}
const result = await shell.openPath(real);
if (result) throw new Error(result);
return true;
});
ipcMain.handle(IPC.showInFolder, async (_e, filePath: string) => {
const target = normalizeSafe(stripFileScheme(filePath));
shell.showItemInFolder(target);
return true;
});
}
+168
View File
@@ -0,0 +1,168 @@
/**
* HTTP proxy: the renderer never talks to the network directly (webSecurity stays ON).
* Every request to Hermes / STT / TTS endpoints flows through here, which also removes
* CORS and mixed-content restrictions for LAN services served over plain HTTP.
*/
import { ipcMain, type WebContents } from 'electron';
import {
IPC,
type HttpProxyRequest,
type HttpProxyResponse,
type HttpStreamStart,
type HttpStreamEvent
} from '../../shared/ipc';
import { log } from '../logger';
const activeStreams = new Map<string, AbortController>();
function assertUrl(url: string): URL {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new Error(`URL invalide : ${url}`);
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(`Protocole non autorise : ${parsed.protocol}`);
}
return parsed;
}
function buildInit(req: HttpProxyRequest, signal: AbortSignal): RequestInit {
const headers: Record<string, string> = { ...(req.headers ?? {}) };
let body: RequestInit['body'];
if (req.multipart) {
const form = new FormData();
for (const [k, v] of Object.entries(req.multipart.fields)) form.append(k, v);
const file = req.multipart.file;
const bytes = new Uint8Array(file.data);
form.append(req.multipart.fileField ?? 'file', new Blob([bytes], { type: file.type }), file.name);
body = form;
for (const key of Object.keys(headers)) {
if (key.toLowerCase() === 'content-type') delete headers[key];
}
} else if (req.body !== undefined) {
body = req.body;
}
return { method: req.method ?? 'GET', headers, body, signal };
}
function headersToObject(headers: Headers): Record<string, string> {
const out: Record<string, string> = {};
headers.forEach((v, k) => {
out[k.toLowerCase()] = v;
});
return out;
}
export async function proxyFetch(req: HttpProxyRequest): Promise<HttpProxyResponse> {
assertUrl(req.url);
const controller = new AbortController();
const timeoutMs = req.timeoutMs ?? 60_000;
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(req.url, buildInit(req, controller.signal));
const base = {
ok: response.ok,
status: response.status,
statusText: response.statusText,
headers: headersToObject(response.headers)
};
if (req.responseType === 'binary') {
return { ...base, binary: new Uint8Array(await response.arrayBuffer()) };
}
return { ...base, text: await response.text() };
} catch (err) {
const e = err as Error;
if (e.name === 'AbortError') throw new Error(`Delai depasse (${Math.round(timeoutMs / 1000)}s) : ${req.url}`);
throw new Error(e.message || String(err));
} finally {
clearTimeout(timer);
}
}
async function startStream(sender: WebContents, id: string, req: HttpProxyRequest): Promise<HttpStreamStart> {
assertUrl(req.url);
const controller = new AbortController();
activeStreams.set(id, controller);
const send = (event: HttpStreamEvent) => {
if (!sender.isDestroyed()) sender.send(IPC.httpStreamEvent, event);
};
let response: Response;
try {
response = await fetch(req.url, buildInit(req, controller.signal));
} catch (err) {
activeStreams.delete(id);
throw new Error((err as Error).message || String(err));
}
const start: HttpStreamStart = {
id,
ok: response.ok,
status: response.status,
statusText: response.statusText,
headers: headersToObject(response.headers)
};
const onDestroyed = () => controller.abort();
sender.once('destroyed', onDestroyed);
const pump = async () => {
try {
if (!response.body) {
send({ id, type: 'end' });
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
// Hermes runs can be long, but a totally silent stream is abandoned after the idle timeout.
const idleMs = req.timeoutMs ?? 10 * 60_000;
for (;;) {
let idleTimer: ReturnType<typeof setTimeout> | undefined;
const idle = new Promise<never>((_, reject) => {
idleTimer = setTimeout(() => reject(new Error('Flux silencieux (timeout)')), idleMs);
});
const { done, value } = await Promise.race([reader.read(), idle]);
if (idleTimer) clearTimeout(idleTimer);
if (done) break;
send({ id, type: 'chunk', text: decoder.decode(value, { stream: true }) });
}
const tail = decoder.decode();
if (tail) send({ id, type: 'chunk', text: tail });
send({ id, type: 'end' });
} catch (err) {
const e = err as Error;
if (e.name === 'AbortError') send({ id, type: 'end' });
else send({ id, type: 'error', message: e.message || String(err) });
} finally {
// Releases the socket when we bailed out early (idle timeout, renderer gone).
controller.abort();
sender.removeListener('destroyed', onDestroyed);
if (activeStreams.get(id) === controller) activeStreams.delete(id);
}
};
void pump();
return start;
}
export function abortAllStreams(): void {
for (const controller of activeStreams.values()) controller.abort();
activeStreams.clear();
}
export function registerHttpIpc(): void {
ipcMain.handle(IPC.httpFetch, (_e, req: HttpProxyRequest) => proxyFetch(req));
ipcMain.handle(IPC.httpStreamStart, (event, id: unknown, req: HttpProxyRequest) => {
if (typeof id !== 'string' || !id || id.length > 64 || activeStreams.has(id)) throw new Error('Identifiant de flux invalide');
if (!req || typeof req.url !== 'string') throw new Error('Requête invalide');
return startStream(event.sender, id, req);
});
ipcMain.on(IPC.httpStreamAbort, (_e, id: string) => {
const controller = activeStreams.get(id);
if (controller) {
controller.abort();
activeStreams.delete(id);
log('DEBUG', 'http', `stream ${id} aborted by renderer`);
}
});
}
+103
View File
@@ -0,0 +1,103 @@
import { app, ipcMain } from 'electron';
import fs from 'node:fs';
import path from 'node:path';
import { IPC } from '../../shared/ipc';
import { log } from '../logger';
type StoreData = Record<string, unknown>;
let cache: StoreData | null = null;
let dirty = false;
let timer: ReturnType<typeof setTimeout> | null = null;
let writing: Promise<void> = Promise.resolve();
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
function storePath(): string {
return path.join(app.getPath('userData'), 'userdata.json');
}
function load(): StoreData {
if (cache) return cache;
const target = storePath();
try {
cache = JSON.parse(fs.readFileSync(target, 'utf8')) as StoreData;
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
// Keep a copy of an unreadable file instead of silently overwriting it.
try {
fs.copyFileSync(target, `${target}.corrupt-${Date.now()}`);
} catch {
/* ignore */
}
log('WARN', 'store', `userdata.json unreadable, starting fresh: ${(err as Error).message}`);
}
cache = {};
}
return cache;
}
/** Debounced, asynchronous, atomic write with retries (Windows AV scanners lock files briefly). */
function schedulePersist(): void {
dirty = true;
if (!timer) timer = setTimeout(() => void flushStore(), 250);
}
export function flushStore(): Promise<void> {
if (timer) {
clearTimeout(timer);
timer = null;
}
if (!dirty) return writing;
dirty = false;
const text = JSON.stringify(cache ?? {}, null, 2);
writing = writing
.then(async () => {
const target = storePath();
const tmp = `${target}.tmp`;
await fs.promises.writeFile(tmp, text, 'utf8');
for (let attempt = 0; ; attempt++) {
try {
await fs.promises.rename(tmp, target);
return;
} catch (err) {
if (attempt >= 5) throw err;
await new Promise((r) => setTimeout(r, 100 * (attempt + 1)));
}
}
})
.catch((err) => log('ERROR', 'store', `cannot persist userdata.json: ${(err as Error).message}`));
return writing;
}
export function storeGet<T = unknown>(key: string): T | null {
const data = load();
return (data[key] as T) ?? null;
}
export function storeSet(key: string, value: unknown): void {
const data = load();
data[key] = value;
schedulePersist();
}
function validKey(key: unknown): key is string {
return typeof key === 'string' && key.length > 0 && key.length <= 128 && !FORBIDDEN_KEYS.has(key);
}
export function registerStoreIpc(): void {
ipcMain.handle(IPC.storeGet, (_e, key: unknown) => (validKey(key) ? storeGet(key) : null));
ipcMain.handle(IPC.storeSet, (_e, key: unknown, value: unknown) => {
if (!validKey(key)) throw new Error('Clé de stockage invalide');
if (typeof value === 'function' || typeof value === 'symbol') throw new Error('Valeur non sérialisable');
storeSet(key, value);
return true;
});
ipcMain.handle(IPC.storeDelete, (_e, key: unknown) => {
if (!validKey(key)) return false;
const data = load();
delete data[key];
schedulePersist();
return true;
});
}
+184
View File
@@ -0,0 +1,184 @@
/**
* Local "JARVIS" actions: screenshot for vision, and an allow-list of system actions
* (lock, open an application or URL, media keys, clipboard, file search).
*/
import { app, clipboard, desktopCapturer, ipcMain, screen, shell } from 'electron';
import { execFile } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { IPC } from '../../shared/ipc';
import type { SystemAction, SystemActionResult } from '../../shared/bridge';
import { log } from '../logger';
const MEDIA_KEYS: Record<string, number> = {
'volume-up': 175,
'volume-down': 174,
mute: 173,
'play-pause': 179,
next: 176,
previous: 177
};
/** Applications the assistant may launch by name (Windows aliases + common Linux/macOS names). */
const APP_ALIASES: Record<string, string[]> = {
'bloc-notes': ['notepad.exe', 'gedit', 'TextEdit'],
notepad: ['notepad.exe', 'gedit', 'TextEdit'],
calculatrice: ['calc.exe', 'gnome-calculator', 'Calculator'],
calc: ['calc.exe', 'gnome-calculator', 'Calculator'],
explorateur: ['explorer.exe', 'nautilus', 'Finder'],
explorer: ['explorer.exe', 'nautilus', 'Finder'],
terminal: ['wt.exe', 'cmd.exe', 'gnome-terminal', 'Terminal'],
cmd: ['cmd.exe'],
powershell: ['powershell.exe'],
paint: ['mspaint.exe'],
chrome: ['chrome', 'google-chrome', 'Google Chrome'],
edge: ['msedge', 'microsoft-edge', 'Microsoft Edge'],
firefox: ['firefox', 'Firefox'],
vscode: ['code', 'Visual Studio Code'],
code: ['code', 'Visual Studio Code'],
spotify: ['spotify', 'Spotify'],
discord: ['discord', 'Discord'],
steam: ['steam', 'Steam'],
word: ['winword', 'Microsoft Word'],
excel: ['excel', 'Microsoft Excel'],
outlook: ['outlook', 'Microsoft Outlook'],
teams: ['ms-teams', 'Microsoft Teams'],
'task manager': ['taskmgr.exe'],
'gestionnaire des tâches': ['taskmgr.exe'],
paramètres: ['ms-settings:'],
settings: ['ms-settings:']
};
function run(cmd: string, args: string[], timeoutMs = 8000): Promise<{ code: number; out: string }> {
return new Promise((resolve) => {
execFile(cmd, args, { timeout: timeoutMs, windowsHide: true }, (err, stdout, stderr) => {
resolve({ code: err ? 1 : 0, out: `${stdout}${stderr}`.trim() });
});
});
}
async function openApp(name: string): Promise<SystemActionResult> {
const key = name.trim().toLowerCase();
if (!key || key.length > 40) return { ok: false, message: 'Nom d’application invalide' };
const candidates = APP_ALIASES[key] ?? [key.replace(/[^a-z0-9 ._-]/gi, '')];
if (process.platform === 'win32') {
for (const candidate of candidates) {
if (candidate.endsWith(':')) {
await shell.openExternal(candidate);
return { ok: true, message: `${name} ouvert` };
}
// `start` resolves App Paths, PATH and Start Menu names.
const result = await run('cmd.exe', ['/c', 'start', '', candidate]);
if (result.code === 0) return { ok: true, message: `${name} lancé` };
}
return { ok: false, message: `Impossible de lancer ${name}` };
}
for (const candidate of candidates) {
const result = process.platform === 'darwin' ? await run('open', ['-a', candidate]) : await run('sh', ['-c', `command -v ${JSON.stringify(candidate)} >/dev/null && (nohup ${JSON.stringify(candidate)} >/dev/null 2>&1 &)`]);
if (result.code === 0) return { ok: true, message: `${name} lancé` };
}
return { ok: false, message: `Application introuvable : ${name}` };
}
async function lockSession(): Promise<SystemActionResult> {
if (process.platform === 'win32') {
const r = await run('rundll32.exe', ['user32.dll,LockWorkStation']);
return { ok: r.code === 0, message: r.code === 0 ? 'Session verrouillée' : r.out };
}
if (process.platform === 'darwin') {
const r = await run('osascript', ['-e', 'tell application "System Events" to keystroke "q" using {command down, control down}']);
return { ok: r.code === 0, message: r.out };
}
const r = await run('sh', ['-c', 'loginctl lock-session || xdg-screensaver lock || gnome-screensaver-command -l']);
return { ok: r.code === 0, message: r.code === 0 ? 'Session verrouillée' : r.out };
}
async function mediaKey(key: string): Promise<SystemActionResult> {
const code = MEDIA_KEYS[key];
if (!code) return { ok: false, message: 'Touche inconnue' };
if (process.platform === 'win32') {
const script = `$s=Add-Type -MemberDefinition '[DllImport("user32.dll")] public static extern void keybd_event(byte b,byte s,uint f,UIntPtr e);' -Name K -Namespace W -PassThru; $s::keybd_event(${code},0,0,[UIntPtr]::Zero); $s::keybd_event(${code},0,2,[UIntPtr]::Zero)`;
const r = await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script]);
return { ok: r.code === 0, message: r.code === 0 ? key : r.out };
}
const xdo: Record<string, string> = { 'volume-up': 'XF86AudioRaiseVolume', 'volume-down': 'XF86AudioLowerVolume', mute: 'XF86AudioMute', 'play-pause': 'XF86AudioPlay', next: 'XF86AudioNext', previous: 'XF86AudioPrev' };
const r = await run('sh', ['-c', `xdotool key ${xdo[key]}`]);
return { ok: r.code === 0, message: r.code === 0 ? key : 'xdotool indisponible' };
}
async function findFiles(query: string): Promise<SystemActionResult> {
const needle = query.trim().toLowerCase();
if (needle.length < 2) return { ok: false, message: 'Recherche trop courte' };
const roots = ['Documents', 'Desktop', 'Downloads', 'Pictures'].map((d) => path.join(os.homedir(), d));
const hits: string[] = [];
const deadline = Date.now() + 4000;
const walk = async (dir: string, depth: number) => {
if (depth > 4 || hits.length >= 25 || Date.now() > deadline) return;
let entries: fs.Dirent[] = [];
try {
entries = await fs.promises.readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
const full = path.join(dir, entry.name);
if (entry.name.toLowerCase().includes(needle)) hits.push(full);
if (entry.isDirectory()) await walk(full, depth + 1);
if (hits.length >= 25) return;
}
};
for (const root of roots) await walk(root, 0);
return { ok: true, message: `${hits.length} résultat(s)`, data: hits };
}
export async function captureScreen(maxWidth = 1600): Promise<string> {
const display = screen.getPrimaryDisplay();
const scale = Math.min(1, maxWidth / display.size.width);
const sources = await desktopCapturer.getSources({
types: ['screen'],
thumbnailSize: { width: Math.round(display.size.width * scale), height: Math.round(display.size.height * scale) }
});
const primary = sources.find((s) => s.display_id === String(display.id)) ?? sources[0];
if (!primary) throw new Error('Aucun écran capturable');
return primary.thumbnail.toJPEG(82).toString('base64').replace(/^/, 'data:image/jpeg;base64,');
}
export async function runSystemAction(action: SystemAction): Promise<SystemActionResult> {
switch (action.type) {
case 'lock':
return lockSession();
case 'open-app':
return openApp(String(action.name ?? ''));
case 'open-url': {
const url = String(action.url ?? '');
if (!/^https?:\/\//i.test(url) || url.length > 2048) return { ok: false, message: 'URL refusée' };
await shell.openExternal(url);
return { ok: true, message: 'Ouvert dans le navigateur' };
}
case 'media':
return mediaKey(String(action.key));
case 'clipboard-read':
return { ok: true, data: (await clipboard.readText()).slice(0, 20_000) };
case 'clipboard-write':
await clipboard.writeText(String(action.text ?? '').slice(0, 100_000));
return { ok: true, message: 'Copié dans le presse-papiers' };
case 'find-files':
return findFiles(String(action.query ?? ''));
default:
return { ok: false, message: 'Action inconnue' };
}
}
export function registerSystemIpc(): void {
ipcMain.handle(IPC.screenCapture, (_e, maxWidth?: number) => captureScreen(Number(maxWidth) || 1600));
ipcMain.handle(IPC.systemAction, async (_e, action: SystemAction) => {
if (!action || typeof action.type !== 'string') throw new Error('Action invalide');
log('INFO', 'system', `action ${action.type}`);
const result = await runSystemAction(action);
if (!result.ok) log('WARN', 'system', `action ${action.type} failed: ${result.message}`);
return result;
});
void app;
}
+51
View File
@@ -0,0 +1,51 @@
import { ipcMain } from 'electron';
import os from 'node:os';
import { IPC, type SystemMetrics } from '../../shared/ipc';
interface CpuSnapshot {
idle: number;
total: number;
}
function snapshot(): CpuSnapshot {
let idle = 0;
let total = 0;
for (const cpu of os.cpus()) {
const t = cpu.times;
idle += t.idle;
total += t.user + t.nice + t.sys + t.idle + t.irq;
}
return { idle, total };
}
let last = snapshot();
function cpuLoadPct(): number {
const now = snapshot();
const idleDiff = now.idle - last.idle;
const totalDiff = now.total - last.total;
last = now;
if (totalDiff <= 0) return 0;
return Math.max(0, Math.min(100, 100 * (1 - idleDiff / totalDiff)));
}
export function collectMetrics(): SystemMetrics {
const cpus = os.cpus();
const totalMem = os.totalmem();
const freeMem = os.freemem();
return {
hostname: os.hostname().toUpperCase(),
platform: `${os.platform()} ${os.release()}`,
cpuModel: cpus[0]?.model?.trim() ?? 'CPU',
cpuCores: cpus.length,
cpuLoad: Math.round(cpuLoadPct() * 10) / 10,
cpuFreqMhz: cpus[0]?.speed ?? 0,
memUsedPct: totalMem > 0 ? Math.round(((totalMem - freeMem) / totalMem) * 1000) / 10 : 0,
memTotalGb: Math.round((totalMem / 1024 ** 3) * 10) / 10,
uptimeSec: Math.round(os.uptime())
};
}
export function registerTelemetryIpc(): void {
ipcMain.handle(IPC.metrics, () => collectMetrics());
}
+69
View File
@@ -0,0 +1,69 @@
import { app } from 'electron';
import fs from 'node:fs';
import path from 'node:path';
import type { LogEntry } from '../shared/ipc';
const MAX_LOG_BYTES = 5 * 1024 * 1024;
let stream: fs.WriteStream | null = null;
let written = 0;
export function getLogPath(): string {
return path.join(app.getPath('userData'), 'eveflow.log');
}
function output(): fs.WriteStream {
if (!stream) {
const target = getLogPath();
try {
written = fs.statSync(target).size;
} catch {
written = 0;
}
stream = fs.createWriteStream(target, { flags: 'a' });
stream.on('error', (err) => console.error('[logger] write failed:', err.message));
}
return stream;
}
function rotate(): void {
const target = getLogPath();
stream?.end();
stream = null;
try {
fs.renameSync(target, `${target}.old`);
} catch {
/* ignore */
}
written = 0;
}
export function writeLog(entry: LogEntry): void {
try {
const data = entry.data !== undefined ? ' | ' + safeJson(entry.data) : '';
const line = `[${entry.ts}] [${entry.level}] [${entry.tag}] ${entry.message}${data}\n`;
written += Buffer.byteLength(line);
if (written > MAX_LOG_BYTES) rotate();
output().write(line);
} catch (err) {
console.error('[logger] cannot write log file:', (err as Error).message);
}
}
export function log(level: LogEntry['level'], tag: string, message: string, data?: unknown): void {
const entry: LogEntry = { ts: new Date().toISOString(), level, tag, message, data };
const consoleLine = `[${tag}] ${message}`;
if (level === 'ERROR') console.error(consoleLine, data ?? '');
else if (level === 'WARN') console.warn(consoleLine, data ?? '');
else console.log(consoleLine, data ?? '');
writeLog(entry);
}
function safeJson(value: unknown): string {
try {
const text = JSON.stringify(value);
return text.length > 4000 ? text.slice(0, 4000) + '...' : text;
} catch {
return String(value);
}
}
+189
View File
@@ -0,0 +1,189 @@
import { app, BrowserWindow, globalShortcut, ipcMain, Menu, nativeImage, session, systemPreferences, Tray } from 'electron';
import path from 'node:path';
import { IPC, type AppInfo, type HotkeyEvent, type LogEntry, type WindowMode } from '../shared/ipc';
import { getLogPath, log, writeLog } from './logger';
import { flushStore, registerStoreIpc, storeGet } from './ipc/store';
import { abortAllStreams, registerHttpIpc } from './ipc/http';
import { registerTelemetryIpc } from './ipc/telemetry';
import { getSharedDirectory, registerFilesIpc } from './ipc/files';
import { createMainWindow, getMainWindow, getWindowMode, setWindowMode, toggleWindowVisibility, windowEvents } from './window';
import { getWebhookStatus, startWebhookServer, stopWebhookServer } from './webhook';
import { registerVoiceIpc } from './voice/ipc';
import { registerSystemIpc } from './ipc/system';
import { stopEngine } from './voice/engine';
// Audio playback must never be blocked behind a user gesture (TTS starts on incoming events).
app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required');
// Test/dev aid: isolate settings, logs and models in a custom directory.
if (process.env.EVEFLOW_USER_DATA) app.setPath('userData', process.env.EVEFLOW_USER_DATA);
let tray: Tray | null = null;
// -- Single instance --------------------------------------------------------
if (!app.requestSingleInstanceLock()) {
app.quit();
} else {
app.on('second-instance', () => {
const win = getMainWindow();
if (win) {
if (win.isMinimized()) win.restore();
win.show();
win.focus();
}
});
}
interface WebhookSettings {
enabled?: boolean;
port?: number;
secret?: string;
}
function readWebhookSettings(): WebhookSettings {
const saved = storeGet<WebhookSettings>('webhook') ?? {};
const port = Number(saved.port);
return { enabled: saved.enabled !== false, port: Number.isInteger(port) && port > 0 && port < 65536 ? port : 7842, secret: typeof saved.secret === 'string' ? saved.secret : '' };
}
async function bootWebhook(): Promise<void> {
const settings = readWebhookSettings();
if (settings.enabled === false) {
await stopWebhookServer();
return;
}
await startWebhookServer(getMainWindow, { port: settings.port ?? 7842, secret: settings.secret || undefined });
}
function sendHotkey(event: HotkeyEvent): void {
const win = getMainWindow();
if (win && !win.isDestroyed()) win.webContents.send(IPC.hotkey, event);
}
function registerShortcuts(): void {
const bindings: Array<[string, () => void]> = [
['CommandOrControl+Shift+Space', () => {
const win = getMainWindow();
if (win && !win.isVisible()) win.show();
sendHotkey('ptt-toggle');
}],
['CommandOrControl+Shift+J', () => toggleWindowVisibility()],
// Ctrl+Shift+Escape is the Windows Task Manager shortcut; use a free combination.
['CommandOrControl+Alt+Escape', () => sendHotkey('stop-speaking')]
];
for (const [accelerator, handler] of bindings) {
try {
if (!globalShortcut.register(accelerator, handler)) log('WARN', 'hotkey', `cannot register ${accelerator}`);
} catch (err) {
log('WARN', 'hotkey', `cannot register ${accelerator}: ${(err as Error).message}`);
}
}
}
function createTray(): void {
try {
const iconPath = app.isPackaged
? path.join(process.resourcesPath, 'icon.png')
: path.join(__dirname, '..', 'build', 'icon.png');
let image = nativeImage.createFromPath(iconPath);
if (image.isEmpty()) image = nativeImage.createEmpty();
else image = image.resize({ width: 16, height: 16 });
tray = new Tray(image);
tray.setToolTip('EveFlow - JARVIS HUD');
const rebuildMenu = () => {
const mode = getWindowMode();
tray?.setContextMenu(Menu.buildFromTemplate([
{ label: 'Afficher / masquer', click: () => toggleWindowVisibility() },
{ label: mode === 'compact' ? 'Mode HUD complet' : 'Mode compact flottant', click: () => setWindowMode(mode === 'compact' ? 'hud' : 'compact') },
{ label: 'Micro (Ctrl+Shift+Espace)', click: () => sendHotkey('ptt-toggle') },
{ type: 'separator' },
{ label: 'Quitter', click: () => app.quit() }
]));
};
rebuildMenu();
tray.on('click', () => toggleWindowVisibility());
windowEvents.on('mode', rebuildMenu);
} catch (err) {
log('WARN', 'tray', `tray unavailable: ${(err as Error).message}`);
}
}
function configureSession(): void {
const ses = session.defaultSession;
ses.setPermissionRequestHandler((_wc, permission, callback) => {
callback(permission === 'media' || permission === 'notifications' || permission === 'clipboard-read');
});
ses.setPermissionCheckHandler((_wc, permission) => permission === 'media' || permission === 'notifications' || permission === 'clipboard-read');
if (process.platform === 'darwin') {
systemPreferences.askForMediaAccess('microphone').then((granted) => {
log('INFO', 'audio', `microphone access ${granted ? 'granted' : 'denied'}`);
}).catch(() => undefined);
}
}
function registerCoreIpc(): void {
ipcMain.on(IPC.windowControl, (_e, action: 'minimize' | 'maximize' | 'close' | 'hide') => {
const win = getMainWindow();
if (!win) return;
switch (action) {
case 'minimize': win.minimize(); break;
case 'maximize': win.isMaximized() ? win.unmaximize() : win.maximize(); break;
case 'hide': win.hide(); break;
case 'close': win.close(); break;
}
});
ipcMain.on(IPC.windowSetMode, (_e, mode: WindowMode) => setWindowMode(mode === 'compact' ? 'compact' : 'hud'));
ipcMain.on(IPC.log, (_e, entry: LogEntry) => {
if (!entry || typeof entry.message !== 'string') return;
writeLog({ ...entry, message: entry.message.slice(0, 8000), tag: String(entry.tag).slice(0, 64) });
});
ipcMain.handle(IPC.appInfo, (): AppInfo => ({
version: app.getVersion(),
platform: process.platform,
isPackaged: app.isPackaged,
userDataPath: app.getPath('userData'),
sharedDir: getSharedDirectory(),
logPath: getLogPath()
}));
ipcMain.handle(IPC.webhookStatus, () => getWebhookStatus());
ipcMain.handle(IPC.webhookRestart, async () => {
await bootWebhook();
return getWebhookStatus();
});
}
async function boot(): Promise<void> {
log('INFO', 'app', `EveFlow ${app.getVersion()} starting (electron ${process.versions.electron}, chrome ${process.versions.chrome})`);
configureSession();
registerStoreIpc();
registerHttpIpc();
registerTelemetryIpc();
registerFilesIpc();
registerCoreIpc();
registerVoiceIpc();
registerSystemIpc();
createMainWindow();
createTray();
registerShortcuts();
await bootWebhook();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createMainWindow();
});
}
if (app.hasSingleInstanceLock()) {
app.whenReady().then(boot).catch((err) => log('ERROR', 'app', `boot failed: ${(err as Error).message}`));
}
app.on('will-quit', (event) => {
globalShortcut.unregisterAll();
abortAllStreams();
stopEngine();
void stopWebhookServer();
event.preventDefault();
flushStore().finally(() => app.exit(0));
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
+191
View File
@@ -0,0 +1,191 @@
/**
* Minimal MCP server (Streamable HTTP, JSON-RPC 2.0) mounted on the webhook HTTP server at /mcp.
* Hermes Agent connects to it as a remote MCP server and gains the PC-side tools: screen capture,
* system actions, voice, notifications and HUD state. No SDK: initialize / tools/list / tools/call
* are the only methods a client needs, and every reply is a plain JSON body.
*/
import { ipcMain, type BrowserWindow } from 'electron';
import type { IncomingMessage, ServerResponse } from 'node:http';
import { IPC, type McpToolRequest, type McpToolResponse } from '../shared/ipc';
import type { SystemAction } from '../shared/bridge';
import { log } from './logger';
import { captureScreen, runSystemAction } from './ipc/system';
export const MCP_PATH = '/mcp';
const PROTOCOL = '2025-03-26';
const RENDERER_TIMEOUT_MS = 20_000;
interface ToolSpec {
name: string;
description: string;
inputSchema: Record<string, unknown>;
/** 'main' = executed here; 'renderer' = forwarded to the UI process. */
where: 'main' | 'renderer';
}
const obj = (properties: Record<string, unknown>, required: string[] = []) => ({ type: 'object', properties, required, additionalProperties: false });
export const MCP_TOOLS: ToolSpec[] = [
{ name: 'capture_screen', description: "Capture l'écran principal de l'utilisateur et renvoie l'image (JPEG). À utiliser quand l'utilisateur parle de ce qu'il voit ou demande de l'aide sur son écran.", inputSchema: obj({ max_width: { type: 'integer', description: 'Largeur maximale en pixels (défaut 1600)' } }), where: 'main' },
{ name: 'lock_session', description: "Verrouille la session Windows/Linux/macOS de l'utilisateur.", inputSchema: obj({}), where: 'main' },
{ name: 'open_app', description: "Lance une application sur le PC de l'utilisateur (bloc-notes, calculatrice, chrome, spotify, vscode, terminal, explorateur…).", inputSchema: obj({ name: { type: 'string' } }, ['name']), where: 'main' },
{ name: 'open_url', description: "Ouvre une URL http(s) dans le navigateur par défaut de l'utilisateur.", inputSchema: obj({ url: { type: 'string' } }, ['url']), where: 'main' },
{ name: 'media_key', description: 'Envoie une touche média : volume-up, volume-down, mute, play-pause, next, previous.', inputSchema: obj({ key: { type: 'string', enum: ['volume-up', 'volume-down', 'mute', 'play-pause', 'next', 'previous'] } }, ['key']), where: 'main' },
{ name: 'clipboard_get', description: 'Lit le texte du presse-papiers.', inputSchema: obj({}), where: 'main' },
{ name: 'clipboard_set', description: 'Place un texte dans le presse-papiers.', inputSchema: obj({ text: { type: 'string' } }, ['text']), where: 'main' },
{ name: 'find_files', description: "Cherche des fichiers par nom dans Documents, Bureau, Téléchargements et Images de l'utilisateur (25 résultats max).", inputSchema: obj({ query: { type: 'string' } }, ['query']), where: 'main' },
{ name: 'speak_text', description: "Fait prononcer un texte par la voix d'EveFlow, immédiatement.", inputSchema: obj({ text: { type: 'string' } }, ['text']), where: 'renderer' },
{ name: 'notify_user', description: 'Affiche une notification système (titre + corps).', inputSchema: obj({ title: { type: 'string' }, body: { type: 'string' } }, ['body']), where: 'renderer' },
{ name: 'set_hud_state', description: 'Change momentanément l’état visuel du HUD : neutral, happy, thinking, alert, error.', inputSchema: obj({ state: { type: 'string', enum: ['neutral', 'happy', 'thinking', 'alert', 'error'] } }, ['state']), where: 'renderer' },
{ name: 'get_app_status', description: "État d'EveFlow : transport, liaison, nom de l'assistant, mains libres, voix en cours, heures calmes.", inputSchema: obj({}), where: 'renderer' },
{ name: 'get_conversation_history', description: 'Derniers messages de la conversation EveFlow (n ≤ 30).', inputSchema: obj({ n: { type: 'integer' } }), where: 'renderer' },
{ name: 'show_message', description: "Affiche un message dans le fil EveFlow (sans le prononcer) — pour les rapports longs ou les résultats de crons.", inputSchema: obj({ text: { type: 'string' }, title: { type: 'string' } }, ['text']), where: 'renderer' }
];
type Rec = Record<string, unknown>;
interface RpcRequest {
jsonrpc?: string;
id?: number | string | null;
method?: string;
params?: Rec;
}
const pending = new Map<string, { resolve: (r: McpToolResponse) => void; timer: ReturnType<typeof setTimeout> }>();
let seq = 0;
let ipcRegistered = false;
function ensureIpc(): void {
if (ipcRegistered) return;
ipcRegistered = true;
ipcMain.on(IPC.mcpResponse, (_e, res: McpToolResponse) => {
if (!res || typeof res.id !== 'string') return;
const entry = pending.get(res.id);
if (!entry) return;
clearTimeout(entry.timer);
pending.delete(res.id);
entry.resolve(res);
});
}
function askRenderer(win: BrowserWindow | null, name: string, args: Rec): Promise<McpToolResponse> {
ensureIpc();
if (!win || win.isDestroyed()) return Promise.resolve({ id: '', ok: false, error: 'EveFlow window unavailable' });
const id = `mcp-${++seq}-${Date.now()}`;
const req: McpToolRequest = { id, name, args };
return new Promise((resolve) => {
const timer = setTimeout(() => {
pending.delete(id);
resolve({ id, ok: false, error: 'renderer timeout' });
}, RENDERER_TIMEOUT_MS);
pending.set(id, { resolve, timer });
win.webContents.send(IPC.mcpRequest, req);
});
}
function text(value: unknown): { content: Array<Rec>; isError?: boolean } {
return { content: [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value) }] };
}
function failure(message: string): { content: Array<Rec>; isError: boolean } {
return { content: [{ type: 'text', text: message }], isError: true };
}
async function callTool(win: BrowserWindow | null, name: string, args: Rec): Promise<{ content: Array<Rec>; isError?: boolean }> {
const spec = MCP_TOOLS.find((t) => t.name === name);
if (!spec) return failure(`Outil inconnu : ${name}`);
if (spec.where === 'renderer') {
const res = await askRenderer(win, name, args);
return res.ok ? text(res.result ?? { ok: true }) : failure(res.error ?? 'échec');
}
const sys = async (action: SystemAction) => {
const r = await runSystemAction(action);
return r.ok ? text(r.data !== undefined ? { ok: true, message: r.message, data: r.data } : { ok: true, message: r.message }) : failure(r.message ?? 'échec');
};
switch (name) {
case 'capture_screen': {
const dataUrl = await captureScreen(Number(args.max_width) || 1600);
const base64 = dataUrl.slice(dataUrl.indexOf(',') + 1);
return { content: [{ type: 'image', data: base64, mimeType: 'image/jpeg' }, { type: 'text', text: `Capture de l'écran principal (${Math.round(base64.length * 0.75 / 1024)} ko).` }] };
}
case 'lock_session':
return sys({ type: 'lock' });
case 'open_app':
return sys({ type: 'open-app', name: String(args.name ?? '') });
case 'open_url':
return sys({ type: 'open-url', url: String(args.url ?? '') });
case 'media_key':
return sys({ type: 'media', key: String(args.key ?? '') as 'mute' });
case 'clipboard_get':
return sys({ type: 'clipboard-read' });
case 'clipboard_set':
return sys({ type: 'clipboard-write', text: String(args.text ?? '') });
case 'find_files':
return sys({ type: 'find-files', query: String(args.query ?? '') });
default:
return failure(`Outil non implémenté : ${name}`);
}
}
/** Handle one JSON-RPC message; returns the response object (or null for notifications). */
export async function handleMcpMessage(win: BrowserWindow | null, msg: RpcRequest): Promise<Rec | null> {
const id = msg.id ?? null;
const reply = (result: unknown) => ({ jsonrpc: '2.0', id, result });
const error = (code: number, message: string) => ({ jsonrpc: '2.0', id, error: { code, message } });
if (!msg.method) return error(-32600, 'Invalid Request');
if (msg.method.startsWith('notifications/')) return null;
switch (msg.method) {
case 'initialize':
return reply({ protocolVersion: PROTOCOL, capabilities: { tools: { listChanged: false } }, serverInfo: { name: 'eveflow', version: process.env.npm_package_version ?? '2.4.0' }, instructions: "Outils du PC de l'utilisateur via EveFlow : écran, applications, volume, presse-papiers, voix et notifications." });
case 'ping':
return reply({});
case 'tools/list':
return reply({ tools: MCP_TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) });
case 'tools/call': {
const params = (msg.params ?? {}) as Rec;
const name = String(params.name ?? '');
const args = (params.arguments && typeof params.arguments === 'object' ? params.arguments : {}) as Rec;
log('INFO', 'mcp', `tools/call ${name}`);
try {
return reply(await callTool(win, name, args));
} catch (err) {
return reply(failure((err as Error).message));
}
}
case 'resources/list':
return reply({ resources: [] });
case 'prompts/list':
return reply({ prompts: [] });
default:
return error(-32601, `Method not found: ${msg.method}`);
}
}
/** HTTP entry point: POST /mcp with one JSON-RPC message or a batch. */
export async function handleMcpHttp(win: BrowserWindow | null, req: IncomingMessage, res: ServerResponse, body: string): Promise<void> {
const send = (code: number, payload: unknown) => {
if (payload === undefined) {
res.writeHead(code, { 'Cache-Control': 'no-store' });
res.end();
return;
}
res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(payload));
};
if (req.method === 'GET') return send(405, { error: 'SSE stream not supported; use POST' });
if (req.method === 'DELETE') return send(204, undefined);
if (req.method !== 'POST') return send(405, { error: 'Method Not Allowed' });
let parsed: unknown;
try {
parsed = body.trim() ? JSON.parse(body) : {};
} catch {
return send(400, { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } });
}
const messages = Array.isArray(parsed) ? (parsed as RpcRequest[]) : [parsed as RpcRequest];
const replies: Rec[] = [];
for (const msg of messages) {
const out = await handleMcpMessage(win, msg && typeof msg === 'object' ? msg : {});
if (out) replies.push(out);
}
if (replies.length === 0) return send(202, undefined);
return send(200, Array.isArray(parsed) ? replies : replies[0]);
}
+91
View File
@@ -0,0 +1,91 @@
import { contextBridge, ipcRenderer } from 'electron';
import {
IPC,
type AppInfo,
type HermesPushEvent,
type HotkeyEvent,
type HttpProxyRequest,
type HttpProxyResponse,
type HttpStreamEvent,
type HttpStreamStart,
type LogEntry,
type SystemMetrics,
type WebhookStatus,
type WindowMode
} from '../shared/ipc';
import type { EveFlowBridge, SystemAction, SystemActionResult, Unsubscribe } from '../shared/bridge';
import type { McpToolRequest, McpToolResponse } from '../shared/ipc';
import { VOICE_IPC, type EdgeSynthesizeRequest, type EdgeSynthesizeResult, type EdgeVoice, type KwsDetection, type KwsStartRequest, type VadEvent, type VadStartRequest, type SynthesizeRequest, type SynthesizeResult, type TranscribeRequest, type TranscribeResult, type VoiceDownloadProgress, type VoiceEngineStatus, type VoiceModelStatus } from '../shared/voice';
function subscribe<T>(channel: string, callback: (payload: T) => void): Unsubscribe {
const listener = (_event: Electron.IpcRendererEvent, payload: T) => callback(payload);
ipcRenderer.on(channel, listener);
return () => ipcRenderer.removeListener(channel, listener);
}
const api: EveFlowBridge = {
window: {
control: (action: 'minimize' | 'maximize' | 'close' | 'hide') => ipcRenderer.send(IPC.windowControl, action),
setMode: (mode: WindowMode) => ipcRenderer.send(IPC.windowSetMode, mode),
onModeChanged: (cb: (mode: WindowMode) => void) => subscribe<WindowMode>(IPC.windowModeChanged, cb),
onVisibility: (cb: (visible: boolean) => void) => subscribe<boolean>(IPC.windowVisibility, cb)
},
log: (entry: LogEntry) => ipcRenderer.send(IPC.log, entry),
store: {
get: <T = unknown>(key: string) => ipcRenderer.invoke(IPC.storeGet, key) as Promise<T | null>,
set: (key: string, value: unknown) => ipcRenderer.invoke(IPC.storeSet, key, value) as Promise<boolean>,
delete: (key: string) => ipcRenderer.invoke(IPC.storeDelete, key) as Promise<boolean>
},
http: {
fetch: (req: HttpProxyRequest) => ipcRenderer.invoke(IPC.httpFetch, req) as Promise<HttpProxyResponse>,
streamStart: (id: string, req: HttpProxyRequest) => ipcRenderer.invoke(IPC.httpStreamStart, id, req) as Promise<HttpStreamStart>,
streamAbort: (id: string) => ipcRenderer.send(IPC.httpStreamAbort, id),
onStreamEvent: (cb: (event: HttpStreamEvent) => void) => subscribe<HttpStreamEvent>(IPC.httpStreamEvent, cb)
},
system: {
metrics: () => ipcRenderer.invoke(IPC.metrics) as Promise<SystemMetrics>,
appInfo: () => ipcRenderer.invoke(IPC.appInfo) as Promise<AppInfo>,
captureScreen: (maxWidth?: number) => ipcRenderer.invoke(IPC.screenCapture, maxWidth) as Promise<string>,
action: (action: SystemAction) => ipcRenderer.invoke(IPC.systemAction, action) as Promise<SystemActionResult>
},
files: {
readLocal: (filePath: string) => ipcRenderer.invoke(IPC.readLocalFile, filePath) as Promise<string>,
writeShared: (filename: string, content: string, isBase64 = false) =>
ipcRenderer.invoke(IPC.writeSharedFile, filename, content, isBase64) as Promise<{ path: string; url: string }>,
openPath: (filePath: string) => ipcRenderer.invoke(IPC.openPath, filePath) as Promise<boolean>,
showInFolder: (filePath: string) => ipcRenderer.invoke(IPC.showInFolder, filePath) as Promise<boolean>
},
hermes: {
onPush: (cb: (event: HermesPushEvent) => void) => subscribe<HermesPushEvent>(IPC.hermesPush, cb),
webhookStatus: () => ipcRenderer.invoke(IPC.webhookStatus) as Promise<WebhookStatus>,
onToolRequest: (cb: (req: McpToolRequest) => void) => subscribe<McpToolRequest>(IPC.mcpRequest, cb),
toolResponse: (res: McpToolResponse) => ipcRenderer.send(IPC.mcpResponse, res),
webhookRestart: () => ipcRenderer.invoke(IPC.webhookRestart) as Promise<WebhookStatus>
},
hotkeys: {
on: (cb: (event: HotkeyEvent) => void) => subscribe<HotkeyEvent>(IPC.hotkey, cb)
},
voice: {
status: () => ipcRenderer.invoke(VOICE_IPC.status) as Promise<VoiceEngineStatus>,
listModels: () => ipcRenderer.invoke(VOICE_IPC.modelsList) as Promise<VoiceModelStatus[]>,
downloadModel: (id: string) => ipcRenderer.invoke(VOICE_IPC.modelsDownload, id) as Promise<VoiceModelStatus>,
cancelDownload: (id: string) => ipcRenderer.invoke(VOICE_IPC.modelsCancel, id) as Promise<boolean>,
removeModel: (id: string) => ipcRenderer.invoke(VOICE_IPC.modelsRemove, id) as Promise<VoiceModelStatus[]>,
onProgress: (cb: (progress: VoiceDownloadProgress) => void) => subscribe<VoiceDownloadProgress>(VOICE_IPC.modelsProgress, cb),
transcribe: (req: TranscribeRequest) => ipcRenderer.invoke(VOICE_IPC.transcribe, req) as Promise<TranscribeResult>,
synthesize: (req: SynthesizeRequest) => ipcRenderer.invoke(VOICE_IPC.synthesize, req) as Promise<SynthesizeResult>,
edgeSynthesize: (req: EdgeSynthesizeRequest) => ipcRenderer.invoke(VOICE_IPC.edgeSynthesize, req) as Promise<EdgeSynthesizeResult>,
edgeVoices: () => ipcRenderer.invoke(VOICE_IPC.edgeVoices) as Promise<EdgeVoice[]>,
unload: (id?: string) => ipcRenderer.invoke(VOICE_IPC.unload, id) as Promise<unknown>,
kwsStart: (req: KwsStartRequest) => ipcRenderer.invoke(VOICE_IPC.kwsStart, req) as Promise<{ accepted: string[]; rejected: string[] }>,
kwsStop: () => ipcRenderer.invoke(VOICE_IPC.kwsStop) as Promise<void>,
kwsAudio: (pcm: Uint8Array, sampleRate: number) => ipcRenderer.send(VOICE_IPC.kwsAudio, pcm, sampleRate),
onKwsDetected: (cb: (detection: KwsDetection) => void) => subscribe<KwsDetection>(VOICE_IPC.kwsDetected, cb),
vadStart: (req: VadStartRequest) => ipcRenderer.invoke(VOICE_IPC.vadStart, req) as Promise<void>,
vadStop: () => ipcRenderer.invoke(VOICE_IPC.vadStop) as Promise<void>,
vadAudio: (pcm: Uint8Array, sampleRate: number) => ipcRenderer.send(VOICE_IPC.vadAudio, pcm, sampleRate),
onVadEvent: (cb: (event: VadEvent) => void) => subscribe<VadEvent>(VOICE_IPC.vadEvent, cb)
}
};
contextBridge.exposeInMainWorld('eveflow', api);
+223
View File
@@ -0,0 +1,223 @@
import type { VoiceModelSpec, VoiceSpeaker } from '../../shared/voice';
const ASR = 'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models';
const KWS = 'https://github.com/k2-fsa/sherpa-onnx/releases/download/kws-models';
const TTS = 'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models';
const KOKORO_SPEAKERS: VoiceSpeaker[] = [
{ id: 30, name: 'Siwis (femme, français)', lang: 'fr' },
{ id: 3, name: 'Heart (femme, anglais US)', lang: 'en' },
{ id: 2, name: 'Bella (femme, anglais US)', lang: 'en' },
{ id: 7, name: 'Nova (femme, anglais US)', lang: 'en' },
{ id: 11, name: 'Adam (homme, anglais US)', lang: 'en' },
{ id: 16, name: 'Michael (homme, anglais US)', lang: 'en' },
{ id: 17, name: 'Onyx (homme, anglais US)', lang: 'en' },
{ id: 21, name: 'Emma (femme, anglais UK)', lang: 'en' },
{ id: 24, name: 'Daniel (homme, anglais UK)', lang: 'en' },
{ id: 26, name: 'George (homme, anglais UK)', lang: 'en' }
];
// Supertonic 3 ships ten voice styles in voice.bin (five feminine, five masculine). The ordering
// was checked by measuring the fundamental frequency of French synthesis: sid 0-4 around
// 170-210 Hz, sid 5-9 around 90-140 Hz.
const SUPERTONIC_SPEAKERS: VoiceSpeaker[] = [
{ id: 6, name: 'Homme 2 (grave, posé)', lang: 'multi', gender: 'm' },
{ id: 9, name: 'Homme 5 (grave)', lang: 'multi', gender: 'm' },
{ id: 7, name: 'Homme 3', lang: 'multi', gender: 'm' },
{ id: 8, name: 'Homme 4', lang: 'multi', gender: 'm' },
{ id: 5, name: 'Homme 1 (clair)', lang: 'multi', gender: 'm' },
{ id: 0, name: 'Femme 1', lang: 'multi', gender: 'f' },
{ id: 1, name: 'Femme 2', lang: 'multi', gender: 'f' },
{ id: 2, name: 'Femme 3', lang: 'multi', gender: 'f' },
{ id: 3, name: 'Femme 4', lang: 'multi', gender: 'f' },
{ id: 4, name: 'Femme 5', lang: 'multi', gender: 'f' }
];
export const VOICE_CATALOG: VoiceModelSpec[] = [
{
id: 'whisper-base',
kind: 'stt',
engine: 'whisper',
name: 'Whisper base (multilingue)',
description: 'Le plus rapide sur CPU ; précision moyenne en français, suffisante pour des commandes courtes.',
languages: ['fr', 'en', 'multi'],
sizeMb: 208,
url: `${ASR}/sherpa-onnx-whisper-base.tar.bz2`,
dir: 'sherpa-onnx-whisper-base',
files: ['base-encoder.int8.onnx', 'base-decoder.int8.onnx', 'base-tokens.txt'],
recommended: true
},
{
id: 'whisper-small',
kind: 'stt',
engine: 'whisper',
name: 'Whisper small (multilingue)',
description: 'Recommandé pour le français : nettement plus précis que base, environ trois fois plus lent.',
languages: ['fr', 'en', 'multi'],
sizeMb: 640,
url: `${ASR}/sherpa-onnx-whisper-small.tar.bz2`,
dir: 'sherpa-onnx-whisper-small',
files: ['small-encoder.int8.onnx', 'small-decoder.int8.onnx', 'small-tokens.txt'],
recommended: true
},
{
id: 'parakeet-v3',
kind: 'stt',
engine: 'nemo-transducer',
name: 'Parakeet TDT 0.6B v3 (25 langues européennes, français)',
description: 'Le plus précis en français et nettement plus rapide que Whisper sur processeur (NVIDIA NeMo, int8). Ponctuation et majuscules incluses.',
languages: ['fr', 'en', 'de', 'es', 'it', 'multi'],
sizeMb: 640,
url: `${ASR}/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8.tar.bz2`,
dir: 'sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8',
files: ['encoder.int8.onnx', 'decoder.int8.onnx', 'joiner.int8.onnx', 'tokens.txt'],
recommended: true
},
{
id: 'whisper-turbo',
kind: 'stt',
engine: 'whisper',
name: 'Whisper large-v3 turbo (multilingue)',
description: 'Très bonne précision multilingue ; 2 à 4 s par phrase sur un processeur récent. Parakeet est plus rapide en français.',
languages: ['fr', 'en', 'multi'],
sizeMb: 564,
url: `${ASR}/sherpa-onnx-whisper-turbo.tar.bz2`,
dir: 'sherpa-onnx-whisper-turbo',
files: ['turbo-encoder.int8.onnx', 'turbo-decoder.int8.onnx', 'turbo-tokens.txt']
},
{
id: 'sense-voice',
kind: 'stt',
engine: 'sense-voice',
name: 'SenseVoice small (zh / en / ja / ko)',
description: 'Très rapide, mais ne comprend pas le français.',
languages: ['en', 'zh', 'ja', 'ko'],
sizeMb: 163,
url: `${ASR}/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17.tar.bz2`,
dir: 'sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17',
files: ['model.int8.onnx', 'tokens.txt']
},
{
id: 'kws-en',
kind: 'kws',
engine: 'kws-transducer',
name: 'Détecteur de mot-clé (zipformer, 3 Mo)',
description: 'Écoute permanente du mot d’activation, quasi gratuite en CPU. Mot-clé libre (« jarvis », « hey jarvis »…).',
languages: ['en', 'fr'],
sizeMb: 4,
url: `${KWS}/sherpa-onnx-kws-zipformer-gigaspeech-3.3M-2024-01-01.tar.bz2`,
dir: 'sherpa-onnx-kws-zipformer-gigaspeech-3.3M-2024-01-01',
files: [
'encoder-epoch-12-avg-2-chunk-16-left-64.int8.onnx',
'decoder-epoch-12-avg-2-chunk-16-left-64.int8.onnx',
'joiner-epoch-12-avg-2-chunk-16-left-64.int8.onnx',
'tokens.txt'
],
recommended: true
},
{
id: 'silero-vad',
kind: 'vad',
engine: 'silero',
name: 'Silero VAD (fin de phrase neuronale, 0,6 Mo)',
description: 'Détecte précisément le début et la fin de la parole pendant l’écoute permanente ; moins de faux départs sur le bruit.',
languages: ['multi'],
sizeMb: 1,
url: `${ASR}/silero_vad.onnx`,
dir: 'silero-vad',
files: ['silero_vad.onnx'],
recommended: true
},
{
id: 'supertonic-3',
kind: 'tts',
engine: 'supertonic',
name: 'Supertonic 3 (31 langues, 5 voix masculines et 5 féminines)',
description:
'La meilleure voix française locale : naturelle, sans accent, dix voix au choix, 44 kHz. Environ 7 fois plus rapide que le temps réel sur 4 cœurs, 100 M de paramètres.',
languages: ['fr', 'en', 'de', 'es', 'it', 'pt', 'multi'],
sizeMb: 129,
url: `${TTS}/sherpa-onnx-supertonic-3-tts-int8-2026-05-11.tar.bz2`,
dir: 'sherpa-onnx-supertonic-3-tts-int8-2026-05-11',
files: ['duration_predictor.int8.onnx', 'text_encoder.int8.onnx', 'vector_estimator.int8.onnx', 'vocoder.int8.onnx', 'tts.json', 'unicode_indexer.bin', 'voice.bin'],
speakers: SUPERTONIC_SPEAKERS,
sampleRate: 44100,
recommended: true
},
{
id: 'kokoro-v1',
kind: 'tts',
engine: 'kokoro',
name: 'Kokoro v1.0 multilingue',
description:
'Excellent en anglais (nombreuses voix). En français : une seule voix, féminine (Siwis), avec un accent marqué (phonémisation espeak). Préférez Supertonic 3 pour le français. 24 kHz.',
languages: ['en', 'fr', 'multi'],
sizeMb: 349,
url: `${TTS}/kokoro-multi-lang-v1_0.tar.bz2`,
dir: 'kokoro-multi-lang-v1_0',
files: ['model.onnx', 'voices.bin', 'tokens.txt', 'lexicon-us-en.txt', 'lexicon-zh.txt', 'espeak-ng-data/phontab'],
speakers: KOKORO_SPEAKERS,
sampleRate: 24000
},
{
id: 'piper-fr-siwis',
kind: 'tts',
engine: 'piper',
name: 'Piper Siwis (femme, français)',
description: 'Léger et instantané, rendu un peu plus mécanique que Kokoro. 22 kHz.',
languages: ['fr'],
sizeMb: 67,
url: `${TTS}/vits-piper-fr_FR-siwis-medium.tar.bz2`,
dir: 'vits-piper-fr_FR-siwis-medium',
files: ['fr_FR-siwis-medium.onnx', 'tokens.txt', 'espeak-ng-data/phontab'],
speakers: [{ id: 0, name: 'Siwis', lang: 'fr' }],
sampleRate: 22050
},
{
id: 'piper-fr-tom',
kind: 'tts',
engine: 'piper',
name: 'Piper Tom (homme, français)',
description: 'Voix masculine légère et instantanée. 22 kHz.',
languages: ['fr'],
sizeMb: 67,
url: `${TTS}/vits-piper-fr_FR-tom-medium.tar.bz2`,
dir: 'vits-piper-fr_FR-tom-medium',
files: ['fr_FR-tom-medium.onnx', 'tokens.txt', 'espeak-ng-data/phontab'],
speakers: [{ id: 0, name: 'Tom', lang: 'fr' }],
sampleRate: 22050
},
{
id: 'piper-fr-upmc',
kind: 'tts',
engine: 'piper',
name: 'Piper UPMC (Jessica et Pierre, français)',
description: 'Deux voix françaises, une féminine et une masculine. 22 kHz.',
languages: ['fr'],
sizeMb: 80,
url: `${TTS}/vits-piper-fr_FR-upmc-medium.tar.bz2`,
dir: 'vits-piper-fr_FR-upmc-medium',
files: ['fr_FR-upmc-medium.onnx', 'tokens.txt', 'espeak-ng-data/phontab'],
speakers: [
{ id: 0, name: 'Jessica (femme)', lang: 'fr' },
{ id: 1, name: 'Pierre (homme)', lang: 'fr' }
],
sampleRate: 22050
}
];
export function findModel(id: string): VoiceModelSpec | undefined {
return VOICE_CATALOG.find((m) => m.id === id);
}
// Speaker gender from the label ("(homme, …)", "(femme, …)", known first names) so the UI and the
// voice preference can pick a masculine or feminine voice without a lookup table per model.
const MALE = /\b(homme|tom|pierre|adam|michael|eric|liam|george|lewis|daniel|fenrir|puck|onyx|echo|santa)\b/i;
/** Languages accepted by the Supertonic 3 text front-end (2-letter codes). */
export const SUPERTONIC_LANGS = new Set(['ar', 'bg', 'hr', 'cs', 'da', 'nl', 'en', 'et', 'fi', 'fr', 'de', 'el', 'hi', 'hu', 'id', 'it', 'ja', 'ko', 'lv', 'lt', 'pl', 'pt', 'ro', 'ru', 'sk', 'sl', 'es', 'sv', 'tr', 'uk', 'vi']);
for (const spec of VOICE_CATALOG) {
for (const sp of spec.speakers ?? []) {
if (!sp.gender) sp.gender = /\b(femme|female)\b/i.test(sp.name) ? 'f' : MALE.test(sp.name) ? 'm' : /\bfemme\b/i.test(spec.name) ? 'f' : /\bhomme\b/i.test(spec.name) ? 'm' : undefined;
}
}
+146
View File
@@ -0,0 +1,146 @@
/**
* Microsoft Edge "Read aloud" neural voices (the service behind the Edge browser's read-aloud
* feature): free, no key, very natural French voices with a real masculine/feminine choice
* (Henri, Denise, Rémy, Vivienne…). Runs in the main process: one WebSocket per sentence,
* MP3 back to the renderer.
*/
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import type { EdgeSynthesizeRequest, EdgeSynthesizeResult, EdgeVoice } from '../../shared/voice';
import {
EDGE_CHROMIUM_VERSION,
EDGE_VOICES_URL,
EDGE_WSS_URL,
edgeConfigMessage,
edgeConnectionId,
edgeHeaders,
edgeSsml,
edgeSsmlMessage,
edgeTextFramePath,
edgeTokenInput,
parseEdgeBinaryFrame
} from '../../shared/edgeTts';
import { log } from '../logger';
/** Seconds to add to the local clock so the signed token matches the server's time window. */
let clockSkewSec = 0;
let voicesCache: { at: number; voices: EdgeVoice[] } | null = null;
const VOICES_TTL_MS = 6 * 60 * 60 * 1000;
const SYNTH_TIMEOUT_MS = 20_000;
function token(): string {
return createHash('sha256').update(edgeTokenInput(Date.now(), clockSkewSec), 'ascii').digest('hex').toUpperCase();
}
function signedUrl(): string {
return `${EDGE_WSS_URL}&ConnectionId=${edgeConnectionId(randomUUID())}&Sec-MS-GEC=${token()}&Sec-MS-GEC-Version=1-${EDGE_CHROMIUM_VERSION}`;
}
function headers(): Record<string, string> {
return { ...edgeHeaders(), Cookie: `muid=${randomBytes(16).toString('hex')};` };
}
/** Learn the server clock from a plain HTTPS response (the WebSocket handshake hides its headers). */
async function syncClock(): Promise<void> {
try {
const res = await fetch(EDGE_VOICES_URL, { method: 'HEAD', headers: headers() });
const date = res.headers.get('date');
if (!date) return;
const server = Date.parse(date);
if (Number.isFinite(server)) {
clockSkewSec = (server - Date.now()) / 1000;
log('INFO', 'edge-tts', `clock skew ${clockSkewSec.toFixed(0)} s`);
}
} catch (err) {
log('WARN', 'edge-tts', `clock sync failed: ${(err as Error).message}`);
}
}
function synthesizeOnce(req: EdgeSynthesizeRequest): Promise<Uint8Array> {
return new Promise((resolve, reject) => {
const chunks: Uint8Array[] = [];
let settled = false;
let ws: WebSocket;
try {
// Node's global WebSocket accepts extra handshake headers (undici), which the service checks.
ws = new (WebSocket as unknown as new (url: string, options: { headers: Record<string, string> }) => WebSocket)(signedUrl(), { headers: headers() });
} catch (err) {
reject(err as Error);
return;
}
ws.binaryType = 'arraybuffer';
const finish = (err?: Error) => {
if (settled) return;
settled = true;
clearTimeout(timer);
try {
ws.close();
} catch {
/* already closed */
}
if (err) reject(err);
else {
const total = chunks.reduce((n, c) => n + c.byteLength, 0);
const out = new Uint8Array(total);
let o = 0;
for (const c of chunks) {
out.set(c, o);
o += c.byteLength;
}
resolve(out);
}
};
const timer = setTimeout(() => finish(new Error('Edge TTS : délai dépassé')), SYNTH_TIMEOUT_MS);
ws.onopen = () => {
ws.send(edgeConfigMessage());
ws.send(edgeSsmlMessage(edgeConnectionId(randomUUID()), edgeSsml(req.text, req.voice, req.speed)));
};
ws.onmessage = (event: MessageEvent) => {
if (typeof event.data === 'string') {
if (edgeTextFramePath(event.data) === 'turn.end') finish();
return;
}
const frame = new Uint8Array(event.data as ArrayBuffer);
const { path, payload } = parseEdgeBinaryFrame(frame);
if (path === 'audio' && payload.byteLength) chunks.push(payload);
};
ws.onerror = (event: Event) => finish(new Error(`Edge TTS : connexion refusée (${(event as { message?: string }).message ?? 'erreur réseau'})`));
ws.onclose = (event: CloseEvent) => {
if (!settled) finish(chunks.length ? undefined : new Error(`Edge TTS : connexion fermée (${event.code}${event.reason ? ' ' + event.reason : ''})`));
};
});
}
export async function edgeSynthesize(req: EdgeSynthesizeRequest): Promise<EdgeSynthesizeResult> {
const started = Date.now();
let mp3: Uint8Array;
try {
mp3 = await synthesizeOnce(req);
} catch (err) {
// A refused handshake is almost always a stale signature: resync the clock and retry once.
log('WARN', 'edge-tts', `first attempt failed (${(err as Error).message}), resyncing clock`);
await syncClock();
mp3 = await synthesizeOnce(req);
}
if (!mp3.byteLength) throw new Error('Edge TTS : aucun audio reçu');
return { mp3, durationMs: Date.now() - started };
}
/** Voice list from the service (cached six hours); falls back to an empty list offline. */
export async function edgeVoices(): Promise<EdgeVoice[]> {
if (voicesCache && Date.now() - voicesCache.at < VOICES_TTL_MS) return voicesCache.voices;
const url = `${EDGE_VOICES_URL}&Sec-MS-GEC=${token()}&Sec-MS-GEC-Version=1-${EDGE_CHROMIUM_VERSION}`;
const res = await fetch(url, { headers: headers() });
if (!res.ok) throw new Error(`Edge TTS : liste des voix HTTP ${res.status}`);
const raw = (await res.json()) as Array<{ ShortName?: string; FriendlyName?: string; Locale?: string; Gender?: string }>;
const voices: EdgeVoice[] = raw
.filter((v) => typeof v.ShortName === 'string' && typeof v.Locale === 'string')
.map((v) => ({
shortName: v.ShortName!,
name: v.ShortName!.split('-')[2]?.replace(/(Multilingual)?Neural$/, '') || v.FriendlyName || v.ShortName!,
locale: v.Locale!,
gender: v.Gender === 'Male' ? ('m' as const) : ('f' as const)
}))
.sort((a, b) => a.locale.localeCompare(b.locale) || a.name.localeCompare(b.name));
voicesCache = { at: Date.now(), voices };
return voices;
}
+240
View File
@@ -0,0 +1,240 @@
/**
* Host side of the voice worker: spawns the utility process on demand, correlates
* requests and responses, restarts the worker if it crashes.
*/
import { utilityProcess, type UtilityProcess, type WebContents } from 'electron';
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { VOICE_IPC, type KwsDetection, type KwsStartRequest, type SynthesizeRequest, type SynthesizeResult, type TranscribeRequest, type TranscribeResult, type VadEvent, type VadStartRequest, type VoiceEngineStatus } from '../../shared/voice';
import { buildKeywordsFile, parseTokens } from '../../shared/keywords';
import { findModel } from './catalog';
import { isInstalled, modelDir, modelsDir } from './models';
import { log } from '../logger';
/** Renderer that receives keyword detections while spotting is active. */
let kwsSubscriber: WebContents | null = null;
let kwsActive = false;
let kwsRequest: KwsStartRequest | null = null;
let vadSubscriber: WebContents | null = null;
let vadActive = false;
interface Pending {
resolve: (value: unknown) => void;
reject: (err: Error) => void;
timer: ReturnType<typeof setTimeout>;
}
let child: UtilityProcess | null = null;
let nextId = 1;
const pending = new Map<number, Pending>();
function rejectAll(message: string): void {
for (const [id, p] of pending) {
clearTimeout(p.timer);
p.reject(new Error(message));
pending.delete(id);
}
}
function spawn(): UtilityProcess {
if (child) return child;
const script = path.join(__dirname, 'voice-worker.js');
const proc = utilityProcess.fork(script, [], { serviceName: 'eveflow-voice', stdio: 'pipe' });
child = proc;
proc.stdout?.on('data', (d: Buffer) => log('DEBUG', 'voice-worker', d.toString().trim()));
proc.stderr?.on('data', (d: Buffer) => log('WARN', 'voice-worker', d.toString().trim()));
proc.on('message', (msg: { id?: number; type?: string; ok?: boolean; result?: unknown; error?: string; keyword?: string; at?: number; event?: { type: string; wav?: string; durationSec?: number } }) => {
if (msg.type === 'vad.event' && msg.event) {
if (vadSubscriber && !vadSubscriber.isDestroyed()) {
const ev = msg.event;
const payload: VadEvent =
ev.type === 'segment' && ev.wav
? { type: 'segment', wav: new Uint8Array(Buffer.from(ev.wav, 'base64')), durationSec: ev.durationSec ?? 0 }
: ev.type === 'speech-start'
? { type: 'speech-start' }
: { type: 'error', message: 'événement VAD inconnu' };
vadSubscriber.send(VOICE_IPC.vadEvent, payload);
}
return;
}
if (msg.type === 'kws.detected') {
if (kwsSubscriber && !kwsSubscriber.isDestroyed()) {
kwsSubscriber.send(VOICE_IPC.kwsDetected, { keyword: msg.keyword ?? '', at: msg.at ?? Date.now() } satisfies KwsDetection);
}
log('INFO', 'voice', `wake word detected: ${msg.keyword}`);
return;
}
if (typeof msg.id !== 'number') return;
const p = pending.get(msg.id);
if (!p) return;
pending.delete(msg.id);
clearTimeout(p.timer);
if (msg.ok) p.resolve(msg.result);
else p.reject(new Error(msg.error ?? 'erreur moteur vocal'));
});
proc.on('exit', (code) => {
log(code === 0 ? 'INFO' : 'ERROR', 'voice-worker', `exited with code ${code}`);
// A newer worker may already have replaced this one; only clean up if we are still current.
if (child !== proc) return;
child = null;
rejectAll('Le moteur vocal local s’est arrêté de façon inattendue.');
// Keyword spotting survives a worker restart: re-arm on the next audio frame.
if (kwsActive) kwsArmed = false;
});
log('INFO', 'voice', 'voice worker started');
return proc;
}
function request<T>(message: Record<string, unknown>, timeoutMs = 120_000): Promise<T> {
const proc = spawn();
const id = nextId++;
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(id);
reject(new Error('Délai dépassé pour le moteur vocal local.'));
// The worker handles requests synchronously: a stuck request wedges everything behind it.
if (child === proc) {
log('WARN', 'voice', 'worker unresponsive, restarting');
stopEngine();
}
}, timeoutMs);
pending.set(id, { resolve: (v) => resolve(v as T), reject, timer });
try {
proc.postMessage({ id, ...message });
} catch (err) {
pending.delete(id);
clearTimeout(timer);
reject(err as Error);
}
});
}
function modelRef(modelId: string) {
const spec = findModel(modelId);
if (!spec) throw new Error(`Modèle inconnu : ${modelId}`);
if (!isInstalled(spec)) throw new Error(`Modèle « ${spec.name} » non installé. Téléchargez-le dans Paramètres → Modèles.`);
return { id: spec.id, engine: spec.engine, dir: modelDir(spec), files: spec.files };
}
export async function engineStatus(): Promise<VoiceEngineStatus> {
try {
const status = await request<Omit<VoiceEngineStatus, 'modelsDir'>>({ type: 'status' }, 20_000);
return { ...status, modelsDir: modelsDir() };
} catch (err) {
return { available: false, error: (err as Error).message, loaded: [], modelsDir: modelsDir() };
}
}
export function transcribe(req: TranscribeRequest): Promise<TranscribeResult> {
// Binary payloads are base64-encoded for the worker: the utility process channel rejects external buffers.
const wav = Buffer.from(req.wav.buffer, req.wav.byteOffset, req.wav.byteLength).toString('base64');
return request<TranscribeResult>({ type: 'transcribe', model: modelRef(req.modelId), wav, language: req.language }, 180_000);
}
export async function synthesize(req: SynthesizeRequest): Promise<SynthesizeResult> {
const result = await request<Omit<SynthesizeResult, 'wav'> & { wav: string }>(
{ type: 'synthesize', model: modelRef(req.modelId), text: req.text, speaker: req.speaker, speed: req.speed, language: req.language },
180_000
);
const buffer = Buffer.from(result.wav, 'base64');
return { ...result, wav: new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength) };
}
let kwsArmed = false;
const SENSITIVITY_THRESHOLD: Record<number, number> = { 1: 0.45, 2: 0.35, 3: 0.25, 4: 0.18, 5: 0.12 };
const SENSITIVITY_SCORE: Record<number, number> = { 1: 1.0, 2: 1.0, 3: 1.2, 4: 1.5, 5: 2.0 };
/** Start keyword spotting; the keywords file is derived from the phrases and the model vocabulary. */
export async function kwsStart(req: KwsStartRequest, sender: WebContents): Promise<{ accepted: string[]; rejected: string[] }> {
const spec = findModel(req.modelId);
if (!spec || spec.kind !== 'kws') throw new Error('Modèle de détection introuvable');
if (!isInstalled(spec)) throw new Error('Détecteur de mot-clé non installé (Paramètres → Modèles locaux).');
const dir = modelDir(spec);
const vocab = parseTokens(fs.readFileSync(path.join(dir, 'tokens.txt'), 'utf8'));
const phrases = req.keywords.map((k) => String(k).slice(0, 40)).filter(Boolean).slice(0, 8);
const file = buildKeywordsFile(phrases, vocab);
if (file.accepted.length === 0) throw new Error(`Aucun mot d’activation encodable : ${file.rejected.join(', ')}`);
const hash = createHash('sha1').update(file.content).digest('hex').slice(0, 10);
const keywordsFile = path.join(modelsDir(), `keywords-${hash}.txt`);
fs.writeFileSync(keywordsFile, file.content, 'utf8');
const sensitivity = Math.min(5, Math.max(1, Math.round(req.sensitivity))) as 1 | 2 | 3 | 4 | 5;
kwsSubscriber = sender;
kwsRequest = req;
await request({ type: 'kws.start', model: { id: spec.id, engine: spec.engine, dir, files: spec.files }, keywordsFile, threshold: SENSITIVITY_THRESHOLD[sensitivity], score: SENSITIVITY_SCORE[sensitivity] }, 60_000);
kwsActive = true;
kwsArmed = true;
log('INFO', 'voice', `keyword spotting on: ${file.accepted.join(', ')} (threshold ${SENSITIVITY_THRESHOLD[sensitivity]})`);
return { accepted: file.accepted, rejected: file.rejected };
}
export async function kwsStop(): Promise<void> {
kwsActive = false;
kwsArmed = false;
kwsSubscriber = null;
if (child) await request({ type: 'kws.stop' }, 10_000).catch(() => undefined);
}
/** Feed 16-bit PCM from the renderer (fire-and-forget). */
export function kwsFeed(pcm: Uint8Array, sampleRate: number): void {
if (!kwsActive) return;
const proc = spawn();
if (!kwsArmed) {
// Worker restarted: re-create the spotter before feeding audio.
if (kwsRequest && kwsSubscriber) {
kwsArmed = true;
kwsStart(kwsRequest, kwsSubscriber).catch((err) => log('WARN', 'voice', `kws re-arm failed: ${(err as Error).message}`));
}
return;
}
const b64 = Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength).toString('base64');
try {
proc.postMessage({ id: -1, type: 'kws.audio', pcm: b64, sampleRate });
} catch (err) {
log('WARN', 'voice', `kws feed failed: ${(err as Error).message}`);
}
}
/** Neural end-of-speech detection (Silero) on frames streamed by the renderer. */
export async function vadStart(req: VadStartRequest, sender: WebContents): Promise<void> {
const spec = findModel(req.modelId);
if (!spec || spec.kind !== 'vad') throw new Error('Modèle VAD introuvable');
if (!isInstalled(spec)) throw new Error('Silero VAD non installé (Paramètres → Modèles locaux).');
vadSubscriber = sender;
await request(
{ type: 'vad.start', model: { id: spec.id, engine: spec.engine, dir: modelDir(spec), files: spec.files }, silenceMs: req.silenceMs, threshold: req.threshold, maxUtteranceSec: req.maxUtteranceSec },
30_000
);
vadActive = true;
}
export async function vadStop(): Promise<void> {
vadActive = false;
vadSubscriber = null;
if (child) await request({ type: 'vad.stop' }, 10_000).catch(() => undefined);
}
export function vadFeed(pcm: Uint8Array, sampleRate: number): void {
if (!vadActive || !child) return;
const b64 = Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength).toString('base64');
try {
child.postMessage({ id: -1, type: 'vad.audio', pcm: b64, sampleRate });
} catch (err) {
log('WARN', 'voice', `vad feed failed: ${(err as Error).message}`);
}
}
/** Native model memory is only released deterministically by restarting the worker. */
export function unload(_modelId?: string): Promise<unknown> {
stopEngine();
return Promise.resolve({ ok: true });
}
export function stopEngine(): void {
const proc = child;
child = null;
if (proc) {
rejectAll('Moteur vocal redémarré.');
proc.kill();
}
}
+60
View File
@@ -0,0 +1,60 @@
import { ipcMain } from 'electron';
import { VOICE_IPC, type EdgeSynthesizeRequest, type KwsStartRequest, type SynthesizeRequest, type TranscribeRequest, type VadStartRequest } from '../../shared/voice';
import { edgeSynthesize, edgeVoices } from './edgeTts';
import { engineStatus, kwsFeed, kwsStart, kwsStop, synthesize, transcribe, unload, vadFeed, vadStart, vadStop } from './engine';
import { cancelDownload, downloadModel, listModels, removeModel } from './models';
export function registerVoiceIpc(): void {
ipcMain.handle(VOICE_IPC.status, () => engineStatus());
ipcMain.handle(VOICE_IPC.modelsList, () => listModels());
ipcMain.handle(VOICE_IPC.modelsDownload, (event, id: string) => downloadModel(id, event.sender));
ipcMain.handle(VOICE_IPC.modelsCancel, (_e, id: string) => {
cancelDownload(id);
return true;
});
ipcMain.handle(VOICE_IPC.modelsRemove, async (_e, id: string) => {
await unload(id).catch(() => undefined);
await removeModel(id);
return listModels();
});
ipcMain.handle(VOICE_IPC.transcribe, (_e, req: TranscribeRequest) => {
if (!req || !(req.wav instanceof Uint8Array) || req.wav.byteLength < 44 || req.wav.byteLength > 64 * 1024 * 1024) throw new Error('Audio invalide');
if (typeof req.modelId !== 'string') throw new Error('Modèle invalide');
return transcribe({ ...req, language: typeof req.language === 'string' ? req.language.slice(0, 8) : 'auto' });
});
ipcMain.handle(VOICE_IPC.synthesize, (_e, req: SynthesizeRequest) => {
if (!req || typeof req.text !== 'string' || !req.text.trim() || req.text.length > 5000) throw new Error('Texte invalide');
if (typeof req.modelId !== 'string') throw new Error('Modèle invalide');
return synthesize({
...req,
speaker: Number.isFinite(req.speaker) ? req.speaker : 0,
speed: Number.isFinite(req.speed) ? req.speed : 1,
language: typeof req.language === 'string' ? req.language.slice(0, 8) : undefined
});
});
ipcMain.handle(VOICE_IPC.edgeSynthesize, (_e, req: EdgeSynthesizeRequest) => {
if (!req || typeof req.text !== 'string' || !req.text.trim() || req.text.length > 5000) throw new Error('Texte invalide');
if (typeof req.voice !== 'string' || !/^[a-z]{2,3}-[A-Za-z]{2,4}-[A-Za-z0-9]+$/.test(req.voice)) throw new Error('Voix Edge invalide');
return edgeSynthesize({ text: req.text, voice: req.voice, speed: Number.isFinite(req.speed) ? req.speed : 1 });
});
ipcMain.handle(VOICE_IPC.edgeVoices, () => edgeVoices());
ipcMain.handle(VOICE_IPC.unload, (_e, id?: string) => unload(id));
ipcMain.handle(VOICE_IPC.kwsStart, (event, req: KwsStartRequest) => {
if (!req || !Array.isArray(req.keywords) || typeof req.modelId !== 'string') throw new Error('Requête invalide');
return kwsStart({ ...req, keywords: req.keywords.filter((k) => typeof k === 'string'), sensitivity: Number(req.sensitivity) || 3 }, event.sender);
});
ipcMain.handle(VOICE_IPC.kwsStop, () => kwsStop());
ipcMain.handle(VOICE_IPC.vadStart, (event, req: VadStartRequest) => {
if (!req || typeof req.modelId !== 'string') throw new Error('Requête invalide');
return vadStart({ modelId: req.modelId, silenceMs: Number(req.silenceMs) || 700, threshold: Number(req.threshold) || 0.5, maxUtteranceSec: Number(req.maxUtteranceSec) || 30 }, event.sender);
});
ipcMain.handle(VOICE_IPC.vadStop, () => vadStop());
ipcMain.on(VOICE_IPC.vadAudio, (_e, pcm: unknown, sampleRate: unknown) => {
if (!(pcm instanceof Uint8Array) || pcm.byteLength === 0 || pcm.byteLength > 1024 * 1024) return;
vadFeed(pcm, typeof sampleRate === 'number' && sampleRate > 0 ? sampleRate : 16000);
});
ipcMain.on(VOICE_IPC.kwsAudio, (_e, pcm: unknown, sampleRate: unknown) => {
if (!(pcm instanceof Uint8Array) || pcm.byteLength === 0 || pcm.byteLength > 1024 * 1024) return;
kwsFeed(pcm, typeof sampleRate === 'number' && sampleRate > 0 ? sampleRate : 16000);
});
}
+159
View File
@@ -0,0 +1,159 @@
/**
* Model manager: downloads sherpa-onnx model archives (tar.bz2) into userData/models,
* extracts them, verifies required files and reports progress to the renderer.
*/
import { app, type WebContents } from 'electron';
import fs from 'node:fs';
import path from 'node:path';
import { pipeline } from 'node:stream/promises';
import { Readable, Transform } from 'node:stream';
import * as tar from 'tar';
import unbzip2 from 'unbzip2-stream';
import { VOICE_IPC, type VoiceDownloadProgress, type VoiceModelSpec, type VoiceModelStatus } from '../../shared/voice';
import { VOICE_CATALOG, findModel } from './catalog';
import { stopEngine } from './engine';
import { log } from '../logger';
const active = new Map<string, AbortController>();
/** Windows keeps files locked briefly (AV scans, memory-mapped models): retry removals. */
const RM = { recursive: true, force: true, maxRetries: 10, retryDelay: 200 } as const;
const DOWNLOAD_IDLE_MS = 60_000;
export function modelsDir(): string {
const dir = path.join(app.getPath('userData'), 'models');
fs.mkdirSync(dir, { recursive: true });
return dir;
}
export function modelDir(spec: VoiceModelSpec): string {
return path.join(modelsDir(), spec.dir);
}
export function isInstalled(spec: VoiceModelSpec): boolean {
const dir = modelDir(spec);
return spec.files.every((f) => fs.existsSync(path.join(dir, f)));
}
function dirSize(dir: string): number {
let total = 0;
try {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) total += dirSize(full);
else total += fs.statSync(full).size;
}
} catch {
/* missing */
}
return total;
}
export function listModels(): VoiceModelStatus[] {
return VOICE_CATALOG.map((spec) => {
const installed = isInstalled(spec);
return {
...spec,
installed,
downloading: active.has(spec.id),
installedBytes: installed ? dirSize(modelDir(spec)) : 0
};
});
}
export async function removeModel(id: string): Promise<void> {
const spec = findModel(id);
if (!spec) throw new Error(`Modèle inconnu : ${id}`);
stopEngine(); // releases memory-mapped model files before deleting them
await fs.promises.rm(modelDir(spec), RM);
log('INFO', 'voice', `model removed: ${id}`);
}
export function cancelDownload(id: string): void {
active.get(id)?.abort();
}
export async function downloadModel(id: string, sender: WebContents | null): Promise<VoiceModelStatus> {
const spec = findModel(id);
if (!spec) throw new Error(`Modèle inconnu : ${id}`);
if (active.has(id)) throw new Error('Téléchargement déjà en cours');
const controller = new AbortController();
active.set(id, controller);
const report = (progress: Omit<VoiceDownloadProgress, 'id'>) => {
if (sender && !sender.isDestroyed()) sender.send(VOICE_IPC.modelsProgress, { id, ...progress });
};
const tmpDir = path.join(modelsDir(), `.tmp-${spec.id}`);
await fs.promises.rm(tmpDir, RM);
await fs.promises.mkdir(tmpDir, { recursive: true });
// Abort the transfer when no byte arrives for a while (fetch itself has no inactivity timeout).
let idleTimer: ReturnType<typeof setTimeout> | null = null;
const touch = () => {
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(() => controller.abort(new Error('Téléchargement interrompu (aucune donnée reçue)')), DOWNLOAD_IDLE_MS);
};
try {
log('INFO', 'voice', `downloading ${spec.id} from ${spec.url}`);
const response = await fetch(spec.url, { signal: controller.signal, redirect: 'follow' });
if (!response.ok || !response.body) throw new Error(`HTTP ${response.status} lors du téléchargement`);
const total = Number(response.headers.get('content-length')) || Math.round(spec.sizeMb * 1024 * 1024);
let received = 0;
let lastReport = 0;
touch();
const counter = new Transform({
transform(chunk: Buffer, _enc, cb) {
received += chunk.length;
touch();
const now = Date.now();
if (now - lastReport > 250) {
lastReport = now;
report({ phase: 'download', received, total, percent: Math.min(99, Math.round((received / total) * 100)) });
}
cb(null, chunk);
}
});
const source = Readable.fromWeb(response.body as import('node:stream/web').ReadableStream<Uint8Array>);
if (spec.url.endsWith('.tar.bz2')) {
report({ phase: 'download', received: 0, total, percent: 0 });
await pipeline(source, counter, unbzip2(), tar.x({ cwd: tmpDir }), { signal: controller.signal });
} else {
const target = path.join(tmpDir, spec.dir, path.basename(spec.url));
await fs.promises.mkdir(path.dirname(target), { recursive: true });
await pipeline(source, counter, fs.createWriteStream(target), { signal: controller.signal });
}
report({ phase: 'extract', received: total, total, percent: 99 });
const extracted = path.join(tmpDir, spec.dir);
const missing = spec.files.filter((f) => !fs.existsSync(path.join(extracted, f)));
if (missing.length) throw new Error(`Archive incomplète, fichiers manquants : ${missing.join(', ')}`);
const finalDir = modelDir(spec);
if (isInstalled(spec)) stopEngine();
await fs.promises.rm(finalDir, RM);
for (let attempt = 0; ; attempt++) {
try {
await fs.promises.rename(extracted, finalDir);
break;
} catch (err) {
const code = (err as NodeJS.ErrnoException).code ?? '';
if (attempt >= 10 || !['EPERM', 'EBUSY', 'ENOTEMPTY', 'EACCES'].includes(code)) throw err;
await new Promise((r) => setTimeout(r, 300));
}
}
await fs.promises.rm(tmpDir, RM);
report({ phase: 'done', received: total, total, percent: 100 });
log('INFO', 'voice', `model installed: ${spec.id}`);
} catch (err) {
await fs.promises.rm(tmpDir, RM).catch(() => undefined);
const e = (controller.signal.reason instanceof Error ? controller.signal.reason : err) as Error;
const cancelled = (e.name === 'AbortError' || controller.signal.aborted) && !(controller.signal.reason instanceof Error);
report({ phase: cancelled ? 'cancelled' : 'error', received: 0, total: 0, percent: 0, message: cancelled ? 'annulé' : e.message });
log(cancelled ? 'INFO' : 'ERROR', 'voice', `download ${spec.id} ${cancelled ? 'cancelled' : 'failed: ' + e.message}`);
if (!cancelled) throw new Error(e.message);
} finally {
if (idleTimer) clearTimeout(idleTimer);
active.delete(id);
}
return listModels().find((m) => m.id === id)!;
}
+476
View File
@@ -0,0 +1,476 @@
/**
* Voice worker: runs sherpa-onnx (speech recognition and synthesis) in an Electron utility
* process so heavy inference never blocks the main process. Also runnable with
* `child_process.fork` (advanced serialization) for local tests.
*/
import os from 'node:os';
import path from 'node:path';
import type { VoiceEngineKind } from '../../shared/voice';
import { SUPERTONIC_LANGS } from './catalog';
interface ModelRef {
id: string;
engine: VoiceEngineKind;
dir: string;
files: string[];
}
type Request =
| { id: number; type: 'status' }
| { id: number; type: 'transcribe'; model: ModelRef; wav: Uint8Array | string; language: string }
| { id: number; type: 'synthesize'; model: ModelRef; text: string; speaker: number; speed: number; language?: string }
| { id: number; type: 'unload'; modelId?: string }
| { id: number; type: 'kws.start'; model: ModelRef; keywordsFile: string; threshold: number; score: number }
| { id: number; type: 'kws.audio'; pcm: string; sampleRate: number }
| { id: number; type: 'kws.stop' }
| { id: number; type: 'vad.start'; model: ModelRef; silenceMs: number; threshold: number; maxUtteranceSec: number }
| { id: number; type: 'vad.audio'; pcm: string; sampleRate: number }
| { id: number; type: 'vad.stop' };
type Response = { id: number; ok: true; result: unknown } | { id: number; ok: false; error: string };
// ── sherpa-onnx loading (lazy, so a missing native package is reported, not fatal) ──
type Sherpa = {
OfflineRecognizer: new (config: unknown) => {
createStream: () => { acceptWaveform: (w: { sampleRate: number; samples: Float32Array }) => void };
decode: (s: unknown) => void;
getResult: (s: unknown) => { text: string; lang?: string };
};
Vad: new (config: unknown, bufferSizeInSeconds: number) => {
acceptWaveform: (samples: Float32Array) => void;
isEmpty: () => boolean;
isDetected: () => boolean;
pop: () => void;
clear: () => void;
front: (enableExternalBuffer?: boolean) => { start: number; samples: Float32Array };
reset: () => void;
flush: () => void;
};
KeywordSpotter: new (config: unknown) => {
createStream: () => KwsStream;
isReady: (s: KwsStream) => boolean;
decode: (s: KwsStream) => void;
reset: (s: KwsStream) => void;
getResult: (s: KwsStream) => { keyword?: string };
};
OfflineTts: new (config: unknown) => {
numSpeakers: number;
sampleRate: number;
generate: (req: { text: string; sid: number; speed: number; enableExternalBuffer?: boolean; generationConfig?: unknown }) => { samples: Float32Array; sampleRate: number };
};
/** Per-request options for the newer engines (Supertonic reads `extra.lang`). */
GenerationConfig: new (opts: { sid: number; speed: number; numSteps?: number; extra?: Record<string, string | number> }) => unknown;
version: string;
};
type KwsStream = { acceptWaveform: (w: { sampleRate: number; samples: Float32Array }) => void };
let sherpa: Sherpa | null = null;
let kws: { spotter: InstanceType<Sherpa['KeywordSpotter']>; stream: KwsStream } | null = null;
let vad: { detector: InstanceType<Sherpa['Vad']>; speaking: boolean; windowSize: number; carry: Float32Array } | null = null;
let notify: ((message: unknown) => void) | null = null;
let loadError: string | null = null;
function loadSherpa(): Sherpa {
if (sherpa) return sherpa;
if (loadError) throw new Error(loadError);
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
sherpa = require('sherpa-onnx-node') as Sherpa;
return sherpa;
} catch (err) {
loadError = `Module natif sherpa-onnx indisponible : ${(err as Error).message}`;
throw new Error(loadError);
}
}
const threads = Math.max(2, Math.min(6, Math.floor(os.cpus().length / 2)));
// ── caches ────────────────────────────────────────────────────────────────
const recognizers = new Map<string, InstanceType<Sherpa['OfflineRecognizer']>>();
const synthesizers = new Map<string, InstanceType<Sherpa['OfflineTts']>>();
function whisperPrefix(model: ModelRef): string {
const encoder = model.files.find((f) => f.includes('-encoder'));
return encoder ? encoder.slice(0, encoder.indexOf('-encoder')) : 'base';
}
function getRecognizer(model: ModelRef, language: string) {
const lang = language === 'auto' ? '' : language.split('-')[0].toLowerCase();
const key = `${model.id}:${lang}`;
const cached = recognizers.get(key);
if (cached) return cached;
const s = loadSherpa();
const p = (f: string) => path.join(model.dir, f);
let modelConfig: Record<string, unknown>;
switch (model.engine) {
case 'whisper': {
const prefix = whisperPrefix(model);
modelConfig = {
whisper: { encoder: p(`${prefix}-encoder.int8.onnx`), decoder: p(`${prefix}-decoder.int8.onnx`), language: lang, task: 'transcribe', tailPaddings: -1 },
tokens: p(`${prefix}-tokens.txt`)
};
break;
}
case 'sense-voice':
modelConfig = { senseVoice: { model: p('model.int8.onnx'), language: lang || 'auto', useInverseTextNormalization: 1 }, tokens: p('tokens.txt') };
break;
case 'nemo-transducer':
modelConfig = {
transducer: { encoder: p('encoder.int8.onnx'), decoder: p('decoder.int8.onnx'), joiner: p('joiner.int8.onnx') },
tokens: p('tokens.txt'),
modelType: 'nemo_transducer'
};
break;
default:
throw new Error(`Moteur STT non supporté : ${model.engine}`);
}
for (const [k, v] of Object.entries({ numThreads: threads, provider: 'cpu', debug: 0 })) modelConfig[k] = v;
// Whisper keeps one recognizer per language; other engines ignore the language key.
for (const [k, r] of recognizers) if (k.startsWith(`${model.id}:`)) recognizers.delete(k) && void r;
const recognizer = new s.OfflineRecognizer({ featConfig: { sampleRate: 16000, featureDim: 80 }, modelConfig, decodingMethod: 'greedy_search' });
recognizers.set(key, recognizer);
return recognizer;
}
function getSynthesizer(model: ModelRef) {
const cached = synthesizers.get(model.id);
if (cached) return cached;
const s = loadSherpa();
const p = (f: string) => path.join(model.dir, f);
let ttsModel: Record<string, unknown>;
switch (model.engine) {
case 'kokoro':
ttsModel = {
kokoro: {
model: p('model.onnx'),
voices: p('voices.bin'),
tokens: p('tokens.txt'),
dataDir: p('espeak-ng-data'),
lexicon: [p('lexicon-us-en.txt'), p('lexicon-zh.txt')].join(',')
}
};
break;
case 'piper': {
const onnx = model.files.find((f) => f.endsWith('.onnx')) ?? 'model.onnx';
ttsModel = { vits: { model: p(onnx), tokens: p('tokens.txt'), dataDir: p('espeak-ng-data') } };
break;
}
case 'supertonic':
ttsModel = {
supertonic: {
durationPredictor: p('duration_predictor.int8.onnx'),
textEncoder: p('text_encoder.int8.onnx'),
vectorEstimator: p('vector_estimator.int8.onnx'),
vocoder: p('vocoder.int8.onnx'),
ttsJson: p('tts.json'),
unicodeIndexer: p('unicode_indexer.bin'),
voiceStyle: p('voice.bin')
}
};
break;
default:
throw new Error(`Moteur TTS non supporté : ${model.engine}`);
}
const tts = new s.OfflineTts({ model: { ...ttsModel, numThreads: threads, provider: 'cpu', debug: 0 }, maxNumSentences: 1 });
synthesizers.set(model.id, tts);
return tts;
}
/** 2-letter code accepted by Supertonic 3 ("fr-FR" → "fr"); English when unknown, as upstream does. */
function supertonicLang(language: string | undefined): string {
const code = (language ?? '').toLowerCase().split(/[-_]/)[0];
return SUPERTONIC_LANGS.has(code) ? code : 'en';
}
// ── audio helpers ──────────────────────────────────────────────────────────
function decodeWav(bytes: Uint8Array): { samples: Float32Array; sampleRate: number } {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
if (String.fromCharCode(bytes[0], bytes[1], bytes[2], bytes[3]) !== 'RIFF' || String.fromCharCode(bytes[8], bytes[9], bytes[10], bytes[11]) !== 'WAVE') {
throw new Error('WAV invalide');
}
let offset = 12;
let sampleRate = 16000;
let channels = 1;
let bits = 16;
let format = 1; // 1 = PCM, 3 = IEEE float
let data: { start: number; length: number } | null = null;
while (offset + 8 <= bytes.byteLength) {
const id = String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]);
const size = view.getUint32(offset + 4, true);
if (id === 'fmt ') {
format = view.getUint16(offset + 8, true);
channels = view.getUint16(offset + 10, true);
sampleRate = view.getUint32(offset + 12, true);
bits = view.getUint16(offset + 22, true);
} else if (id === 'data') {
data = { start: offset + 8, length: Math.min(size, bytes.byteLength - offset - 8) };
break;
}
offset += 8 + size + (size % 2);
}
if (!data) throw new Error('WAV sans données');
const bytesPerSample = bits / 8;
const frames = Math.floor(data.length / bytesPerSample / channels);
const samples = new Float32Array(frames);
for (let i = 0; i < frames; i++) {
let sum = 0;
for (let c = 0; c < channels; c++) {
const pos = data.start + (i * channels + c) * bytesPerSample;
sum +=
format === 3 && bits === 32
? view.getFloat32(pos, true)
: bits === 16
? view.getInt16(pos, true) / 32768
: bits === 24
? (((bytes[pos] | (bytes[pos + 1] << 8) | (bytes[pos + 2] << 16)) << 8) >> 8) / 8388608
: bits === 32
? view.getInt32(pos, true) / 2147483648
: (bytes[pos] - 128) / 128;
}
samples[i] = sum / channels;
}
return { samples, sampleRate };
}
function resampleTo16k(samples: Float32Array, rate: number): Float32Array {
if (rate === 16000) return samples;
const ratio = rate / 16000;
const out = new Float32Array(Math.round(samples.length / ratio));
for (let i = 0; i < out.length; i++) {
const pos = i * ratio;
const idx = Math.floor(pos);
const frac = pos - idx;
const a = samples[Math.min(idx, samples.length - 1)];
const b = samples[Math.min(idx + 1, samples.length - 1)];
out[i] = a + (b - a) * frac;
}
return out;
}
function encodeWav(samples: Float32Array, sampleRate: number): Uint8Array {
const buffer = new ArrayBuffer(44 + samples.length * 2);
const view = new DataView(buffer);
const str = (o: number, s: string) => {
for (let i = 0; i < s.length; i++) view.setUint8(o + i, s.charCodeAt(i));
};
str(0, 'RIFF');
view.setUint32(4, 36 + samples.length * 2, true);
str(8, 'WAVE');
str(12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, 1, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * 2, true);
view.setUint16(32, 2, true);
view.setUint16(34, 16, true);
str(36, 'data');
view.setUint32(40, samples.length * 2, true);
let o = 44;
for (let i = 0; i < samples.length; i++, o += 2) {
const s = Math.max(-1, Math.min(1, samples[i]));
view.setInt16(o, s < 0 ? s * 0x8000 : s * 0x7fff, true);
}
return new Uint8Array(buffer);
}
function startKws(model: ModelRef, keywordsFile: string, threshold: number, score: number): void {
const s = loadSherpa();
kws = null;
const p = (f: string) => path.join(model.dir, f);
const enc = model.files.find((f) => f.startsWith('encoder')) ?? 'encoder.int8.onnx';
const dec = model.files.find((f) => f.startsWith('decoder')) ?? 'decoder.int8.onnx';
const join = model.files.find((f) => f.startsWith('joiner')) ?? 'joiner.int8.onnx';
const spotter = new s.KeywordSpotter({
featConfig: { sampleRate: 16000, featureDim: 80 },
modelConfig: { transducer: { encoder: p(enc), decoder: p(dec), joiner: p(join) }, tokens: p('tokens.txt'), numThreads: 1, provider: 'cpu', debug: 0 },
maxActivePaths: 4,
numTrailingBlanks: 1,
keywordsScore: score,
keywordsThreshold: threshold,
keywordsFile
});
kws = { spotter, stream: spotter.createStream() };
}
function feedKws(pcmBase64: string, sampleRate: number): void {
if (!kws) return;
const bytes = Buffer.from(pcmBase64, 'base64');
const int16 = new Int16Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 2));
let samples: Float32Array = new Float32Array(int16.length);
for (let i = 0; i < int16.length; i++) samples[i] = int16[i] / 32768;
if (sampleRate !== 16000) samples = resampleTo16k(samples, sampleRate);
kws.stream.acceptWaveform({ sampleRate: 16000, samples });
while (kws.spotter.isReady(kws.stream)) {
kws.spotter.decode(kws.stream);
const result = kws.spotter.getResult(kws.stream);
if (result.keyword) {
kws.spotter.reset(kws.stream);
notify?.({ type: 'kws.detected', keyword: result.keyword, at: Date.now() });
}
}
}
function startVad(model: ModelRef, silenceMs: number, threshold: number, maxUtteranceSec: number): void {
const s = loadSherpa();
const windowSize = 512;
const detector = new s.Vad(
{
sileroVad: {
model: path.join(model.dir, 'silero_vad.onnx'),
threshold: Math.max(0.1, Math.min(0.95, threshold)),
minSilenceDuration: Math.max(0.15, silenceMs / 1000),
minSpeechDuration: 0.2,
windowSize,
maxSpeechDuration: Math.max(3, maxUtteranceSec)
},
sampleRate: 16000,
numThreads: 1,
provider: 'cpu',
debug: 0
},
Math.max(10, maxUtteranceSec + 5)
);
vad = { detector, speaking: false, windowSize, carry: new Float32Array(0) };
}
function feedVad(pcmBase64: string, sampleRate: number): void {
if (!vad) return;
const bytes = Buffer.from(pcmBase64, 'base64');
const int16 = new Int16Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 2));
let samples: Float32Array = new Float32Array(int16.length);
for (let i = 0; i < int16.length; i++) samples[i] = int16[i] / 32768;
if (sampleRate !== 16000) samples = resampleTo16k(samples, sampleRate);
// Silero expects fixed windows: keep the remainder for the next frame.
const merged = new Float32Array(vad.carry.length + samples.length);
merged.set(vad.carry, 0);
merged.set(samples, vad.carry.length);
const usable = merged.length - (merged.length % vad.windowSize);
for (let i = 0; i < usable; i += vad.windowSize) {
vad.detector.acceptWaveform(merged.subarray(i, i + vad.windowSize));
const detected = vad.detector.isDetected();
if (detected && !vad.speaking) {
vad.speaking = true;
notify?.({ type: 'vad.event', event: { type: 'speech-start' } });
}
while (!vad.detector.isEmpty()) {
const segment = vad.detector.front(false);
vad.detector.pop();
vad.speaking = false;
const wav = encodeWav(segment.samples, 16000);
notify?.({
type: 'vad.event',
event: { type: 'segment', wav: Buffer.from(wav.buffer, wav.byteOffset, wav.byteLength).toString('base64'), durationSec: segment.samples.length / 16000 }
});
}
}
vad.carry = merged.slice(usable);
}
// ── request handling ───────────────────────────────────────────────────────
function handle(req: Request): unknown {
switch (req.type) {
case 'vad.start':
startVad(req.model, req.silenceMs, req.threshold, req.maxUtteranceSec);
return { ok: true };
case 'vad.audio':
feedVad(req.pcm, req.sampleRate);
return { ok: true };
case 'vad.stop':
vad = null;
return { ok: true };
case 'kws.start':
startKws(req.model, req.keywordsFile, req.threshold, req.score);
return { ok: true };
case 'kws.audio':
feedKws(req.pcm, req.sampleRate);
return { ok: true };
case 'kws.stop':
kws = null;
return { ok: true };
case 'status': {
try {
const s = loadSherpa();
return { available: true, version: s.version, loaded: [...recognizers.keys(), ...synthesizers.keys(), ...(kws ? ['kws'] : [])] };
} catch (err) {
return { available: false, error: (err as Error).message, loaded: [] };
}
}
case 'transcribe': {
const started = Date.now();
// Audio crosses the process boundary as base64: V8 refuses to serialize external buffers.
const bytes = typeof req.wav === 'string' ? new Uint8Array(Buffer.from(req.wav, 'base64')) : req.wav;
const { samples, sampleRate } = decodeWav(bytes);
const raw = resampleTo16k(samples, sampleRate);
if (raw.length < 1600) throw new Error('Audio trop court');
// 0.4 s of silence on both sides: utterances cut close to the words lose the first/last syllable
// and short clips make Whisper hallucinate.
const pad = 6400;
const pcm = new Float32Array(raw.length + 2 * pad);
pcm.set(raw, pad);
const recognizer = getRecognizer(req.model, req.language);
const stream = recognizer.createStream();
stream.acceptWaveform({ sampleRate: 16000, samples: pcm });
recognizer.decode(stream);
const result = recognizer.getResult(stream);
return { text: (result.text ?? '').trim(), language: result.lang, durationMs: Date.now() - started, audioSec: pcm.length / 16000 };
}
case 'synthesize': {
const started = Date.now();
const tts = getSynthesizer(req.model);
const sid = Math.max(0, Math.min(tts.numSpeakers - 1, Math.floor(req.speaker)));
const speed = Math.max(0.5, Math.min(2, req.speed || 1));
// Electron forbids N-API external buffers: ask sherpa-onnx to copy the samples into a V8 buffer.
const audio =
req.model.engine === 'supertonic'
? tts.generate({
text: req.text,
sid,
speed,
enableExternalBuffer: false,
// Supertonic needs the language of the text; 5 denoising steps is the quality/speed sweet spot.
generationConfig: new (loadSherpa().GenerationConfig)({ sid, speed, numSteps: 5, extra: { lang: supertonicLang(req.language) } })
})
: tts.generate({ text: req.text, sid, speed, enableExternalBuffer: false });
const wav = encodeWav(audio.samples, audio.sampleRate);
return {
wav: Buffer.from(wav.buffer, wav.byteOffset, wav.byteLength).toString('base64'),
sampleRate: audio.sampleRate,
durationMs: Date.now() - started,
audioSec: audio.samples.length / audio.sampleRate
};
}
case 'unload': {
if (req.modelId) {
for (const k of [...recognizers.keys()]) if (k.startsWith(`${req.modelId}:`)) recognizers.delete(k);
synthesizers.delete(req.modelId);
} else {
recognizers.clear();
synthesizers.clear();
kws = null;
vad = null;
}
return { ok: true };
}
default:
throw new Error('requête inconnue');
}
}
function respond(req: Request): Response {
try {
return { id: req.id, ok: true, result: handle(req) };
} catch (err) {
return { id: req.id, ok: false, error: (err as Error).message || String(err) };
}
}
// Electron utility process transport, with a child_process fallback for tests.
const parentPort = (process as unknown as { parentPort?: { on: (ev: 'message', cb: (e: { data: Request }) => void) => void; postMessage: (m: unknown) => void } }).parentPort;
if (parentPort) {
notify = (m) => parentPort.postMessage(m);
parentPort.on('message', (event) => parentPort.postMessage(respond(event.data)));
} else if (process.send) {
notify = (m) => process.send!(m);
process.on('message', (msg: Request) => process.send!(respond(msg)));
}
+137
View File
@@ -0,0 +1,137 @@
/**
* Local webhook server: Hermes (cron deliveries, gateway mirrors, scripts) pushes JSON here
* and EveFlow displays / speaks the result. POST /eveflow/hook, GET /health.
*/
import http from 'node:http';
import { timingSafeEqual } from 'node:crypto';
import type { BrowserWindow } from 'electron';
import { IPC, type WebhookStatus } from '../shared/ipc';
import { normalizeHermesPush } from '../shared/hermesPush';
import { log } from './logger';
import { handleMcpHttp, MCP_PATH } from './mcp';
export const WEBHOOK_PATH = '/eveflow/hook';
const MAX_BODY_BYTES = 2 * 1024 * 1024;
export interface WebhookOptions {
port: number;
secret?: string;
host?: string;
}
let server: http.Server | null = null;
let status: WebhookStatus = { listening: false, port: 7842, path: WEBHOOK_PATH, secretConfigured: false };
function secretMatches(provided: string | undefined, expected: string): boolean {
if (!provided) return false;
const a = Buffer.from(provided);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}
export function getWebhookStatus(): WebhookStatus {
return status;
}
export function stopWebhookServer(): Promise<void> {
return new Promise((resolve) => {
if (!server) return resolve();
const current = server;
server = null;
status = { ...status, listening: false };
current.closeAllConnections();
current.close(() => resolve());
});
}
export async function startWebhookServer(getWindow: () => BrowserWindow | null, options: WebhookOptions): Promise<WebhookStatus> {
await stopWebhookServer();
const { port, secret } = options;
// Without a shared secret the hook only listens on loopback: anyone on the LAN could otherwise inject messages.
const host = options.host ?? (secret ? '0.0.0.0' : '127.0.0.1');
if (host !== '127.0.0.1' && !secret) log('WARN', 'webhook', `listening on ${host} without a secret`);
server = http.createServer((req, res) => {
const json = (code: number, payload: unknown) => {
res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(payload));
};
if (req.method === 'GET' && (req.url === '/health' || req.url === '/')) {
return json(200, { ok: true, app: 'eveflow', path: WEBHOOK_PATH, mcp: MCP_PATH });
}
const isMcp = !!req.url && (req.url === MCP_PATH || req.url.startsWith(`${MCP_PATH}?`));
if (!isMcp && (req.method !== 'POST' || !req.url?.startsWith(WEBHOOK_PATH))) {
return json(404, { error: 'Not Found' });
}
if (secret) {
const auth = req.headers.authorization?.replace(/^Bearer\s+/i, '');
const custom = req.headers['x-eveflow-secret'];
const provided = typeof custom === 'string' ? custom : auth;
if (!secretMatches(provided, secret)) {
log('WARN', 'webhook', `rejected push from ${req.socket.remoteAddress}: bad secret`);
return json(401, { error: 'Unauthorized' });
}
}
const chunks: Buffer[] = [];
let bytes = 0;
let tooLarge = false;
req.on('data', (chunk: Buffer) => {
if (tooLarge) return;
bytes += chunk.length;
if (bytes > MAX_BODY_BYTES) {
tooLarge = true;
res.writeHead(413, { 'Content-Type': 'application/json', Connection: 'close' });
res.end(JSON.stringify({ error: 'Payload too large' }), () => req.socket.destroy());
return;
}
chunks.push(chunk);
});
req.on('end', () => {
if (tooLarge) return;
// Concatenate before decoding so multibyte UTF-8 (accents, emoji) split across chunks stays intact.
const body = Buffer.concat(chunks).toString('utf8');
if (isMcp) {
handleMcpHttp(getWindow(), req, res, body).catch((err: Error) => {
log('ERROR', 'mcp', err.message);
if (!res.headersSent) json(500, { error: err.message });
});
return;
}
try {
const raw: unknown = body.trim() ? JSON.parse(body) : {};
const events = normalizeHermesPush(raw);
log('INFO', 'webhook', `${events.length} event(s) from ${events[0]?.source ?? '?'} (${req.socket.remoteAddress})`);
const win = getWindow();
if (win && !win.isDestroyed()) {
for (const event of events) win.webContents.send(IPC.hermesPush, event);
}
json(200, { ok: true, events: events.length });
} catch (err) {
log('WARN', 'webhook', `invalid JSON payload: ${(err as Error).message}`);
json(400, { error: 'Invalid JSON' });
}
});
});
return new Promise((resolve) => {
const instance = server!;
instance.on('error', (err: NodeJS.ErrnoException) => {
log('ERROR', 'webhook', `server error: ${err.message}`);
status = { listening: false, port, path: WEBHOOK_PATH, secretConfigured: !!secret, error: err.message };
if (server === instance) server = null;
resolve(status);
});
if (!Number.isInteger(port) || port < 1 || port > 65535) {
instance.emit('error', new Error(`port invalide : ${port}`));
return;
}
instance.listen(port, host, () => {
log('INFO', 'webhook', `listening on http://${host}:${port}${WEBHOOK_PATH}`);
status = { listening: true, port, path: WEBHOOK_PATH, secretConfigured: !!secret };
resolve(status);
});
});
}
+156
View File
@@ -0,0 +1,156 @@
import { app, BrowserWindow, screen, shell } from 'electron';
import { EventEmitter } from 'node:events';
import fs from 'node:fs';
import path from 'node:path';
import { IPC, type WindowMode } from '../shared/ipc';
import { log } from './logger';
const HUD_SIZE = { width: 1320, height: 860, minWidth: 980, minHeight: 640 };
const COMPACT_SIZE = { width: 380, height: 620, minWidth: 320, minHeight: 420 };
let mainWindow: BrowserWindow | null = null;
let currentMode: WindowMode = 'hud';
export const windowEvents = new EventEmitter();
// Transparent frameless windows cannot be resized reliably on Windows; use an opaque, rounded window there.
const TRANSPARENT = process.platform !== 'win32';
let hudBounds: Electron.Rectangle | null = null;
export function getMainWindow(): BrowserWindow | null {
return mainWindow;
}
export function getWindowMode(): WindowMode {
return currentMode;
}
function fallbackHtml(reason: string): string {
return `<!doctype html><html><body style="margin:0;font-family:Segoe UI,Arial,sans-serif;background:#03070d;color:#9fe9ff;display:flex;align-items:center;justify-content:center;height:100vh">
<div style="max-width:560px;padding:28px;border:1px solid #1d5c70;border-radius:14px;background:#061018">
<h1 style="margin:0 0 12px;font-size:20px;letter-spacing:.08em">EVEFLOW - INTERFACE INDISPONIBLE</h1>
<p style="line-height:1.5">${reason}</p><p>Lancez <code>npm run build</code> puis relancez l'application.</p></div></body></html>`;
}
async function loadRenderer(win: BrowserWindow): Promise<void> {
const devUrl = process.env.VITE_DEV_SERVER_URL;
if (devUrl && !app.isPackaged) {
try {
await win.loadURL(devUrl);
win.webContents.openDevTools({ mode: 'detach' });
return;
} catch (err) {
log('WARN', 'window', `dev server unreachable (${(err as Error).message}), loading built bundle`);
}
}
const builtIndex = path.join(__dirname, '..', 'dist', 'index.html');
if (fs.existsSync(builtIndex)) {
await win.loadFile(builtIndex);
return;
}
await win.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(fallbackHtml('Le bundle dist/index.html est introuvable.')));
}
export function createMainWindow(): BrowserWindow {
const win = new BrowserWindow({
...HUD_SIZE,
frame: false,
transparent: TRANSPARENT,
backgroundColor: TRANSPARENT ? '#00000000' : '#03070d',
roundedCorners: true,
hasShadow: true,
show: false,
title: 'EveFlow',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
webSecurity: true,
allowRunningInsecureContent: false,
backgroundThrottling: false,
spellcheck: false
}
});
mainWindow = win;
win.once('ready-to-show', () => {
setTimeout(() => {
if (!win.isDestroyed()) win.show();
}, 60);
});
win.webContents.setWindowOpenHandler(({ url }) => {
if (/^https?:\/\//i.test(url)) void shell.openExternal(url);
return { action: 'deny' };
});
win.webContents.on('will-navigate', (event, url) => {
if (url === win.webContents.getURL()) return;
event.preventDefault();
if (/^https?:\/\//i.test(url)) void shell.openExternal(url);
});
win.webContents.on('will-redirect', (event) => event.preventDefault());
const notifyVisibility = () => {
if (!win.isDestroyed()) win.webContents.send(IPC.windowVisibility, win.isVisible() && !win.isMinimized());
};
win.on('show', notifyVisibility);
win.on('hide', notifyVisibility);
win.on('minimize', notifyVisibility);
win.on('restore', notifyVisibility);
win.on('closed', () => {
mainWindow = null;
});
loadRenderer(win).catch((err) => {
log('ERROR', 'window', `renderer load failed: ${(err as Error).message}`);
void win.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(fallbackHtml("Une erreur inattendue a interrompu le chargement de l'interface.")));
});
return win;
}
export function setWindowMode(mode: WindowMode): void {
const win = mainWindow;
if (!win || win.isDestroyed()) return;
if (mode === currentMode) {
win.webContents.send(IPC.windowModeChanged, mode);
return;
}
if (mode === 'compact') {
hudBounds = win.getBounds();
const display = screen.getDisplayMatching(win.getBounds());
const area = display.workArea;
win.setMinimumSize(COMPACT_SIZE.minWidth, COMPACT_SIZE.minHeight);
win.setBounds({
x: area.x + area.width - COMPACT_SIZE.width - 24,
y: area.y + area.height - COMPACT_SIZE.height - 24,
width: COMPACT_SIZE.width,
height: COMPACT_SIZE.height
}, true);
win.setAlwaysOnTop(true, 'floating');
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
} else {
win.setAlwaysOnTop(false);
win.setVisibleOnAllWorkspaces(false);
win.setMinimumSize(HUD_SIZE.minWidth, HUD_SIZE.minHeight);
if (hudBounds) win.setBounds(hudBounds, true);
else {
win.setSize(HUD_SIZE.width, HUD_SIZE.height, true);
win.center();
}
}
currentMode = mode;
win.webContents.send(IPC.windowModeChanged, mode);
windowEvents.emit('mode', mode);
}
export function toggleWindowVisibility(): void {
const win = mainWindow;
if (!win || win.isDestroyed()) return;
if (win.isVisible() && win.isFocused()) {
win.hide();
} else {
win.show();
win.focus();
}
}
+4 -3
View File
@@ -2,10 +2,11 @@
<html lang="fr">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🤖</text></svg>" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="EveFlow - Assistant IA compagnon 3D rétro-futuriste." />
<title>EveFlow Companion</title>
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob: http: https:; media-src 'self' blob: data:; connect-src 'self' http: https: ws: wss: blob: data:; worker-src 'self' blob:;" />
<meta name="description" content="EveFlow — interface vocale JARVIS pour Hermes Agent." />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Ccircle cx='32' cy='32' r='26' fill='none' stroke='%2334e4ff' stroke-width='4'/%3E%3Ccircle cx='32' cy='32' r='10' fill='%2334e4ff'/%3E%3C/svg%3E" />
<title>EveFlow</title>
</head>
<body>
<div id="root"></div>
-755
View File
@@ -1,755 +0,0 @@
const { app, BrowserWindow, ipcMain, session } = require('electron');
const path = require('path');
const fs = require('fs');
const os = require('os');
// Autoriser le chargement de fichiers locaux via fetch sous le protocole file://
app.commandLine.appendSwitch('disable-web-security');
app.commandLine.appendSwitch('allow-file-access-from-files');
app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required');
let mainWindow;
// ── Single Instance Lock ───────────────────────────────────────────────────
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
process.exit(0);
}
app.on('second-instance', () => {
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
});
// ── Logging Setup ───────────────────────────────────────────────────────────
function getLogPath() {
const userDataDir = app.getPath('userData');
return path.join(userDataDir, 'eveflow.log');
}
function writeLogEntry(entry) {
try {
const logPath = getLogPath();
const line = `[${entry.ts}] [${entry.level}] [${entry.tag}] ${entry.message}` +
(entry.data !== undefined ? ' | ' + JSON.stringify(entry.data) : '') + '\n';
// Rotation simple : si le fichier dépasse 5 Mo, on le renomme et on repart
try {
const stat = fs.statSync(logPath);
if (stat.size > 5 * 1024 * 1024) {
fs.renameSync(logPath, logPath + '.old');
}
} catch { /* fichier inexistant — normal au premier démarrage */ }
fs.appendFileSync(logPath, line, 'utf8');
} catch (err) {
console.error('[main] Impossible d\'écrire dans le log:', err.message);
}
}
// ── Webhook Server (Hermes → EveFlow push events) ──────────────────────────
const http = require('http');
const WEBHOOK_PORT = 7842;
let webhookServer = null;
// Normalize any Hermes push payload into an array of { type, role, text, source } events.
// Handles multiple formats so EveFlow mirrors both sides of Telegram conversations.
function normalizeHermesEvents(raw) {
const src = raw.platform || raw.source || 'webhook';
const collectImages = (obj) => {
const images = [];
const push = (value) => {
if (!value) return;
if (typeof value === 'string') images.push(value);
else if (typeof value === 'object') push(value.url || value.href || value.path || value.file || value.file_url || value.media_url);
};
for (const key of ['image', 'image_url', 'photo', 'media', 'url', 'file', 'file_url', 'media_url']) {
push(obj?.[key]);
}
for (const key of ['images', 'photos', 'media_urls', 'attachments', 'files']) {
const value = obj?.[key];
if (Array.isArray(value)) value.forEach(push);
else push(value);
}
return [...new Set(images)];
};
const withImages = (event, obj) => {
const images = collectImages(obj);
return images.length ? { ...event, images } : event;
};
// Format A — { type:'message', payload:{ text, role?, platform? } }
if (raw.type === 'message' && raw.payload?.text) {
return [withImages({
type: 'message',
role: raw.payload.role || 'assistant',
text: raw.payload.text,
source: raw.payload.platform || src
}, raw.payload)];
}
// Format B — run completed: { event:'run.completed', input?, output, platform? }
if ((raw.event === 'run.completed' || raw.type === 'run.completed') && raw.output) {
const events = [];
if (raw.input) events.push({ type: 'message', role: 'user', text: raw.input, source: src });
events.push(withImages({ type: 'message', role: 'assistant', text: raw.output, source: src }, raw));
return events;
}
// Format C — direct: { role, text, source? }
if (raw.role && raw.text) {
return [withImages({ type: 'message', role: raw.role, text: raw.text, source: src }, raw)];
}
// Format D — legacy: { type:'user_message'|'assistant_message', text }
if (raw.text && raw.type) {
const role = raw.type.includes('user') ? 'user' : 'assistant';
return [withImages({ type: 'message', role, text: raw.text, source: src }, raw)];
}
// Passthrough — let renderer decide
return [raw];
}
function startWebhookServer() {
webhookServer = http.createServer((req, res) => {
// Only accept POST /eveflow/hook
if (req.method !== 'POST' || req.url !== '/eveflow/hook') {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Not Found' }));
return;
}
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', () => {
try {
const raw = JSON.parse(body);
const events = normalizeHermesEvents(raw);
console.log(`[EveFlow Webhook] ${events.length} event(s) from ${events[0]?.source || '?'}`);
writeLogEntry({
ts: new Date().toISOString(),
level: 'INFO',
tag: 'Webhook',
message: `${events.length} event(s) received`,
data: events
});
// Forward each normalized event to the renderer
if (mainWindow && !mainWindow.isDestroyed()) {
for (const event of events) {
mainWindow.webContents.send('hermes-push', event);
}
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, events: events.length }));
} catch (err) {
console.error('[EveFlow Webhook] Parse error:', err.message);
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid JSON' }));
}
});
});
webhookServer.listen(WEBHOOK_PORT, '0.0.0.0', () => {
console.log(`[EveFlow] Webhook server listening on http://0.0.0.0:${WEBHOOK_PORT}/eveflow/hook`);
});
webhookServer.on('error', (err) => {
console.error(`[EveFlow] Webhook server error: ${err.message}`);
});
}
function stopWebhookServer() {
if (webhookServer) {
webhookServer.close();
webhookServer = null;
}
}
function loadFallbackPage(reason) {
const html = `
<html>
<body style="margin:0;font-family:Arial,sans-serif;background:#f4f8fc;color:#0f172a;display:flex;align-items:center;justify-content:center;height:100vh;">
<div style="max-width:560px;padding:28px;border:2px solid #0f172a;background:white;border-radius:18px;">
<h1 style="margin:0 0 12px;font-size:22px;">EveFlow n'a pas pu charger l'interface</h1>
<p style="line-height:1.5;">${reason}</p>
<p style="line-height:1.5;">Lance <code>npm run build</code>, puis relance l'application.</p>
</div>
</body>
</html>
`;
return mainWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
}
function loadBuiltApp() {
const builtIndex = path.join(__dirname, 'dist', 'index.html');
if (fs.existsSync(builtIndex)) {
return mainWindow.loadFile(builtIndex);
}
return loadFallbackPage("Le serveur Vite est indisponible et le dossier dist/index.html n'existe pas.");
}
async function loadApp() {
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged;
if (!isDev) {
await loadBuiltApp();
return;
}
const devServerUrl = process.env.VITE_DEV_SERVER_URL || 'http://localhost:5173';
try {
await mainWindow.loadURL(devServerUrl);
} catch (error) {
console.error(`Impossible de charger ${devServerUrl}; bascule sur le build local.`, error);
await loadBuiltApp();
}
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 1120,
height: 860,
minWidth: 900,
minHeight: 680,
frame: false, // Supprime la barre de titre classique pour un style rétro-futuriste personnalisé
transparent: true, // Permet la transparence (fond de fenêtre transparent)
backgroundColor: '#00000000', // Fond transparent
hasShadow: true,
show: false, // Cache la fenêtre au démarrage pour éviter les problèmes visuels de DWM
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true, // Sécurité Zero Trust active
nodeIntegration: false,
webSecurity: false, // Permet les requêtes HTTP vers API locales/LAN
allowRunningInsecureContent: true, // Autorise HTTP depuis le renderer (API locales)
backgroundThrottling: false, // Empêche Chromium de suspendre les processus audio/TTS en arrière-plan
},
});
mainWindow.once('ready-to-show', () => {
// Petit délai de sécurité (80ms) pour garantir la synchronisation DWM sous Windows
setTimeout(() => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.show();
}
}, 80);
});
loadApp().catch((error) => {
console.error("Echec du chargement de l'application.", error);
loadFallbackPage("Une erreur inattendue a interrompu le chargement de l'interface.");
});
mainWindow.on('closed', () => {
mainWindow = null;
});
}
app.whenReady().then(() => {
// Configurer l'intercepteur de requêtes pour Google TTS pour contourner le blocage HTTP 403 (User-Agent Electron)
session.defaultSession.webRequest.onBeforeSendHeaders(
{ urls: ['https://translate.google.com/*'] },
(details, callback) => {
details.requestHeaders['User-Agent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
details.requestHeaders['Referer'] = 'https://translate.google.com/';
callback({ cancel: false, requestHeaders: details.requestHeaders });
}
);
// Autoriser l'accès aux périphériques de capture (microphone) sous Electron
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback) => {
if (permission === 'media') {
return callback(true);
}
callback(false);
});
startWebhookServer();
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
app.on('window-all-closed', () => {
stopWebhookServer();
if (process.platform !== 'darwin') {
app.quit();
}
});
// IPC Handler pour le contrôle de la fenêtre (Header personnalisé)
ipcMain.on('window-control', (event, action) => {
if (!mainWindow) return;
switch (action) {
case 'minimize':
mainWindow.minimize();
break;
case 'maximize':
if (mainWindow.isMaximized()) {
mainWindow.unmaximize();
} else {
mainWindow.maximize();
}
break;
case 'close':
mainWindow.close();
break;
}
});
// IPC Handler pour basculer en mode flottant compact transparent (Eve Widget)
ipcMain.on('set-window-mode', (event, mode) => {
if (!mainWindow) return;
if (mode === 'floating') {
// Mode flottant compact : 300x600
mainWindow.setResizable(true);
mainWindow.setMinimumSize(250, 350);
mainWindow.setSize(300, 600);
mainWindow.setAlwaysOnTop(true, 'screen-saver');
// Positionner dans le coin inférieur droit de l'écran principal
const { screen } = require('electron');
const primaryDisplay = screen.getPrimaryDisplay();
const { width, height } = primaryDisplay.workAreaSize;
mainWindow.setPosition(width - 320, height - 620);
event.reply('window-mode-changed', 'floating');
} else {
// Mode normal : chat complet
mainWindow.setAlwaysOnTop(false);
mainWindow.setMinimumSize(900, 680);
mainWindow.setSize(1120, 860);
mainWindow.center();
event.reply('window-mode-changed', 'normal');
}
});
// IPC Handler pour ouvrir/fermer le cockpit sans ecraser Eve.
ipcMain.on('set-cockpit-mode', (_event, isOpen) => {
if (!mainWindow) return;
if (mainWindow.isMaximized()) return;
const { screen } = require('electron');
const display = screen.getDisplayMatching(mainWindow.getBounds());
const workArea = display.workArea;
const bounds = mainWindow.getBounds();
const targetWidth = isOpen ? 1460 : 1120;
const nextWidth = Math.min(targetWidth, Math.max(900, workArea.width - 40));
const nextHeight = Math.min(Math.max(bounds.height, 820), workArea.height - 40);
const nextX = Math.min(bounds.x, workArea.x + workArea.width - nextWidth - 20);
const nextY = Math.min(bounds.y, workArea.y + workArea.height - nextHeight - 20);
mainWindow.setMinimumSize(isOpen ? 1180 : 900, 680);
mainWindow.setBounds({
x: Math.max(workArea.x + 20, nextX),
y: Math.max(workArea.y + 20, nextY),
width: nextWidth,
height: nextHeight
}, true);
});
// ── IPC Handler : écriture des logs du renderer vers disque ─────────────────
ipcMain.on('write-log', (event, entry) => {
writeLogEntry(entry);
});
// ── IPC Handler : store persistant (indépendant de l'origine HTTP/file://) ──
// Lit/écrit C:\Users\...\AppData\Roaming\eveflow\userdata.json
function getStorePath() {
return path.join(app.getPath('userData'), 'userdata.json');
}
function readStoreFile() {
try {
const raw = fs.readFileSync(getStorePath(), 'utf8');
return JSON.parse(raw);
} catch {
return {};
}
}
function writeStoreFile(data) {
try {
fs.writeFileSync(getStorePath(), JSON.stringify(data, null, 2), 'utf8');
} catch (err) {
console.error('[EveFlow] Impossible d\'écrire userdata.json:', err.message);
}
}
ipcMain.handle('store-get', (_event, key) => {
const store = readStoreFile();
return store[key] ?? null;
});
ipcMain.handle('store-set', (_event, key, value) => {
const store = readStoreFile();
store[key] = value;
writeStoreFile(store);
return true;
});
// Handler de lecture de fichiers locaux sécurisé (Zéro Trust)
// Retourne une Data URL (base64) pour contourner les restrictions CORS de Chromium
ipcMain.handle('read-local-file', async (_event, filePath) => {
try {
if (!filePath || typeof filePath !== 'string') {
throw new Error("Chemin invalide");
}
// Normalisation du chemin
const normalizedPath = path.normalize(filePath);
// Prévention des attaques par traversée de répertoires
if (normalizedPath.includes('..')) {
throw new Error("Accès interdit (tentative de traversée de répertoires)");
}
// Validation des extensions de fichiers (Zéro Trust)
const ext = path.extname(normalizedPath).toLowerCase();
const allowedExtensions = [
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp',
'.txt', '.csv', '.json', '.md', '.log', '.xml'
];
if (!allowedExtensions.includes(ext)) {
throw new Error(`Type de fichier non autorisé : ${ext}`);
}
// Vérifier l'existence du fichier
if (!fs.existsSync(normalizedPath)) {
throw new Error("Fichier introuvable");
}
// Mappage des types MIME
let mimeType = 'application/octet-stream';
if (ext === '.png') mimeType = 'image/png';
else if (ext === '.jpg' || ext === '.jpeg') mimeType = 'image/jpeg';
else if (ext === '.gif') mimeType = 'image/gif';
else if (ext === '.webp') mimeType = 'image/webp';
else if (ext === '.svg') mimeType = 'image/svg+xml';
else if (ext === '.txt' || ext === '.log') mimeType = 'text/plain';
else if (ext === '.csv') mimeType = 'text/csv';
else if (ext === '.json') mimeType = 'application/json';
else if (ext === '.md') mimeType = 'text/markdown';
// Lecture du fichier sur le disque
const content = fs.readFileSync(normalizedPath);
const base64 = content.toString('base64');
return `data:${mimeType};base64,${base64}`;
} catch (err) {
console.error(`[main] Erreur de lecture de fichier local (${filePath}) :`, err.message);
writeLogEntry({
ts: new Date().toISOString(),
level: 'ERROR',
tag: 'IPC-File',
message: `Échec de lecture du fichier: ${filePath} - ${err.message}`
});
throw err;
}
});
// Handler pour charger une image distante via Node.js (contourne CORS et les restrictions du navigateur Electron)
// Retourne une Data URL base64 pour un affichage garanti sans flickering ni erreur CORS
ipcMain.handle('fetch-remote-image', async (_event, url, extraHeaders = {}) => {
const https = require('https');
const http = require('http');
writeLogEntry({ ts: new Date().toISOString(), level: 'INFO', tag: 'IPC-Image', message: `fetch-remote-image: ${url}` });
return new Promise((resolve, reject) => {
try {
if (!url || typeof url !== 'string' || (!url.startsWith('http://') && !url.startsWith('https://'))) {
const err = `URL invalide : ${url}`;
writeLogEntry({ ts: new Date().toISOString(), level: 'ERROR', tag: 'IPC-Image', message: err });
reject(new Error(err));
return;
}
const client = url.startsWith('https://') ? https : http;
let timeoutId;
let isSettled = false;
const settleReject = (err) => {
if (isSettled) return;
isSettled = true;
clearTimeout(timeoutId);
reject(err);
};
const settleResolve = (value) => {
if (isSettled) return;
isSettled = true;
clearTimeout(timeoutId);
resolve(value);
};
const req = client.get(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': 'image/*, */*',
...extraHeaders,
},
}, (res) => {
writeLogEntry({ ts: new Date().toISOString(), level: 'INFO', tag: 'IPC-Image', message: `HTTP ${res.statusCode} — Content-Type: ${res.headers['content-type']} — URL: ${url}` });
// Gérer les redirects HTTP 301/302
if ((res.statusCode === 301 || res.statusCode === 302) && res.headers.location) {
writeLogEntry({ ts: new Date().toISOString(), level: 'INFO', tag: 'IPC-Image', message: `Redirect → ${res.headers.location}` });
// Suivre le redirect récursivement
const newUrl = res.headers.location;
const newClient = newUrl.startsWith('https://') ? https : http;
newClient.get(newUrl, { headers: { 'User-Agent': 'Mozilla/5.0', 'Accept': 'image/*' } }, (res2) => {
if (res2.statusCode !== 200) { reject(new Error(`HTTP ${res2.statusCode} après redirect`)); return; }
const chunks2 = [];
res2.on('data', c => chunks2.push(c));
res2.on('end', () => {
const buf = Buffer.concat(chunks2);
const ct = ((res2.headers['content-type'] || 'image/jpeg').split(';')[0]).trim();
settleResolve(`data:${ct};base64,${buf.toString('base64')}`);
});
}).on('error', settleReject);
return;
}
if (res.statusCode !== 200) {
const err = `HTTP ${res.statusCode} pour ${url}`;
writeLogEntry({ ts: new Date().toISOString(), level: 'ERROR', tag: 'IPC-Image', message: err });
res.resume();
settleReject(new Error(err));
return;
}
const chunks = [];
res.on('data', chunk => chunks.push(chunk));
res.on('end', () => {
try {
const buf = Buffer.concat(chunks);
const ct = ((res.headers['content-type'] || 'image/jpeg').split(';')[0]).trim();
const dataUrl = `data:${ct};base64,${buf.toString('base64')}`;
writeLogEntry({ ts: new Date().toISOString(), level: 'INFO', tag: 'IPC-Image', message: `Image chargée OK — ${buf.length} octets — ${ct}` });
settleResolve(dataUrl);
} catch (e) {
settleReject(e);
}
});
res.on('error', settleReject);
});
timeoutId = setTimeout(() => {
req.destroy();
const err = `Timeout (12s) pour ${url}`;
writeLogEntry({ ts: new Date().toISOString(), level: 'ERROR', tag: 'IPC-Image', message: err });
settleReject(new Error(err));
}, 12000);
req.on('error', (err) => {
writeLogEntry({ ts: new Date().toISOString(), level: 'ERROR', tag: 'IPC-Image', message: `Erreur réseau: ${err.message} — ${url}` });
settleReject(err);
});
} catch (err) {
writeLogEntry({ ts: new Date().toISOString(), level: 'ERROR', tag: 'IPC-Image', message: `Exception: ${err.message}` });
reject(err);
}
});
});
// ── CPU Telemetry Helper ───────────────────────────────────────────────────
function getCpuLoad() {
const cpus = os.cpus();
if (!cpus || cpus.length === 0) return { idle: 0, total: 0 };
let user = 0, nice = 0, sys = 0, idle = 0, irq = 0;
for (const cpu of cpus) {
user += cpu.times.user;
nice += cpu.times.nice;
sys += cpu.times.sys;
idle += cpu.times.idle;
irq += cpu.times.irq;
}
const total = user + nice + sys + idle + irq;
return { idle, total };
}
let lastCpuStats = getCpuLoad();
function getRealCpuPercentage() {
const current = getCpuLoad();
const idleDiff = current.idle - lastCpuStats.idle;
const totalDiff = current.total - lastCpuStats.total;
lastCpuStats = current;
if (totalDiff === 0) return 0;
return Math.max(0, Math.min(100, 100 * (1 - idleDiff / totalDiff)));
}
ipcMain.handle('get-system-metrics', async () => {
try {
const hostname = os.hostname().toUpperCase();
const cpus = os.cpus();
const baseFreq = (cpus && cpus.length > 0) ? cpus[0].speed : 2400; // fallback to 2.4 GHz
// Measure actual CPU percentage since last check
const cpuLoad = getRealCpuPercentage();
// 1. Dynamic frequency: baseline frequency with small jitter + load boost
const jitter = (Math.random() - 0.5) * 12; // dynamic fluctuation
const dynamicFreq = Math.round(baseFreq * (1.0 + (cpuLoad / 100) * 0.15) + jitter);
// 2. Dynamic temperature matching load: baseline of 35.8°C + load increase + thermal noise
const thermalNoise = (Math.random() - 0.5) * 0.6;
const cpuTemp = parseFloat((35.8 + (cpuLoad * 0.38) + thermalNoise).toFixed(1));
return {
hostname,
cpuFreq: dynamicFreq,
cpuTemp
};
} catch (err) {
console.error('[main] Erreur lors de la télémétrie:', err.message);
return {
hostname: 'UNKNOWN_HOST',
cpuFreq: 2400,
cpuTemp: 37.0
};
}
});
// ── Shared Directory Resolution ─────────────────────────────────────────────
function getSharedDirectory() {
try {
const docsDir = path.join(os.homedir(), 'Documents', 'EveFlow_Shared');
if (!fs.existsSync(docsDir)) {
fs.mkdirSync(docsDir, { recursive: true });
}
return docsDir;
} catch (err) {
console.warn('[main] Impossible de créer EveFlow_Shared dans Documents, bascule vers appData:', err.message);
const fallbackDir = path.join(app.getPath('userData'), 'shared');
if (!fs.existsSync(fallbackDir)) {
fs.mkdirSync(fallbackDir, { recursive: true });
}
return fallbackDir;
}
}
// ── IPC Handler : Écriture de fichier partagé sécurisé ──────────────────────
ipcMain.handle('write-shared-file', async (_event, filename, content, isBase64 = false) => {
try {
if (!filename || typeof filename !== 'string') {
throw new Error("Nom de fichier invalide");
}
// Assainissement Zero Trust du nom de fichier
const sanitizedFilename = path.basename(filename);
if (sanitizedFilename.includes('..') || sanitizedFilename !== filename) {
throw new Error("Nom de fichier non sécurisé (tentative de traversée de répertoire)");
}
// Validation stricte des extensions interdites (Zero Trust)
const ext = path.extname(sanitizedFilename).toLowerCase();
const forbiddenExtensions = [
'.exe', '.bat', '.cmd', '.ps1', '.vbs', '.msi', '.scr', '.pif', '.reg', '.sh',
'.com', '.hta', '.vbe', '.wsf', '.lnk'
];
if (forbiddenExtensions.includes(ext)) {
throw new Error(`Extension de fichier interdite pour des raisons de sécurité : ${ext}`);
}
const sharedDir = getSharedDirectory();
const targetPath = path.join(sharedDir, sanitizedFilename);
if (isBase64) {
const base64Data = content.replace(/^data:image\/\w+;base64,/, "");
fs.writeFileSync(targetPath, Buffer.from(base64Data, 'base64'));
} else {
fs.writeFileSync(targetPath, content, 'utf8');
}
writeLogEntry({
ts: new Date().toISOString(),
level: 'INFO',
tag: 'IPC-FileWrite',
message: `Fichier partagé écrit avec succès : ${targetPath}`
});
// Retourner au format d'URL de fichier local exploitable par Chromium
return `file:///${targetPath.replace(/\\/g, '/')}`;
} catch (err) {
console.error('[main] Erreur write-shared-file:', err.message);
writeLogEntry({
ts: new Date().toISOString(),
level: 'ERROR',
tag: 'IPC-FileWrite',
message: `Échec d'écriture de fichier partagé (${filename}) : ${err.message}`
});
throw err;
}
});
// ── IPC Handler : Ouverture sécurisée de fichier local par l'OS ─────────────
ipcMain.handle('open-local-file', async (_event, filePath) => {
try {
if (!filePath || typeof filePath !== 'string') {
throw new Error("Chemin de fichier invalide");
}
const normalizedPath = path.normalize(filePath);
if (normalizedPath.includes('..')) {
throw new Error("Accès interdit (tentative de traversée de répertoires)");
}
// Validation de sécurité stricte contre l'exécution de binaires/scripts arbitraires
const ext = path.extname(normalizedPath).toLowerCase();
const forbiddenExtensions = [
'.exe', '.bat', '.cmd', '.ps1', '.vbs', '.msi', '.scr', '.pif', '.reg', '.sh',
'.com', '.hta', '.vbe', '.wsf', '.lnk', '.js', '.jar'
];
if (forbiddenExtensions.includes(ext)) {
throw new Error(`Ouverture refusée pour des raisons de sécurité : l'exécution directe de scripts ou exécutables (${ext}) est désactivée.`);
}
if (!fs.existsSync(normalizedPath)) {
throw new Error("Le fichier spécifié n'existe pas sur le disque.");
}
const { shell } = require('electron');
await shell.openPath(normalizedPath);
writeLogEntry({
ts: new Date().toISOString(),
level: 'INFO',
tag: 'IPC-FileOpen',
message: `Fichier ouvert avec succès via l'OS : ${normalizedPath}`
});
return { success: true };
} catch (err) {
console.error('[main] Erreur open-local-file:', err.message);
writeLogEntry({
ts: new Date().toISOString(),
level: 'ERROR',
tag: 'IPC-FileOpen',
message: `Échec d'ouverture de fichier (${filePath}) : ${err.message}`
});
throw err;
}
});
// Afficher le chemin du log au démarrage pour faciliter le débogage
app.whenReady().then(() => {
console.log(`[EveFlow] Log file: ${getLogPath()}`);
});
+2596 -2743
View File
File diff suppressed because it is too large. Load diff
+65 -30
View File
@@ -1,31 +1,42 @@
{
"name": "eveflow",
"version": "1.0.7",
"description": "Retro-futuristic Windows companion with 3D Eve bot, local TTS/STT, and multi-agent connectivity",
"main": "main.js",
"version": "2.5.0",
"releaseVersion": "2.5.0",
"description": "JARVIS-style desktop HUD for Hermes Agent: voice, streaming runs, scheduled jobs, skills and telemetry",
"main": "dist-electron/main.js",
"private": true,
"license": "MIT",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"dev": "vite --host 127.0.0.1 --port 5173 --strictPort",
"build": "npm run typecheck && npm run build:renderer && npm run build:electron",
"build:renderer": "vite build",
"build:electron": "node scripts/build-electron.mjs",
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.electron.json --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"electron": "electron .",
"start": "concurrently -k \"npm run dev -- --host 127.0.0.1 --port 5173 --strictPort\" \"wait-on http://localhost:5173 && npm run electron\"",
"dist": "electron-builder --win"
"start": "npm run build:electron && concurrently -k -s first \"npm run dev\" \"wait-on http://127.0.0.1:5173 && cross-env NODE_ENV=development VITE_DEV_SERVER_URL=http://127.0.0.1:5173 electron .\"",
"preview": "vite preview",
"dist": "node scripts/dist.mjs",
"typecheck:electron": "tsc -p tsconfig.electron.json --noEmit"
},
"build": {
"appId": "com.romik3.eveflow",
"productName": "EveFlow",
"copyright": "Copyright © 2026 R0m1k3",
"asar": false,
"asar": true,
"directories": {
"output": "out"
"output": "out",
"buildResources": "build"
},
"files": [
"main.js",
"preload.js",
"dist/**/*"
"dist-electron/**/*",
"dist/**/*",
"package.json"
],
"win": {
"target": "nsis",
"icon": "build/icon.png",
"requestedExecutionLevel": "asInvoker",
"signAndEditExecutable": false,
"verifyUpdateCodeSignature": false
@@ -36,28 +47,52 @@
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"shortcutName": "EveFlow"
}
},
"extraResources": [
{
"from": "build/icon.png",
"to": "icon.png"
}
],
"asarUnpack": [
"dist-electron/voice-worker.js",
"node_modules/sherpa-onnx-node/**",
"node_modules/sherpa-onnx-win-x64/**",
"node_modules/sherpa-onnx-linux-x64/**",
"node_modules/sherpa-onnx-darwin-arm64/**",
"node_modules/sherpa-onnx-darwin-x64/**"
],
"npmRebuild": false
},
"dependencies": {
"@react-three/drei": "^9.105.0",
"@react-three/fiber": "^8.16.0",
"lucide-react": "^0.379.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"@fontsource/orbitron": "^5.3.0",
"@fontsource/rajdhani": "^5.3.0",
"@fontsource/share-tech-mono": "^5.3.0",
"lucide-react": "^1.40.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"three": "^0.163.0"
"sherpa-onnx-node": "^1.13.7",
"zustand": "^5.0.15"
},
"devDependencies": {
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@types/three": "^0.163.0",
"@vitejs/plugin-react": "^4.2.1",
"concurrently": "^8.2.2",
"electron": "^30.0.1",
"electron-builder": "^26.8.1",
"typescript": "^5.4.2",
"vite": "^5.2.2",
"wait-on": "^7.2.0"
"@types/node": "^24.0.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.0",
"@types/unbzip2-stream": "^1.4.3",
"@vitejs/plugin-react": "^6.1.1",
"concurrently": "^10.0.5",
"cross-env": "^10.0.0",
"electron": "^44.1.1",
"electron-builder": "^26.15.3",
"esbuild": "^0.28.2",
"jsdom": "^30.0.1",
"tar": "^7.5.22",
"typescript": "^5.9.3",
"unbzip2-stream": "^1.4.3",
"vite": "^8.2.2",
"vitest": "^5.0.0",
"wait-on": "^9.1.0"
}
}
Loaded 100 of 272 files, more files were not shown because too many files have changed in this diff. Show more