mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-11 17:29:03 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba859aa78c |
No files matched your search
Generated
+2537
File diff suppressed because it is too large.
Load diff
+28
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eveflow",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.2",
|
||||
"description": "Retro-futuristic Windows companion with 3D Eve bot, local TTS/STT, and multi-agent connectivity",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
@@ -8,7 +8,32 @@
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"electron": "electron .",
|
||||
"start": "concurrently -k \"npm run dev -- --host 127.0.0.1 --port 5173 --strictPort\" \"wait-on http://localhost:5173 && npm run electron\""
|
||||
"start": "concurrently -k \"npm run dev -- --host 127.0.0.1 --port 5173 --strictPort\" \"wait-on http://localhost:5173 && npm run electron\"",
|
||||
"dist": "electron-builder --win"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.romik3.eveflow",
|
||||
"productName": "EveFlow",
|
||||
"copyright": "Copyright © 2026 R0m1k3",
|
||||
"directories": {
|
||||
"output": "out"
|
||||
},
|
||||
"files": [
|
||||
"main.js",
|
||||
"preload.js",
|
||||
"dist/**/*"
|
||||
],
|
||||
"win": {
|
||||
"target": "nsis",
|
||||
"requestedExecutionLevel": "asInvoker"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true,
|
||||
"shortcutName": "EveFlow"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-three/drei": "^9.105.0",
|
||||
@@ -27,6 +52,7 @@
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"concurrently": "^8.2.2",
|
||||
"electron": "^30.0.1",
|
||||
"electron-builder": "^26.8.1",
|
||||
"typescript": "^5.4.2",
|
||||
"vite": "^5.2.2",
|
||||
"wait-on": "^7.2.0"
|
||||
|
||||
+8
-180
@@ -11,6 +11,7 @@ import { AgentService, AgentConfig, AgentProvider } from './services/agentServic
|
||||
import { EmotionService, EveAvatar, EveEmotion } from './services/emotionService';
|
||||
import { PollingService } from './services/pollingService';
|
||||
import { AppCallbacks } from './services/toolRegistry';
|
||||
import { version } from '../package.json';
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
@@ -395,10 +396,7 @@ export const App: React.FC = () => {
|
||||
hermesUrl: 'http://127.0.0.1:8642/v1',
|
||||
hermesModel: 'hermes-agent',
|
||||
hermesApiKey: '',
|
||||
hermesSessionKey: 'eveflow-user-session',
|
||||
minimaxApiKey: '',
|
||||
minimaxGroupId: '',
|
||||
minimaxModel: 'abab6.5-chat'
|
||||
hermesSessionKey: 'eveflow-user-session'
|
||||
});
|
||||
|
||||
const [availableVoices, setAvailableVoices] = useState<any[]>([]);
|
||||
@@ -407,19 +405,7 @@ export const App: React.FC = () => {
|
||||
const [sttLang, setSttLang] = useState<string>('fr-FR');
|
||||
const activeAvatar = AVATAR_PROFILES[avatarId];
|
||||
|
||||
// Minimax dynamic models states
|
||||
const [minimaxModelsList, setMinimaxModelsList] = useState<string[]>([
|
||||
'MiniMax-Text-01',
|
||||
'MiniMax-M2.7',
|
||||
'MiniMax-M2.5',
|
||||
'MiniMax-M2.1',
|
||||
'MiniMax-M2',
|
||||
'MiniMax-M2-her',
|
||||
'abab6.5-chat'
|
||||
]);
|
||||
const [isLoadingMinimaxModels, setIsLoadingMinimaxModels] = useState(false);
|
||||
const [minimaxModelsError, setMinimaxModelsError] = useState<string | null>(null);
|
||||
const [lastLoadedMinimaxCredentials, setLastLoadedMinimaxCredentials] = useState<{ apiKey: string, groupId: string } | null>(null);
|
||||
|
||||
|
||||
// Session Hermes persistante — garantit la continuité mémoire/skills entre redémarrages
|
||||
const [hermesSessionId, setHermesSessionId] = useState<string>('');
|
||||
@@ -570,22 +556,7 @@ export const App: React.FC = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages, showSettings]);
|
||||
|
||||
// Chargement automatique des modèles Minimax lorsque les identifiants requis sont saisis et que le fournisseur est Minimax
|
||||
useEffect(() => {
|
||||
const hasCredentialsChanged = !lastLoadedMinimaxCredentials ||
|
||||
lastLoadedMinimaxCredentials.apiKey !== agentConfig.minimaxApiKey ||
|
||||
lastLoadedMinimaxCredentials.groupId !== agentConfig.minimaxGroupId;
|
||||
|
||||
if (
|
||||
agentConfig.provider === 'minimax' &&
|
||||
agentConfig.minimaxApiKey &&
|
||||
agentConfig.minimaxGroupId &&
|
||||
hasCredentialsChanged &&
|
||||
!isLoadingMinimaxModels
|
||||
) {
|
||||
handleLoadMinimaxModels();
|
||||
}
|
||||
}, [agentConfig.provider, agentConfig.minimaxApiKey, agentConfig.minimaxGroupId, lastLoadedMinimaxCredentials]);
|
||||
|
||||
// Polling Hermes Jobs
|
||||
useEffect(() => {
|
||||
@@ -654,16 +625,7 @@ export const App: React.FC = () => {
|
||||
persistWrite('eveflow_agent_config', JSON.stringify(newConfig));
|
||||
};
|
||||
|
||||
const handleAvatarChange = (nextAvatarId: EveAvatar) => {
|
||||
setAvatarId(nextAvatarId);
|
||||
setCurrentEmotion('neutral');
|
||||
persistWrite('eveflow_avatar_id', nextAvatarId);
|
||||
|
||||
setMessages(prev => {
|
||||
const canReplaceWelcome = prev.length === 0 || (prev.length === 1 && prev[0].id === 'welcome');
|
||||
return canReplaceWelcome ? [createWelcomeMessage(nextAvatarId)] : prev;
|
||||
});
|
||||
};
|
||||
|
||||
const handleWindowControl = (action: 'minimize' | 'maximize' | 'close') => {
|
||||
if (window.electronAPI) {
|
||||
@@ -947,40 +909,7 @@ export const App: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadMinimaxModels = async () => {
|
||||
if (!agentConfig.minimaxApiKey) {
|
||||
setMinimaxModelsError("Veuillez saisir votre clé API Minimax.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingMinimaxModels(true);
|
||||
setMinimaxModelsError(null);
|
||||
|
||||
try {
|
||||
const models = await AgentService.getMinimaxModels(
|
||||
agentConfig.minimaxApiKey,
|
||||
agentConfig.minimaxGroupId
|
||||
);
|
||||
if (models && models.length > 0) {
|
||||
setMinimaxModelsList(models);
|
||||
setLastLoadedMinimaxCredentials({
|
||||
apiKey: agentConfig.minimaxApiKey,
|
||||
groupId: agentConfig.minimaxGroupId
|
||||
});
|
||||
// Si le modèle configuré n'est pas dans la liste des modèles disponibles, on bascule vers le premier
|
||||
if (!models.includes(agentConfig.minimaxModel)) {
|
||||
saveConfig({ ...agentConfig, minimaxModel: models[0] });
|
||||
}
|
||||
} else {
|
||||
throw new Error("Aucun modèle retourné par l'API.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("Échec de chargement des modèles Minimax:", err);
|
||||
setMinimaxModelsError(err.message || "Impossible de charger les modèles.");
|
||||
} finally {
|
||||
setIsLoadingMinimaxModels(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className={`app-container ${isFloatingMode ? 'transparent-bg' : ''}`}>
|
||||
@@ -993,7 +922,7 @@ export const App: React.FC = () => {
|
||||
<h1 className="neon-title" style={{ fontFamily: 'var(--font-title)', letterSpacing: '2px', color: 'var(--text-primary)', fontSize: '18px', fontWeight: 800, display: 'flex', alignItems: 'center', gap: '10px', lineHeight: 1 }}>
|
||||
EVEFLOW
|
||||
<span className="header-subtitle" style={{ color: 'var(--text-secondary)', fontWeight: 500, fontSize: '14px' }}>// ANALOG SPACE OS</span>
|
||||
<span className="sys-ready-badge" style={{ fontSize: '9px', padding: '3px 10px', backgroundColor: 'var(--accent-light)', color: 'var(--text-neon)', border: '1px solid rgba(0,212,245,0.4)', borderRadius: '20px', fontWeight: 'bold', fontFamily: 'var(--font-mono)' }}>SYS_READY_V74</span>
|
||||
<span className="sys-ready-badge" style={{ fontSize: '9px', padding: '3px 10px', backgroundColor: 'var(--accent-light)', color: 'var(--text-neon)', border: '1px solid rgba(0,212,245,0.4)', borderRadius: '20px', fontWeight: 'bold', fontFamily: 'var(--font-mono)' }}>SYS_READY_V{version}</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@@ -1332,52 +1261,7 @@ export const App: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Avatar Selector */}
|
||||
<div className="settings-card">
|
||||
<h3 className="label-title" style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<Sparkles style={{ width: '14px', height: '14px' }} /> Selection d'Avatar
|
||||
</h3>
|
||||
<div className="grid-3">
|
||||
{(Object.keys(AVATAR_PROFILES) as EveAvatar[]).map((id) => {
|
||||
const profile = AVATAR_PROFILES[id];
|
||||
const isActive = avatarId === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => handleAvatarChange(id)}
|
||||
className="glow-input"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
minHeight: '112px',
|
||||
padding: '14px',
|
||||
textAlign: 'left',
|
||||
borderColor: isActive ? profile.accent : 'var(--text-primary)',
|
||||
backgroundColor: isActive ? 'var(--bg-glass-active)' : 'var(--bg-secondary)',
|
||||
boxShadow: isActive ? `0 0 18px ${profile.glow}` : 'none'
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
|
||||
<span style={{ width: '10px', height: '10px', borderRadius: '50%', backgroundColor: profile.accent, boxShadow: `0 0 12px ${profile.accent}` }} />
|
||||
<span style={{ fontFamily: 'var(--font-title)', fontWeight: 900, fontSize: '13px', textTransform: 'uppercase' }}>{profile.name}</span>
|
||||
</span>
|
||||
<span style={{ display: 'block', fontSize: '10px', lineHeight: 1.4, color: 'var(--text-secondary)', fontFamily: 'var(--font-mono)', textTransform: 'uppercase' }}>
|
||||
{profile.role}
|
||||
</span>
|
||||
{profile.assetPath && (
|
||||
<span style={{ display: 'block', marginTop: '10px', fontSize: '9px', lineHeight: 1.35, color: isActive ? profile.accent : 'var(--text-secondary)', fontFamily: 'var(--font-mono)', textTransform: 'uppercase', fontWeight: 800 }}>
|
||||
Asset requis: {profile.assetPath}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{avatarId !== 'eve' && activeAvatar.assetPath && (
|
||||
<div style={{ marginTop: '12px', padding: '10px 12px', border: '1px solid rgba(0,212,245,0.2)', borderRadius: 'var(--radius-sm)', backgroundColor: 'rgba(0,212,245,0.06)', fontSize: '10px', lineHeight: 1.45, color: 'var(--text-secondary)', fontFamily: 'var(--font-mono)', fontWeight: 700 }}>
|
||||
Si le fichier GLB premium est absent, EveFlow utilise Eve comme fallback visuel. Installe l'asset dans {activeAvatar.assetPath} pour activer le vrai modele {activeAvatar.name}.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* A. Agents */}
|
||||
<div className="settings-card">
|
||||
@@ -1386,7 +1270,7 @@ export const App: React.FC = () => {
|
||||
</h3>
|
||||
|
||||
<div className="grid-3">
|
||||
{(['ollama', 'hermes', 'minimax'] as AgentProvider[]).map((prov) => (
|
||||
{(['ollama', 'hermes'] as AgentProvider[]).map((prov) => (
|
||||
<button
|
||||
key={prov}
|
||||
onClick={() => saveConfig({ ...agentConfig, provider: prov })}
|
||||
@@ -1487,63 +1371,7 @@ export const App: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{agentConfig.provider === 'minimax' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<div>
|
||||
<label className="label-title">Clé API Minimax</label>
|
||||
<input
|
||||
type="password"
|
||||
value={agentConfig.minimaxApiKey}
|
||||
onChange={(e) => saveConfig({ ...agentConfig, minimaxApiKey: e.target.value })}
|
||||
className="glow-input"
|
||||
style={{ padding: '8px 12px', fontSize: '12px' }}
|
||||
placeholder="Ex: MM-..."
|
||||
/>
|
||||
</div>
|
||||
<div className="grid-2">
|
||||
<div>
|
||||
<label className="label-title">Minimax Group ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={agentConfig.minimaxGroupId}
|
||||
onChange={(e) => saveConfig({ ...agentConfig, minimaxGroupId: e.target.value })}
|
||||
className="glow-input"
|
||||
style={{ padding: '8px 12px', fontSize: '12px' }}
|
||||
placeholder="Ex: 1234567"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label-title">Modèle Actuel</label>
|
||||
<select
|
||||
value={agentConfig.minimaxModel}
|
||||
onChange={(e) => saveConfig({ ...agentConfig, minimaxModel: e.target.value })}
|
||||
className="glow-input"
|
||||
style={{ padding: '8px 12px', fontSize: '12px', width: '100%' }}
|
||||
>
|
||||
{minimaxModelsList.map((m) => (
|
||||
<option key={m} value={m}>{m}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<button
|
||||
onClick={handleLoadMinimaxModels}
|
||||
className="neon-btn neon-btn-secondary"
|
||||
style={{ width: '100%', padding: '8px 16px', fontSize: '11px', gap: '6px', cursor: 'pointer' }}
|
||||
disabled={isLoadingMinimaxModels}
|
||||
>
|
||||
{isLoadingMinimaxModels ? "Chargement des modèles..." : "Charger les modèles disponibles"}
|
||||
</button>
|
||||
{minimaxModelsError && (
|
||||
<span style={{ fontSize: '10px', color: '#ef4444', display: 'block', marginTop: '6px', fontWeight: 'bold' }}>
|
||||
⚠️ {minimaxModelsError} (Utilisation des modèles par défaut)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* B. TTS / STT */}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { EveAvatar } from './emotionService';
|
||||
import { LogService } from './logService';
|
||||
import { HERMES_TOOLS, executeTool, AppCallbacks } from './toolRegistry';
|
||||
|
||||
export type AgentProvider = 'ollama' | 'hermes' | 'minimax';
|
||||
export type AgentProvider = 'ollama' | 'hermes';
|
||||
|
||||
export interface AgentConfig {
|
||||
provider: AgentProvider;
|
||||
@@ -12,9 +12,6 @@ export interface AgentConfig {
|
||||
hermesModel: string;
|
||||
hermesApiKey: string;
|
||||
hermesSessionKey: string;
|
||||
minimaxApiKey: string;
|
||||
minimaxGroupId: string;
|
||||
minimaxModel: string;
|
||||
}
|
||||
|
||||
export class AgentService {
|
||||
@@ -80,8 +77,6 @@ export class AgentService {
|
||||
return this.sendToOllama(message, updatedHistory, config.ollamaUrl, config.ollamaModel, images);
|
||||
case 'hermes':
|
||||
return this.sendToHermes(message, updatedHistory, config, callbacks, onToken, onToolCall, sessionId, onSessionIdChange, images);
|
||||
case 'minimax':
|
||||
return this.sendToMinimax(message, history, config, avatarId);
|
||||
default:
|
||||
throw new Error("Fournisseur d'agent non reconnu");
|
||||
}
|
||||
@@ -378,107 +373,4 @@ export class AgentService {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
+129
-13
@@ -4,6 +4,52 @@ export interface VoiceOption {
|
||||
voice: SpeechSynthesisVoice;
|
||||
}
|
||||
|
||||
// Dictionnaire phonétique local pour corriger la prononciation des acronymes et anglicismes en français
|
||||
const PHONETIC_DICTIONARY: Record<string, string> = {
|
||||
'api': 'a-pé-i',
|
||||
'cors': 'korss',
|
||||
'svg': 'ess-vé-gé',
|
||||
'cpu': 'cé-pé-u',
|
||||
'fps': 'eff-pé-ess',
|
||||
'tts': 'té-té-ess',
|
||||
'ui': 'u-i',
|
||||
'ai': 'a-i',
|
||||
'ia': 'i-a',
|
||||
'json': 'dji-zone',
|
||||
'url': 'u-err-el',
|
||||
'html': 'ach-té-em-el',
|
||||
'css': 'cé-ess-ess',
|
||||
'js': 'ji-ess',
|
||||
'typescript': 'taïpe-skript',
|
||||
'javascript': 'djava-skript',
|
||||
'electron': 'élèktron',
|
||||
'react': 'ri-akt',
|
||||
'github': 'gitte-ub',
|
||||
'git': 'gitte',
|
||||
'setup': 'sétup',
|
||||
'c++': 'cé-plus-plus',
|
||||
'node.js': 'node-j-s',
|
||||
'nodejs': 'node-j-s',
|
||||
'database': 'deïta-beïss',
|
||||
'token': 'tokène',
|
||||
'cli': 'cé-el-i',
|
||||
'npm': 'en-pé-em',
|
||||
'npx': 'en-pé-ix',
|
||||
'vite': 'vite',
|
||||
'vscode': 'vé-ess-code',
|
||||
'vs code': 'vé-ess-code',
|
||||
'windows': 'windoze',
|
||||
'macos': 'mak-os',
|
||||
'linux': 'linux',
|
||||
'docker': 'dokeur',
|
||||
'cyberpunk': 'saïbeur-pounk',
|
||||
'avatar': 'avatar',
|
||||
'chat': 'tchat',
|
||||
'prompt': 'prompte',
|
||||
'welcome': 'ouel-kome',
|
||||
'ipc': 'i-pé-cé'
|
||||
};
|
||||
|
||||
export class AudioService {
|
||||
public ttsSynth: SpeechSynthesis;
|
||||
private recognition: any = null;
|
||||
@@ -27,10 +73,52 @@ export class AudioService {
|
||||
}
|
||||
}
|
||||
|
||||
// Calculer un score de qualité pour trier les voix locales
|
||||
private _getVoiceScore(v: SpeechSynthesisVoice): number {
|
||||
const lang = v.lang.toLowerCase();
|
||||
const name = v.name.toLowerCase();
|
||||
|
||||
if (!lang.startsWith('fr')) return 0;
|
||||
|
||||
let score = 100; // Base français
|
||||
|
||||
if (name.includes('google') || name.includes('online')) {
|
||||
score += 50;
|
||||
}
|
||||
if (name.includes('natural') || name.includes('neural')) {
|
||||
score += 40;
|
||||
}
|
||||
if (name.includes('mobile')) {
|
||||
score += 30;
|
||||
}
|
||||
if (name.includes('julie') || name.includes('paul')) {
|
||||
score += 20;
|
||||
}
|
||||
if (name.includes('hortense')) {
|
||||
score += 10;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
// Récupérer les voix françaises et globales disponibles
|
||||
public getVoices(): VoiceOption[] {
|
||||
if (!this.ttsSynth) return [];
|
||||
return this.ttsSynth.getVoices().map(v => ({
|
||||
|
||||
const allVoices = this.ttsSynth.getVoices();
|
||||
|
||||
// Trier pour mettre les meilleures voix françaises en haut
|
||||
const sorted = [...allVoices].sort((a, b) => {
|
||||
const scoreA = this._getVoiceScore(a);
|
||||
const scoreB = this._getVoiceScore(b);
|
||||
|
||||
if (scoreA !== scoreB) {
|
||||
return scoreB - scoreA;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return sorted.map(v => ({
|
||||
name: v.name,
|
||||
lang: v.lang,
|
||||
voice: v
|
||||
@@ -40,34 +128,58 @@ export class AudioService {
|
||||
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'));
|
||||
|
||||
const frVoices = voices.filter(v => v.lang.toLowerCase().startsWith('fr'));
|
||||
if (frVoices.length === 0) return voices.find(v => v.lang.startsWith('en'));
|
||||
|
||||
// Sélectionner la voix française locale avec le meilleur score
|
||||
return frVoices.sort((a, b) => this._getVoiceScore(b) - this._getVoiceScore(a))[0];
|
||||
}
|
||||
|
||||
private _applyPhoneticCorrections(text: string): string {
|
||||
let corrected = text;
|
||||
for (const [key, replacement] of Object.entries(PHONETIC_DICTIONARY)) {
|
||||
const escapedKey = key.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
|
||||
// Regex recherchant le mot technique avec des limites Unicode ou de ponctuations
|
||||
const regex = new RegExp(`(?<=^|\\s|\\p{P})${escapedKey}(?=$|\\s|\\p{P})`, 'giu');
|
||||
corrected = corrected.replace(regex, replacement);
|
||||
}
|
||||
return corrected;
|
||||
}
|
||||
|
||||
private _cleanForTTS(raw: string): string {
|
||||
// 1. Extraire le texte des liens Markdown [Texte](url). Gère aussi les parenthèses dans les URLs
|
||||
let cleaned = raw.replace(/\[([^\]]+)\]\((?:[^)(]+|\([^)(]*\))*\)/g, '$1');
|
||||
// 1. Améliorer la ponctuation Markdown pour forcer des pauses de diction naturelles
|
||||
// Remplacer les puces de liste (ex: - item, * item) par une pause douce (virgule)
|
||||
let formatted = raw.replace(/(?:\r?\n)\s*[-*+]\s+/g, ', ');
|
||||
|
||||
// Remplacer les deux-points suivis d'un saut de ligne par un point
|
||||
formatted = formatted.replace(/:\s*(?:\r?\n)/g, '. ');
|
||||
|
||||
// Remplacer les doubles sauts de ligne par des points (pauses de paragraphes)
|
||||
formatted = formatted.replace(/(?:\r?\n){2,}/g, '. ');
|
||||
|
||||
// Remplacer les sauts de ligne simples restant par des espaces
|
||||
formatted = formatted.replace(/(?:\r?\n)/g, ' ');
|
||||
|
||||
// 2. Supprimer toutes les URLs nues (http, https, ftp, file) complexes ou simples
|
||||
// 2. Extraire le texte des liens Markdown [Texte](url)
|
||||
let cleaned = formatted.replace(/\[([^\]]+)\]\((?:[^)(]+|\([^)(]*\))*\)/g, '$1');
|
||||
|
||||
// 3. Supprimer toutes les URLs nues (http, https, ftp, file)
|
||||
cleaned = cleaned.replace(/(?:https?|ftp|file):\/\/\S+/gi, '');
|
||||
|
||||
// 3. Supprimer les adresses commençant par www.
|
||||
// 4. Supprimer les adresses 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)
|
||||
// 5. Filtrer les mots techniques complexes (chemins de fichiers, adresses IP)
|
||||
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;
|
||||
}
|
||||
@@ -75,7 +187,8 @@ export class AudioService {
|
||||
})
|
||||
.join(' ');
|
||||
|
||||
return cleaned
|
||||
// 6. Nettoyer les caractères Markdown, Emojis et symboles non prononçables
|
||||
cleaned = 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 restant : **, *, _, __, #, `, ~
|
||||
@@ -89,6 +202,9 @@ export class AudioService {
|
||||
// Espaces multiples
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim();
|
||||
|
||||
// 7. Appliquer les corrections phonétiques locales
|
||||
return this._applyPhoneticCorrections(cleaned);
|
||||
}
|
||||
|
||||
private _makeUtterance(text: string, voiceName?: string, rate = 1.0, pitch = 1.1): SpeechSynthesisUtterance {
|
||||
|
||||
Reference in new issue
Block a user