openvibers/openvibe.live Commits

ai: show AI's resting sentence in Vibe's stream answer (#621)

OP OpenVibers committed 7 days ago · 91cb1ba00a · 3 files, +34 −3

When OpenVibe.AI answers 503 ai.resting (every provider out of balance,
paused or busy), Live kept only the failed run and Vibe on stream said
"I couldn't answer that just now". Keep AI's own sentence on the last
run and use it: the stream hears "AI is resting for a moment. Try again in
a minute." instead of a generic failure.

Changes

ddiff --git a/server/ai/ai-service.js b/server/ai/ai-service.js
iindex e9d32be4..97a49464 100644
---- a/server/ai/ai-service.js
++++ b/server/ai/ai-service.js
@@@ -47,6 +47,15 @@ function warn(msg) {
console.warn(`[AI service] ${msg}`);
}
++/**
++ * The last problem AI answered with, for the caller that shows it to a person: today only `ai.resting` (503:
++ * every provider is out of balance, paused or busy). A product asks this instead of inventing an error, and
++ * shows the human sentence AI sent. Cleared when a run succeeds.
++ */
++let _lastProblem = null;
++function lastProblem() { return _lastProblem; }
++const RESTING = { code: 'ai.resting', detail: 'AI is resting for a moment' };
++
async function _fetchJson(method, path, body, timeoutMs, retried = false) {
let headers;
try { headers = await principal.serviceHeaders(AUDIENCE); } catch (e) { warn(`no service token for ${AUDIENCE}: ${e.message}`); return null; }
@@@ -65,7 +74,12 @@ async function _fetchJson(method, path, body, timeoutMs, retried = false) {
return await _fetchJson(method, path, body, timeoutMs, true);
}
if (res.status === 429) { warn(`quota: ${(json && json.detail) || 'refused'} (retry after ${res.headers.get('retry-after') || '?'}s)`); return null; }
-- if (!res.ok && res.status !== 202) { warn(`${method} ${path} -> ${res.status} ${(json && (json.code || json.detail)) || ''}`); return null; }
++ if (!res.ok && res.status !== 202) {
++ // AI is resting (ai.resting): keep its sentence so a product can show it instead of "could not answer".
++ if (res.status === 503 && json && json.code === 'ai.resting') _lastProblem = { ...RESTING, retry_after_seconds: Number(res.headers.get('retry-after')) || null };
++ warn(`${method} ${path} -> ${res.status} ${(json && (json.code || json.detail)) || ''}`);
++ return null;
++ }
return json;
}
@@@ -75,6 +89,7 @@ async function _fetchJson(method, path, body, timeoutMs, retried = false) {
*/
async function run(workflow, input, { target, attribution, idempotencyKey, waitMs = 60000, pollMs = 0, credentialSubject = null } = {}) {
if (!enabled()) return null;
++ _lastProblem = null;
const wait = Math.max(0, Math.min(60000, waitMs));
const created = await _fetchJson('POST', `/api/v1/runs?wait=${wait}`, {
workflow, input, target: target || undefined, attribution: attribution || undefined, idempotency_key: idempotencyKey || undefined,
@@@ -91,6 +106,7 @@ async function run(workflow, input, { target, attribution, idempotencyKey, waitM
if (!again || !again.run) return null;
r = again.run;
}
++ if (r.error && r.error.code === 'ai.resting') _lastProblem = { ...RESTING, detail: r.error.detail || RESTING.detail };
return r;
}
@@@ -181,4 +197,4 @@ async function complete(o = {}, { toVisionJpeg } = {}) {
};
}
--module.exports = { enabled, run, structured, complete, usable, meter, imageInput, ownerRef, workflowFor, setRecorder, KIND_TO_WORKFLOW, AUDIENCE };
++module.exports = { enabled, run, structured, complete, usable, meter, imageInput, ownerRef, workflowFor, setRecorder, lastProblem, KIND_TO_WORKFLOW, AUDIENCE };
ddiff --git a/server/ai/vibe-voice.js b/server/ai/vibe-voice.js
iindex 535eab7b..f83c7c80 100644
---- a/server/ai/vibe-voice.js
++++ b/server/ai/vibe-voice.js
@@@ -160,7 +160,14 @@ async function answer(stream, request, ev, settings, deps) {
.catch(() => null);
let text = r && typeof r.text === 'string' ? r.text.replace(/\s+/g, ' ').replace(/^["'“”]+|["'“”]+$/g, '').trim() : '';
text = text.slice(0, ANSWER_MAX).trim();
-- if (!text) return say(stream, 'I couldn\'t answer that just now. Try again in a minute.', { speak: false, deps });
++ if (!text) {
++ // AI is resting (503 ai.resting: every provider is out of balance, paused or busy): show its sentence, not
++ // a generic failure. deps.problem lets a test (or another caller) supply the same state.
++ const problem = typeof deps.problem === 'function' ? deps.problem()
++ : (typeof llm.lastProblem === 'function' ? llm.lastProblem() : require('./ai-service').lastProblem());
++ const resting = Boolean(problem && problem.code === 'ai.resting');
++ return say(stream, resting ? 'AI is resting for a moment. Try again in a minute.' : 'I couldn\'t answer that just now. Try again in a minute.', { speak: false, deps });
++ }
try {
const wf = require('../chat/word-filter');
if (!wf.check(text).safe) return null;
ddiff --git a/test/vibe-voice.test.js b/test/vibe-voice.test.js
iindex e4423a9c..58a7735b 100644
---- a/test/vibe-voice.test.js
++++ b/test/vibe-voice.test.js
@@@ -80,6 +80,14 @@ const voice = require('../server/ai/vibe-voice');
done = await voice.onSpeech(stream, [speech(500, 'hey vibe clip that'), speech(520, 'okay robot, clip that')], deps);
assert.deepStrictEqual(done.map((d) => d.start_sec), [520], 'only their word wakes it');
++ // AI is resting (503 ai.resting): the stream hears AI's own sentence, not a generic failure.
++ const resting = { ...deps, llm: { complete: async () => null }, problem: () => ({ code: 'ai.resting', detail: 'AI is resting for a moment' }) };
++ await voice.onSpeech(stream, [speech(600, 'okay robot, tell me a joke')], resting);
++ assert.ok(/AI is resting for a moment/.test(posted[posted.length - 1].message), posted[posted.length - 1].message);
++ const generic = { ...deps, llm: { complete: async () => null }, problem: () => null };
++ await voice.onSpeech(stream, [speech(700, 'okay robot, tell me a joke')], generic);
++ assert.ok(/couldn't answer that just now/.test(posted[posted.length - 1].message), posted[posted.length - 1].message);
++
console.log('vibe voice: all checks passed');
process.exit(0);
})().catch((e) => { console.error(e); process.exit(1); });