openvibers/openvibe.chat Commits
ai: Vibe says AI is resting when every provider is paused (#356)
Changes
@@@ -13,6 +13,11 @@ const config = require('../config');let _ai = null;let _http = null;let _tokens = null;++// Why the last run answered nothing, when the caller can show it: `ai.resting` today. Cleared by a run that++// succeeds. A product asks lastProblem() instead of turning "no answer" into an error.++let _lastProblem = null;++const problemOf = (run) => (run && run.error && run.error.code === 'ai.resting' ? 'ai.resting' : null);++function lastProblem() { return _lastProblem; }/** The HTTP client behind ai() (Chat's token for openvibe.ai), for calls the SDK's AI client has no method for. */function http() { ai(); return _http; }@@@ -48,6 +53,7 @@ function enabled() {*/async function structured(workflow, input, opts = {}) {if (!(opts.force ? config.oauth.clientSecret : enabled())) return null;++ _lastProblem = null;try {const { runs } = ai();// A person's own provider key (ai.run-request `credential`): the SDK's runs.create does not carry it, so such a@@@ -65,6 +71,7 @@ async function structured(workflow, input, opts = {}) {...(opts.attribution !== undefined ? { attribution: opts.attribution } : {}),});const run = body && (body.run || body);++ _lastProblem = problemOf(run);if (!run || run.status !== 'succeeded' || run.synthetic) return null;return run.output || null;} catch (err) {@@@ -82,6 +89,7 @@ async function structured(workflow, input, opts = {}) {async function stream(workflow, input, opts = {}, onText = () => {}) {if (!(opts.force ? config.oauth.clientSecret : enabled())) return null;if (_ai && !_tokens) return structured(workflow, input, opts);++ _lastProblem = null;try {if (!_tokens) ai();const token = await _tokens.getToken();@@@ -92,7 +100,14 @@ async function stream(workflow, input, opts = {}, onText = () => {}) {body: JSON.stringify({ workflow, input, ...(opts.credential ? { credential: opts.credential } : {}), ...(opts.attribution !== undefined ? { attribution: opts.attribution } : {}) }),signal: opts.signal ? AbortSignal.any([opts.signal, timeout]) : timeout,});-- if (!res.ok) { console.warn(`[AI] ${workflow}: AI answered ${res.status}`); return null; }++ if (!res.ok) {++ if (res.status === 503) {++ const problem = await res.json().catch(() => null);++ _lastProblem = problem && problem.code === 'ai.resting' ? 'ai.resting' : null;++ }++ console.warn(`[AI] ${workflow}: AI answered ${res.status}`);++ return null;++ }// Stopped by the person (opts.signal): the run is cancelled at AI, so it spends nothing more.const runId = res.headers.get('x-openvibe-run-id');if (opts.signal && runId) opts.signal.addEventListener('abort', () => { try { const { runs } = ai(); if (runs && runs.cancel) runs.cancel(runId).catch(() => {}); } catch { /* AI away */ } }, { once: true });@@@ -120,6 +135,7 @@ async function stream(workflow, input, opts = {}, onText = () => {}) {}}}++ _lastProblem = problemOf(run);if (!run || run.status !== 'succeeded' || run.synthetic) return null;return run.output || null;} catch (err) {@@@ -128,4 +144,5 @@ async function stream(workflow, input, opts = {}, onText = () => {}) {}}--module.exports = { structured, stream, enabled, http, _setClient(c) { _ai = c; }, _setHttp(h) { _http = h; }, _setTokens(t) { _tokens = t; }, _reset() { _ai = null; _http = null; _tokens = null; } };++module.exports = { structured, stream, enabled, http, lastProblem, _setClient(c) { _ai = c; }, _setHttp(h) { _http = h; }, _setTokens(t) { _tokens = t; },++ _setProblem(p) { _lastProblem = p; }, _reset() { _ai = null; _http = null; _tokens = null; _lastProblem = null; } };@@@ -480,7 +480,12 @@ async function afterPost(room, message, user, deps = {}) {// A model on someone's own computer (credential provider node): its label reads "<model> on Fran's computer(s)".const onComputer = own && /'s computers?$/.test(String(member.engine_label || ''));if (!text) {-- if (!own) return await say('I could not answer just now. Try again in a minute.');++ if (!own) {++ // AI is resting (503 ai.resting: every provider is out of balance, paused or busy): say its sentence++ // instead of a generic failure, so the person knows to try again in a minute rather than debug.++ const resting = typeof aiClient.lastProblem === 'function' && aiClient.lastProblem() === 'ai.resting';++ return await say(resting ? 'AI is resting for a moment. Try again in a minute.' : 'I could not answer just now. Try again in a minute.');++ }if (onComputer) return await say(vibe? 'I could not reach the model on your computer just now. Is it on, with its model server running (Ollama, LM Studio or llama.cpp), and "Let my AI use this computer\'s models" on at openvibe.bot/machines? You can switch me back to OpenVibe\'s model in Settings, under "Your AI key".': 'I could not reach the model on my manager\'s computer just now. Try again later, or the room\'s owner can switch me to OpenVibe\'s model.');@@@ -83,6 +83,15 @@ t('falls back to the extractive summary when AI does not answer', async () => {aiClient._setClient(null);});++t('a failed run remembers ai.resting for the caller to show; a success clears it', async () => {++ aiClient._setClient({ runs: { create: async () => ({ run: { status: 'failed', error: { code: 'ai.resting', detail: 'AI is resting for a moment' } } }) } });++ assert.strictEqual(await aiClient.structured('chat.insight', {}), null);++ assert.strictEqual(aiClient.lastProblem(), 'ai.resting');++ aiClient._setClient({ runs: { create: async () => ({ run: { status: 'succeeded', output: { ok: 1 } } }) } });++ assert.deepStrictEqual(await aiClient.structured('chat.insight', {}), { ok: 1 });++ assert.strictEqual(aiClient.lastProblem(), null);++});++t('the job is off unless CHAT_AI_ENABLED is set', async () => {const cfg = require('../server/config');assert.strictEqual(cfg.ai.enabled, true, 'this boot enabled it');@@@ -112,6 +112,15 @@ t('the switch: CHAT_AI_MEMBERS=0 keeps every AI member quiet', async () => {} finally { config.ai.members = true; }});++t('AI is resting: the member says so in AI\'s own words instead of a generic failure', async () => {++ aiClient._setClient({ runs: { create: async () => ({ run: { status: 'failed', error: { code: 'ai.resting', detail: 'AI is resting for a moment' } } }) } });++ await api('POST', '/our-kitchen/messages', ann, { message: '@pantry one more question?' });++ await settle(300);++ const last = (await api('GET', '/our-kitchen/messages', ann)).body.messages.pop();++ assert.match(last.message, /AI is resting for a moment/, last.message);++ assert.ok(!/could not answer just now/.test(last.message), last.message);++});++t('the pages: AI messages are badged and not profile links; settings add and remove AI members', async () => {const page = await web('GET', '/r/our-kitchen', ann);assert.strictEqual(page.status, 200);
@@ -13,6 +13,11 @@ const config = require('../config');
@@ -13,6 +13,11 @@ const config = require('../config');
let _ai = null;
let _ai = null;
let _http = null;
let _http = null;
let _tokens = null;
let _tokens = null;
+// Why the last run answered nothing, when the caller can show it: `ai.resting` today. Cleared by a run that
+// succeeds. A product asks lastProblem() instead of turning "no answer" into an error.
+let _lastProblem = null;
+const problemOf = (run) => (run && run.error && run.error.code === 'ai.resting' ? 'ai.resting' : null);
+function lastProblem() { return _lastProblem; }
/** The HTTP client behind ai() (Chat's token for openvibe.ai), for calls the SDK's AI client has no method for. */
/** The HTTP client behind ai() (Chat's token for openvibe.ai), for calls the SDK's AI client has no method for. */
function http() { ai(); return _http; }
function http() { ai(); return _http; }
@@ -48,6 +53,7 @@ function enabled() {
@@ -48,6 +53,7 @@ function enabled() {
*/
*/
async function structured(workflow, input, opts = {}) {
async function structured(workflow, input, opts = {}) {
if (!(opts.force ? config.oauth.clientSecret : enabled())) return null;
if (!(opts.force ? config.oauth.clientSecret : enabled())) return null;
+ _lastProblem = null;
try {
try {
const { runs } = ai();
const { runs } = ai();
// A person's own provider key (ai.run-request `credential`): the SDK's runs.create does not carry it, so such a
// A person's own provider key (ai.run-request `credential`): the SDK's runs.create does not carry it, so such a
@@ -65,6 +71,7 @@ async function structured(workflow, input, opts = {}) {
@@ -65,6 +71,7 @@ async function structured(workflow, input, opts = {}) {
...(opts.attribution !== undefined ? { attribution: opts.attribution } : {}),
...(opts.attribution !== undefined ? { attribution: opts.attribution } : {}),
});
});
const run = body && (body.run || body);
const run = body && (body.run || body);
+ _lastProblem = problemOf(run);
if (!run || run.status !== 'succeeded' || run.synthetic) return null;
if (!run || run.status !== 'succeeded' || run.synthetic) return null;
return run.output || null;
return run.output || null;
} catch (err) {
} catch (err) {
@@ -82,6 +89,7 @@ async function structured(workflow, input, opts = {}) {
@@ -82,6 +89,7 @@ async function structured(workflow, input, opts = {}) {
async function stream(workflow, input, opts = {}, onText = () => {}) {
async function stream(workflow, input, opts = {}, onText = () => {}) {
if (!(opts.force ? config.oauth.clientSecret : enabled())) return null;
if (!(opts.force ? config.oauth.clientSecret : enabled())) return null;
if (_ai && !_tokens) return structured(workflow, input, opts);
if (_ai && !_tokens) return structured(workflow, input, opts);
+ _lastProblem = null;
try {
try {
if (!_tokens) ai();
if (!_tokens) ai();
const token = await _tokens.getToken();
const token = await _tokens.getToken();
@@ -92,7 +100,14 @@ async function stream(workflow, input, opts = {}, onText = () => {}) {
@@ -92,7 +100,14 @@ async function stream(workflow, input, opts = {}, onText = () => {}) {
body: JSON.stringify({ workflow, input, ...(opts.credential ? { credential: opts.credential } : {}), ...(opts.attribution !== undefined ? { attribution: opts.attribution } : {}) }),
body: JSON.stringify({ workflow, input, ...(opts.credential ? { credential: opts.credential } : {}), ...(opts.attribution !== undefined ? { attribution: opts.attribution } : {}) }),
signal: opts.signal ? AbortSignal.any([opts.signal, timeout]) : timeout,
signal: opts.signal ? AbortSignal.any([opts.signal, timeout]) : timeout,
});
});
- if (!res.ok) { console.warn(`[AI] ${workflow}: AI answered ${res.status}`); return null; }
+ if (!res.ok) {
+ if (res.status === 503) {
+ const problem = await res.json().catch(() => null);
+ _lastProblem = problem && problem.code === 'ai.resting' ? 'ai.resting' : null;
+ }
+ console.warn(`[AI] ${workflow}: AI answered ${res.status}`);
+ return null;
+ }
// Stopped by the person (opts.signal): the run is cancelled at AI, so it spends nothing more.
// Stopped by the person (opts.signal): the run is cancelled at AI, so it spends nothing more.
const runId = res.headers.get('x-openvibe-run-id');
const runId = res.headers.get('x-openvibe-run-id');
if (opts.signal && runId) opts.signal.addEventListener('abort', () => { try { const { runs } = ai(); if (runs && runs.cancel) runs.cancel(runId).catch(() => {}); } catch { /* AI away */ } }, { once: true });
if (opts.signal && runId) opts.signal.addEventListener('abort', () => { try { const { runs } = ai(); if (runs && runs.cancel) runs.cancel(runId).catch(() => {}); } catch { /* AI away */ } }, { once: true });
@@ -120,6 +135,7 @@ async function stream(workflow, input, opts = {}, onText = () => {}) {
@@ -120,6 +135,7 @@ async function stream(workflow, input, opts = {}, onText = () => {}) {
}
}
}
}
}
}
+ _lastProblem = problemOf(run);
if (!run || run.status !== 'succeeded' || run.synthetic) return null;
if (!run || run.status !== 'succeeded' || run.synthetic) return null;
return run.output || null;
return run.output || null;
} catch (err) {
} catch (err) {
@@ -128,4 +144,5 @@ async function stream(workflow, input, opts = {}, onText = () => {}) {
@@ -128,4 +144,5 @@ async function stream(workflow, input, opts = {}, onText = () => {}) {
}
}
}
}
-module.exports = { structured, stream, enabled, http, _setClient(c) { _ai = c; }, _setHttp(h) { _http = h; }, _setTokens(t) { _tokens = t; }, _reset() { _ai = null; _http = null; _tokens = null; } };
+module.exports = { structured, stream, enabled, http, lastProblem, _setClient(c) { _ai = c; }, _setHttp(h) { _http = h; }, _setTokens(t) { _tokens = t; },
+ _setProblem(p) { _lastProblem = p; }, _reset() { _ai = null; _http = null; _tokens = null; _lastProblem = null; } };
@@ -480,7 +480,12 @@ async function afterPost(room, message, user, deps = {}) {
@@ -480,7 +480,12 @@ async function afterPost(room, message, user, deps = {}) {
// A model on someone's own computer (credential provider node): its label reads "<model> on Fran's computer(s)".
// A model on someone's own computer (credential provider node): its label reads "<model> on Fran's computer(s)".
const onComputer = own && /'s computers?$/.test(String(member.engine_label || ''));
const onComputer = own && /'s computers?$/.test(String(member.engine_label || ''));
if (!text) {
if (!text) {
- if (!own) return await say('I could not answer just now. Try again in a minute.');
+ if (!own) {
+ // AI is resting (503 ai.resting: every provider is out of balance, paused or busy): say its sentence
+ // instead of a generic failure, so the person knows to try again in a minute rather than debug.
+ const resting = typeof aiClient.lastProblem === 'function' && aiClient.lastProblem() === 'ai.resting';
+ return await say(resting ? 'AI is resting for a moment. Try again in a minute.' : 'I could not answer just now. Try again in a minute.');
+ }
if (onComputer) return await say(vibe
if (onComputer) return await say(vibe
? 'I could not reach the model on your computer just now. Is it on, with its model server running (Ollama, LM Studio or llama.cpp), and "Let my AI use this computer\'s models" on at openvibe.bot/machines? You can switch me back to OpenVibe\'s model in Settings, under "Your AI key".'
? 'I could not reach the model on your computer just now. Is it on, with its model server running (Ollama, LM Studio or llama.cpp), and "Let my AI use this computer\'s models" on at openvibe.bot/machines? You can switch me back to OpenVibe\'s model in Settings, under "Your AI key".'
: 'I could not reach the model on my manager\'s computer just now. Try again later, or the room\'s owner can switch me to OpenVibe\'s model.');
: 'I could not reach the model on my manager\'s computer just now. Try again later, or the room\'s owner can switch me to OpenVibe\'s model.');
@@ -83,6 +83,15 @@ t('falls back to the extractive summary when AI does not answer', async () => {
@@ -83,6 +83,15 @@ t('falls back to the extractive summary when AI does not answer', async () => {
aiClient._setClient(null);
aiClient._setClient(null);
});
});
+t('a failed run remembers ai.resting for the caller to show; a success clears it', async () => {
+ aiClient._setClient({ runs: { create: async () => ({ run: { status: 'failed', error: { code: 'ai.resting', detail: 'AI is resting for a moment' } } }) } });
+ assert.strictEqual(await aiClient.structured('chat.insight', {}), null);
+ assert.strictEqual(aiClient.lastProblem(), 'ai.resting');
+ aiClient._setClient({ runs: { create: async () => ({ run: { status: 'succeeded', output: { ok: 1 } } }) } });
+ assert.deepStrictEqual(await aiClient.structured('chat.insight', {}), { ok: 1 });
+ assert.strictEqual(aiClient.lastProblem(), null);
+});
+
t('the job is off unless CHAT_AI_ENABLED is set', async () => {
t('the job is off unless CHAT_AI_ENABLED is set', async () => {
const cfg = require('../server/config');
const cfg = require('../server/config');
assert.strictEqual(cfg.ai.enabled, true, 'this boot enabled it');
assert.strictEqual(cfg.ai.enabled, true, 'this boot enabled it');
@@ -112,6 +112,15 @@ t('the switch: CHAT_AI_MEMBERS=0 keeps every AI member quiet', async () => {
@@ -112,6 +112,15 @@ t('the switch: CHAT_AI_MEMBERS=0 keeps every AI member quiet', async () => {
} finally { config.ai.members = true; }
} finally { config.ai.members = true; }
});
});
+t('AI is resting: the member says so in AI\'s own words instead of a generic failure', async () => {
+ aiClient._setClient({ runs: { create: async () => ({ run: { status: 'failed', error: { code: 'ai.resting', detail: 'AI is resting for a moment' } } }) } });
+ await api('POST', '/our-kitchen/messages', ann, { message: '@pantry one more question?' });
+ await settle(300);
+ const last = (await api('GET', '/our-kitchen/messages', ann)).body.messages.pop();
+ assert.match(last.message, /AI is resting for a moment/, last.message);
+ assert.ok(!/could not answer just now/.test(last.message), last.message);
+});
+
t('the pages: AI messages are badged and not profile links; settings add and remove AI members', async () => {
t('the pages: AI messages are badged and not profile links; settings add and remove AI members', async () => {
const page = await web('GET', '/r/our-kitchen', ann);
const page = await web('GET', '/r/our-kitchen', ann);
assert.strictEqual(page.status, 200);
assert.strictEqual(page.status, 200);