openvibers/openvibe.actor Commits

A parked chat turn says what it waits for, with Wake now and Stop waiting (A2c) (#269)

OP OpenVibers committed 11 days ago · bf2dc11104 · 4 files, +100 −1

A turn whose run parked on a wait_for (a Codes task, a Run job, a Watch
alert, a channel going live, a release, or time) showed only a working line.
It now shows a card: what it waits for, that nothing is spent, when it gives
up (in the reader's own zone) and what happens then, with Wake now (resume
with no event; the run checks for itself) and Stop waiting (the run carries
on without it). POST /chat/:id/wait, the task's own person from
openvibe.actor only; a refresh adds or removes the card as the wait comes
and goes. Until now nothing in the UI could wake or stop a wait.

Changes

ddiff --git a/public/css/chat.css b/public/css/chat.css
iindex d460915..5ca7657 100644
---- a/public/css/chat.css
++++ b/public/css/chat.css
@@@ -49,6 +49,8 @@ body:has(.page-app) > footer, body:has(.page-app) #ov-footer, body:has(.page-app
/* The screen: what Actor sees while it works, with Bot's live view one tap away. */
.ct-screen { margin: 0; display: grid; gap: 6px; }
++/* A parked turn (plan A2): what it waits for, until when, and Wake now / Stop waiting. */
++.ct-wait .ct-wait-until { margin: 6px 0 0; color: var(--text-secondary, #96a7c2); font-size: 14px; }
.ct-screen[hidden] { display: none; }
.ct-screen-frame { position: relative; overflow: hidden; border-radius: 14px; border: 1px solid var(--line); background: #05070d; aspect-ratio: 16 / 10; display: grid; place-items: center; }
.ct-screen-frame img { display: block; width: 100%; height: 100%; object-fit: contain; }
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex a2f9a1d..4744004 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -271,6 +271,24 @@
var said = typeof e.text === 'string' ? e.text.replace(/…$/, '').trim() : '';
if (e.step === 'tool_call' || (e.step === 'text' && said && d.text.trim().indexOf(said) === 0)) { turn._draft = null; draft(turn); }
}
++ // A wait's deadline in the reader's own zone ("at 18:40", or with the day when it is not today).
++ function localTimes(root) {
++ (root || document).querySelectorAll('time[data-ct-local]').forEach(function (t) {
++ var d = new Date(t.getAttribute('datetime'));
++ if (isNaN(d)) return;
++ var today = d.toDateString() === new Date().toDateString();
++ t.textContent = 'at ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + (today ? '' : ' on ' + d.toLocaleDateString([], { weekday: 'short', day: 'numeric', month: 'short' }));
++ });
++ }
++ localTimes(chat);
++ // A parked turn's card (plan A2) comes and goes with the wait: it is outside the work list the refresh keeps.
++ function syncWait(turn, next) {
++ var cur = turn.querySelector('[data-ct-wait]'), fresh = next.querySelector('[data-ct-wait]');
++ if (cur && !fresh) cur.remove();
++ else if (fresh && !cur) { var reply = turn.querySelector('.ovt-reply'); if (reply) reply.appendChild(fresh); }
++ else if (fresh && cur && cur.outerHTML !== fresh.outerHTML) cur.replaceWith(fresh);
++ localTimes(turn);
++ }
function refresh(turn, done) {
var id = turn.getAttribute('data-task');
return fetch('/chat/turn/' + encodeURIComponent(id), { credentials: 'same-origin' })
@@@ -296,6 +314,7 @@
if (scr && scr2 && (scr.hidden !== scr2.hidden || scr.getAttribute('data-desk') !== scr2.getAttribute('data-desk'))) { scr.replaceWith(scr2); screen(turn); }
liveLine(turn);
draft(turn);
++ syncWait(turn, next);
} else {
stopScreen(turn);
// An answer the person already watched being written is not typed in again: it only takes its
ddiff --git a/server/http/chat-pages.js b/server/http/chat-pages.js
iindex e6376bc..75e6ef9 100644
---- a/server/http/chat-pages.js
++++ b/server/http/chat-pages.js
@@@ -244,6 +244,22 @@ ${acting ? '' : html`<form method="post" action="/tasks/${row.id}/answer" class=
<form method="post" action="/chat" class="ct-retry"><input type="hidden" name="task" value="${row.task}"><input type="hidden" name="mode" value="best">${isFollow(row) ? html`<input type="hidden" name="parent" value="${row.parent_id}">` : ''}<button class="ct-opt" type="submit">Try again in Best mode</button><a class="ct-act" href="/tasks/${row.id}?view=details">Details</a></form></div>`;
}
++ /**
++ * A parked turn (plan A2, Watch mode): what it waits for, until when, and the person's two moves. Wake now resumes
++ * it with no event (the run checks for itself); Stop waiting ends the wait and the run carries on without it. The
++ * deadline is written as a <time> the page shows in the reader's own zone (js/chat.js), "in about 2 h" without it.
++ */
++ function waitBlock(row, wait) {
++ if (!wait) return '';
++ const left = Math.max(0, Date.parse(wait.deadline_at) - now());
++ const mins = Math.round(left / 60000);
++ const about = mins < 1 ? 'any moment now' : mins < 90 ? `in about ${mins} min` : `in about ${Math.round(mins / 60)} h`;
++ return html`<form class="ovt-card is-info ct-wait" method="post" action="/chat/${row.id}/wait" data-ct-wait>
++<p class="ovt-card-h">${raw(glyphs.svg('clock', { size: 18 }))}<span><b>Waiting</b> ${String(wait.line || 'for something to happen').replace(/^waiting\s+/i, '').replace(/[.\s]+$/, '')}. Nothing is spent while it waits.</span></p>
++<p class="ct-wait-until">It gives up <time datetime="${new Date(wait.deadline_at).toISOString()}" data-ct-local>${about}</time>, then ${wait.on_timeout === 'fail' ? 'stops' : wait.on_timeout === 'retry' ? 'tries that step once more' : 'asks you what next'}.</p>
++<div class="ovt-card-acts"><button type="submit" class="ovt-card-btn" name="do" value="wake">Wake now</button><button type="submit" class="ovt-card-btn is-quiet" name="do" value="cancel">Stop waiting</button></div></form>`;
++ }
++
/** The files beside the question (thread.files draws them). */
function filesOf(row, files) {
return (files || []).map((f) => ({ href: `/chat/file/${row.id}/${f.id}`, name: f.name, size: f.size, image: inputs.IMAGE.test(f.mime) }));
@@@ -259,7 +275,11 @@ ${acting ? '' : html`<form method="post" action="/tasks/${row.id}/answer" class=
else if (row.state === 'needs_you') reply = html`${workBlock(row, evs, false)}${questionBlock(row, evs)}`;
else if (row.state === 'failed') reply = html`${workBlock(row, evs, false)}${failedBlock(row, t)}`;
else if (row.state === 'cancelled') reply = html`${workBlock(row, evs, false)}<p class="ct-stopped">Stopped. What it spent before stopping stays spent.</p>`;
-- else reply = html`${workBlock(row, evs, true)}${screenBlock(row, evs, true)}`;
++ else {
++ // A turn parked on a wait says what it waits for (plan A2); otherwise it is working.
++ const parked = ctx.waits && typeof ctx.waits.listForTask === 'function' ? (await ctx.waits.listForTask(row.id).catch(() => [])).find((w) => w.status === 'waiting') : null;
++ reply = html`${workBlock(row, evs, true)}${screenBlock(row, evs, true)}${waitBlock(row, parked)}`;
++ }
// data-task and data-last are Actor's (js/chat.js follows the task's event stream from data-last).
return raw(thread.turn({ id: row.id, state: row.state, open, you: { text: row.task, files: filesOf(row, attached) }, reply: String(reply), attrs: { 'data-task': row.id, 'data-last': row.last_seq } }));
}
@@@ -456,6 +476,24 @@ ${radio('own:node', 'Your computer', 'A model you run at home (Ollama, LM Studio
return res.redirect(303, `/chat/${row.id}#t-${row.id}`);
});
++ // A parked turn's two moves (plan A2): Wake now, or Stop waiting. The task's own person, from openvibe.actor only.
++ r.post('/chat/:id/wait', express.urlencoded({ extended: false, limit: '1kb' }), async (req, res) => {
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
++ const wantsJson = String(req.get('accept') || '').includes('application/json');
++ if (!signedIn(req) || !TASK_ID.test(req.params.id)) return wantsJson ? res.status(401).json({ error: 'Sign in first.' }) : res.redirect(303, '/auth/login?next=%2Fchat');
++ if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('Wake or stop a wait from openvibe.actor itself.');
++ const row = await store.getTask(s, req.params.id);
++ if (!row || row.requester !== requesterOf(req)) return wantsJson ? res.status(404).json({ error: 'That chat is not here.' }) : res.redirect(303, '/chat');
++ const move = String((req.body && req.body.do) || '');
++ const waits = ctx.waits;
++ const open = waits ? (await waits.listForTask(row.id)).find((w) => w.status === 'waiting') : null;
++ let done = null;
++ if (open && move === 'wake') done = await waits.wakeByPerson(open.id, requesterOf(req));
++ else if (open && move === 'cancel') done = await waits.cancel(open.id, requesterOf(req));
++ if (wantsJson) return res.status(done ? 202 : 409).json(done ? { ok: true } : { error: open ? 'Say wake or cancel.' : 'It is not waiting any more.' });
++ return res.redirect(303, `/chat/${row.id}#t-${row.id}`);
++ });
++
// The live desk in a working turn (plan L55 C1b; the owner, 11-01: "show you the display… watch it… and steer it").
// The page asks once the turn is on a desk; the answer is a short-lived Bot page it frames, where the person can
// take the mouse. Only the task's requester, only same-origin, only while the turn works.
ddiff --git a/test/chat.test.js b/test/chat.test.js
iindex fc5b1b1..ff7b2c8 100644
---- a/test/chat.test.js
++++ b/test/chat.test.js
@@@ -197,6 +197,46 @@ const LOC = /^\/chat\/(tsk_[0-9A-HJKMNP-TV-Z]{26})#t-\1$/;
assert.deepStrictEqual(await t.ctx.engine.steer('tsk_00000000000000000000000000', 'hi'), { ok: false, reason: 'this task is not running' });
});
++ await check('a parked turn (plan A2): it says what it waits for and until when; Wake now and Stop waiting, for its person only', async () => {
++ const last = (await s.db.maybe('SELECT id FROM tasks WHERE requester = $1 ORDER BY id DESC LIMIT 1', [`user:${kim.subject}`])).id;
++ await s.db.query("UPDATE tasks SET state = 'running' WHERE id = $1", [last]);
++ const woke = [];
++ const engine = t.ctx.engine;
++ const before = { ev: engine.resumeWithEvent, note: engine.resumeWithNotice };
++ engine.resumeWithEvent = async (id, p) => { woke.push(['wake', id, p && p.event]); return { id }; };
++ engine.resumeWithNotice = async (id, n) => { woke.push(['cancel', id, n]); return { id }; };
++ try {
++ const w = await t.ctx.waits.create({ taskId: last, requester: `user:${kim.subject}`, source: 'time', seconds: 7200, line: 'waiting for maticus to go live' });
++ const page = await t.get(`/chat/${last}`, { as: kim });
++ assert.match(page.text, new RegExp(`<form class="ovt-card is-info ct-wait" method="post" action="/chat/${last}/wait" data-ct-wait>`));
++ assert.match(page.text, /<b>Waiting<\/b> for maticus to go live\. Nothing is spent while it waits\./, 'the line, without saying waiting twice');
++ assert.match(page.text, /<time datetime="[^"]+" data-ct-local>in about 2 h<\/time>, then asks you what next\./);
++ assert.match(page.text, /name="do" value="wake">Wake now<\/button>/);
++ // Another person, another site: nothing changes.
++ assert.strictEqual((await t.get(`/chat/${last}/wait`, { as: bo, form: { do: 'wake' }, headers: { ...SAME, accept: 'application/json' } })).status, 404);
++ assert.strictEqual((await t.get(`/chat/${last}/wait`, { as: kim, form: { do: 'wake' }, headers: { 'sec-fetch-site': 'cross-site' } })).status, 403);
++ assert.strictEqual((await t.ctx.waits.get(w.id)).status, 'waiting');
++ const wake = await t.get(`/chat/${last}/wait`, { as: kim, form: { do: 'wake' }, headers: SAME });
++ assert.strictEqual(wake.status, 303);
++ assert.strictEqual(wake.headers.get('location'), `/chat/${last}#t-${last}`);
++ assert.strictEqual((await t.ctx.waits.get(w.id)).status, 'woken');
++ assert.deepStrictEqual(woke.at(-1).slice(0, 3), ['wake', last, null], 'Wake now resumes it with no event');
++ assert.doesNotMatch((await t.get(`/chat/${last}`, { as: kim })).text, /data-ct-wait/, 'no wait, no card');
++ const again = await t.get(`/chat/${last}/wait`, { as: kim, form: { do: 'wake' }, headers: { ...SAME, accept: 'application/json' } });
++ assert.strictEqual(again.status, 409, 'nothing to wake twice');
++ await s.db.query("UPDATE tasks SET state = 'running' WHERE id = $1", [last]);
++ const w2 = await t.ctx.waits.create({ taskId: last, requester: `user:${kim.subject}`, source: 'time', seconds: 900, line: 'waiting a while' });
++ const stop = await t.get(`/chat/${last}/wait`, { as: kim, form: { do: 'cancel' }, headers: { ...SAME, accept: 'application/json' } });
++ assert.strictEqual(stop.status, 202);
++ assert.strictEqual((await t.ctx.waits.get(w2.id)).status, 'cancelled');
++ assert.strictEqual(woke.at(-1)[0], 'cancel', 'Stop waiting: the run carries on without it');
++ } finally {
++ engine.resumeWithEvent = before.ev;
++ engine.resumeWithNotice = before.note;
++ await s.db.query("UPDATE tasks SET state = 'succeeded' WHERE id = $1", [last]);
++ }
++ });
++
await check('a working turn: the composer steers it and Stop is its own button; the screen waits for a picture', async () => {
const last = (await s.db.maybe('SELECT id FROM tasks WHERE requester = $1 ORDER BY id DESC LIMIT 1', [`user:${kim.subject}`])).id;
await s.db.query("UPDATE tasks SET state = 'running' WHERE id = $1", [last]);