openvibers/openvibe.codes Commits

Show AG-UI run steps on Codes task pages (#405)

OP OpenVibers committed 2 days ago · 24565ba0d4 · 8 files, +77 −11

Changes

ddiff --git a/docs/screenshots/ask-354-1280.png b/docs/screenshots/ask-354-1280.png
nnew file mode 100644
iindex 0000000..0d58304
BBinary files /dev/null and b/docs/screenshots/ask-354-1280.png differ
ddiff --git a/docs/screenshots/ask-354-390.png b/docs/screenshots/ask-354-390.png
nnew file mode 100644
iindex 0000000..66206b5
BBinary files /dev/null and b/docs/screenshots/ask-354-390.png differ
ddiff --git a/package-lock.json b/package-lock.json
iindex 362bb59..5141a3d 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,7 +17,7 @@
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.147.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.202.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.205.0",
"pg": "^8.23.0"
},
"bin": {
@@@ -1274,9 +1274,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.202.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.202.0",
-- "integrity": "sha512-lSZavwvUc8nI7eS7ZzLjVaz2d2xVmvYGh81H83d+i1LPp7cbS7eK9HCiQS8ESK9N8eH0i/Ok+sqBFxeUwsnVPQ==",
++ "version": "4.205.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.205.0",
++ "integrity": "sha512-Ch4rkxgGhjsGrdFDsJlZ6lzRrtH90MLdi00Huw7nJB8SDt7D8S3wpnxgOs5eQYx4wLHcFDWRmCKQ5FlVzsCaWA==",
"license": "MIT",
"dependencies": {
"clean-css": "^5.3.3",
ddiff --git a/package.json b/package.json
iindex c32c245..b33cd9d 100644
---- a/package.json
++++ b/package.json
@@@ -32,7 +32,7 @@
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.147.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.202.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.205.0",
"pg": "^8.23.0"
},
"engines": {
ddiff --git a/public/js/c4.js b/public/js/c4.js
iindex 13bcca4..89dd9a4 100644
---- a/public/js/c4.js
++++ b/public/js/c4.js
@@@ -227,6 +227,35 @@
const label = root.querySelector('[data-live-label]');
const live = root.dataset.c4Conductor === 'live';
const url = root.dataset.c4ConductorStream;
++ const taskId = root.dataset.task;
++ if (taskId && window.OpenVibeAgentSession && window.EventSource) {
++ const runUrl = '/api/v1/runs/' + encodeURIComponent(taskId);
++ fetch(runUrl, { credentials: 'same-origin' }).then((r) => r.ok ? r.json() : null).then((run) => {
++ if (!run || !root.isConnected) return;
++ const host = document.createElement('div');
++ root.appendChild(host);
++ if (out) out.hidden = true;
++ if (label) label.hidden = true;
++ const form = document.querySelector('[data-c4-conductor-form]');
++ if (form) form.hidden = true;
++ const view = window.OpenVibeAgentSession.mount(host, {
++ title: run.title || 'Conductor', status: run.status || 'running', follow: live, files: false,
++ onSend: (text) => fetch('/api/v1/conductor/' + encodeURIComponent(taskId) + '/messages', {
++ method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text })
++ }).then((r) => { if (!r.ok) throw new Error('The message could not be sent.'); window.location.reload(); })
++ });
++ const stream = new EventSource(runUrl + '/events');
++ ['RUN_STARTED', 'RUN_FINISHED', 'RUN_ERROR', 'STEP_STARTED', 'STEP_FINISHED', 'TEXT_MESSAGE_START',
++ 'TEXT_MESSAGE_CONTENT', 'TEXT_MESSAGE_END', 'TOOL_CALL_START', 'TOOL_CALL_ARGS', 'TOOL_CALL_END',
++ 'TOOL_CALL_RESULT', 'REASONING_START', 'REASONING_MESSAGE_START', 'REASONING_MESSAGE_CONTENT',
++ 'REASONING_MESSAGE_END', 'REASONING_END', 'REASONING_ENCRYPTED_VALUE', 'STATE_SNAPSHOT', 'STATE_DELTA',
++ 'ACTIVITY_SNAPSHOT', 'ACTIVITY_DELTA', 'CUSTOM'].forEach((type) => stream.addEventListener(type, (e) => {
++ try { view.push(JSON.parse(e.data)); } catch { /* ignore one invalid event */ }
++ if (type === 'RUN_FINISHED' || type === 'RUN_ERROR') stream.close();
++ }));
++ window.addEventListener('pagehide', () => stream.close(), { once: true });
++ }).catch(() => { /* the plain transcript and composer remain */ });
++ }
if (!live || !window.EventSource || !url) return; // between turns the lines are drawn on the server
const stream = new EventSource(url);
const textOf = (line) => {
ddiff --git a/public/js/run.js b/public/js/run.js
iindex 5477176..70b0108 100644
---- a/public/js/run.js
++++ b/public/js/run.js
@@@ -35,6 +35,41 @@
}
}
++ // AG-UI is the task's durable run timeline. EventSource keeps the last SSE id on reconnect,
++ // so a dropped connection resumes without repeating steps or losing streamed text.
++ function agentRun(section, host, oldView) {
++ var id = (section.getAttribute('data-run-id') || '').trim();
++ if (!id || !window.OpenVibeAgentSession || !window.EventSource) return;
++ var url = '/api/v1/runs/' + encodeURIComponent(id);
++ fetch(url, { credentials: 'same-origin' }).then(function (r) { return r.ok ? r.json() : null; }).then(function (run) {
++ if (!run || !host.isConnected) return;
++ if (oldView) oldView.destroy();
++ host.textContent = '';
++ var view = window.OpenVibeAgentSession.mount(host, {
++ title: run.title || section.getAttribute('data-title') || 'Task', status: run.status || 'running',
++ follow: section.hasAttribute('data-events'), files: false,
++ onSend: section.getAttribute('data-owner') === '1' && !section.hasAttribute('data-events') ? function (text) {
++ return fetch('/api/v1/tasks/' + encodeURIComponent(id) + '/continue', {
++ method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ note: text })
++ }).then(function (r) { if (!r.ok) throw new Error('The task could not take the message yet.'); });
++ } : undefined
++ });
++ section.dataset.agentUi = '1';
++ var source = new EventSource(url + '/events');
++ ['RUN_STARTED', 'RUN_FINISHED', 'RUN_ERROR', 'STEP_STARTED', 'STEP_FINISHED', 'TEXT_MESSAGE_START',
++ 'TEXT_MESSAGE_CONTENT', 'TEXT_MESSAGE_END', 'TOOL_CALL_START', 'TOOL_CALL_ARGS', 'TOOL_CALL_END',
++ 'TOOL_CALL_RESULT', 'REASONING_START', 'REASONING_MESSAGE_START', 'REASONING_MESSAGE_CONTENT',
++ 'REASONING_MESSAGE_END', 'REASONING_END', 'REASONING_ENCRYPTED_VALUE', 'STATE_SNAPSHOT', 'STATE_DELTA',
++ 'ACTIVITY_SNAPSHOT', 'ACTIVITY_DELTA', 'CUSTOM'].forEach(function (type) {
++ source.addEventListener(type, function (e) {
++ try { view.push(JSON.parse(e.data)); } catch (err) { /* one malformed event cannot stop the run */ }
++ if (type === 'RUN_FINISHED' || type === 'RUN_ERROR') source.close();
++ });
++ });
++ window.addEventListener('pagehide', function () { source.close(); }, { once: true });
++ }).catch(function () { /* the saved session remains available */ });
++ }
++
function follow(section) {
var log = section.querySelector('[data-log]');
var pill = document.querySelector('[data-state]');
@@@ -46,6 +81,7 @@
log.parentNode.replaceChild(host, log);
view = window.OpenVibeAgentSession.mount(host, { title: section.getAttribute('data-title') || 'Task', status: 'queued', empty: 'Waiting for your machine to take the task…' });
log = null;
++ agentRun(section, host, view);
}
var source = new EventSource(section.getAttribute('data-events'));
source.addEventListener('output', function (e) {
@@@ -58,7 +94,7 @@
if (!raw.trim()) return;
var doc;
try { doc = JSON.parse(raw); } catch (err) { doc = null; }
-- if (view) { if (doc) view.push(doc); return; }
++ if (view) { if (doc && section.dataset.agentUi !== '1') view.push(doc); return; }
if (doc) show(log, doc); else line(log, 'tool', raw);
});
});
@@@ -67,7 +103,7 @@
var s = JSON.parse(e.data).state;
if (LABEL[s]) lastState = s;
if (pill && LABEL[s]) { pill.textContent = LABEL[s]; pill.className = 'run-pill run-pill-' + s; }
-- if (view) view.status(s, LABEL[s]);
++ if (view && section.dataset.agentUi !== '1') view.status(s, LABEL[s]);
} catch (err) { /* ignore */ }
});
var lastState = section.getAttribute('data-state') || 'queued';
@@@ -75,7 +111,7 @@
source.close();
// Switch from live streaming to replay without a full reload: the session lines are read back from
// /run/tasks/:id/session.json and pushed into the same agent-session view.
-- if (view && window.fetch) {
++ if (view && window.fetch && section.dataset.agentUi !== '1') {
var sessionUrl = section.getAttribute('data-events').replace('/events', '/session.json') + '?after=0';
fetch(sessionUrl, { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
@@@ -118,6 +154,7 @@
var view = window.OpenVibeAgentSession.mount(into, { title: section.getAttribute('data-title') || 'Task', status: 'running', follow: false });
view.pushAll(lines);
view.status(state, LABEL[state]);
++ agentRun(section, into, view);
}
document.querySelectorAll('[data-events]').forEach(follow);
ddiff --git a/server/http/c4.js b/server/http/c4.js
iindex b6ef185..709c5b4 100644
---- a/server/http/c4.js
++++ b/server/http/c4.js
@@@ -379,7 +379,7 @@ ${pr.problem ? `<p class="c4-hint">${esc(pr.problem.detail)}</p>` : `<dl class="
const transcript = `<section class="c4-card c4-transcript" data-c4-conductor-stream="${esc(`${api}/events?last_event_id=${lastSeq}`)}" data-task="${esc(row.id)}" data-started-at="${esc(row.started_at || row.created_at || '')}" data-c4-conductor="${live ? 'live' : 'idle'}"><div class="c4-card-head"><h2>${live ? 'This turn, live' : 'The last turn'}</h2><span class="c4-muted" data-live-label>${live ? 'Connecting…' : 'Nothing is running'}</span></div><pre class="c4-log" data-task-log aria-live="polite" tabindex="0">${esc(lastLines)}</pre></section>`;
const composer = row.outcome === 'stopped' ? '' : `<section class="c4-card c4-conductor-composer"><form method="post" action="/tasks/${encodeURIComponent(row.id)}/messages" enctype="multipart/form-data" data-c4-conductor-form data-conductor-api="/api/v1/conductor/${encodeURIComponent(row.id)}/messages"><label for="c4-message">${live ? 'Say something: it is the next turn' : 'What next?'}</label><textarea id="c4-message" name="text" rows="3" placeholder="Tell your conductor what to do next, or ask what it is doing"></textarea><div class="c4-conductor-actions"><label class="c4-file">Attach files<input type="file" name="files" multiple data-c4-images></label><button class="c4-button" type="submit">Send</button></div><p class="c4-hint" data-c4-message-status role="status">${live ? 'A message waits until the current turn ends; it is not lost.' : ''}</p></form></section>`;
const content = `${outcome}<section class="c4-card c4-conductor-head"><div class="c4-task-heading"><div class="c4-task-title-wrap"><h1>Conductor</h1></div><span class="c4-status c4-${esc(row.state)}">${esc(conductorState(row))}</span></div><div class="c4-chips" aria-label="Conductor details"><span class="c4-chip">${esc(row.folder || 'a folder')}</span><span class="c4-chip">${esc(row.agent === 'claude-code' ? modelLabel(row.model) : (row.model || 'its default model'))}</span><span class="c4-chip">safety: ${esc(row.safety || 'ask')}</span><span class="c4-chip">${esc(machineLabel || 'your machine')}</span></div><p class="c4-hint">One long session on your own machine, on the agent you chose and your own login. Each message is the next turn of the same conversation; it hands the work to your other agents through the Codes tools.</p></section>${approvals}${usageCard}<section class="c4-card c4-turns"><div class="c4-card-head"><h2>Turns</h2><span class="c4-muted">${(view.messages || []).length} so far</span></div>${turns ? `<ol class="c4-turn-list">${turns}</ol>` : '<div class="c4-emptyline">Its first turn is starting.</div>'}</section>${transcript}${composer}`;
-- page(req, res, 'Conductor', wrap(content));
++ page(req, res, 'Conductor', wrap(content), { sharedScripts: ['agent-session.js'] });
}
// ── The front door (a visitor): what Codes is, shown rather than told ──
ddiff --git a/server/http/run.js b/server/http/run.js
iindex d60ab59..c77309c 100644
---- a/server/http/run.js
++++ b/server/http/run.js
@@@ -338,7 +338,7 @@ ${i.summary ? html`<p class="run-proof-summary" data-proof-summary>${i.summary}<
// after the end from codes_session_lines; a task from before this change falls back to the result's old log.
const lines = live ? null : await sessionStore.linesForRow(row);
const title = String(row.prompt || '').split('\n')[0].slice(0, 90);
-- const session = lines && lines.length ? html`<section class="run-session" data-session data-title="${title}" data-state="${row.state}"><h2>The session</h2><div data-session-view></div><script type="application/json" data-session-log>${raw(JSON.stringify(lines).replace(/</g, '\\u003c'))}</script></section>` : '';
++ const session = lines && lines.length ? html`<section class="run-session" data-session data-run-id="${row.id}" data-owner="${mine ? '1' : '0'}" data-title="${title}" data-state="${row.state}"><h2>The session</h2><div data-session-view></div><script type="application/json" data-session-log>${raw(JSON.stringify(lines).replace(/</g, '\\u003c'))}</script></section>` : '';
const body = html`${shareHead ? raw(shareHead) : ''}<p class="crumbs"><a href="/run">Run locally</a></p>
<h1 class="run-repo">${repoName(row.repo)}${row.ref ? html` <small>${row.ref}</small>` : ''}</h1>
<p><span class="run-pill run-pill-${row.state}" data-state>${STATE_LABEL[row.state] || row.state}</span>${row.agent ? html` <span class="run-pill">${row.agent}</span>` : ''}</p>
@@@ -347,7 +347,7 @@ ${mine ? shareButton(row) : html`<p class="muted">This task was shared with you.
${await timelineHtml(row)}
${goalHtml(row)}
${await proofHtml(row)}
--${live ? html`<section class="run-live" data-events="/run/tasks/${row.id}/events" data-title="${title}" aria-live="polite"><h2>Live</h2><ol class="run-log" data-log></ol></section>
++${live ? html`<section class="run-live" data-events="/run/tasks/${row.id}/events" data-run-id="${row.id}" data-owner="${mine ? '1' : '0'}" data-title="${title}" aria-live="polite"><h2>Live</h2><ol class="run-log" data-log></ol></section>
${mine ? html`<form method="post" action="/run/tasks/${row.id}/cancel"><button class="run-btn" type="submit">Cancel</button></form>` : ''}` : html`${session}${resultHtml(row, { session: !!(lines && lines.length) })}`}`;
return page(req, res, { title: 'Task', body, sharedScripts: ['agent-session.js'] });
});