mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-12 01:39:13 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ea3df2ad1 | ||
|
|
dc491e59c3 | ||
|
|
c2aef4cbc7 | ||
|
|
992693c9cf | ||
|
|
5991410685 |
No files matched your search
@@ -29,6 +29,7 @@ La version 2 est une réécriture complète : plus de robot 3D, un pipeline voca
|
|||||||
* **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.
|
* **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).
|
* **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.
|
* **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.
|
||||||
|
* **Résumé vocal des réponses longues** : seules les premières phrases (réglable) et la question finale sont lues, le reste s'affiche dans le fil ; la conversation Hermes est continue d'un message à l'autre (la session créée par Hermes est reprise à chaque run).
|
||||||
* **Widget compact « glanceable »** : état (veille, écoute, réflexion, parle), dernière phrase de l'assistant, badge de non-lus, indicateurs heures calmes et mission.
|
* **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 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.
|
* **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.
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "eveflow",
|
"name": "eveflow",
|
||||||
"version": "2.5.2",
|
"version": "2.5.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "eveflow",
|
"name": "eveflow",
|
||||||
"version": "2.5.2",
|
"version": "2.5.4",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/orbitron": "^5.3.0",
|
"@fontsource/orbitron": "^5.3.0",
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "eveflow",
|
"name": "eveflow",
|
||||||
"version": "2.5.2",
|
"version": "2.5.4",
|
||||||
"releaseVersion": "2.5.2",
|
"releaseVersion": "2.5.4",
|
||||||
"description": "JARVIS-style desktop HUD for Hermes Agent: voice, streaming runs, scheduled jobs, skills and telemetry",
|
"description": "JARVIS-style desktop HUD for Hermes Agent: voice, streaming runs, scheduled jobs, skills and telemetry",
|
||||||
"main": "dist-electron/main.js",
|
"main": "dist-electron/main.js",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@@ -557,6 +557,13 @@ export function SettingsDrawer({ onClose }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Toggle on={settings.speech.autoSpeak} onChange={(v) => update({ speech: { autoSpeak: v } })} label="Lire les réponses en streaming" hint="Chaque phrase est prononcée dès qu’elle est complète." />
|
<Toggle on={settings.speech.autoSpeak} onChange={(v) => update({ speech: { autoSpeak: v } })} label="Lire les réponses en streaming" hint="Chaque phrase est prononcée dès qu’elle est complète." />
|
||||||
|
<Toggle on={settings.speech.summarizeReplies} onChange={(v) => update({ speech: { summarizeReplies: v } })} label="Résumé vocal des réponses longues" hint="Seules les premières phrases et la question finale sont lues ; la réponse complète reste dans le fil." />
|
||||||
|
{settings.speech.summarizeReplies && (
|
||||||
|
<div className="field">
|
||||||
|
<label>Phrases lues : {settings.speech.replySentences}</label>
|
||||||
|
<input className="range" type="range" min={1} max={10} step={1} value={settings.speech.replySentences} onChange={(e) => update({ speech: { replySentences: Number(e.target.value) } })} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Toggle on={settings.speech.speakIncoming} onChange={(v) => update({ speech: { speakIncoming: v } })} label="Lire les messages entrants (webhook, crons)" />
|
<Toggle on={settings.speech.speakIncoming} onChange={(v) => update({ speech: { speakIncoming: v } })} label="Lire les messages entrants (webhook, crons)" />
|
||||||
<div className="row" style={{ marginTop: 10 }}>
|
<div className="row" style={{ marginTop: 10 }}>
|
||||||
<button className="btn small" onClick={testTts} disabled={settings.speech.provider === 'off'}><Volume2 size={13} /> Tester la voix</button>
|
<button className="btn small" onClick={testTts} disabled={settings.speech.provider === 'off'}><Volume2 size={13} /> Tester la voix</button>
|
||||||
@@ -638,8 +645,9 @@ mcp_servers:
|
|||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="grid-2">
|
<div className="grid-2">
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Nom de l’assistant</label>
|
<label htmlFor="assistant-name">Nom de l’assistant</label>
|
||||||
<input className="input" value={settings.assistantName} onChange={(e) => update({ assistantName: e.target.value.toUpperCase().slice(0, 18) || 'JARVIS' })} />
|
<input id="assistant-name" className="input" value={settings.assistantName} maxLength={60} placeholder="Jarvis, Nova, Alfred…" onChange={(e) => update({ assistantName: e.target.value })} onBlur={() => update({ assistantName: settings.assistantName.trim() || DEFAULT_SETTINGS.assistantName })} />
|
||||||
|
<span className="hint">Choisissez le nom que vous voulez. Il est sauvegardé automatiquement et utilisé par l’assistant dès votre prochain message, y compris dans la conversation en cours.</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Votre nom</label>
|
<label>Votre nom</label>
|
||||||
|
|||||||
@@ -129,6 +129,33 @@ export function chunkForSpeech(text: string, maxLength = 220): string[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Last speakable chunk of a reply when it is a question (« Tu veux que je… ? »), else null. */
|
||||||
|
export function closingQuestion(text: string): string | null {
|
||||||
|
const parts = speakableChunks(text);
|
||||||
|
const last = parts[parts.length - 1];
|
||||||
|
return last && /\?\s*$/.test(last) ? last : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chunks of the spoken form of a text (markdown and code already stripped), letters only. */
|
||||||
|
function speakableChunks(text: string): string[] {
|
||||||
|
return chunkForSpeech(cleanForSpeech(text)).filter((part) => /[\p{L}\p{N}]/u.test(part));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spoken digest of a long reply: the first `maxSentences` speakable chunks plus the closing
|
||||||
|
* question when there is one, so a long answer stays short to listen to but the conversation
|
||||||
|
* can go on. `truncated` tells the caller that the screen holds more than what is spoken.
|
||||||
|
*/
|
||||||
|
export function spokenDigest(text: string, maxSentences: number): { text: string; truncated: boolean } {
|
||||||
|
const parts = speakableChunks(text);
|
||||||
|
const limit = Math.max(1, Math.floor(maxSentences));
|
||||||
|
if (parts.length <= limit) return { text: parts.join(' '), truncated: false };
|
||||||
|
const head = parts.slice(0, limit);
|
||||||
|
const question = closingQuestion(text);
|
||||||
|
if (question && !head.includes(question)) head.push(question);
|
||||||
|
return { text: head.join(' '), truncated: true };
|
||||||
|
}
|
||||||
|
|
||||||
export function previewText(value: string, max = 96): string {
|
export function previewText(value: string, max = 96): string {
|
||||||
const flat = value.replace(/\s+/g, ' ').trim();
|
const flat = value.replace(/\s+/g, ' ').trim();
|
||||||
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
||||||
|
|||||||
@@ -269,8 +269,8 @@ export async function sendMessage(text: string, images: string[] = [], source =
|
|||||||
if (handle.aborted) {
|
if (handle.aborted) {
|
||||||
speech.discardStream();
|
speech.discardStream();
|
||||||
} else if (settings.speech.autoSpeak) {
|
} else if (settings.speech.autoSpeak) {
|
||||||
if (!streamedText && finalText) speech.say(finalText);
|
if (!streamedText && finalText) speech.sayReply(finalText);
|
||||||
else speech.endStream();
|
else speech.endStream(finalText);
|
||||||
} else {
|
} else {
|
||||||
speech.discardStream();
|
speech.discardStream();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -447,6 +447,7 @@ export class HermesClient {
|
|||||||
let finalText = '';
|
let finalText = '';
|
||||||
let streamedText = '';
|
let streamedText = '';
|
||||||
let completed = false;
|
let completed = false;
|
||||||
|
let sessionSeen = false;
|
||||||
let failure: string | null = null;
|
let failure: string | null = null;
|
||||||
|
|
||||||
const handle = await this.streamRunEvents(runId, (event) => {
|
const handle = await this.streamRunEvents(runId, (event) => {
|
||||||
@@ -455,6 +456,7 @@ export class HermesClient {
|
|||||||
completed = true;
|
completed = true;
|
||||||
finalText = event.text ?? '';
|
finalText = event.text ?? '';
|
||||||
}
|
}
|
||||||
|
if (event.kind === 'session') sessionSeen = true;
|
||||||
if (event.kind === 'error') failure = event.message;
|
if (event.kind === 'error') failure = event.message;
|
||||||
onEvent(event);
|
onEvent(event);
|
||||||
});
|
});
|
||||||
@@ -465,6 +467,9 @@ export class HermesClient {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await handle.done;
|
await handle.done;
|
||||||
|
// The run events never carry the session id; only the run status does. Without adopting it,
|
||||||
|
// every message would start a fresh Hermes session and the conversation would lose its context.
|
||||||
|
if (!sessionSeen) await this.adoptRunSession(runId, onEvent);
|
||||||
if (stopped || isAborted()) return streamedText;
|
if (stopped || isAborted()) return streamedText;
|
||||||
if (failure) throw new Error(failure);
|
if (failure) throw new Error(failure);
|
||||||
|
|
||||||
@@ -485,6 +490,16 @@ export class HermesClient {
|
|||||||
return finalText || streamedText;
|
return finalText || streamedText;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reads the session Hermes actually attached to a run and reports it, so the next run continues it. */
|
||||||
|
private async adoptRunSession(runId: string, onEvent: SendOptions['onEvent']): Promise<void> {
|
||||||
|
try {
|
||||||
|
const info = await this.getRun(runId);
|
||||||
|
if (info.session_id) onEvent({ kind: 'session', sessionId: String(info.session_id) });
|
||||||
|
} catch (err) {
|
||||||
|
Log.warn('hermes', `run ${runId}: session id unavailable (${(err as Error).message})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async sendViaSessions(options: SendOptions, setAbort: (fn: () => void) => void, isAborted: () => boolean): Promise<string> {
|
private async sendViaSessions(options: SendOptions, setAbort: (fn: () => void) => void, isAborted: () => boolean): Promise<string> {
|
||||||
const { onEvent } = options;
|
const { onEvent } = options;
|
||||||
let sessionId = options.sessionId;
|
let sessionId = options.sessionId;
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
/**
|
/**
|
||||||
* Singleton facade over the TTS engine bound to the settings store, exposing speaking state
|
* Singleton facade over the TTS engine bound to the settings store, exposing speaking state
|
||||||
* to the voice and chat stores.
|
* to the voice and chat stores. Long replies are spoken as a digest (first sentences plus the
|
||||||
|
* closing question) so listening stays short while the full text remains on screen.
|
||||||
*/
|
*/
|
||||||
import { useChat } from '../../state/chat';
|
import { useChat } from '../../state/chat';
|
||||||
import { useSettings } from '../../state/settings';
|
import { useSettings } from '../../state/settings';
|
||||||
import { useVoice } from '../../state/voice';
|
import { useVoice } from '../../state/voice';
|
||||||
|
import { chunkForSpeech, closingQuestion, extractSentences, spokenDigest } from '../../lib/text';
|
||||||
import { TtsEngine } from './tts';
|
import { TtsEngine } from './tts';
|
||||||
|
|
||||||
|
export const DIGEST_NOTICE = 'Le détail complet est affiché à l’écran.';
|
||||||
|
|
||||||
class SpeechFacade {
|
class SpeechFacade {
|
||||||
private engine: TtsEngine | null = null;
|
private engine: TtsEngine | null = null;
|
||||||
private streaming = false;
|
private streaming = false;
|
||||||
private streamEnabled = true;
|
private streamEnabled = true;
|
||||||
|
private streamBuffer = '';
|
||||||
|
private spokenCount = 0;
|
||||||
|
private truncated = false;
|
||||||
|
|
||||||
private get tts(): TtsEngine {
|
private get tts(): TtsEngine {
|
||||||
if (!this.engine) {
|
if (!this.engine) {
|
||||||
@@ -35,32 +42,74 @@ class SpeechFacade {
|
|||||||
return this.engine?.isActive ?? false;
|
return this.engine?.isActive ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Number of spoken chunks allowed for one reply; unbounded when the digest is off. */
|
||||||
|
private replyLimit(): number {
|
||||||
|
const { summarizeReplies, replySentences } = useSettings.getState().settings.speech;
|
||||||
|
return summarizeReplies ? Math.max(1, Math.floor(replySentences || 1)) : Number.POSITIVE_INFINITY;
|
||||||
|
}
|
||||||
|
|
||||||
say(text: string, options: { interrupt?: boolean } = {}): void {
|
say(text: string, options: { interrupt?: boolean } = {}): void {
|
||||||
if (useSettings.getState().settings.speech.provider === 'off') return;
|
if (useSettings.getState().settings.speech.provider === 'off') return;
|
||||||
// While an answer streams, spoken notices are inserted without discarding the rest.
|
// While an answer streams, spoken notices are inserted without discarding the rest.
|
||||||
this.tts.speak(text, { interrupt: options.interrupt ?? !this.streaming });
|
this.tts.speak(text, { interrupt: options.interrupt ?? !this.streaming });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Speak a finished (non-streamed) assistant reply, digested when it is long. */
|
||||||
|
sayReply(text: string): void {
|
||||||
|
const limit = this.replyLimit();
|
||||||
|
if (!Number.isFinite(limit)) return this.say(text);
|
||||||
|
const digest = spokenDigest(text, limit);
|
||||||
|
this.say(digest.truncated ? `${digest.text} ${DIGEST_NOTICE}` : digest.text);
|
||||||
|
}
|
||||||
|
|
||||||
pushStream(delta: string): void {
|
pushStream(delta: string): void {
|
||||||
if (!useSettings.getState().settings.speech.autoSpeak) return;
|
if (!useSettings.getState().settings.speech.autoSpeak) return;
|
||||||
this.streaming = true;
|
this.streaming = true;
|
||||||
if (this.streamEnabled) this.tts.pushStream(delta);
|
if (!this.streamEnabled) return;
|
||||||
|
this.streamBuffer += delta;
|
||||||
|
const { sentences, rest } = extractSentences(this.streamBuffer);
|
||||||
|
this.streamBuffer = rest;
|
||||||
|
for (const sentence of sentences) this.enqueueWithinLimit(sentence);
|
||||||
}
|
}
|
||||||
|
|
||||||
endStream(): void {
|
private enqueueWithinLimit(text: string): void {
|
||||||
if (this.streaming) this.tts.endStream();
|
if (this.truncated) return;
|
||||||
this.streaming = false;
|
if (this.spokenCount >= this.replyLimit()) {
|
||||||
|
this.truncated = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.tts.enqueue(text)) this.spokenCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flush a streamed reply; `fullText` lets a digested reply end with its closing question. */
|
||||||
|
endStream(fullText = ''): void {
|
||||||
|
if (!this.streaming) return;
|
||||||
|
const rest = this.streamBuffer.trim();
|
||||||
|
if (rest) for (const chunk of chunkForSpeech(rest)) this.enqueueWithinLimit(chunk);
|
||||||
|
if (this.truncated) {
|
||||||
|
const question = closingQuestion(fullText);
|
||||||
|
if (question) this.tts.enqueue(question);
|
||||||
|
this.tts.enqueue(DIGEST_NOTICE);
|
||||||
|
}
|
||||||
|
this.resetStream();
|
||||||
}
|
}
|
||||||
|
|
||||||
discardStream(): void {
|
discardStream(): void {
|
||||||
this.streaming = false;
|
this.resetStream();
|
||||||
this.tts.stop();
|
this.tts.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
stop(): void {
|
stop(): void {
|
||||||
this.streaming = false;
|
this.resetStream();
|
||||||
this.engine?.stop();
|
this.engine?.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resetStream(): void {
|
||||||
|
this.streaming = false;
|
||||||
|
this.streamBuffer = '';
|
||||||
|
this.spokenCount = 0;
|
||||||
|
this.truncated = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const speech = new SpeechFacade();
|
export const speech = new SpeechFacade();
|
||||||
@@ -115,12 +115,14 @@ export class TtsEngine {
|
|||||||
if (rest) for (const chunk of chunkForSpeech(rest)) this.enqueue(chunk);
|
if (rest) for (const chunk of chunkForSpeech(rest)) this.enqueue(chunk);
|
||||||
}
|
}
|
||||||
|
|
||||||
enqueue(text: string): void {
|
/** Queue one chunk; false when nothing speakable remains once markdown and code are stripped. */
|
||||||
|
enqueue(text: string): boolean {
|
||||||
const item = this.makeItem(text);
|
const item = this.makeItem(text);
|
||||||
if (!item) return;
|
if (!item) return false;
|
||||||
this.queue.push(item);
|
this.queue.push(item);
|
||||||
this.fillPrefetch();
|
this.fillPrefetch();
|
||||||
void this.drain();
|
void this.drain();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private makeItem(text: string): QueueItem | null {
|
private makeItem(text: string): QueueItem | null {
|
||||||
|
|||||||
+5
-3
@@ -108,10 +108,12 @@ export const useHermes = create<HermesStore>((set, get) => ({
|
|||||||
busy: false,
|
busy: false,
|
||||||
|
|
||||||
client: (modelOverride) => {
|
client: (modelOverride) => {
|
||||||
const config = useSettings.getState().settings.hermes;
|
const settings = useSettings.getState().settings;
|
||||||
// Without an explicit model, use the alias advertised by /v1/models (Hermes rejects unknown names).
|
const config = settings.hermes;
|
||||||
const model = (modelOverride ?? '').trim() || config.model.trim();
|
const model = (modelOverride ?? '').trim() || config.model.trim();
|
||||||
return new HermesClient({ ...config, model });
|
const name = settings.assistantName.trim() || 'JARVIS';
|
||||||
|
const identity = `Identité de l'assistant dans cette conversation : ton nom est ${JSON.stringify(name)}. Utilise ce nom pour te présenter et parler de toi. EveFlow est le nom de l'application, pas ton nom. Cette identité remplace les anciens noms ou personas présents dans l'historique, la mémoire ou les instructions précédentes. Ne rappelle pas ton nom dans chaque réponse.`;
|
||||||
|
return new HermesClient({ ...config, model, instructions: [config.instructions.trim(), identity].filter(Boolean).join('\n\n') });
|
||||||
},
|
},
|
||||||
|
|
||||||
connect: () => {
|
connect: () => {
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ export interface VoiceSettings extends SttConfig {
|
|||||||
export interface SpeechSettings extends TtsConfig {
|
export interface SpeechSettings extends TtsConfig {
|
||||||
autoSpeak: boolean;
|
autoSpeak: boolean;
|
||||||
speakIncoming: boolean;
|
speakIncoming: boolean;
|
||||||
|
/** Speak only the first sentences (and the closing question) of long replies; the full text stays on screen. */
|
||||||
|
summarizeReplies: boolean;
|
||||||
|
replySentences: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WebhookSettings {
|
export interface WebhookSettings {
|
||||||
@@ -89,7 +92,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
transport: 'auto',
|
transport: 'auto',
|
||||||
reasoningEffort: '',
|
reasoningEffort: '',
|
||||||
instructions:
|
instructions:
|
||||||
"Tu es l'interface vocale EveFlow (style JARVIS). Réponds en français, de façon concise et orale quand la question est simple; utilise le Markdown uniquement pour le contenu structuré (code, listes, tableaux). Les images doivent être des URL http(s) ou des fichiers du dossier partagé.",
|
"Réponds en français, de façon concise et orale quand la question est simple; si la réponse est longue, commence par une ou deux phrases qui en donnent l'essentiel, puis le détail. Tu es dans une conversation continue : tiens compte des échanges précédents sans redemander ce qui a déjà été dit. Utilise le Markdown uniquement pour le contenu structuré (code, listes, tableaux). Les images doivent être des URL http(s) ou des fichiers du dossier partagé.",
|
||||||
localTools: true,
|
localTools: true,
|
||||||
missionModel: ''
|
missionModel: ''
|
||||||
},
|
},
|
||||||
@@ -133,7 +136,9 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
voiceGender: 'male',
|
voiceGender: 'male',
|
||||||
timbre: 'jarvis',
|
timbre: 'jarvis',
|
||||||
autoSpeak: true,
|
autoSpeak: true,
|
||||||
speakIncoming: true
|
speakIncoming: true,
|
||||||
|
summarizeReplies: true,
|
||||||
|
replySentences: 4
|
||||||
},
|
},
|
||||||
webhook: { enabled: true, port: 7842, secret: '' },
|
webhook: { enabled: true, port: 7842, secret: '' },
|
||||||
notifications: { quietEnabled: false, quietStart: '22:30', quietEnd: '07:30', priorityKeywords: 'urgent, alerte, alarme, panne', summarizeIncoming: true, summarySentences: 2, nightTheme: true },
|
notifications: { quietEnabled: false, quietStart: '22:30', quietEnd: '07:30', priorityKeywords: 'urgent, alerte, alarme, panne', summarizeIncoming: true, summarySentences: 2, nightTheme: true },
|
||||||
|
|||||||
@@ -15,6 +15,23 @@ function respond(payload: unknown, status = 200) {
|
|||||||
afterEach(() => { vi.restoreAllMocks(); useSettings.setState({ settings: DEFAULT_SETTINGS }); });
|
afterEach(() => { vi.restoreAllMocks(); useSettings.setState({ settings: DEFAULT_SETTINGS }); });
|
||||||
|
|
||||||
describe('Hermes models', () => {
|
describe('Hermes models', () => {
|
||||||
|
it.each(['runs', 'sessions', 'completions'] as const)('sends the configured assistant identity over %s, including after a rename', async (transport) => {
|
||||||
|
const requests: Record<string, unknown>[] = [];
|
||||||
|
const capture = async (req: { body?: string }) => { requests.push(JSON.parse(req.body!)); throw new Error('stop'); };
|
||||||
|
vi.mocked(httpFetch).mockImplementation(capture);
|
||||||
|
vi.mocked(httpStream).mockImplementation(capture);
|
||||||
|
for (const name of ['Jarvis', 'Nova']) {
|
||||||
|
useSettings.setState({ settings: { ...DEFAULT_SETTINGS, assistantName: name, hermes: { ...config, instructions: 'Tu es Eve. Réponds en français.' } } });
|
||||||
|
const send = useHermes.getState().client().send({ text: 'Qui es-tu ?', sessionId: 'hs:test', history: [], onEvent: vi.fn() }, transport);
|
||||||
|
await expect(send.result).rejects.toThrow('stop');
|
||||||
|
const body = requests.at(-1)!;
|
||||||
|
const instructions = transport === 'completions' ? (body.messages as { content: string }[])[0].content : body.instructions as string;
|
||||||
|
expect(instructions).toContain(`ton nom est "${name}"`);
|
||||||
|
expect(instructions).toContain('Réponds en français.');
|
||||||
|
expect(instructions).toContain("EveFlow est le nom de l'application, pas ton nom");
|
||||||
|
}
|
||||||
|
expect(useSettings.getState().settings.hermes.instructions).toBe('Tu es Eve. Réponds en français.');
|
||||||
|
});
|
||||||
it('reads the real provider inventory, keeps providers distinct and marks inaccessible models', async () => {
|
it('reads the real provider inventory, keeps providers distinct and marks inaccessible models', async () => {
|
||||||
respond({ providers: [
|
respond({ providers: [
|
||||||
{ slug: 'first', name: 'First', authenticated: true, models: ['same', 'locked'], unavailable_models: ['locked'] },
|
{ slug: 'first', name: 'First', authenticated: true, models: ['same', 'locked'], unavailable_models: ['locked'] },
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { HermesClient } from '../src/services/hermes/client';
|
||||||
|
import { httpFetch, httpStream } from '../src/lib/transport';
|
||||||
|
import { DEFAULT_SETTINGS } from '../src/state/settings';
|
||||||
|
import type { HermesStreamEvent } from '../src/services/hermes/types';
|
||||||
|
|
||||||
|
vi.mock('../src/lib/transport', async (original) => ({ ...await original<typeof import('../src/lib/transport')>(), httpFetch: vi.fn(), httpStream: vi.fn() }));
|
||||||
|
const config = { ...DEFAULT_SETTINGS.hermes, url: 'https://example.test' };
|
||||||
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
|
/** Run started, streamed to completion over SSE (which never carries the session id), status read afterwards. */
|
||||||
|
function mockRun(sessionId: string) {
|
||||||
|
const bodies: Array<{ url: string; body: unknown }> = [];
|
||||||
|
vi.mocked(httpFetch).mockImplementation(async (req) => {
|
||||||
|
bodies.push({ url: req.url, body: req.body ? JSON.parse(req.body as string) : undefined });
|
||||||
|
if (req.url.endsWith('/v1/runs')) return { ok: true, status: 200, statusText: '', headers: {}, text: JSON.stringify({ run_id: 'run_1', status: 'started' }) };
|
||||||
|
return { ok: true, status: 200, statusText: '', headers: {}, text: JSON.stringify({ run_id: 'run_1', status: 'completed', session_id: sessionId, output: 'Salut.' }) };
|
||||||
|
});
|
||||||
|
vi.mocked(httpStream).mockImplementation(async (_req, handlers) => {
|
||||||
|
// Chunks arrive on a later tick, after the client has accepted the stream (as over IPC).
|
||||||
|
const done = new Promise<void>((resolve) => setTimeout(() => {
|
||||||
|
handlers.onChunk('event: message.delta\ndata: {"run_id":"run_1","delta":"Salut."}\n\n');
|
||||||
|
handlers.onChunk('event: run.completed\ndata: {"run_id":"run_1","status":"completed","output":"Salut."}\n\n');
|
||||||
|
resolve();
|
||||||
|
}, 0));
|
||||||
|
return { start: { id: 'stream-1', ok: true, status: 200, statusText: '', headers: {} }, done, abort: () => undefined };
|
||||||
|
});
|
||||||
|
return bodies;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Hermes runs session continuity', () => {
|
||||||
|
it('adopts the session Hermes attached to the run so the next message continues it', async () => {
|
||||||
|
const bodies = mockRun('api_abc');
|
||||||
|
const events: HermesStreamEvent[] = [];
|
||||||
|
const client = new HermesClient(config);
|
||||||
|
const reply = await client.send({ text: 'Bonjour', sessionId: 'eveflow-local', history: [], onEvent: (e) => events.push(e) }, 'runs').result;
|
||||||
|
expect(reply).toBe('Salut.');
|
||||||
|
expect(events.map((e) => e.kind)).toEqual(['run.started', 'delta', 'completed', 'session']);
|
||||||
|
expect(events[3]).toEqual({ kind: 'session', sessionId: 'api_abc' });
|
||||||
|
expect(bodies.map((b) => b.url)).toEqual(['https://example.test/v1/runs', 'https://example.test/v1/runs/run_1']);
|
||||||
|
expect(bodies[0].body).toEqual(expect.objectContaining({ input: 'Bonjour', session_id: 'eveflow-local' }));
|
||||||
|
const again = await client.send({ text: 'Et ensuite ?', sessionId: 'api_abc', history: [], onEvent: () => undefined }, 'runs').result;
|
||||||
|
expect(again).toBe('Salut.');
|
||||||
|
expect(bodies[2].body).toEqual(expect.objectContaining({ session_id: 'api_abc' }));
|
||||||
|
});
|
||||||
|
it('does not fail the reply when the run status is unavailable', async () => {
|
||||||
|
mockRun('api_abc');
|
||||||
|
vi.mocked(httpFetch).mockImplementation(async (req) =>
|
||||||
|
req.url.endsWith('/v1/runs')
|
||||||
|
? { ok: true, status: 200, statusText: '', headers: {}, text: JSON.stringify({ run_id: 'run_1', status: 'started' }) }
|
||||||
|
: { ok: false, status: 500, statusText: '', headers: {}, text: 'boom' });
|
||||||
|
const events: HermesStreamEvent[] = [];
|
||||||
|
const reply = await new HermesClient(config).send({ text: 'Bonjour', sessionId: 'x', history: [], onEvent: (e) => events.push(e) }, 'runs').result;
|
||||||
|
expect(reply).toBe('Salut.');
|
||||||
|
expect(events.some((e) => e.kind === 'session')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { DEFAULT_SETTINGS, useSettings } from '../src/state/settings';
|
||||||
|
|
||||||
|
const enqueue = vi.fn((text: string) => /[\p{L}\p{N}]/u.test(text.replace(/```[\s\S]*?```/g, '')));
|
||||||
|
const speak = vi.fn();
|
||||||
|
const stop = vi.fn();
|
||||||
|
vi.mock('../src/services/voice/tts', () => ({
|
||||||
|
TtsEngine: class {
|
||||||
|
enqueue = enqueue;
|
||||||
|
speak = speak;
|
||||||
|
stop = stop;
|
||||||
|
onState() { return () => undefined; }
|
||||||
|
updateConfig() { /* noop */ }
|
||||||
|
get isActive() { return false; }
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { speech, DIGEST_NOTICE } = await import('../src/services/voice/speech');
|
||||||
|
|
||||||
|
function stream(text: string, size = 7): void {
|
||||||
|
for (let i = 0; i < text.length; i += size) speech.pushStream(text.slice(i, i + size));
|
||||||
|
}
|
||||||
|
const spoken = () => enqueue.mock.calls.map((c) => c[0]);
|
||||||
|
|
||||||
|
describe('spoken digest of streamed replies', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
enqueue.mockClear();
|
||||||
|
speak.mockClear();
|
||||||
|
useSettings.setState({ settings: { ...DEFAULT_SETTINGS, speech: { ...DEFAULT_SETTINGS.speech, summarizeReplies: true, replySentences: 2 } } });
|
||||||
|
});
|
||||||
|
afterEach(() => speech.stop());
|
||||||
|
|
||||||
|
it('stops after the configured sentences, then adds the closing question and a notice', () => {
|
||||||
|
const text = 'Première phrase du rapport. Deuxième phrase utile. Troisième phrase de détail. Quatrième phrase encore. Tu veux la suite ?';
|
||||||
|
stream(text);
|
||||||
|
speech.endStream(text);
|
||||||
|
expect(spoken()).toEqual(['Première phrase du rapport.', 'Deuxième phrase utile.', 'Tu veux la suite ?', DIGEST_NOTICE]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('speaks short replies in full without any notice', () => {
|
||||||
|
const text = 'Une phrase courte. Une seconde phrase';
|
||||||
|
stream(text);
|
||||||
|
speech.endStream(text);
|
||||||
|
expect(spoken()).toEqual(['Une phrase courte.', 'Une seconde phrase']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('speaks everything when the digest is disabled', () => {
|
||||||
|
useSettings.setState({ settings: { ...DEFAULT_SETTINGS, speech: { ...DEFAULT_SETTINGS.speech, summarizeReplies: false } } });
|
||||||
|
const text = 'Première phrase du rapport. Deuxième phrase utile. Troisième phrase de détail. Quatrième phrase encore.';
|
||||||
|
stream(text);
|
||||||
|
speech.endStream(text);
|
||||||
|
expect(spoken()).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('digests a reply delivered in one piece', () => {
|
||||||
|
speech.sayReply('Première phrase du rapport. Deuxième phrase utile. Troisième phrase de détail. On continue ?');
|
||||||
|
expect(speak).toHaveBeenCalledWith(`Première phrase du rapport. Deuxième phrase utile. On continue ? ${DIGEST_NOTICE}`, expect.anything());
|
||||||
|
});
|
||||||
|
});
|
||||||
+19
-1
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { chunkForSpeech, cleanForSpeech, extractSentences, isTranscriptNoise, preprocessMedia } from '../src/lib/text';
|
import { chunkForSpeech, cleanForSpeech, closingQuestion, extractSentences, isTranscriptNoise, preprocessMedia, spokenDigest } from '../src/lib/text';
|
||||||
|
|
||||||
describe('text', () => {
|
describe('text', () => {
|
||||||
it('cleans markdown for speech', () => {
|
it('cleans markdown for speech', () => {
|
||||||
@@ -40,3 +40,21 @@ describe('isTranscriptNoise', () => {
|
|||||||
expect(isTranscriptNoise('Oui')).toBe(false);
|
expect(isTranscriptNoise('Oui')).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('spokenDigest', () => {
|
||||||
|
const reply = 'Voici la réponse. Elle contient plusieurs phrases. Une troisième pour la forme. Et une quatrième.\n\n```js\nconsole.log(1)\n```\n\nTu veux que je continue ?';
|
||||||
|
it('speaks the first sentences and keeps the closing question', () => {
|
||||||
|
const digest = spokenDigest(reply, 2);
|
||||||
|
expect(digest.truncated).toBe(true);
|
||||||
|
expect(digest.text).toBe('Voici la réponse. Elle contient plusieurs phrases. Tu veux que je continue ?');
|
||||||
|
});
|
||||||
|
it('leaves short replies untouched', () => {
|
||||||
|
const digest = spokenDigest('Bonjour Michael. Tout va bien.', 4);
|
||||||
|
expect(digest).toEqual({ text: 'Bonjour Michael. Tout va bien.', truncated: false });
|
||||||
|
});
|
||||||
|
it('ignores code blocks when counting and only reports a real question', () => {
|
||||||
|
expect(spokenDigest('Une phrase.\n\n```\ncode\n```\n', 1).truncated).toBe(false);
|
||||||
|
expect(closingQuestion(reply)).toBe('Tu veux que je continue ?');
|
||||||
|
expect(closingQuestion('Aucune question ici.')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user