mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-11 17:29:03 +02:00
Over the runs transport Hermes never reports the session it attached to a
run in the SSE stream, only in GET /v1/runs/{id}. EveFlow only read it in the
polling fallback, so the locally generated id was sent again and again and
each message opened a fresh Hermes session, losing the conversation context.
The client now adopts the run's session id after every run.
Long answers are now spoken as a digest: the first sentences (configurable,
4 by default) plus the closing question, followed by a short notice that the
full text is on screen. The default instructions ask Hermes to open long
replies with the essentials and to rely on the ongoing conversation.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ln4KL1feHtnV4nee4sZFhM
208 lines
7.7 KiB
TypeScript
208 lines
7.7 KiB
TypeScript
/** Text utilities shared by the chat renderer and the speech pipeline. */
|
|
|
|
const PHONETIC: Record<string, string> = {
|
|
api: 'a-pé-i',
|
|
cors: 'korss',
|
|
svg: 'ess-vé-gé',
|
|
cpu: 'cé-pé-u',
|
|
gpu: 'gé-pé-u',
|
|
fps: 'eff-pé-ess',
|
|
tts: 'té-té-ess',
|
|
stt: 'ess-té-té',
|
|
ui: 'u-i',
|
|
json: 'djé-zone',
|
|
url: 'u-err-el',
|
|
html: 'ach-té-em-el',
|
|
css: 'cé-ess-ess',
|
|
js: 'ji-ess',
|
|
github: 'guite-heub',
|
|
git: 'guite',
|
|
npm: 'enne-pé-em',
|
|
cli: 'cé-el-i',
|
|
ipc: 'i-pé-cé',
|
|
hermes: 'hermès',
|
|
ok: 'oké'
|
|
};
|
|
|
|
/** Convert `MEDIA:/path` tokens (Hermes gateway shorthand) to markdown images. */
|
|
export function preprocessMedia(content: string): string {
|
|
if (!content) return content;
|
|
return content.replace(/MEDIA:\s*(\S+)/g, (_m, p: string) => `\n\n\n\n`);
|
|
}
|
|
|
|
/** Strip markdown, code, links, URLs and emojis so the text can be spoken naturally. */
|
|
export function cleanForSpeech(raw: string): string {
|
|
if (!raw) return '';
|
|
let text = raw.replace(/MEDIA:\s*\S+/g, '');
|
|
text = text.replace(/```[\s\S]*?```/g, ' ');
|
|
text = text.replace(/<think>[\s\S]*?<\/think>/gi, ' ');
|
|
text = text.replace(/`([^`]+)`/g, '$1');
|
|
text = text.replace(/!\[[^\]]*\]\([^)]*\)/g, '');
|
|
text = text.replace(/\[([^\]]+)\]\((?:[^)(]+|\([^)(]*\))*\)/g, '$1');
|
|
text = text.replace(/(?:https?|ftp|file):\/\/\S+/gi, '');
|
|
text = text.replace(/www\.\S+/gi, '');
|
|
text = text.replace(/^\s{0,3}#{1,6}\s+/gm, '');
|
|
text = text.replace(/^\s*[-*+]\s+/gm, ', ');
|
|
text = text.replace(/^\s*\d+[.)]\s+/gm, ', ');
|
|
text = text.replace(/^\s*>\s?/gm, '');
|
|
text = text.replace(/\|/g, ' ');
|
|
text = text.replace(/:\s*(?:\r?\n)/g, '. ');
|
|
text = text.replace(/(?:\r?\n){2,}/g, '. ');
|
|
text = text.replace(/\r?\n/g, ' ');
|
|
text = text
|
|
.split(/\s+/)
|
|
.filter((word) => !(word.includes('/') && word.length > 3) && !/^[\d.]+:\d+$/.test(word) && !/^\d{1,3}(\.\d{1,3}){3}$/.test(word))
|
|
.join(' ');
|
|
text = text
|
|
.replace(/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{FE00}-\u{FEFF}\u{200D}]/gu, '')
|
|
.replace(/(\*\*|__|~~|[*_#`~])/g, '')
|
|
.replace(/\[[^\]]*\]/g, '')
|
|
.replace(/(-{2,}|={2,}|~{2,})/g, ' ')
|
|
.replace(/\.{3,}/g, '...')
|
|
.replace(/[<>{}\\^]/g, '')
|
|
.replace(/\s{2,}/g, ' ')
|
|
.trim();
|
|
return applyPhonetics(text);
|
|
}
|
|
|
|
const PHONETIC_RULES: Array<[RegExp, string]> = Object.entries(PHONETIC).map(([key, value]) => {
|
|
const escaped = key.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
|
|
return [new RegExp(`(?<=^|\\s|\\p{P})${escaped}(?=$|\\s|\\p{P})`, 'giu'), value];
|
|
});
|
|
|
|
function applyPhonetics(text: string): string {
|
|
let out = text;
|
|
for (const [regex, value] of PHONETIC_RULES) out = out.replace(regex, value);
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Pull complete sentences out of a streaming buffer.
|
|
* Returns the sentences ready to be spoken and the remainder to keep buffering.
|
|
*/
|
|
export function extractSentences(buffer: string, minLength = 12): { sentences: string[]; rest: string } {
|
|
const sentences: string[] = [];
|
|
let rest = buffer;
|
|
// Never split inside an unfinished code block.
|
|
const fences = (buffer.match(/```/g) ?? []).length;
|
|
if (fences % 2 === 1) return { sentences, rest };
|
|
const regex = /[^.!?\n\r]+(?:[.!?]+(?=\s|$)|[\n\r]+)/g;
|
|
let consumed = 0;
|
|
let match: RegExpExecArray | null;
|
|
let pending = '';
|
|
while ((match = regex.exec(buffer)) !== null) {
|
|
if (match.index !== consumed) break; // gap: stop at first non-contiguous match
|
|
const candidate = (pending + match[0]).trim();
|
|
consumed = match.index + match[0].length;
|
|
if (candidate.length < minLength || /\b(?:\d+|[A-Z]|M|Mme|Dr|etc|ex)\.$/.test(candidate)) {
|
|
pending = pending + match[0];
|
|
continue;
|
|
}
|
|
sentences.push(candidate);
|
|
pending = '';
|
|
}
|
|
rest = pending + buffer.slice(consumed);
|
|
return { sentences, rest };
|
|
}
|
|
|
|
/** Split a finished text into speakable chunks of bounded size. */
|
|
export function chunkForSpeech(text: string, maxLength = 220): string[] {
|
|
const { sentences, rest } = extractSentences(text + '\n', 1);
|
|
const all = [...sentences, rest.trim()].filter(Boolean);
|
|
const out: string[] = [];
|
|
for (const sentence of all) {
|
|
if (sentence.length <= maxLength) {
|
|
out.push(sentence);
|
|
continue;
|
|
}
|
|
let current = '';
|
|
for (const word of sentence.split(/\s+/)) {
|
|
if ((current + ' ' + word).trim().length > maxLength) {
|
|
if (current) out.push(current.trim());
|
|
current = word;
|
|
} else {
|
|
current = current ? `${current} ${word}` : word;
|
|
}
|
|
}
|
|
if (current) out.push(current.trim());
|
|
}
|
|
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 {
|
|
const flat = value.replace(/\s+/g, ' ').trim();
|
|
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
}
|
|
|
|
export function formatTime(date: Date | string | number): string {
|
|
const d = date instanceof Date ? date : new Date(date);
|
|
return Number.isNaN(d.getTime()) ? '' : d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
|
|
}
|
|
|
|
export function formatDateTime(date: Date | string | number | undefined): string {
|
|
if (!date) return '—';
|
|
const d = date instanceof Date ? date : new Date(date);
|
|
return Number.isNaN(d.getTime()) ? String(date) : d.toLocaleString([], { dateStyle: 'short', timeStyle: 'short' });
|
|
}
|
|
|
|
export function formatDuration(seconds: number): string {
|
|
const s = Math.max(0, Math.floor(seconds));
|
|
const d = Math.floor(s / 86400);
|
|
const h = Math.floor((s % 86400) / 3600);
|
|
const m = Math.floor((s % 3600) / 60);
|
|
if (d > 0) return `${d}j ${h}h`;
|
|
if (h > 0) return `${h}h ${m.toString().padStart(2, '0')}m`;
|
|
return `${m}m ${(s % 60).toString().padStart(2, '0')}s`;
|
|
}
|
|
|
|
const NOISE_PHRASES = [
|
|
'sous-titres réalisés par la communauté d\'amara.org',
|
|
'sous-titrage société radio-canada',
|
|
'merci d\'avoir regardé',
|
|
'abonnez-vous',
|
|
'thank you for watching',
|
|
'thanks for watching',
|
|
'...'
|
|
];
|
|
|
|
/**
|
|
* Whisper-style hallucinations on silence or clicks: "(cliquant)", "*Claire*", "[Musique]",
|
|
* "Sous-titres réalisés par…". Such transcripts must not be sent to Hermes.
|
|
*/
|
|
export function isTranscriptNoise(text: string): boolean {
|
|
const t = text.trim();
|
|
if (!t) return true;
|
|
if (/^[\s\p{P}\p{S}]*$/u.test(t)) return true;
|
|
// whole transcript wrapped in brackets/asterisks: a sound description
|
|
if (/^[(\[*«"'\s]+[^()\[\]*]{0,60}[)\]*»"'\s]+$/u.test(t) && !/[a-zà-ÿ]{3,}\s+[a-zà-ÿ]{3,}\s+[a-zà-ÿ]{3,}/i.test(t)) return true;
|
|
const lower = t.toLowerCase().replace(/[.!?…\s]+$/u, '');
|
|
return NOISE_PHRASES.some((p) => lower === p.replace(/[.!?…\s]+$/u, ''));
|
|
}
|