mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-14 02:36:32 +02:00
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:
1 parent
d77ace03cd
commit
bf247e7e5f
9 files changed
+1676
-99
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}` });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user