muxmind-ai 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +11 -0
- package/README.md +81 -0
- package/assets/favicon.png +0 -0
- package/assets/muxmind-logo-full.png +0 -0
- package/assets/muxmind-logo-icon.png +0 -0
- package/bin/cli.js +98 -0
- package/index.html +293 -0
- package/package.json +47 -0
- package/server.js +265 -0
- package/src/api-manager.js +227 -0
- package/src/auth.js +159 -0
- package/src/config.js +267 -0
- package/src/file-parser.js +122 -0
- package/src/image-engine.js +73 -0
- package/src/router.js +317 -0
- package/src/tts-engine.js +88 -0
- package/src/ui-render.js +91 -0
- package/src-client/app.js +1306 -0
- package/src-client/i18n.js +229 -0
- package/style.css +948 -0
package/src/router.js
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MuxMind AI — Smart Router & Failover Engine
|
|
3
|
+
*
|
|
4
|
+
* SMART ROUTING:
|
|
5
|
+
* - Scores each available (provider, model) pair on: tier/power fit for the
|
|
6
|
+
* task, provider priority in the fallback chain, and a live penalty
|
|
7
|
+
* accumulated from failures during this request.
|
|
8
|
+
* - Picks the single best-scoring candidate and uses it for the ENTIRE
|
|
9
|
+
* reply. A reply is never split across multiple models mid-answer —
|
|
10
|
+
* that was the source of the "answers mixing up" bug in earlier builds:
|
|
11
|
+
* the old engine re-ranked and potentially swapped providers on every
|
|
12
|
+
* ~48-token fragment, so one answer could be stitched together out of
|
|
13
|
+
* several different models' voices/styles/facts.
|
|
14
|
+
* - Failover to the next-best candidate only happens if the CURRENT
|
|
15
|
+
* candidate's request fails outright (network error, 4xx/5xx) before
|
|
16
|
+
* producing any output. Once a model has started streaming a reply, we
|
|
17
|
+
* stay with it for the rest of that reply.
|
|
18
|
+
* - "Smart Token Saver" only ever considers vault entries the caller has
|
|
19
|
+
* already proven live (status === 'active' client-side, and provider is
|
|
20
|
+
* a recognized id server-side) — a candidate is never invented here.
|
|
21
|
+
* - Hard failures (401/403/404) permanently drop that candidate for the
|
|
22
|
+
* rest of the request; soft failures (timeouts, 429, 5xx) apply a
|
|
23
|
+
* smaller penalty and can still be retried once other options are
|
|
24
|
+
* exhausted.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
'use strict';
|
|
28
|
+
|
|
29
|
+
const { PROVIDERS, FALLBACK_CHAIN, COMPRESSION_LEVELS, MODEL_HINTS } = require('./config');
|
|
30
|
+
|
|
31
|
+
function resolveCompression(level) {
|
|
32
|
+
const keys = Object.keys(COMPRESSION_LEVELS).map(Number).sort((a, b) => a - b);
|
|
33
|
+
let chosen = keys[0];
|
|
34
|
+
for (const k of keys) if (level >= k) chosen = k;
|
|
35
|
+
return COMPRESSION_LEVELS[chosen];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function classifyModel(modelId) {
|
|
39
|
+
for (const hint of MODEL_HINTS) {
|
|
40
|
+
if (hint.pattern.test(modelId)) return { tier: hint.tier, power: hint.power };
|
|
41
|
+
}
|
|
42
|
+
return { tier: 'balanced', power: 2 };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Rough task-complexity estimate from the live conversation, used to bias
|
|
47
|
+
* candidate scoring toward a stronger or lighter model tier.
|
|
48
|
+
*/
|
|
49
|
+
function estimateTaskComplexity(messages) {
|
|
50
|
+
const lastUser = [...messages].reverse().find((m) => m.role === 'user');
|
|
51
|
+
const text = lastUser?.content || '';
|
|
52
|
+
const len = text.length;
|
|
53
|
+
const heavySignals = /\b(code|debug|explain|architecture|analy[sz]e|proof|algorithm|refactor|design|compare)\b/i.test(text);
|
|
54
|
+
const codeBlock = /```/.test(text);
|
|
55
|
+
|
|
56
|
+
if (len > 800 || codeBlock) return 3;
|
|
57
|
+
if (len > 200 || heavySignals) return 2;
|
|
58
|
+
return 1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Score a candidate (provider+model) for the current request.
|
|
63
|
+
* Higher is better. Combines: tier-match to task, provider priority in
|
|
64
|
+
* fallback chain, and a live penalty accumulated from failures this run.
|
|
65
|
+
*/
|
|
66
|
+
function scoreCandidate(candidate, taskComplexity, penalties) {
|
|
67
|
+
const { power } = classifyModel(candidate.model);
|
|
68
|
+
const tierFit = 3 - Math.abs(power - taskComplexity); // closer match = higher score
|
|
69
|
+
const chainIndex = FALLBACK_CHAIN.indexOf(candidate.providerId);
|
|
70
|
+
const chainBonus = chainIndex === -1 ? 0 : (FALLBACK_CHAIN.length - chainIndex) * 0.3;
|
|
71
|
+
const penalty = penalties.get(`${candidate.providerId}::${candidate.model}`) || 0;
|
|
72
|
+
|
|
73
|
+
return tierFit + chainBonus - penalty;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function rankCandidates(vault, taskComplexity, penalties) {
|
|
77
|
+
return [...vault]
|
|
78
|
+
.filter((c) => c && c.providerId && c.model && PROVIDERS[c.providerId])
|
|
79
|
+
.map((c) => ({ ...c, _score: scoreCandidate(c, taskComplexity, penalties) }))
|
|
80
|
+
.sort((a, b) => b._score - a._score);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function buildRequestPayload(providerId, model, messages, maxTokens, stream) {
|
|
84
|
+
const cleanMessages = messages
|
|
85
|
+
.filter((m) => m.role !== 'system')
|
|
86
|
+
.map((m) => ({ role: m.role, content: m.content }));
|
|
87
|
+
|
|
88
|
+
if (providerId === 'anthropic') {
|
|
89
|
+
return {
|
|
90
|
+
model,
|
|
91
|
+
max_tokens: maxTokens,
|
|
92
|
+
messages: cleanMessages,
|
|
93
|
+
system: messages.find((m) => m.role === 'system')?.content || undefined,
|
|
94
|
+
stream: !!stream,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
if (providerId === 'gemini') {
|
|
98
|
+
return {
|
|
99
|
+
contents: cleanMessages.map((m) => ({ role: m.role === 'assistant' ? 'model' : 'user', parts: [{ text: m.content }] })),
|
|
100
|
+
generationConfig: { maxOutputTokens: maxTokens },
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return { model, messages: cleanMessages, max_tokens: maxTokens, stream: !!stream };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function buildUrl(provider, model, apiKey) {
|
|
107
|
+
let path = provider.chatEndpoint.replace('{model}', model);
|
|
108
|
+
let url = `${provider.baseUrl}${path}`;
|
|
109
|
+
if (provider.authQuery) url += `?${provider.authQuery(apiKey)}`;
|
|
110
|
+
return url;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function extractText(providerId, data) {
|
|
114
|
+
try {
|
|
115
|
+
if (providerId === 'anthropic') return (data.content || []).map((b) => b.text || '').join('');
|
|
116
|
+
if (providerId === 'gemini') return (data.candidates?.[0]?.content?.parts || []).map((p) => p.text || '').join('');
|
|
117
|
+
return data.choices?.[0]?.message?.content || '';
|
|
118
|
+
} catch {
|
|
119
|
+
return '';
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Non-streaming fallback path (used for providers without a simple SSE
|
|
125
|
+
* shape here, e.g. Gemini). Returns the full text in one shot.
|
|
126
|
+
*/
|
|
127
|
+
async function requestFull({ providerId, apiKey, model, messages, maxTokens }) {
|
|
128
|
+
const provider = PROVIDERS[providerId];
|
|
129
|
+
if (!provider) throw new Error(`Unknown provider: ${providerId}`);
|
|
130
|
+
|
|
131
|
+
const url = buildUrl(provider, model, apiKey);
|
|
132
|
+
const headers = { 'Content-Type': 'application/json', ...provider.authHeader(apiKey) };
|
|
133
|
+
const payload = buildRequestPayload(providerId, model, messages, maxTokens, false);
|
|
134
|
+
|
|
135
|
+
const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(payload) });
|
|
136
|
+
|
|
137
|
+
if (!res.ok) {
|
|
138
|
+
const errText = await res.text().catch(() => res.statusText);
|
|
139
|
+
const err = new Error(`${provider.label} error (HTTP ${res.status}): ${errText.slice(0, 200)}`);
|
|
140
|
+
err.httpStatus = res.status;
|
|
141
|
+
throw err;
|
|
142
|
+
}
|
|
143
|
+
const data = await res.json();
|
|
144
|
+
return extractText(providerId, data);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Streaming path for OpenAI-compatible SSE APIs (OpenAI, Groq, Mistral,
|
|
149
|
+
* DeepSeek, OpenRouter, xAI, Together, Cerebras, Perplexity) and
|
|
150
|
+
* Anthropic's own SSE format. Emits text via onDelta as it arrives.
|
|
151
|
+
*/
|
|
152
|
+
async function requestStreaming({ providerId, apiKey, model, messages, maxTokens, onDelta, abortSignal }) {
|
|
153
|
+
const provider = PROVIDERS[providerId];
|
|
154
|
+
if (!provider) throw new Error(`Unknown provider: ${providerId}`);
|
|
155
|
+
|
|
156
|
+
const url = buildUrl(provider, model, apiKey);
|
|
157
|
+
const headers = { 'Content-Type': 'application/json', ...provider.authHeader(apiKey) };
|
|
158
|
+
const payload = buildRequestPayload(providerId, model, messages, maxTokens, true);
|
|
159
|
+
|
|
160
|
+
const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(payload) });
|
|
161
|
+
|
|
162
|
+
if (!res.ok) {
|
|
163
|
+
const errText = await res.text().catch(() => res.statusText);
|
|
164
|
+
const err = new Error(`${provider.label} error (HTTP ${res.status}): ${errText.slice(0, 200)}`);
|
|
165
|
+
err.httpStatus = res.status;
|
|
166
|
+
throw err;
|
|
167
|
+
}
|
|
168
|
+
if (!res.body) {
|
|
169
|
+
// Some environments/providers won't give a readable stream; fall back.
|
|
170
|
+
const data = await res.json();
|
|
171
|
+
const text = extractText(providerId, data);
|
|
172
|
+
if (text) onDelta(text);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const reader = res.body.getReader();
|
|
177
|
+
const decoder = new TextDecoder();
|
|
178
|
+
let buffer = '';
|
|
179
|
+
let gotAny = false;
|
|
180
|
+
|
|
181
|
+
while (true) {
|
|
182
|
+
if (abortSignal?.aborted) { try { await reader.cancel(); } catch {} break; }
|
|
183
|
+
const { done, value } = await reader.read();
|
|
184
|
+
if (done) break;
|
|
185
|
+
buffer += decoder.decode(value, { stream: true });
|
|
186
|
+
|
|
187
|
+
const lines = buffer.split('\n');
|
|
188
|
+
buffer = lines.pop();
|
|
189
|
+
|
|
190
|
+
for (const line of lines) {
|
|
191
|
+
const trimmed = line.trim();
|
|
192
|
+
if (!trimmed.startsWith('data:')) continue;
|
|
193
|
+
const dataStr = trimmed.slice(5).trim();
|
|
194
|
+
if (dataStr === '[DONE]') continue;
|
|
195
|
+
let json;
|
|
196
|
+
try { json = JSON.parse(dataStr); } catch { continue; }
|
|
197
|
+
|
|
198
|
+
let delta = '';
|
|
199
|
+
if (providerId === 'anthropic') {
|
|
200
|
+
if (json.type === 'content_block_delta' && json.delta?.text) delta = json.delta.text;
|
|
201
|
+
} else {
|
|
202
|
+
delta = json.choices?.[0]?.delta?.content || '';
|
|
203
|
+
}
|
|
204
|
+
if (delta) { gotAny = true; onDelta(delta); }
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (!gotAny) {
|
|
209
|
+
// Nothing streamed (e.g. provider ignored `stream:true`) — surface a
|
|
210
|
+
// soft failure so the caller can fail over instead of returning empty.
|
|
211
|
+
const err = new Error(`${provider.label} returned no streamed content.`);
|
|
212
|
+
err.httpStatus = 0;
|
|
213
|
+
throw err;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Core smart routing loop: pick the single best candidate for THIS whole
|
|
219
|
+
* reply, stream it end-to-end, and only fail over to the next-best
|
|
220
|
+
* candidate if the current one errors before/while producing output.
|
|
221
|
+
*/
|
|
222
|
+
async function staggeredMultiModelStream({
|
|
223
|
+
vault,
|
|
224
|
+
messages,
|
|
225
|
+
compressionLevel = 50,
|
|
226
|
+
onChunk,
|
|
227
|
+
onProviderSwitch,
|
|
228
|
+
abortSignal,
|
|
229
|
+
}) {
|
|
230
|
+
if (!Array.isArray(vault) || vault.length === 0) {
|
|
231
|
+
throw new Error('No configured providers available in vault for this request.');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const compression = resolveCompression(compressionLevel);
|
|
235
|
+
// maxTokensFactor scales a base budget — this only affects verbosity of
|
|
236
|
+
// the *single* chosen model's answer, it does not fragment the reply
|
|
237
|
+
// across multiple models.
|
|
238
|
+
const baseBudget = 2048;
|
|
239
|
+
const maxTokens = Math.max(64, Math.round(baseBudget * (compression.maxTokensFactor ?? 1)));
|
|
240
|
+
const taskComplexity = estimateTaskComplexity(messages);
|
|
241
|
+
|
|
242
|
+
const penalties = new Map();
|
|
243
|
+
const hardFailCounts = new Map();
|
|
244
|
+
|
|
245
|
+
let fullText = '';
|
|
246
|
+
let attempts = 0;
|
|
247
|
+
const maxAttempts = Math.min(vault.length + 2, 6);
|
|
248
|
+
|
|
249
|
+
while (attempts < maxAttempts) {
|
|
250
|
+
if (abortSignal?.aborted) break;
|
|
251
|
+
attempts += 1;
|
|
252
|
+
|
|
253
|
+
const ranked = rankCandidates(vault, taskComplexity, penalties)
|
|
254
|
+
.filter((c) => (hardFailCounts.get(`${c.providerId}::${c.model}`) || 0) < 2);
|
|
255
|
+
|
|
256
|
+
if (ranked.length === 0) {
|
|
257
|
+
if (fullText) break; // we already streamed something usable before the rest died
|
|
258
|
+
throw new Error('All configured providers failed for this request.');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const entry = ranked[0];
|
|
262
|
+
const key = `${entry.providerId}::${entry.model}`;
|
|
263
|
+
|
|
264
|
+
if (onProviderSwitch) onProviderSwitch(entry.providerId, entry.model);
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
let streamed = '';
|
|
268
|
+
await requestStreaming({
|
|
269
|
+
providerId: entry.providerId,
|
|
270
|
+
apiKey: entry.apiKey,
|
|
271
|
+
model: entry.model,
|
|
272
|
+
messages,
|
|
273
|
+
maxTokens,
|
|
274
|
+
abortSignal,
|
|
275
|
+
onDelta: (delta) => {
|
|
276
|
+
streamed += delta;
|
|
277
|
+
fullText += delta;
|
|
278
|
+
if (onChunk) onChunk(delta, { provider: entry.providerId, model: entry.model });
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// Success — this one model produced the whole reply. Done.
|
|
283
|
+
return fullText;
|
|
284
|
+
} catch (err) {
|
|
285
|
+
// If we'd already streamed partial text for THIS attempt before it
|
|
286
|
+
// died, keep that text but stop (don't silently continue writing
|
|
287
|
+
// with a different model into the same paragraph — surface it as
|
|
288
|
+
// done so the UI doesn't show a hybrid answer).
|
|
289
|
+
if (fullText) {
|
|
290
|
+
return fullText;
|
|
291
|
+
}
|
|
292
|
+
if (err.httpStatus === 401 || err.httpStatus === 403 || err.httpStatus === 404) {
|
|
293
|
+
hardFailCounts.set(key, (hardFailCounts.get(key) || 0) + 1);
|
|
294
|
+
penalties.set(key, (penalties.get(key) || 0) + 5);
|
|
295
|
+
} else {
|
|
296
|
+
penalties.set(key, (penalties.get(key) || 0) + 1.5);
|
|
297
|
+
}
|
|
298
|
+
// No output produced yet — safe to fail over to the next candidate
|
|
299
|
+
// for a clean, single-model attempt at the full reply.
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (!fullText) {
|
|
305
|
+
throw new Error('All configured providers failed for this request.');
|
|
306
|
+
}
|
|
307
|
+
return fullText;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
module.exports = {
|
|
311
|
+
staggeredMultiModelStream,
|
|
312
|
+
resolveCompression,
|
|
313
|
+
requestFull,
|
|
314
|
+
requestStreaming,
|
|
315
|
+
estimateTaskComplexity,
|
|
316
|
+
classifyModel,
|
|
317
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MuxMind AI — Text-To-Speech Controller
|
|
3
|
+
* Server-side helper for sanitizing/segmenting text before it is sent to
|
|
4
|
+
* the browser's Web Speech API (SpeechSynthesis) for actual audio output.
|
|
5
|
+
* Real synthesis happens client-side (index.html/ui-render.js) since that
|
|
6
|
+
* is zero-cost and requires no API key; this module prepares clean text
|
|
7
|
+
* chunks and language hints.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const { TTS_CONFIG } = require('./config');
|
|
13
|
+
|
|
14
|
+
const ARABIC_RANGE = /[\u0600-\u06FF\u0750-\u077F]/;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Guess whether a string of text is primarily Arabic or English so the
|
|
18
|
+
* client picks the right SpeechSynthesisVoice/lang.
|
|
19
|
+
*/
|
|
20
|
+
function detectLanguage(text) {
|
|
21
|
+
if (!text) return 'en';
|
|
22
|
+
const arabicChars = (text.match(new RegExp(ARABIC_RANGE, 'g')) || []).length;
|
|
23
|
+
const ratio = arabicChars / Math.max(text.length, 1);
|
|
24
|
+
return ratio > 0.15 ? 'ar' : 'en';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Strip markdown/code fences and other non-speakable artifacts so TTS
|
|
29
|
+
* doesn't read out "hashtag hashtag" or raw code syntax.
|
|
30
|
+
*/
|
|
31
|
+
function sanitizeForSpeech(text) {
|
|
32
|
+
if (!text) return '';
|
|
33
|
+
return text
|
|
34
|
+
.replace(/```[\s\S]*?```/g, ' code block omitted ')
|
|
35
|
+
.replace(/`([^`]+)`/g, '$1')
|
|
36
|
+
.replace(/[*_#>~-]{1,3}/g, '')
|
|
37
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
|
38
|
+
.replace(/\s{2,}/g, ' ')
|
|
39
|
+
.trim();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Split long text into speech-friendly sentence chunks so the browser's
|
|
44
|
+
* speechSynthesis queue doesn't choke on very long utterances.
|
|
45
|
+
*/
|
|
46
|
+
function segmentForSpeech(text, maxLen = 220) {
|
|
47
|
+
const clean = sanitizeForSpeech(text);
|
|
48
|
+
const sentences = clean.split(/(?<=[.!?؟۔])\s+/);
|
|
49
|
+
const segments = [];
|
|
50
|
+
let current = '';
|
|
51
|
+
|
|
52
|
+
for (const sentence of sentences) {
|
|
53
|
+
if ((current + ' ' + sentence).trim().length > maxLen && current) {
|
|
54
|
+
segments.push(current.trim());
|
|
55
|
+
current = sentence;
|
|
56
|
+
} else {
|
|
57
|
+
current = (current + ' ' + sentence).trim();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (current) segments.push(current.trim());
|
|
61
|
+
return segments;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Build the full TTS job descriptor the client will consume to drive
|
|
66
|
+
* window.speechSynthesis.
|
|
67
|
+
*/
|
|
68
|
+
function buildSpeechJob(text, forcedLang = null) {
|
|
69
|
+
const lang = forcedLang || detectLanguage(text);
|
|
70
|
+
const langMeta = TTS_CONFIG.languages[lang] || TTS_CONFIG.languages.en;
|
|
71
|
+
const segments = segmentForSpeech(text);
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
lang: langMeta.code,
|
|
75
|
+
langKey: lang,
|
|
76
|
+
label: langMeta.label,
|
|
77
|
+
rate: TTS_CONFIG.defaultRate,
|
|
78
|
+
pitch: TTS_CONFIG.defaultPitch,
|
|
79
|
+
segments,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = {
|
|
84
|
+
detectLanguage,
|
|
85
|
+
sanitizeForSpeech,
|
|
86
|
+
segmentForSpeech,
|
|
87
|
+
buildSpeechJob,
|
|
88
|
+
};
|
package/src/ui-render.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MuxMind AI — Dynamic SVG Renderer, Task Tracker & Chat UI helpers
|
|
3
|
+
* Server-side utilities that prepare structured render instructions.
|
|
4
|
+
* The actual DOM/SVG painting happens client-side in index.html, but the
|
|
5
|
+
* shapes/schemas below are shared so client and server agree on format.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Build a task-tracker state object representing the lifecycle of one
|
|
12
|
+
* user request as it moves through the router (queued -> streaming ->
|
|
13
|
+
* provider-switch events -> complete/error).
|
|
14
|
+
*/
|
|
15
|
+
function createTaskTracker(taskId) {
|
|
16
|
+
return {
|
|
17
|
+
id: taskId,
|
|
18
|
+
status: 'queued', // queued | streaming | complete | error | aborted
|
|
19
|
+
startedAt: Date.now(),
|
|
20
|
+
completedAt: null,
|
|
21
|
+
providerTimeline: [], // [{ provider, model, turn, at }]
|
|
22
|
+
tokensApprox: 0,
|
|
23
|
+
error: null,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function recordProviderTurn(tracker, provider, model, turn) {
|
|
28
|
+
tracker.status = 'streaming';
|
|
29
|
+
tracker.providerTimeline.push({ provider, model, turn, at: Date.now() });
|
|
30
|
+
return tracker;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function completeTask(tracker, fullText) {
|
|
34
|
+
tracker.status = 'complete';
|
|
35
|
+
tracker.completedAt = Date.now();
|
|
36
|
+
tracker.tokensApprox = Math.ceil((fullText || '').length / 4);
|
|
37
|
+
return tracker;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function failTask(tracker, errorMessage) {
|
|
41
|
+
tracker.status = 'error';
|
|
42
|
+
tracker.completedAt = Date.now();
|
|
43
|
+
tracker.error = errorMessage;
|
|
44
|
+
return tracker;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Compute a rough "savings" percentage for the status badge, comparing
|
|
49
|
+
* the actual tokens spent against an estimated single-shot baseline.
|
|
50
|
+
*/
|
|
51
|
+
function computeSavingsBadge(tracker, baselineTokensEstimate) {
|
|
52
|
+
if (!baselineTokensEstimate || baselineTokensEstimate <= 0) return { savingsPct: 0 };
|
|
53
|
+
const used = tracker.tokensApprox || 0;
|
|
54
|
+
const savings = Math.max(0, Math.min(99, Math.round((1 - used / baselineTokensEstimate) * 100)));
|
|
55
|
+
return {
|
|
56
|
+
savingsPct: savings,
|
|
57
|
+
used,
|
|
58
|
+
baseline: baselineTokensEstimate,
|
|
59
|
+
providersInvolved: [...new Set(tracker.providerTimeline.map((t) => t.provider))],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Generate a small inline SVG spinner/status icon string used by the
|
|
65
|
+
* status badge in the toolbar (isolated, no external deps).
|
|
66
|
+
*/
|
|
67
|
+
function renderStatusIconSVG(status) {
|
|
68
|
+
const colors = {
|
|
69
|
+
queued: '#8892a0',
|
|
70
|
+
streaming: '#00f2fe',
|
|
71
|
+
complete: '#2ee6a6',
|
|
72
|
+
error: '#ff4d6d',
|
|
73
|
+
aborted: '#ffb020',
|
|
74
|
+
};
|
|
75
|
+
const color = colors[status] || colors.queued;
|
|
76
|
+
return `<svg viewBox="0 0 24 24" width="14" height="14" xmlns="http://www.w3.org/2000/svg">
|
|
77
|
+
<circle cx="12" cy="12" r="8" fill="none" stroke="${color}" stroke-width="3"
|
|
78
|
+
stroke-dasharray="${status === 'streaming' ? '12 6' : '50 0'}">
|
|
79
|
+
${status === 'streaming' ? '<animateTransform attributeName="transform" type="rotate" from="0 12 12" to="360 12 12" dur="1s" repeatCount="indefinite"/>' : ''}
|
|
80
|
+
</circle>
|
|
81
|
+
</svg>`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = {
|
|
85
|
+
createTaskTracker,
|
|
86
|
+
recordProviderTurn,
|
|
87
|
+
completeTask,
|
|
88
|
+
failTask,
|
|
89
|
+
computeSavingsBadge,
|
|
90
|
+
renderStatusIconSVG,
|
|
91
|
+
};
|