openvibers/openvibe.surf Commits

The web task page: what, where, what you can do; then the answer; then every step (#96)

OP OpenVibers committed 6 days ago · ce4a60d93e · 3 files, +88 −13

Changes

ddiff --git a/public/css/surf.css b/public/css/surf.css
iindex ff83fd5..0c74a9f 100644
---- a/public/css/surf.css
++++ b/public/css/surf.css
@@@ -305,3 +305,45 @@ html[data-ov-motion=reduced] .sf-desk-page[data-sf-desk-open="1"] .sf-desk-state
.sf-tiles::-webkit-scrollbar { display: none; }
.sf-tiles > li { flex: 0 0 72%; min-width: 0; scroll-snap-align: start; }
}
++
++/* The web task (11-07): one card first — what, where, what you can do — then the answer, then every step. */
++.sf-task { gap: 16px; }
++.sf-task > * { margin: 0; }
++.sf-task-head { padding-top: 6px; }
++.sf-task-q { margin: 0; font-size: clamp(1.5rem, 3.4vw, 2.1rem); line-height: 1.2; letter-spacing: -.02em; overflow-wrap: anywhere; text-wrap: balance; }
++.sf-task-card { display: grid; gap: 10px; padding: 18px; border-radius: 22px; border: 1px solid color-mix(in srgb, var(--room-strong, var(--accent, #3b82f6)) 35%, var(--border, #1f2d47));
++ background: radial-gradient(120% 140% at 0% 0%, color-mix(in srgb, var(--room-strong, var(--accent, #3b82f6)) 14%, transparent), transparent 60%), var(--bg-secondary, #101828); }
++.sf-task-card p { margin: 0; }
++.sf-task-now { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; font-size: 1.05rem; }
++.sf-task-now b { font-weight: 800; }
++.sf-task-latest { min-width: 0; color: var(--text-secondary, #96a7c2); font-size: .95rem; overflow-wrap: anywhere; }
++.sf-task-dot { align-self: center; width: 10px; height: 10px; border-radius: 50%; background: var(--text-muted, #7386a3); }
++.sf-task[data-sf-desk-open="1"] .sf-task-dot { background: #22c55e; animation: sf-desk-pulse 1.6s ease-out infinite; }
++.sf-task[data-state="done"] .sf-task-dot, .sf-task[data-state="succeeded"] .sf-task-dot { background: #22c55e; }
++.sf-task[data-state="failed"] .sf-task-dot, .sf-task[data-state="stopped"] .sf-task-dot { background: #ef4444; }
++.sf-task-where { display: flex; align-items: center; gap: 8px; color: var(--text-secondary, #96a7c2); font-size: .92rem; }
++.sf-task-where .ovk-i { width: 16px; height: 16px; flex: none; color: var(--room-strong, var(--accent, #3b82f6)); }
++.sf-task-needs { padding: 10px 12px; border-radius: 12px; border: 1px solid color-mix(in srgb, #f59e0b 45%, transparent); background: color-mix(in srgb, #f59e0b 14%, transparent); font-weight: 650; }
++.sf-task-acts { display: flex; flex-wrap: wrap; gap: 10px; }
++.sf-task-acts .ovk-btn { min-height: 44px; }
++.sf-task-more { display: flex; flex-wrap: wrap; gap: 8px; font-size: .9rem; }
++.sf-task-more span { color: var(--text-muted, #7386a3); }
++.sf-task [hidden] { display: none !important; }
++.sf-task-answer h2 { margin: 0 0 10px; font-size: .76rem; letter-spacing: .08em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
++.sf-task-answer .sf-desk-report-text { font-size: 1rem; }
++.sf-task-answer h3 { margin: 16px 0 8px; font-size: .76rem; letter-spacing: .08em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
++.sf-task-wait { margin: 0; color: var(--text-secondary, #96a7c2); }
++.sf-task-sources { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }
++.sf-task-sources a { display: inline-grid; gap: 1px; max-width: 100%; padding: 8px 12px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: color-mix(in srgb, var(--text-primary, #e6edf7) 4%, transparent); text-decoration: none; }
++.sf-task-sources a:hover { border-color: color-mix(in srgb, var(--room-strong, var(--accent, #3b82f6)) 50%, var(--border, #1f2d47)); }
++.sf-task-sources b { font-size: .88rem; color: var(--text-primary, #e6edf7); }
++.sf-task-sources span { font-size: .78rem; color: var(--text-muted, #7386a3); overflow-wrap: anywhere; }
++.sf-task-steps summary { display: flex; align-items: center; gap: 6px; min-height: 44px; cursor: pointer; font-weight: 750; list-style: none; }
++.sf-task-steps summary::-webkit-details-marker { display: none; }
++.sf-task-steps summary::before { content: '›'; display: inline-block; width: 14px; font-size: 1.2em; transition: transform .2s; }
++.sf-task-steps[open] summary::before { transform: rotate(90deg); }
++.sf-task-count { color: var(--text-muted, #7386a3); font-weight: 600; }
++.sf-task-steps ol { margin-top: 8px; }
++.sf-task-rule { margin: 0; text-align: center; }
++@media (prefers-reduced-motion: reduce) { .sf-task[data-sf-desk-open="1"] .sf-task-dot { animation: none; } .sf-task-steps summary::before { transition: none; } }
++html[data-ov-motion=reduced] .sf-task[data-sf-desk-open="1"] .sf-task-dot { animation: none; }
ddiff --git a/public/js/surf.js b/public/js/surf.js
iindex e47b1e9..6bd2fd6 100644
---- a/public/js/surf.js
++++ b/public/js/surf.js
@@@ -363,10 +363,15 @@
var u = safeUrl(value);
return u && new URL(u).origin === 'https://openvibe.bot' && /^\/machines\/dev_[0-9A-HJKMNP-TV-Z]{26}\/screen$/.test(new URL(u).pathname) ? u : null;
}
++ var WORDS = { queued: 'Getting ready', running: 'Working', verifying: 'Checking the answer', succeeded: 'Done', done: 'Done', failed: 'Did not finish', stopped: 'Stopped', cancelled: 'Stopped' };
++ var count = root.querySelector('[data-sf-desk-count]');
++ var latest = root.querySelector('[data-sf-desk-latest]');
function snapshot(t) {
-- root.querySelector('[data-sf-desk-state]').textContent = t.state || 'Working';
++ var st = t.state || 'running';
++ root.setAttribute('data-state', st);
++ root.querySelector('[data-sf-desk-state]').textContent = WORDS[st] || st;
var desk = botDesk(t.desk && t.desk.url);
-- root.querySelector('[data-sf-desk-note]').textContent = desk ? 'Your agent is working on its private desk. Open the live viewer to watch or take over.' : 'The agent has not opened a desk, or its desk has ended.';
++ root.querySelector('[data-sf-desk-note]').textContent = desk ? 'On a private desk: a real browser of its own' : ['queued', 'running', 'verifying'].includes(st) ? 'Searching and reading; no desk open' : 'Searched and read the web';
var links = root.querySelector('[data-sf-desk-links]');
links.hidden = !desk;
if (desk) {
@@@ -382,8 +387,11 @@
sources.forEach(function (value) {
var url = safeUrl(value);
if (!url) return;
-- var li = document.createElement('li'), a = document.createElement('a');
-- a.href = url; a.textContent = url; a.target = '_blank'; a.rel = 'nofollow noopener noreferrer';
++ var li = document.createElement('li'), a = document.createElement('a'), u = new URL(url);
++ a.href = url; a.target = '_blank'; a.rel = 'nofollow noopener noreferrer';
++ var host = document.createElement('b'); host.textContent = u.hostname.replace(/^www\./, ''); a.appendChild(host);
++ var path = (u.pathname + u.search).replace(/\/$/, '');
++ if (path) { var sp = document.createElement('span'); sp.textContent = path.length > 48 ? path.slice(0, 47) + '…' : path; a.appendChild(sp); }
li.appendChild(a); list.appendChild(li);
});
root.querySelector('[data-sf-desk-sources-empty]').hidden = !!list.children.length;
@@@ -394,6 +402,7 @@
if (timer) clearInterval(timer);
if (stream) stream.close();
root.setAttribute('data-sf-desk-open', '0'); root.querySelector('[data-sf-desk-steps-note]').textContent = 'The task has ended.';
++ if (latest) latest.textContent = '';
}
}
function refresh() {
@@@ -412,6 +421,8 @@
line.className = 'sf-desk-step';
line.textContent = [item.step === 'tool_call' ? 'Used ' + (item.tool || 'a tool') : item.step === 'tool_result' ? 'Evidence' : 'Step', item.text || item.input || ''].filter(Boolean).join(': ').slice(0, 1000);
steps.appendChild(line);
++ if (count) count.textContent = '(' + steps.children.length + ')';
++ if (latest) latest.textContent = line.textContent.slice(0, 140);
root.querySelector('[data-sf-desk-steps-note]').textContent = 'Updating live as the agent works.';
} catch (_) { /* ignore a malformed event */ }
}
ddiff --git a/server/views/index.js b/server/views/index.js
iindex fc230a1..cd5925f 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -299,21 +299,43 @@ const deskUrl = (value) => {
return url && new URL(url).origin === 'https://openvibe.bot' && /^\/machines\/dev_[0-9A-HJKMNP-TV-Z]{26}\/screen$/.test(new URL(url).pathname) ? url : null;
};
++// The words a person reads for each state (the same in surf.js).
++const TASK_STATE = { queued: 'Getting ready', running: 'Working', verifying: 'Checking the answer', succeeded: 'Done', done: 'Done', failed: 'Did not finish', stopped: 'Stopped', cancelled: 'Stopped' };
++const sourceChip = (url) => {
++ const u = new URL(url);
++ const path = `${u.pathname}${u.search}`.replace(/\/$/, '');
++ return `<li><a href="${esc(url)}" target="_blank" rel="nofollow noopener noreferrer"><b>${esc(u.hostname.replace(/^www\./, ''))}</b>${path ? `<span>${esc(path.length > 48 ? `${path.slice(0, 47)}…` : path)}</span>` : ''}</a></li>`;
++};
++
++/**
++ * One web task (plan Z, 11-07: "Surf and computer and actor all kinda go hand in hand … clear interfaces"). The same
++ * three things every agent surface shows, in the same order: what it is doing (one line), where it works, and what you
++ * can do (take over, watch). Then the answer with its sources; every step sits behind a count. surf.js keeps it live.
++ */
function webTask({ task, actorUrl } = {}) {
const id = String(task.id);
const actor = `${String(actorUrl).replace(/\/+$/, '')}/tasks/${encodeURIComponent(id)}`;
const desk = deskUrl(task.desk && task.desk.url);
const sources = task.result && Array.isArray(task.result.sources) ? task.result.sources.map(safeWebUrl).filter(Boolean) : [];
const report = task.result && typeof task.result.answer === 'string' ? task.result.answer : '';
-- const body = `<div class="sf-wrap sf-page-pad sf-narrow sf-desk-page" data-sf-desk-task="${esc(id)}" data-sf-desk-open="${['queued', 'running', 'verifying'].includes(task.state) ? '1' : '0'}">
--<header class="sf-page-h sf-desk-head"><p class="sf-kicker">Web · Surf with Vibe</p><h1>Surf report</h1><p class="sf-desk-query">${esc(task.task || 'Your web task')}</p><p class="sf-desk-state" data-sf-desk-state role="status">${esc(task.state || 'queued')}</p></header>
--<section class="sf-card sf-desk-view" aria-labelledby="sf-desk-view-h"><h2 id="sf-desk-view-h">Agent desk</h2><p data-sf-desk-note>${desk ? 'Your agent is working on its private desk. Open the live viewer to watch or take over.' : 'The agent has not opened a desk, or its desk has ended.'}</p>
--<p data-sf-desk-links${desk ? '' : ' hidden'}><a class="sf-desk-watch" data-sf-desk-watch href="${esc(desk || '#')}" target="_blank" rel="noopener noreferrer">Watch live</a> · <a class="sf-desk-takeover" data-sf-desk-takeover href="${esc(desk || '#')}" target="_blank" rel="noopener noreferrer">Take over</a></p><p data-sf-desk-needs${task.needs_you ? '' : ' hidden'}>${esc(task.needs_you && task.needs_you.what || '')}</p></section>
--<section class="sf-card sf-desk-steps" aria-labelledby="sf-desk-steps-h"><h2 id="sf-desk-steps-h">Live steps</h2><ol data-sf-desk-steps></ol><p class="sf-fine" data-sf-desk-steps-note>Connecting to the agent’s live steps…</p></section>
--<section class="sf-card sf-desk-evidence" aria-labelledby="sf-desk-evidence-h"><h2 id="sf-desk-evidence-h">Sources cited</h2><ul data-sf-desk-sources>${sources.map((url) => `<li><a href="${esc(url)}" target="_blank" rel="nofollow noopener noreferrer">${esc(url)}</a></li>`).join('')}</ul><p data-sf-desk-sources-empty${sources.length ? ' hidden' : ''}>Sources will appear when the report is ready.</p></section>
--<section class="sf-card sf-desk-report" aria-labelledby="sf-desk-report-h"><h2 id="sf-desk-report-h">Final report</h2><div class="sf-desk-report-text" data-sf-desk-report>${esc(report)}</div><p data-sf-desk-report-empty${report ? ' hidden' : ''}>The agent is still working.</p></section>
--<p class="sf-fine sf-desk-actor"><a href="${esc(actor)}" target="_blank" rel="noopener noreferrer">Open full task in Actor</a> · <a href="/web">Start another surf</a></p></div>`;
-- return layout({ title: 'Surf report', path: `/web/t/${encodeURIComponent(id)}`, robots: 'noindex, nofollow', body, active: '/web' });
++ const state = String(task.state || 'queued');
++ const open = ['queued', 'running', 'verifying'].includes(state);
++ const body = `<div class="sf-wrap sf-page-pad sf-narrow sf-desk-page sf-task" data-sf-desk-task="${esc(id)}" data-sf-desk-open="${open ? '1' : '0'}" data-state="${esc(state)}">
++<header class="sf-task-head"><p class="sf-kicker">Surf with Vibe</p><h1 class="sf-task-q">${esc(task.task || 'Your web task')}</h1></header>
++<section class="sf-task-card" aria-label="What your agent is doing">
++<p class="sf-task-now"><span class="sf-task-dot" aria-hidden="true"></span><b data-sf-desk-state role="status">${esc(TASK_STATE[state] || state)}</b><span class="sf-task-latest" data-sf-desk-latest>${open ? 'Your agent is on it.' : ''}</span></p>
++<p class="sf-task-where">${kit.icon('globe')}<span data-sf-desk-note>${desk ? 'On a private desk: a real browser of its own' : open ? 'Searching and reading; no desk open' : 'Searched and read the web'}</span></p>
++<p class="sf-task-needs" data-sf-desk-needs${task.needs_you ? '' : ' hidden'}>${esc(task.needs_you && task.needs_you.what || '')}</p>
++<p class="sf-task-acts" data-sf-desk-links${desk ? '' : ' hidden'}><a class="ovk-btn ovk-btn--primary sf-desk-takeover" data-sf-desk-takeover href="${esc(desk || '#')}" target="_blank" rel="noopener noreferrer">Take over</a><a class="ovk-btn sf-desk-watch" data-sf-desk-watch href="${esc(desk || '#')}" target="_blank" rel="noopener noreferrer">Watch the desk</a></p>
++<p class="sf-task-more"><a href="${esc(actor)}" target="_blank" rel="noopener noreferrer">Open in Actor</a><span aria-hidden="true">·</span><a href="/web">Ask another</a></p>
++</section>
++<section class="sf-card sf-desk-report sf-task-answer" aria-labelledby="sf-desk-report-h"><h2 id="sf-desk-report-h">The answer</h2>
++<div class="sf-desk-report-text" data-sf-desk-report>${esc(report)}</div><p class="sf-task-wait" data-sf-desk-report-empty${report ? ' hidden' : ''}>It appears here when your agent finishes, with every source it used.</p>
++<div class="sf-task-cites"><h3>Sources</h3><ul class="sf-task-sources" data-sf-desk-sources>${sources.map(sourceChip).join('')}</ul><p class="sf-task-wait" data-sf-desk-sources-empty${sources.length ? ' hidden' : ''}>None yet.</p></div>
++</section>
++<details class="sf-card sf-desk-steps sf-task-steps"${open ? ' open' : ''}><summary>Every step <span class="sf-task-count" data-sf-desk-count></span></summary><ol data-sf-desk-steps></ol><p class="sf-fine" data-sf-desk-steps-note>Connecting to the agent’s live steps…</p></details>
++<p class="sf-fine sf-task-rule">It never signs in or pays for you: at a login, a captcha or a checkout it stops and hands over.</p></div>`;
++ return layout({ title: 'Surf task', path: `/web/t/${encodeURIComponent(id)}`, robots: 'noindex, nofollow', body, active: '/web' });
}
function webTaskError({ notFound = false } = {}) {