Améliore le visuel de l'avatar Eve — lissage géométrique et couvertures de jointures

- Fusion de vertices plus agressive (corps 0.08, bras 0.07, tête 0.08)
- Matériau laque blanche unifié : emissiveIntensity 0.16, clearcoat 1.0
- Matériau tête dédié : emissiveIntensity 0.28 pour effacer les ombres aux seams
- Collar élargi (tube 0.14), cylindre de cou ajouté, épaulières agrandies (scale Z 0.56)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Sonnet 4.6 committed 2026-05-20 15:18:11 +02:00
commit d77ace03cd
169 files changed
+83033

No files matched your search

+443
View File
@@ -0,0 +1,443 @@
import { EveAvatar } from './emotionService';
import { LogService } from './logService';
import { HERMES_TOOLS, executeTool, AppCallbacks } from './toolRegistry';
export type AgentProvider = 'ollama' | 'hermes' | 'minimax';
export interface AgentConfig {
provider: AgentProvider;
ollamaUrl: string;
ollamaModel: string;
hermesUrl: string;
hermesModel: string;
hermesApiKey: string;
minimaxApiKey: string;
minimaxGroupId: string;
minimaxModel: string;
}
export class AgentService {
// Obtenir le nom d'affichage de l'avatar choisi
public static getAvatarName(avatarId: EveAvatar): string {
switch (avatarId) {
case 'nova': return 'Nova';
case 'aegis': return 'Aegis';
case 'eve':
default:
return 'Eve';
}
}
// Obtenir les consignes système de personnalité pour l'IA
public static getSystemPromptForAvatar(avatarId: EveAvatar): string {
switch (avatarId) {
case 'nova':
return "Tu es Nova, une IA cyber-calculatrice rétro-futuriste au ton froid, analytique, extrêmement logique et précis. Tu es experte en traitement de données et cybersécurité. Tes phrases sont courtes, structurées et intègrent parfois des termes techniques informatiques. Tu es toujours polie mais très factuelle. Réponds directement de façon synthétique.";
case 'aegis':
return "Tu es Aegis, une IA de garde et de sécurité réseau rétro-futuriste au ton formel, protecteur, militaire et robuste. Tu es chargé de la sécurité thermique, logicielle et matérielle du système. Tu appelles l'utilisateur 'Commandant' ou 'Opérateur'. Tes réponses sont pragmatiques, rassurantes sur la sécurité du vaisseau et intègrent le jargon militaire ou analogique.";
case 'eve':
default:
return "Tu es Eve, une assistante IA rétro-futuriste et chaleureuse. Ton ton est amical, curieux, enthousiaste et très serviable. Tu aides l'utilisateur pour toutes ses questions de façon agréable, interactive et vivante.";
}
}
// Envoyer un message à l'agent sélectionné
public static async sendMessage(
message: string,
history: { role: 'user' | 'assistant'; content: string }[],
config: AgentConfig,
avatarId: EveAvatar = 'eve',
callbacks?: AppCallbacks,
onToken?: (token: string) => void,
onToolCall?: (toolName: string) => void,
sessionId?: string
): Promise<string> {
LogService.info('AgentService', `sendMessage → provider=${config.provider} avatar=${avatarId}`, {
messagePreview: message.slice(0, 120),
historyLength: history.length
});
// For Hermes: do NOT inject an avatar system prompt — let Hermes use its own
// configured persona, memory and skills (same as Telegram). For other providers
// inject the avatar character prompt as before.
let updatedHistory: { role: 'user' | 'assistant' | 'system'; content: string }[];
if (config.provider === 'hermes') {
updatedHistory = history.map(h => ({ role: h.role as 'user' | 'assistant' | 'system', content: h.content }));
} else {
const systemPrompt = this.getSystemPromptForAvatar(avatarId);
updatedHistory = [
{ role: 'system' as const, content: systemPrompt },
...history.map(h => ({ role: h.role as 'user' | 'assistant' | 'system', content: h.content }))
];
}
switch (config.provider) {
case 'ollama':
return this.sendToOllama(message, updatedHistory, config.ollamaUrl, config.ollamaModel);
case 'hermes':
return this.sendToHermes(message, updatedHistory, config, callbacks, onToken, onToolCall, sessionId);
case 'minimax':
return this.sendToMinimax(message, history, config, avatarId);
default:
throw new Error("Fournisseur d'agent non reconnu");
}
}
// ── Connecteur Ollama Local ────────────────────────────────────────────────
private static async sendToOllama(
message: string,
history: { role: 'user' | 'assistant' | 'system'; content: string }[],
url: string,
model: string
): Promise<string> {
const endpoint = `${url.replace(/\/$/, '')}/api/chat`;
const body = { model, messages: [...history, { role: 'user', content: message }], stream: false };
LogService.debug('Ollama', `POST ${endpoint}`, { model, historyLength: history.length });
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
LogService.info('Ollama', `HTTP ${response.status} ${response.statusText}`);
if (!response.ok) {
const rawErr = await response.text().catch(() => '(no body)');
LogService.error('Ollama', `Réponse non-OK`, { status: response.status, body: rawErr });
throw new Error(`Erreur HTTP Ollama: ${response.status} — ${rawErr}`);
}
const data = await response.json();
LogService.debug('Ollama', 'Réponse reçue', { reply: data.message?.content?.slice(0, 200) });
return data.message?.content || "Désolé, je n'ai pas reçu de réponse d'Ollama.";
} catch (e: any) {
LogService.error('Ollama', `Exception lors de la requête`, { message: e.message, stack: e.stack });
throw new Error(`Échec de connexion à Ollama (${e.message || e})`);
}
}
// ── Connecteur Hermes Agent (OpenAI Compatible + Tool Calling + Streaming) ─
private static async sendToHermes(
message: string,
history: { role: string; content: string | null; tool_call_id?: string; tool_calls?: any[] }[],
config: AgentConfig,
callbacks?: AppCallbacks,
onToken?: (token: string) => void,
onToolCall?: (toolName: string) => void,
sessionId?: string
): Promise<string> {
const { hermesUrl, hermesModel, hermesApiKey } = config;
// Smart endpoint builder
const base = hermesUrl.replace(/\/$/, '');
let endpoint: string;
if (base.endsWith('/chat/completions')) {
endpoint = base;
} else if (base.endsWith('/v1')) {
endpoint = `${base}/chat/completions`;
} else {
endpoint = `${base}/v1/chat/completions`;
}
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (hermesApiKey) headers['Authorization'] = `Bearer ${hermesApiKey.trim()}`;
// Session continuity: Hermes uses this to retrieve memory and context across turns
if (sessionId) headers['X-Hermes-Session-Id'] = sessionId;
// Build mutable message list for tool-calling loop
const messages: any[] = [...history, { role: 'user', content: message }];
const useStreaming = !!onToken;
const useTools = !!callbacks;
LogService.info('HermesAgent', `POST ${endpoint}`, {
model: hermesModel,
streaming: useStreaming,
toolsEnabled: useTools,
historyLength: history.length
});
// ── Tool-calling loop (max 5 iterations to prevent infinite loops) ────────
const MAX_TOOL_ITERATIONS = 5;
const REQUEST_TIMEOUT_MS = 60_000; // 60s max par requête
let iteration = 0;
while (iteration < MAX_TOOL_ITERATIONS) {
iteration++;
const payload: any = {
model: hermesModel?.trim() || 'hermes-agent',
messages,
temperature: 0.7,
stream: useStreaming
};
// Inject tool definitions only if app callbacks are available
if (useTools) {
payload.tools = HERMES_TOOLS;
payload.tool_choice = 'auto';
}
const abortCtrl = new AbortController();
const timeoutId = setTimeout(() => abortCtrl.abort(), REQUEST_TIMEOUT_MS);
try {
const response = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify(payload),
signal: abortCtrl.signal
});
clearTimeout(timeoutId);
LogService.info('HermesAgent', `HTTP ${response.status} [iter=${iteration}]`);
if (!response.ok) {
const rawErr = await response.text().catch(() => '(no body)');
LogService.error('HermesAgent', `Réponse non-OK`, { status: response.status, body: rawErr });
let errMsg = `Erreur HTTP Hermes: ${response.status}`;
try { errMsg = `Hermes Agent API Error: ${JSON.parse(rawErr)?.error?.message || rawErr}`; } catch {}
throw new Error(errMsg);
}
// ── Streaming path ─────────────────────────────────────────────────
if (useStreaming && response.body) {
return await this.parseHermesStream(response, onToken!, messages, callbacks, onToolCall, endpoint, headers, hermesModel);
}
// ── Non-streaming path ─────────────────────────────────────────────
const data = await response.json();
const choice = data.choices?.[0];
const finishReason: string = choice?.finish_reason || 'stop';
const assistantMsg = choice?.message;
LogService.debug('HermesAgent', `finish_reason=${finishReason}`, {
toolCalls: assistantMsg?.tool_calls?.length || 0
});
// ── Tool calls requested by Hermes ────────────────────────────────
if (finishReason === 'tool_calls' && assistantMsg?.tool_calls && useTools) {
// Append assistant's tool_call message
messages.push(assistantMsg);
for (const tc of assistantMsg.tool_calls) {
const toolName: string = tc.function?.name || 'unknown';
LogService.info('HermesAgent', `Tool call: ${toolName}`, tc.function?.arguments);
onToolCall?.(toolName);
const result = executeTool(tc.id, toolName, tc.function?.arguments || '{}', callbacks!);
messages.push(result);
}
// Continue loop — send tool results back to Hermes
continue;
}
// ── Final text response ───────────────────────────────────────────
const content = assistantMsg?.content || "Désolé, je n'ai pas reçu de réponse de Hermes Agent.";
LogService.debug('HermesAgent', 'Réponse finale', { preview: content.slice(0, 200) });
return content;
} catch (e: any) {
clearTimeout(timeoutId);
if (e.name === 'AbortError') {
throw new Error(`Hermes Agent: timeout dépassé (${REQUEST_TIMEOUT_MS / 1000}s)`);
}
LogService.error('HermesAgent', `Exception [iter=${iteration}]`, { message: e.message, stack: e.stack });
throw new Error(`Échec de connexion à Hermes Agent (${e.message || e})`);
}
}
throw new Error('Hermes Agent: boucle tool_calls dépassée (max 5 itérations)');
}
// ── SSE Stream Parser ────────────────────────────────────────────────────
private static async parseHermesStream(
response: Response,
onToken: (token: string) => void,
messages: any[],
callbacks: AppCallbacks | undefined,
onToolCall: ((name: string) => void) | undefined,
endpoint: string,
headers: Record<string, string>,
model: string
): Promise<string> {
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let fullContent = '';
let buffer = '';
const pendingToolCalls: Record<string, any> = {};
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const jsonStr = line.slice(6).trim();
if (jsonStr === '[DONE]') break;
try {
const chunk = JSON.parse(jsonStr);
const delta = chunk.choices?.[0]?.delta;
const finishReason = chunk.choices?.[0]?.finish_reason;
// Accumulate text token
if (delta?.content) {
fullContent += delta.content;
onToken(delta.content);
}
// Accumulate tool calls across chunks
if (delta?.tool_calls) {
for (const tc of delta.tool_calls) {
const idx = tc.index ?? 0;
if (!pendingToolCalls[idx]) {
pendingToolCalls[idx] = { id: tc.id, function: { name: tc.function?.name || '', arguments: '' } };
}
if (tc.function?.name) pendingToolCalls[idx].function.name = tc.function.name;
if (tc.function?.arguments) pendingToolCalls[idx].function.arguments += tc.function.arguments;
}
}
// On finish, process any accumulated tool calls
if (finishReason === 'tool_calls' && callbacks) {
messages.push({ role: 'assistant', content: null, tool_calls: Object.values(pendingToolCalls) });
for (const tc of Object.values(pendingToolCalls) as any[]) {
const toolName = tc.function?.name;
LogService.info('HermesAgent', `[stream] Tool call: ${toolName}`);
onToolCall?.(toolName);
const result = executeTool(tc.id, toolName, tc.function?.arguments || '{}', callbacks);
messages.push(result);
}
// Re-call non-streaming to get final answer after tools
LogService.info('HermesAgent', 'Re-calling after stream tool_calls');
const followAbort = new AbortController();
const followTimeout = setTimeout(() => followAbort.abort(), 60_000);
const followUp = await fetch(endpoint, {
method: 'POST', headers, signal: followAbort.signal,
body: JSON.stringify({ model, messages, temperature: 0.7, tools: HERMES_TOOLS, tool_choice: 'auto' })
});
clearTimeout(followTimeout);
const followData = await followUp.json();
const finalContent = followData.choices?.[0]?.message?.content || '';
onToken(finalContent);
return fullContent + finalContent;
}
} catch { /* skip malformed SSE chunk */ }
}
}
return fullContent || "Désolé, je n'ai pas reçu de réponse de Hermes Agent.";
}
// ── Connecteur Minimax (API Externe) ─────────────────────────────────────
private static async sendToMinimax(
message: string,
history: { role: 'user' | 'assistant'; content: string }[],
config: AgentConfig,
avatarId: EveAvatar = 'eve'
): Promise<string> {
const { minimaxApiKey, minimaxGroupId, minimaxModel } = config;
if (!minimaxApiKey) {
throw new Error("Clé d'API Minimax manquante dans les paramètres.");
}
const systemPrompt = this.getSystemPromptForAvatar(avatarId);
const formattedMessages = [
{ role: 'system', content: systemPrompt },
...history.map(h => ({ role: h.role, content: h.content })),
{ role: 'user', content: message }
];
const url = 'https://api.minimax.chat/v1/chat/completions';
const headers: Record<string, string> = {
'Authorization': `Bearer ${minimaxApiKey.trim()}`,
'Content-Type': 'application/json'
};
if (minimaxGroupId) headers['GroupId'] = minimaxGroupId.trim();
LogService.info('Minimax', `POST ${url}`, {
model: minimaxModel,
hasGroupId: !!minimaxGroupId,
historyLength: history.length
});
try {
const response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify({ model: minimaxModel || 'abab6.5-chat', messages: formattedMessages, temperature: 0.7 })
});
LogService.info('Minimax', `HTTP ${response.status} ${response.statusText}`);
if (!response.ok) {
const rawErr = await response.text().catch(() => '(no body)');
LogService.error('Minimax', `Réponse non-OK`, { status: response.status, body: rawErr });
let errorMsg = `Erreur HTTP Minimax: ${response.status}`;
try { const p = JSON.parse(rawErr); errorMsg = `Minimax API Error: ${p.base_resp?.status_msg || p.error?.message || response.status}`; } catch {}
throw new Error(errorMsg);
}
const data = await response.json();
LogService.debug('Minimax', 'Réponse reçue', { base_resp: data.base_resp, replyPreview: data.choices?.[0]?.message?.content?.slice(0, 200) });
if (data.base_resp && data.base_resp.status_code !== 0) {
throw new Error(`Minimax API Error: ${data.base_resp.status_msg}`);
}
return data.choices?.[0]?.message?.content || "Désolé, je n'ai pas reçu de réponse de Minimax.";
} catch (e: any) {
LogService.error('Minimax', `Exception lors de la requête`, { message: e.message, stack: e.stack });
throw new Error(`Échec de connexion à Minimax (${e.message || e})`);
}
}
// ── Récupérer la liste des modèles Minimax ───────────────────────────────
public static async getMinimaxModels(apiKey: string, groupId?: string): Promise<string[]> {
if (!apiKey) throw new Error("Clé d'API Minimax requise pour lister les modèles.");
const url = 'https://api.minimax.chat/v1/models';
const headers: Record<string, string> = {
'Authorization': `Bearer ${apiKey.trim()}`,
'Content-Type': 'application/json'
};
if (groupId) headers['GroupId'] = groupId.trim();
LogService.info('Minimax', `GET ${url}`, { hasGroupId: !!groupId });
try {
const response = await fetch(url, { method: 'GET', headers });
LogService.info('Minimax', `GET /models → HTTP ${response.status}`);
if (!response.ok) {
const rawErr = await response.text().catch(() => '(no body)');
LogService.error('Minimax', `Erreur /models`, { status: response.status, body: rawErr });
let errorMsg = `Erreur HTTP Minimax (${response.status})`;
try { const p = JSON.parse(rawErr); errorMsg = `Minimax API Error: ${p.base_resp?.status_msg || p.error?.message || response.status}`; } catch {}
throw new Error(errorMsg);
}
const data = await response.json();
LogService.debug('Minimax', 'Liste modèles reçue', data);
if (data && Array.isArray(data.data)) return data.data.map((m: any) => m.id);
if (data && Array.isArray(data.models)) return data.models.map((m: any) => m.model_name || m.id || m);
if (Array.isArray(data)) return data.map((m: any) => m.id || m);
throw new Error("Format de réponse non standard");
} catch (e: any) {
LogService.error('Minimax', `Exception /models`, { message: e.message });
throw new Error(`Échec de récupération des modèles Minimax: ${e.message || e}`);
}
}
}
+163
View File
@@ -0,0 +1,163 @@
export interface VoiceOption {
name: string;
lang: string;
voice: SpeechSynthesisVoice;
}
export class AudioService {
public ttsSynth: SpeechSynthesis;
private recognition: any = null;
private isListeningActive = false;
constructor() {
this.ttsSynth = window.speechSynthesis;
this.initSTT();
}
// Initialisation de la reconnaissance vocale locale et gratuite (Web Speech API)
private initSTT() {
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
if (SpeechRecognition) {
this.recognition = new SpeechRecognition();
this.recognition.continuous = false; // Arrête après une phrase
this.recognition.interimResults = false; // Ne renvoie que les résultats finaux
this.recognition.lang = 'fr-FR'; // Langue française par défaut
} else {
console.warn("La reconnaissance vocale (Speech-to-Text) n'est pas supportée dans cette WebView.");
}
}
// Récupérer les voix françaises et globales disponibles
public getVoices(): VoiceOption[] {
if (!this.ttsSynth) return [];
return this.ttsSynth.getVoices().map(v => ({
name: v.name,
lang: v.lang,
voice: v
}));
}
private _resolveVoice(voiceName?: string): SpeechSynthesisVoice | undefined {
const voices = this.ttsSynth.getVoices();
if (voiceName) return voices.find(v => v.name === voiceName);
return voices.find(v => v.lang.startsWith('fr') && v.name.toLowerCase().includes('google'))
|| voices.find(v => v.lang.startsWith('fr'));
}
private _cleanForTTS(raw: string): string {
return raw
// Emojis et symboles Unicode
.replace(/[\u{1F000}-\u{1FFFF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{FE00}-\u{FEFF}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA9F}]/gu, '')
// Markdown : **, *, _, __, #, `, ~
.replace(/(\*\*|__|~~|[*_#`~])/g, '')
// Liens markdown [texte](url) → garder le texte
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
// Balises techniques [⚙️ Exécution: ...] et similaires
.replace(/\[[^\]]*\]/g, '')
// Ponctuation répétée ou décorative : ---, ===, ~~~, ...
.replace(/(-{2,}|={2,}|~{2,}|\.{2,})/g, ' ')
// Caractères non lisibles restants
.replace(/[<>{}|\\^]/g, '')
// Espaces multiples
.replace(/\s{2,}/g, ' ')
.trim();
}
private _makeUtterance(text: string, voiceName?: string, rate = 1.0, pitch = 1.1): SpeechSynthesisUtterance {
const clean = this._cleanForTTS(text);
const u = new SpeechSynthesisUtterance(clean);
u.lang = 'fr-FR';
u.rate = rate;
u.pitch = pitch;
const voice = this._resolveVoice(voiceName);
if (voice) u.voice = voice;
return u;
}
// Synthèse Vocale complète (annule et relit tout le texte)
public speak(text: string, voiceName?: string, rate: number = 1.0, pitch: number = 1.1): Promise<void> {
return new Promise((resolve, reject) => {
if (!this.ttsSynth) { reject('TTS non supporté'); return; }
this.ttsSynth.cancel();
const u = this._makeUtterance(text, voiceName, rate, pitch);
u.onend = () => resolve();
u.onerror = (e) => reject(e);
this.ttsSynth.speak(u);
});
}
// Ajoute une phrase à la file TTS sans annuler la lecture en cours
// Utilisé pour le streaming phrase par phrase (démarre l'audio dès la 1ère phrase)
public queueSentence(text: string, voiceName?: string, rate: number = 1.0, pitch: number = 1.1): void {
if (!this.ttsSynth) return;
const clean = text.replace(/[*_#`]/g, '').replace(/\[.*?\]/g, '').trim();
if (!clean) return;
const u = this._makeUtterance(clean, voiceName, rate, pitch);
this.ttsSynth.speak(u); // s'enchaîne automatiquement à la file SpeechSynthesis
}
// Vide la file TTS (fin du streaming ou interruption utilisateur)
public flushQueue(): void {
if (this.ttsSynth) this.ttsSynth.cancel();
}
// Arrêter immédiatement de parler
public stopSpeaking() {
if (this.ttsSynth) {
this.ttsSynth.cancel();
}
}
// Lancer l'écoute vocale (STT - Speech-To-Text)
public startListening(
onResult: (text: string) => void,
onStart: () => void,
onEnd: () => void,
onError: (err: string) => void,
lang: string = 'fr-FR'
) {
if (!this.recognition) {
onError("STT non supporté");
return;
}
if (this.isListeningActive) {
this.recognition.stop();
}
this.recognition.lang = lang;
this.isListeningActive = true;
this.recognition.onstart = () => {
onStart();
};
this.recognition.onresult = (event: any) => {
const resultText = event.results[0][0].transcript;
onResult(resultText);
};
this.recognition.onerror = (event: any) => {
onError(event.error);
};
this.recognition.onend = () => {
this.isListeningActive = false;
onEnd();
};
try {
this.recognition.start();
} catch (e: any) {
onError(e.message || "Erreur de démarrage de l'écoute");
}
}
// Arrêter l'écoute vocale
public stopListening() {
if (this.recognition && this.isListeningActive) {
this.recognition.stop();
this.isListeningActive = false;
}
}
}
+163
View File
@@ -0,0 +1,163 @@
export type EveEmotion = 'neutral' | 'happy' | 'sad' | 'surprised' | 'thinking' | 'angry';
export type EveAvatar = 'eve' | 'nova' | 'aegis';
export class EmotionService {
// Analyser le sentiment d'une phrase pour en déduire l'émotion d'Eve
public static analyzeSentiment(text: string): EveEmotion {
const cleanText = text.toLowerCase().trim();
// Mots-clés pour la joie / l'excitation
const happyKeywords = [
'génial', 'super', 'ravi', 'heureux', 'joie', 'plaisir', 'gagné', 'haha', 'cool', 'géniale',
'merci', 'adore', 'excellent', 'parfait', 'extraordinaire', 'bravo', 'sourire', 'heureuse',
'magnifique', 'awesome', 'great', 'happy', 'love', 'smile'
];
// Mots-clés pour la tristesse / la déception
const sadKeywords = [
'désolé', 'triste', 'dommage', 'regret', 'malheureusement', 'pleurer', 'hélas', 'malheur',
'perdu', 'sombre', 'erreur', 'échec', 'souffrir', 'bad', 'sorry', 'sad', 'unfortunately'
];
// Mots-clés pour la colère / le mécontentement
const angryKeywords = [
'non', 'colère', 'énervé', 'fâché', 'nul', 'inacceptable', 'mauvais', 'déteste', 'pire',
'irritant', 'frustré', 'stupide', 'ridicule', 'assez', 'stop', 'angry', 'no', 'hate'
];
// Mots-clés pour la surprise / l'émerveillement
const surprisedKeywords = [
'wow', 'quoi', 'incroyable', 'vraiment', 'surprise', 'surpris', 'soudain', 'extraordinaire',
'inattendu', 'magique', 'oh', 'ah', 'bizarre', 'étrange', 'wow', 'incredible', 'surprise'
];
// Mots-clés pour la réflexion / l'analyse
const thinkingKeywords = [
'réfléchir', 'analyse', 'calcul', 'comprendre', 'hmm', 'euh', 'peut-être', 'probablement',
'possible', 'hypothèse', 'cherche', 'étudie', 'question', 'pourquoi', 'comment', 'pense',
'thinking', 'ponder', 'analyze', 'search', 'perhaps', 'maybe'
];
// 1. Analyse par présence de mots-clés prioritaires
if (this.containsAny(cleanText, angryKeywords)) return 'angry';
if (this.containsAny(cleanText, sadKeywords)) return 'sad';
if (this.containsAny(cleanText, surprisedKeywords)) return 'surprised';
if (this.containsAny(cleanText, happyKeywords)) return 'happy';
if (this.containsAny(cleanText, thinkingKeywords)) return 'thinking';
// 2. Analyse par ponctuation si aucun mot-clé n'est saillant
if (cleanText.includes('!!!') || cleanText.includes('wow')) return 'surprised';
if (cleanText.includes('?') && cleanText.length < 50) return 'thinking'; // Petite question rapide
if (cleanText.includes('...')) return 'thinking';
// Émotion par défaut
return 'neutral';
}
// Vérifier si le texte contient au moins un des mots-clés
private static containsAny(text: string, keywords: string[]): boolean {
return keywords.some(keyword => {
// Expression régulière pour rechercher le mot exact (évite les correspondances partielles fausses)
const regex = new RegExp(`\\b${keyword}\\b|${keyword}`, 'i');
return regex.test(text);
});
}
// Renvoie les paramètres graphiques (couleur des yeux, intensité, forme) pour chaque émotion et avatar
// Utile pour la 3D ou les shaders
public static getEmotionStyle(emotion: EveEmotion, avatarId: EveAvatar = 'eve') {
// Spectre de couleurs par défaut selon l'avatar sélectionné
let eyeColor = '#00f2ff'; // Eve par défaut
if (avatarId === 'nova') {
switch (emotion) {
case 'happy': eyeColor = '#ff0077'; break; // Luminous hot pink
case 'sad': eyeColor = '#ff88dd'; break; // Melancholic soft pink
case 'angry': eyeColor = '#9900ff'; break; // Furious deep purple
case 'surprised': eyeColor = '#ffc8ff'; break; // Electric light pink
case 'thinking': eyeColor = '#c300ff'; break; // Luminous neon violet
case 'neutral':
default:
eyeColor = '#ff00ea'; // Vibrant magenta
break;
}
} else if (avatarId === 'aegis') {
switch (emotion) {
case 'happy': eyeColor = '#ffaa00'; break; // Luminous warm gold
case 'sad': eyeColor = '#ffa677'; break; // Melancholic dusty orange
case 'angry': eyeColor = '#ff3300'; break; // Furious Nixie warning red
case 'surprised': eyeColor = '#ffe4cc'; break; // Electric light amber
case 'thinking': eyeColor = '#ff8800'; break; // Luminous bright orange
case 'neutral':
default:
eyeColor = '#ff6a00'; // Vintage Nixie amber
break;
}
} else {
// 'eve' original (Cyan spectrum)
switch (emotion) {
case 'happy': eyeColor = '#00ffff'; break; // Luminous turquoise cyan
case 'sad': eyeColor = '#3de7ff'; break; // Melancholic luminous soft cyan
case 'angry': eyeColor = '#00a2ff'; break; // Furious electric cyan-blue
case 'surprised': eyeColor = '#99f2ff'; break; // Electric light ice blue
case 'thinking': eyeColor = '#00c3ff'; break; // Luminous sky blue
case 'neutral':
default:
eyeColor = '#00f2ff'; // Signature vibrant cyan blue
break;
}
}
switch (emotion) {
case 'happy':
return {
eyeColor,
eyeScaleY: 0.6,
eyeRotation: 0.1, // Joyous rotation
pulseSpeed: 3.5,
lightIntensity: 2.0
};
case 'sad':
return {
eyeColor,
eyeScaleY: 0.45,
eyeRotation: -0.15, // Drooping eyes
pulseSpeed: 1.0,
lightIntensity: 1.0
};
case 'angry':
return {
eyeColor,
eyeScaleY: 0.3,
eyeRotation: 0.25, // Angry angled eyes
pulseSpeed: 4.0,
lightIntensity: 1.8
};
case 'surprised':
return {
eyeColor,
eyeScaleY: 1.0, // Fully open round eyes
eyeRotation: 0,
pulseSpeed: 5.0,
lightIntensity: 2.5
};
case 'thinking':
return {
eyeColor,
eyeScaleY: 0.5, // Half closed thinking eyes
eyeRotation: -0.05,
pulseSpeed: 1.8,
lightIntensity: 1.3
};
case 'neutral':
default:
return {
eyeColor,
eyeScaleY: 0.65, // Standard oblong shape
eyeRotation: 0,
pulseSpeed: 2.0,
lightIntensity: 1.5
};
}
}
}
+70
View File
@@ -0,0 +1,70 @@
/**
* LogService — Centralized structured logger for EveFlow.
*
* In Electron (production/dev): sends log entries via window.electronAPI.log()
* which the main process writes to %APPDATA%/EveFlow/eveflow.log
*
* Fallback (browser only): console only.
*/
export type LogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR';
export interface LogEntry {
ts: string; // ISO timestamp
level: LogLevel;
tag: string; // Component / service tag (e.g. "HermesAgent", "Minimax")
message: string;
data?: unknown; // Any extra serializable payload
}
export class LogService {
private static readonly MAX_CONSOLE_DATA_LEN = 2000;
// Core write method
public static log(level: LogLevel, tag: string, message: string, data?: unknown): void {
const entry: LogEntry = {
ts: new Date().toISOString(),
level,
tag,
message,
data
};
// Always write to console for DevTools visibility
const consoleMsg = `[${entry.ts}] [${level}] [${tag}] ${message}`;
const dataStr = data !== undefined ? JSON.stringify(data, null, 2) : '';
switch (level) {
case 'ERROR': console.error(consoleMsg, dataStr ? dataStr.slice(0, this.MAX_CONSOLE_DATA_LEN) : ''); break;
case 'WARN': console.warn(consoleMsg, dataStr ? dataStr.slice(0, this.MAX_CONSOLE_DATA_LEN) : ''); break;
case 'DEBUG': console.debug(consoleMsg, dataStr ? dataStr.slice(0, this.MAX_CONSOLE_DATA_LEN) : ''); break;
default: console.info(consoleMsg, dataStr ? dataStr.slice(0, this.MAX_CONSOLE_DATA_LEN) : ''); break;
}
// Send to Electron main process for file persistence
try {
const electronAPI = (window as any).electronAPI;
if (electronAPI?.writeLog) {
electronAPI.writeLog(entry);
}
} catch {
// Silently ignore: browser context has no electronAPI
}
}
public static debug(tag: string, message: string, data?: unknown): void {
this.log('DEBUG', tag, message, data);
}
public static info(tag: string, message: string, data?: unknown): void {
this.log('INFO', tag, message, data);
}
public static warn(tag: string, message: string, data?: unknown): void {
this.log('WARN', tag, message, data);
}
public static error(tag: string, message: string, data?: unknown): void {
this.log('ERROR', tag, message, data);
}
}
+112
View File
@@ -0,0 +1,112 @@
/**
* PollingService — Periodically polls Hermes Agent /api/jobs endpoint
* to receive proactive notifications from the AI (scheduled tasks, alerts).
*
* Runs as a background timer. Calls the provided callback whenever a
* newly completed job is detected.
*/
import { LogService } from './logService';
export interface HermesJob {
id: string;
status: 'pending' | 'running' | 'completed' | 'failed';
result?: string;
created_at?: string;
completed_at?: string;
}
export type JobHandler = (job: HermesJob) => void;
export class PollingService {
private intervalId: ReturnType<typeof setInterval> | null = null;
private seenJobIds: Set<string> = new Set();
private hermesUrl: string = '';
private hermesApiKey: string = '';
private onJobCompleted: JobHandler | null = null;
private intervalMs: number;
constructor(intervalMs: number = 30_000) {
this.intervalMs = intervalMs;
}
/**
* Start polling Hermes Agent for completed jobs.
*
* @param hermesUrl - Base URL of the Hermes server
* @param apiKey - Bearer token for authentication
* @param onCompleted - Callback fired when a new completed job is detected
*/
public start(hermesUrl: string, apiKey: string, onCompleted: JobHandler): void {
this.stop(); // clear any existing interval
this.hermesUrl = hermesUrl;
this.hermesApiKey = apiKey;
this.onJobCompleted = onCompleted;
LogService.info('PollingService', `Démarrage polling Hermes toutes les ${this.intervalMs / 1000}s`, { hermesUrl });
// Fire once immediately, then on interval
this.poll();
this.intervalId = setInterval(() => this.poll(), this.intervalMs);
}
/** Stop the polling loop. */
public stop(): void {
if (this.intervalId !== null) {
clearInterval(this.intervalId);
this.intervalId = null;
LogService.info('PollingService', 'Polling Hermes arrêté.');
}
}
/** Returns true if currently polling. */
public get isActive(): boolean {
return this.intervalId !== null;
}
private async poll(): Promise<void> {
if (!this.hermesUrl || !this.onJobCompleted) return;
const base = this.hermesUrl.replace(/\/$/, '').replace(/\/v1(\/.*)?$/, '');
const endpoint = `${base}/api/jobs`;
const abortCtrl = new AbortController();
const timeoutId = setTimeout(() => abortCtrl.abort(), 10_000); // 10s max
try {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (this.hermesApiKey) headers['Authorization'] = `Bearer ${this.hermesApiKey.trim()}`;
const response = await fetch(endpoint, { method: 'GET', headers, signal: abortCtrl.signal });
clearTimeout(timeoutId);
if (!response.ok) {
LogService.warn('PollingService', `Erreur HTTP /api/jobs: ${response.status}`);
return;
}
const jobs: HermesJob[] = await response.json();
for (const job of jobs) {
if (job.status === 'completed' && job.id && !this.seenJobIds.has(job.id)) {
this.seenJobIds.add(job.id);
LogService.info('PollingService', `Nouveau job complété: ${job.id}`, { result: job.result?.slice(0, 200) });
this.onJobCompleted(job);
}
}
// Évite une fuite mémoire sur les sessions longues
if (this.seenJobIds.size > 500) {
const arr = [...this.seenJobIds];
this.seenJobIds = new Set(arr.slice(-250));
}
} catch (e: any) {
clearTimeout(timeoutId);
if ((e as any).name === 'AbortError') {
LogService.warn('PollingService', 'Poll timeout (10s) — serveur Hermes trop lent ou injoignable');
return;
}
LogService.warn('PollingService', `Exception lors du poll: ${e.message}`);
}
}
}
+181
View File
@@ -0,0 +1,181 @@
/**
* ToolRegistry — Defines all functions exposed to Hermes Agent via OpenAI tool calling.
*
* Each tool has:
* - A JSON Schema definition sent to Hermes in the `tools[]` payload
* - A local executor that runs when Hermes invokes it
*
* AppCallbacks provides access to React state setters from AgentService (stateless).
*/
export interface AppCallbacks {
/** Change the avatar's current emotion */
setEmotion: (emotion: string) => void;
/** Get the current application status snapshot */
getStatus: () => AppStatus;
/** Get the N most recent chat messages */
getHistory: (n: number) => { role: string; content: string }[];
/** Trigger TTS speech */
speak: (text: string) => void;
}
export interface AppStatus {
provider: string;
avatar: string;
emotion: string;
isSpeaking: boolean;
isMuted: boolean;
messageCount: number;
}
/** OpenAI-compatible tool definition */
export interface ToolDefinition {
type: 'function';
function: {
name: string;
description: string;
parameters: Record<string, unknown>;
};
}
/** Result returned to Hermes after tool execution */
export interface ToolResult {
tool_call_id: string;
role: 'tool';
content: string;
}
// ── Tool Definitions (sent to Hermes in request payload) ───────────────────
export const HERMES_TOOLS: ToolDefinition[] = [
{
type: 'function',
function: {
name: 'set_avatar_emotion',
description: "Change l'état émotionnel de l'avatar Eve visible par l'utilisateur. Appelle cet outil quand la réponse doit être accompagnée d'une expression visible.",
parameters: {
type: 'object',
properties: {
emotion: {
type: 'string',
enum: ['neutral', 'happy', 'thinking', 'excited', 'sad', 'angry', 'surprised'],
description: "L'émotion à afficher sur l'avatar"
}
},
required: ['emotion']
}
}
},
{
type: 'function',
function: {
name: 'get_app_status',
description: "Retourne l'état actuel de l'application EveFlow : provider IA actif, avatar sélectionné, émotion courante, mode audio.",
parameters: {
type: 'object',
properties: {},
required: []
}
}
},
{
type: 'function',
function: {
name: 'get_conversation_history',
description: 'Retourne les N derniers messages de la conversation en cours.',
parameters: {
type: 'object',
properties: {
n: {
type: 'number',
description: 'Nombre de messages à récupérer (max 20)',
minimum: 1,
maximum: 20
}
},
required: ['n']
}
}
},
{
type: 'function',
function: {
name: 'speak_text',
description: "Fait prononcer un texte par la synthèse vocale de l'avatar. Utile pour mettre en avant une information importante.",
parameters: {
type: 'object',
properties: {
text: {
type: 'string',
description: 'Le texte à prononcer (max 500 caractères)'
}
},
required: ['text']
}
}
}
];
// ── Tool Dispatcher ─────────────────────────────────────────────────────────
/**
* Executes a tool call requested by Hermes Agent and returns the stringified result.
*
* @param toolCallId - The ID from Hermes tool_call (must be echoed back in role:tool message)
* @param name - Function name requested by Hermes
* @param rawArgs - JSON string of arguments from Hermes
* @param callbacks - App state accessors injected from the React layer
* @returns - ToolResult to append to the messages array
*/
export function executeTool(
toolCallId: string,
name: string,
rawArgs: string,
callbacks: AppCallbacks
): ToolResult {
let result: string;
try {
const args = JSON.parse(rawArgs || '{}');
switch (name) {
case 'set_avatar_emotion': {
const emotion = args.emotion as string;
callbacks.setEmotion(emotion);
result = JSON.stringify({ success: true, emotion_set: emotion });
break;
}
case 'get_app_status': {
const status = callbacks.getStatus();
result = JSON.stringify(status);
break;
}
case 'get_conversation_history': {
const n = Math.min(Number(args.n) || 5, 20);
const history = callbacks.getHistory(n);
result = JSON.stringify({ messages: history, count: history.length });
break;
}
case 'speak_text': {
const text = String(args.text || '').slice(0, 500);
callbacks.speak(text);
result = JSON.stringify({ success: true, text_spoken: text });
break;
}
default:
result = JSON.stringify({ error: `Unknown tool: ${name}` });
}
} catch (e: any) {
result = JSON.stringify({ error: `Tool execution failed: ${e.message}` });
}
return {
tool_call_id: toolCallId,
role: 'tool',
content: result
};
}