diff --git a/public/js/assistant/citations.js b/public/js/assistant/citations.js index 17837c8b..47d9b1e3 100644 --- a/public/js/assistant/citations.js +++ b/public/js/assistant/citations.js @@ -119,6 +119,15 @@ function sourceByNumber(sources, n) { * Returns new objects. Renumbering in place would corrupt a stored answer whose * text still holds the original markers. */ +// Fenced code, inline code and math, then a citation cluster. Alternation, so +// a cluster inside one of those regions matches the protecting branch first and +// is never rewritten — the same trick renderCitationLinks uses on HTML. +// +// This is not cosmetic. An answer containing arr[2][1] in a code block would +// otherwise have its indices rewritten to whatever the source mapping said, and +// nothing downstream could tell that the code had been altered. +var CITATION_SCAN = /```[\s\S]*?```|~~~[\s\S]*?~~~|`[^`\n]*`|\$\$[\s\S]*?\$\$|\$[^$\n]*\$|\[((?:\d+\s*,\s*)*\d+)\]/g; + export function orderSourcesByCitation(text, sources) { var list = Array.isArray(sources) ? sources : []; if (!list.length) return { text: String(text || ''), sources: list }; @@ -126,7 +135,8 @@ export function orderSourcesByCitation(text, sources) { // First appearance wins, and only markers that resolve to a real source // count — an invented number must not reserve a position in the list. var order = []; - String(text || '').replace(/\[((?:\d+\s*,\s*)*\d+)\]/g, function (_, cluster) { + String(text || '').replace(CITATION_SCAN, function (match, cluster) { + if (!cluster) return match; // a protected region cluster.split(',').forEach(function (part) { var n = Number(part.trim()); if (!Number.isInteger(n) || n < 1) return; @@ -152,7 +162,8 @@ export function orderSourcesByCitation(text, sources) { // Rewrite the markers in one pass. Doing it number by number would renumber // something twice — 2 becomes 1, then that 1 becomes whatever 1 maps to. - var rewritten = String(text || '').replace(/\[((?:\d+\s*,\s*)*\d+)\]/g, function (match, cluster) { + var rewritten = String(text || '').replace(CITATION_SCAN, function (match, cluster) { + if (!cluster) return match; // a protected region var nums = cluster.split(',').map(function (p) { return Number(p.trim()); }); if (nums.some(function (n) { return !mapping[n]; })) return match; // leave anything unresolved alone return '[' + nums.map(function (n) { return mapping[n]; }).join(', ') + ']'; diff --git a/src/utils/ttsProvider.js b/src/utils/ttsProvider.js index 0e791203..bf07de06 100644 --- a/src/utils/ttsProvider.js +++ b/src/utils/ttsProvider.js @@ -15,6 +15,59 @@ function parseList(value) { var GROQ_ORPHEUS_ENGLISH_VOICES = ['autumn', 'diana', 'hannah', 'austin', 'daniel', 'troy']; var GROQ_ORPHEUS_ARABIC_VOICES = ['abdullah', 'fahad', 'sultan', 'lulwa', 'noura', 'aisha']; +// ── Which voices belong to which model ─────────────────────────────────── +// A voice is not a property of the gateway, it is a property of the model, and +// the two were being listed side by side as if interchangeable. Picking Orpheus +// and testing it with a Kokoro voice is not a configuration, it is an error — +// every one of those calls came back 500, which is what "these settings don't +// work" was. +// +// The gateway cannot help: /model/info reports mode audio_speech for all four +// of these and carries no voice field at all. So the mapping lives here. +// +// Every list below was taken from the provider rather than from documentation. +// Groq states its own when refused: "voice must be one of the following +// voices: [...]". Fish was found by trying the OpenAI names — alloy returns +// audio and the rest are refused. Kokoro's come from the speech gateway's own +// /v1/audio/voices, which is authoritative and is preferred over this list +// whenever it answers. +// Keyed by family, not by id: getLiteLLMTTSModelFamily already maps both the +// gateway alias (groq-orpheus-english) and the upstream id +// (canopylabs/orpheus-v1-english) to one family, and a table keyed by id would +// have to repeat every list under every spelling. +var MODEL_VOICES = Object.freeze({ + 'groq-orpheus-english': Object.freeze(GROQ_ORPHEUS_ENGLISH_VOICES.slice()), + 'groq-orpheus-arabic': Object.freeze(GROQ_ORPHEUS_ARABIC_VOICES.slice()), + 'fish': Object.freeze(['alloy']), + 'kokoro': Object.freeze([ + 'sherpa/kokoro:am_adam', 'sherpa/kokoro:am_michael', 'sherpa/kokoro:af_bella', + 'sherpa/kokoro:af_nicole', 'sherpa/kokoro:bf_emma', 'sherpa/kokoro:bm_lewis' + ]) +}); + +/** + * The voices a model will actually accept, or [] when we do not know. + * + * [] is an honest answer and a useful one: the screen can say "no voice list + * for this model, type one" instead of offering another model's voices, which + * is the thing that was broken. + */ +function voicesForModel(model) { + var id = String(model || ''); + // The environment wins for the model it was written for. LITELLM_TTS_VOICES + // names the voices of LITELLM_TTS_MODEL, so a deployment can change that + // model's voices without changing this file — which matters for the local + // gateway, whose list is whatever it has been built with. It answers for + // that model only: it never described any other, and treating it as a + // general list is what offered Kokoro's voices for Fish. + if (id && id === String(process.env.LITELLM_TTS_MODEL || '')) { + var configured = parseList(process.env.LITELLM_TTS_VOICES); + if (configured.length) return configured; + } + var known = MODEL_VOICES[getLiteLLMTTSModelFamily(id)]; + return known ? known.slice() : []; +} + function uniqueList(values) { var seen = new Set(); return (values || []).filter(function(value) { @@ -46,6 +99,7 @@ function getLiteLLMTTSModelFamily(model) { if (id === 'local-kokoro-tts') return 'kokoro'; if (id === 'groq-orpheus-english' || id === 'canopylabs/orpheus-v1-english') return 'groq-orpheus-english'; if (id === 'groq-orpheus-arabic-saudi' || id === 'canopylabs/orpheus-arabic-saudi') return 'groq-orpheus-arabic'; + if (id === 'openrouter-fish-s2.1-pro-tts' || id === 'fish-audio/s2.1-pro') return 'fish'; return 'unknown'; } @@ -59,25 +113,27 @@ function getLiteLLMTTSRequestOptions(model) { function isLiteLLMTTSVoiceCompatible(model, voice) { if (typeof voice !== 'string' || !voice.trim()) return false; + var known = voicesForModel(model); + // A model we have a list for accepts what is on it and nothing else — that + // refusal is the whole point, and it is what stops a Kokoro voice being + // offered for Orpheus. + if (known.length) return known.indexOf(String(voice)) !== -1 || known.indexOf(String(voice).toLowerCase()) !== -1; + // A model we have no list for: anything is allowed, because refusing would + // mean refusing every voice of a model added after this code was written. + // Another model's voice is still refused — those we do know to be wrong. var family = getLiteLLMTTSModelFamily(model); - if (family === 'groq-orpheus-english') return GROQ_ORPHEUS_ENGLISH_VOICES.indexOf(String(voice).toLowerCase()) !== -1; - if (family === 'groq-orpheus-arabic') return GROQ_ORPHEUS_ARABIC_VOICES.indexOf(String(voice).toLowerCase()) !== -1; - if (family === 'kokoro') { - // Kokoro names its own voices (sherpa/kokoro:am_adam and the rest) and the - // list is open, so anything that is not another family's voice is allowed. - return GROQ_ORPHEUS_ENGLISH_VOICES.indexOf(String(voice).toLowerCase()) === -1 && - GROQ_ORPHEUS_ARABIC_VOICES.indexOf(String(voice).toLowerCase()) === -1; - } - return true; + return !Object.keys(MODEL_VOICES).some(function (other) { + return other !== family && MODEL_VOICES[other].indexOf(String(voice).toLowerCase()) !== -1; + }); } function getLiteLLMTTSVoicesForModel(model, opts) { opts = opts || {}; - var family = getLiteLLMTTSModelFamily(model); - var voices = []; - if (family === 'groq-orpheus-english') voices = GROQ_ORPHEUS_ENGLISH_VOICES.slice(); - else if (family === 'groq-orpheus-arabic') voices = GROQ_ORPHEUS_ARABIC_VOICES.slice(); - else voices = parseList(process.env.LITELLM_TTS_VOICES); + // One table, MODEL_VOICES, rather than a second copy of the same knowledge. + // The old branch fell through to LITELLM_TTS_VOICES for any model it did not + // recognise, so an unknown model was offered Kokoro's voices — which is how + // Fish came to be listed with six voices it refuses. + var voices = voicesForModel(model); [opts.currentVoice, process.env.LITELLM_TTS_VOICE].forEach(function(voice) { if (isLiteLLMTTSVoiceCompatible(model, voice)) voices.push(voice); @@ -114,23 +170,22 @@ function getLiteLLMTTSDiscoveryItems(models, opts) { if (opts.currentVoice) { pushUniqueTTSItem(items, { id: opts.currentVoice, name: opts.currentVoice, source: 'configured-voice', kind: 'voice' }); } - getTTSVoiceLists().litellm.forEach(function(voice) { - pushUniqueTTSItem(items, { id: voice, name: voice, source: 'configured-voice-list', kind: 'voice' }); + // Every voice now says which model it belongs to. They used to be pushed + // into one flat list, so the screen offered twelve Orpheus voices and six + // Kokoro ones together with no way to tell which model would accept which. + var offered = getLiteLLMTTSModels(models).slice(); + if (opts.currentModel && offered.indexOf(opts.currentModel) === -1) offered.push(opts.currentModel); + offered.forEach(function(model) { + voicesForModel(model).forEach(function(voice) { + pushUniqueTTSItem(items, { id: voice, name: voice, source: model, kind: 'voice', model: model }); + }); }); - if (getLiteLLMTTSModels(models).indexOf('groq-orpheus-english') !== -1 || opts.currentModel === 'groq-orpheus-english') { - GROQ_ORPHEUS_ENGLISH_VOICES.forEach(function(voice) { - pushUniqueTTSItem(items, { id: voice, name: 'Groq Orpheus ' + voice, source: 'groq-orpheus-english', kind: 'voice' }); - }); - } - if (getLiteLLMTTSModels(models).indexOf('groq-orpheus-arabic-saudi') !== -1 || opts.currentModel === 'groq-orpheus-arabic-saudi') { - GROQ_ORPHEUS_ARABIC_VOICES.forEach(function(voice) { - pushUniqueTTSItem(items, { id: voice, name: 'Groq Orpheus Arabic ' + voice, source: 'groq-orpheus-arabic-saudi', kind: 'voice' }); - }); - } return items; } module.exports = { + voicesForModel, + MODEL_VOICES, getTTSEnvProvider, getLiteLLMTTSDiscoveryItems, getLiteLLMHeaders, diff --git a/test/citation-ordering.test.js b/test/citation-ordering.test.js index b3a2ad1f..66a8c377 100644 --- a/test/citation-ordering.test.js +++ b/test/citation-ordering.test.js @@ -109,3 +109,34 @@ test('sources with no number field fall back to position', () => { assert.equal(out.sources[0].title, 'Three'); assert.equal(out.text, 'Cite the third [1].'); }); + +// ---- what must never be touched --------------------------------------------- + +test('a citation-shaped index inside code is not a citation', () => { + // arr[2][1] is array indexing. Renumbering it would alter the code and + // nothing downstream could tell. + const { orderSourcesByCitation } = load(); + const text = 'Use [3] here.\n\n```js\nconst x = arr[2][1];\n```\n\nAnd `m[1][2]` inline.'; + const out = orderSourcesByCitation(text, four); + assert.match(out.text, /Use \[1\] here/); + assert.match(out.text, /arr\[2\]\[1\]/, 'the fenced code was rewritten'); + assert.match(out.text, /`m\[1\]\[2\]`/, 'the inline code was rewritten'); +}); + +test('a bracket inside math is not a citation', () => { + const { orderSourcesByCitation } = load(); + const text = 'See [2].\n\n$$ f[1] = x $$ and $g[3]$.'; + const out = orderSourcesByCitation(text, four); + assert.match(out.text, /See \[1\]/); + assert.match(out.text, /\$\$ f\[1\] = x \$\$/); + assert.match(out.text, /\$g\[3\]\$/); +}); + +test('a bracket inside code reserves no place in the order either', () => { + // If [1] inside a code block counted as a citation, the first real citation + // would come out numbered 2. + const { orderSourcesByCitation } = load(); + const out = orderSourcesByCitation('```\nx[1]\n```\n\nReal [4].', four); + assert.equal(out.sources[0].title, 'Delta'); + assert.match(out.text, /Real \[1\]/); +}); diff --git a/test/tts-provider.test.js b/test/tts-provider.test.js index c4aa843b..03e8789f 100644 --- a/test/tts-provider.test.js +++ b/test/tts-provider.test.js @@ -79,7 +79,7 @@ test('LiteLLM TTS model extraction filters gateway model objects', () => { test('LiteLLM TTS discovery includes metadata models and configured fallbacks', () => { const ttsProvider = require('../src/utils/ttsProvider'); - withEnv({ LITELLM_TTS_VOICES: 'sherpa/kokoro:am_adam,sherpa/kokoro:af_bella' }, () => { + withEnv({ LITELLM_TTS_MODEL: 'local-kokoro-tts', LITELLM_TTS_VOICES: 'sherpa/kokoro:am_adam,sherpa/kokoro:af_bella' }, () => { const items = ttsProvider.getLiteLLMTTSDiscoveryItems([ { model_name: 'local-kokoro-tts', model_info: { mode: 'audio_speech' } }, { model_name: 'not-tts-by-name-only' }, @@ -91,7 +91,10 @@ test('LiteLLM TTS discovery includes metadata models and configured fallbacks', assert.deepEqual(items.slice(0, 3), [ { id: 'local-kokoro-tts', name: 'local-kokoro-tts', source: 'gateway-api', kind: 'model' }, { id: 'sherpa/kokoro:am_adam', name: 'sherpa/kokoro:am_adam', source: 'configured-voice', kind: 'voice' }, - { id: 'sherpa/kokoro:af_bella', name: 'sherpa/kokoro:af_bella', source: 'configured-voice-list', kind: 'voice' } + // A voice now says which model it belongs to. It used to say + // "configured-voice-list", which named the file it came from and not the + // model that would accept it — the distinction the screen was missing. + { id: 'sherpa/kokoro:af_bella', name: 'sherpa/kokoro:af_bella', source: 'local-kokoro-tts', kind: 'voice', model: 'local-kokoro-tts' } ]); assert.equal(items.some(function(item) { return item.id === 'not-tts-by-name-only'; }), false); }); @@ -126,7 +129,10 @@ test('LiteLLM TTS discovery still shows configured model if metadata lookup fail test('LiteLLM TTS voices are scoped to the active local model', () => { const ttsProvider = require('../src/utils/ttsProvider'); - withEnv({ LITELLM_TTS_VOICES: 'sherpa/kokoro:am_adam,sherpa/kokoro:af_bella', LITELLM_TTS_VOICE: 'sherpa/kokoro:am_adam' }, () => { + // LITELLM_TTS_VOICES names the voices of LITELLM_TTS_MODEL and no other + // model — production sets both. Treating that list as universal is what + // offered Kokoro's voices for Fish. + withEnv({ LITELLM_TTS_MODEL: 'local-kokoro-tts', LITELLM_TTS_VOICES: 'sherpa/kokoro:am_adam,sherpa/kokoro:af_bella', LITELLM_TTS_VOICE: 'sherpa/kokoro:am_adam' }, () => { assert.deepEqual(ttsProvider.getLiteLLMTTSVoicesForModel('local-kokoro-tts'), ['sherpa/kokoro:am_adam', 'sherpa/kokoro:af_bella']); assert.deepEqual(ttsProvider.getLiteLLMTTSVoicesForModel('groq-orpheus-english'), ['autumn', 'diana', 'hannah', 'austin', 'daniel', 'troy']); assert.deepEqual(ttsProvider.getLiteLLMTTSVoicesForModel('canopylabs/orpheus-arabic-saudi'), ['abdullah', 'fahad', 'sultan', 'lulwa', 'noura', 'aisha']);