openvibers/openvibe.live Commits
ai: show AI's resting sentence in Vibe's stream answer (#621)
Changes
@@@ -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, waitMif (!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 };@@@ -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;@@@ -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); });
@@ -47,6 +47,15 @@ function warn(msg) {
@@ -47,6 +47,15 @@ function warn(msg) {
console.warn(`[AI service] ${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) {
async function _fetchJson(method, path, body, timeoutMs, retried = false) {
let headers;
let headers;
try { headers = await principal.serviceHeaders(AUDIENCE); } catch (e) { warn(`no service token for ${AUDIENCE}: ${e.message}`); return null; }
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) {
@@ -65,7 +74,12 @@ async function _fetchJson(method, path, body, timeoutMs, retried = false) {
return await _fetchJson(method, path, body, timeoutMs, true);
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.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;
return json;
}
}
@@ -75,6 +89,7 @@ async function _fetchJson(method, path, body, timeoutMs, retried = false) {
@@ -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 } = {}) {
async function run(workflow, input, { target, attribution, idempotencyKey, waitMs = 60000, pollMs = 0, credentialSubject = null } = {}) {
if (!enabled()) return null;
if (!enabled()) return null;
+ _lastProblem = null;
const wait = Math.max(0, Math.min(60000, waitMs));
const wait = Math.max(0, Math.min(60000, waitMs));
const created = await _fetchJson('POST', `/api/v1/runs?wait=${wait}`, {
const created = await _fetchJson('POST', `/api/v1/runs?wait=${wait}`, {
workflow, input, target: target || undefined, attribution: attribution || undefined, idempotency_key: idempotencyKey || undefined,
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
@@ -91,6 +106,7 @@ async function run(workflow, input, { target, attribution, idempotencyKey, waitM
if (!again || !again.run) return null;
if (!again || !again.run) return null;
r = again.run;
r = again.run;
}
}
+ if (r.error && r.error.code === 'ai.resting') _lastProblem = { ...RESTING, detail: r.error.detail || RESTING.detail };
return r;
return r;
}
}
@@ -181,4 +197,4 @@ async function complete(o = {}, { toVisionJpeg } = {}) {
@@ -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 };
@@ -160,7 +160,14 @@ async function answer(stream, request, ev, settings, deps) {
@@ -160,7 +160,14 @@ async function answer(stream, request, ev, settings, deps) {
.catch(() => null);
.catch(() => null);
let text = r && typeof r.text === 'string' ? r.text.replace(/\s+/g, ' ').replace(/^["'“”]+|["'“”]+$/g, '').trim() : '';
let text = r && typeof r.text === 'string' ? r.text.replace(/\s+/g, ' ').replace(/^["'“”]+|["'“”]+$/g, '').trim() : '';
text = text.slice(0, ANSWER_MAX).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 {
try {
const wf = require('../chat/word-filter');
const wf = require('../chat/word-filter');
if (!wf.check(text).safe) return null;
if (!wf.check(text).safe) return null;
@@ -80,6 +80,14 @@ const voice = require('../server/ai/vibe-voice');
@@ -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);
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');
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');
console.log('vibe voice: all checks passed');
process.exit(0);
process.exit(0);
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });