feat: implement file sharing, SVG image rendering, avatar emotional animations, live telemetry, and dynamic TTS description with cyberpunk playback controls

This commit is contained in:
Michael committed 2026-05-20 15:59:16 +02:00
1 parent d77ace03cd
commit bf247e7e5f
9 files changed
+1676 -99

No files matched your search

+53 -12
View File
@@ -11,6 +11,7 @@ export interface AgentConfig {
hermesUrl: string;
hermesModel: string;
hermesApiKey: string;
hermesSessionKey: string;
minimaxApiKey: string;
minimaxGroupId: string;
minimaxModel: string;
@@ -50,11 +51,14 @@ export class AgentService {
callbacks?: AppCallbacks,
onToken?: (token: string) => void,
onToolCall?: (toolName: string) => void,
sessionId?: string
sessionId?: string,
onSessionIdChange?: (newSessionId: string) => void,
images?: string[]
): Promise<string> {
LogService.info('AgentService', `sendMessage → provider=${config.provider} avatar=${avatarId}`, {
messagePreview: message.slice(0, 120),
historyLength: history.length
historyLength: history.length,
imagesCount: images?.length || 0
});
// For Hermes: do NOT inject an avatar system prompt — let Hermes use its own
@@ -73,9 +77,9 @@ export class AgentService {
switch (config.provider) {
case 'ollama':
return this.sendToOllama(message, updatedHistory, config.ollamaUrl, config.ollamaModel);
return this.sendToOllama(message, updatedHistory, config.ollamaUrl, config.ollamaModel, images);
case 'hermes':
return this.sendToHermes(message, updatedHistory, config, callbacks, onToken, onToolCall, sessionId);
return this.sendToHermes(message, updatedHistory, config, callbacks, onToken, onToolCall, sessionId, onSessionIdChange, images);
case 'minimax':
return this.sendToMinimax(message, history, config, avatarId);
default:
@@ -88,10 +92,23 @@ export class AgentService {
message: string,
history: { role: 'user' | 'assistant' | 'system'; content: string }[],
url: string,
model: string
model: string,
images?: string[]
): Promise<string> {
const endpoint = `${url.replace(/\/$/, '')}/api/chat`;
const body = { model, messages: [...history, { role: 'user', content: message }], stream: false };
// Pour Ollama local, extraire uniquement le bloc base64 de la Data URL
const formattedImages = images?.map(img => {
const parts = img.split(',');
return parts.length > 1 ? parts[1] : parts[0];
});
const userMessage: any = { role: 'user', content: message };
if (formattedImages && formattedImages.length > 0) {
userMessage.images = formattedImages;
}
const body = { model, messages: [...history, userMessage], stream: false };
LogService.debug('Ollama', `POST ${endpoint}`, { model, historyLength: history.length });
@@ -127,9 +144,11 @@ export class AgentService {
callbacks?: AppCallbacks,
onToken?: (token: string) => void,
onToolCall?: (toolName: string) => void,
sessionId?: string
sessionId?: string,
onSessionIdChange?: (newSessionId: string) => void,
images?: string[]
): Promise<string> {
const { hermesUrl, hermesModel, hermesApiKey } = config;
const { hermesUrl, hermesModel, hermesApiKey, hermesSessionKey } = config;
// Smart endpoint builder
const base = hermesUrl.replace(/\/$/, '');
@@ -146,9 +165,23 @@ export class AgentService {
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;
// Stable memory scoping: link this request to long-term memory (USER.md / MEMORY.md)
if (hermesSessionKey) headers['X-Hermes-Session-Key'] = hermesSessionKey.trim();
// Construction du format de message multimodal d'OpenAI pour Hermes Agent
let userContent: any = message;
if (images && images.length > 0) {
userContent = [
{ type: 'text', text: message },
...images.map(img => ({
type: 'image_url',
image_url: { url: img }
}))
];
}
// Build mutable message list for tool-calling loop
const messages: any[] = [...history, { role: 'user', content: message }];
const messages: any[] = [...history, { role: 'user', content: userContent }];
const useStreaming = !!onToken;
const useTools = !!callbacks;
@@ -156,7 +189,8 @@ export class AgentService {
model: hermesModel,
streaming: useStreaming,
toolsEnabled: useTools,
historyLength: history.length
historyLength: history.length,
hasSessionKey: !!hermesSessionKey
});
// ── Tool-calling loop (max 5 iterations to prevent infinite loops) ────────
@@ -194,6 +228,13 @@ export class AgentService {
LogService.info('HermesAgent', `HTTP ${response.status} [iter=${iteration}]`);
// Capture session rotation from response headers (context compression or new session)
const returnedSessionId = response.headers.get('x-hermes-session-id') || response.headers.get('X-Hermes-Session-Id');
if (returnedSessionId && onSessionIdChange && returnedSessionId !== sessionId) {
LogService.info('HermesAgent', `Session rotated: ${sessionId} -> ${returnedSessionId}`);
onSessionIdChange(returnedSessionId);
}
if (!response.ok) {
const rawErr = await response.text().catch(() => '(no body)');
LogService.error('HermesAgent', `Réponse non-OK`, { status: response.status, body: rawErr });
@@ -227,7 +268,7 @@ export class AgentService {
LogService.info('HermesAgent', `Tool call: ${toolName}`, tc.function?.arguments);
onToolCall?.(toolName);
const result = executeTool(tc.id, toolName, tc.function?.arguments || '{}', callbacks!);
const result = await executeTool(tc.id, toolName, tc.function?.arguments || '{}', callbacks!);
messages.push(result);
}
@@ -313,7 +354,7 @@ export class AgentService {
const toolName = tc.function?.name;
LogService.info('HermesAgent', `[stream] Tool call: ${toolName}`);
onToolCall?.(toolName);
const result = executeTool(tc.id, toolName, tc.function?.arguments || '{}', callbacks);
const result = await executeTool(tc.id, toolName, tc.function?.arguments || '{}', callbacks);
messages.push(result);
}
// Re-call non-streaming to get final answer after tools
+38 -8
View File
@@ -45,18 +45,46 @@ export class AudioService {
}
private _cleanForTTS(raw: string): string {
return raw
// 1. Extraire le texte des liens Markdown [Texte](url). Gère aussi les parenthèses dans les URLs
let cleaned = raw.replace(/\[([^\]]+)\]\((?:[^)(]+|\([^)(]*\))*\)/g, '$1');
// 2. Supprimer toutes les URLs nues (http, https, ftp, file) complexes ou simples
cleaned = cleaned.replace(/(?:https?|ftp|file):\/\/\S+/gi, '');
// 3. Supprimer les adresses commençant par www.
cleaned = cleaned.replace(/www\.\S+/gi, '');
// 4. Supprimer tout mot technique contenant des chemins de fichiers, adresses IP ou segments techniques
// (ex: C:\chemin, localhost:8080, v1/chat/completions)
const words = cleaned.split(/\s+/);
cleaned = words
.filter(word => {
// Exclure les mots qui ressemblent à des chemins ou contiennent des slashes / backslashes
if (word.includes('/') || word.includes('\\')) {
return false;
}
// Exclure les hôtes avec port (ex: localhost:8080, 127.0.0.1:8642)
if (/(?:[a-zA-Z0-9-]+\.[a-zA-Z]{2,6}|localhost|[\d.]+):\d+/.test(word)) {
return false;
}
// Exclure les adresses IP nues
if (/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/.test(word)) {
return false;
}
return true;
})
.join(' ');
return cleaned
// 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 : **, *, _, __, #, `, ~
// Markdown restant : **, *, _, __, #, `, ~
.replace(/(\*\*|__|~~|[*_#`~])/g, '')
// Liens markdown [texte](url) → garder le texte
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
// Balises techniques [⚙️ Exécution: ...] et similaires
// Balises techniques résiduelles [⚙️ Exécution...]
.replace(/\[[^\]]*\]/g, '')
// Ponctuation répétée ou décorative : ---, ===, ~~~, ...
.replace(/(-{2,}|={2,}|~{2,}|\.{2,})/g, ' ')
// Caractères non lisibles restants
// Caractères techniques restants non lisibles
.replace(/[<>{}|\\^]/g, '')
// Espaces multiples
.replace(/\s{2,}/g, ' ')
@@ -90,10 +118,12 @@ export class AudioService {
// 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();
const clean = text.trim();
if (!clean) return;
const u = this._makeUtterance(clean, voiceName, rate, pitch);
this.ttsSynth.speak(u); // s'enchaîne automatiquement à la file SpeechSynthesis
if (u.text.trim()) {
this.ttsSynth.speak(u); // s'enchaîne automatiquement à la file SpeechSynthesis
}
}
// Vide la file TTS (fin du streaming ou interruption utilisateur)
+68 -2
View File
@@ -113,6 +113,48 @@ export const HERMES_TOOLS: ToolDefinition[] = [
required: ['text']
}
}
},
{
type: 'function',
function: {
name: 'write_shared_file',
description: "Sauvegarde un fichier (rapport, script python, javascript, données JSON, HTML) dans le dossier partagé EveFlow de l'utilisateur Windows et lui partage un lien d'accès. Utilise cet outil chaque fois que l'utilisateur te demande de générer, d'écrire ou de sauvegarder un fichier sur son ordinateur.",
parameters: {
type: 'object',
properties: {
filename: {
type: 'string',
description: "Le nom du fichier avec son extension (ex: rapport.txt, script.py, data.json, index.html)"
},
content: {
type: 'string',
description: "Le contenu textuel intégral à écrire dans le fichier"
}
},
required: ['filename', 'content']
}
}
},
{
type: 'function',
function: {
name: 'generate_shared_image',
description: "Génère un schéma technique, un dessin vectoriel, ou une illustration de haute qualité au format SVG, l'enregistre sur le disque et l'affiche dans le chat. Utilise cet outil dès que l'utilisateur te demande d'afficher, de dessiner ou de générer un graphique, un diagramme ou une image.",
parameters: {
type: 'object',
properties: {
filename: {
type: 'string',
description: "Le nom du fichier SVG (ex: architecture.svg, graphique.svg)"
},
svgContent: {
type: 'string',
description: "Le code source SVG complet et valide (incluant les balises <svg>, viewBox, styles et éléments géométriques)"
}
},
required: ['filename', 'svgContent']
}
}
}
];
@@ -127,12 +169,12 @@ export const HERMES_TOOLS: ToolDefinition[] = [
* @param callbacks - App state accessors injected from the React layer
* @returns - ToolResult to append to the messages array
*/
export function executeTool(
export async function executeTool(
toolCallId: string,
name: string,
rawArgs: string,
callbacks: AppCallbacks
): ToolResult {
): Promise<ToolResult> {
let result: string;
try {
@@ -166,6 +208,30 @@ export function executeTool(
break;
}
case 'write_shared_file': {
const filename = String(args.filename || '');
const content = String(args.content || '');
if ((window as any).electronAPI?.writeSharedFile) {
const fileUrl = await (window as any).electronAPI.writeSharedFile(filename, content, false);
result = JSON.stringify({ success: true, url: fileUrl, filename });
} else {
result = JSON.stringify({ error: "L'API d'écriture de fichier local n'est pas disponible (hors Electron)." });
}
break;
}
case 'generate_shared_image': {
const filename = String(args.filename || '');
const svgContent = String(args.svgContent || '');
if ((window as any).electronAPI?.writeSharedFile) {
const fileUrl = await (window as any).electronAPI.writeSharedFile(filename, svgContent, false);
result = JSON.stringify({ success: true, url: fileUrl, filename });
} else {
result = JSON.stringify({ error: "L'API de génération d'image locale n'est pas disponible." });
}
break;
}
default:
result = JSON.stringify({ error: `Unknown tool: ${name}` });
}