openvibers/openvibe.surf Commits
Surf with Vibe on its own page: live steps, desk links, sources and the report at /web/t/:id (#2)
Changes
@@@ -216,3 +216,36 @@ html.ov-motion .sf-party-playing .sf-party-dot { animation: sf-pulse 1.6s ease-o.sf-party-chat-url { flex: 1 1 240px; min-height: 38px; padding: 0 12px; border-radius: 10px; border: 1px solid var(--border, #1f2d47); background: var(--bg-input, var(--bg-primary, #0a0f1c)); color: inherit; font: inherit; }.sf-party-id { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .8rem; color: var(--text-muted, #7386a3); }@media (prefers-reduced-motion: reduce) { .sf-party-dot { animation: none !important; } }++++/* Surf with Vibe: the task page (/web/t/:id): state, desk links, live steps as a timeline, sources, the report */++.sf-desk-page { display: grid; gap: 14px; }++.sf-desk-head h1 { margin-bottom: 6px; }++.sf-desk-query { margin: 0; font-size: 1.05rem; line-height: 1.5; color: var(--text-primary, #e6edf7); overflow-wrap: anywhere; }++.sf-desk-state { display: inline-flex; align-items: center; gap: 8px; margin: 12px 0 0; padding: 5px 12px 5px 10px; border: 1px solid var(--border, #1f2d47); border-radius: 999px; background: var(--bg-secondary, #101828); font-size: .8rem; font-weight: 800; letter-spacing: .03em; text-transform: capitalize; }++.sf-desk-state::before { content: ''; width: 8px; height: 8px; border-radius: 50%; background: var(--text-muted, #7386a3); }++.sf-desk-page[data-sf-desk-open="1"] .sf-desk-state::before { background: #22c55e; animation: sf-desk-pulse 1.6s ease-out infinite; }++@keyframes sf-desk-pulse { from { box-shadow: 0 0 0 0 rgba(34, 197, 94, .55); } to { box-shadow: 0 0 0 8px rgba(34, 197, 94, 0); } }++.sf-desk-page section h2 { margin: 0 0 10px; font-size: .76rem; letter-spacing: .08em; text-transform: uppercase; color: var(--text-muted, #7386a3); }++.sf-desk-view p { margin: 0; line-height: 1.5; }++.sf-desk-view [data-sf-desk-links] { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; font-size: 0; }++.sf-desk-view [data-sf-desk-links][hidden], .sf-desk-view [data-sf-desk-needs][hidden] { display: none; }++.sf-desk-watch, .sf-desk-takeover { display: inline-flex; align-items: center; min-height: 40px; padding: 0 16px; border-radius: 999px; font-size: .9rem; font-weight: 800; text-decoration: none; }++.sf-desk-watch { background: var(--accent-strong, var(--accent, #3b82f6)); color: var(--on-accent-strong, var(--on-accent, #fff)); }++.sf-desk-takeover { border: 1px solid var(--border, #1f2d47); color: var(--text-primary, #e6edf7); }++.sf-desk-watch:focus-visible, .sf-desk-takeover:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }++.sf-desk-view [data-sf-desk-needs] { margin-top: 12px; padding: 10px 12px; border: 1px solid color-mix(in srgb, #f59e0b 45%, transparent); border-radius: 10px; background: color-mix(in srgb, #f59e0b 14%, transparent); font-weight: 600; }++.sf-desk-steps ol { list-style: none; margin: 0; padding: 0; max-height: 420px; overflow-y: auto; overscroll-behavior: contain; }++.sf-desk-step { position: relative; padding: 0 0 12px 22px; font-size: .92rem; line-height: 1.45; color: var(--text-secondary, #96a7c2); overflow-wrap: anywhere; }++.sf-desk-step::before { content: ''; position: absolute; left: 0; top: .42em; width: 9px; height: 9px; border-radius: 50%; background: var(--accent, #3b82f6); }++.sf-desk-step::after { content: ''; position: absolute; left: 4px; top: calc(.42em + 13px); bottom: 2px; width: 1px; background: var(--border, #1f2d47); }++.sf-desk-step:last-child { color: var(--text-primary, #e6edf7); padding-bottom: 4px; }++.sf-desk-step:last-child::after { display: none; }++.sf-desk-steps .sf-fine { margin: 8px 0 0; }++.sf-desk-evidence ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }++.sf-desk-evidence li a { display: block; padding: 8px 10px; border-radius: 10px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 5%, transparent); font-size: .86rem; text-decoration: none; overflow-wrap: anywhere; }++.sf-desk-evidence li a:hover { background: color-mix(in srgb, var(--text-primary, #e6edf7) 9%, transparent); }++.sf-desk-report-text { white-space: pre-wrap; line-height: 1.6; overflow-wrap: anywhere; }++.sf-desk-report p { margin: 0; color: var(--text-secondary, #96a7c2); }++.sf-desk-actor { margin: 4px 0 0; text-align: center; }++@media (prefers-reduced-motion: reduce) { .sf-desk-page[data-sf-desk-open="1"] .sf-desk-state::before { animation: none; } }++html[data-ov-motion=reduced] .sf-desk-page[data-sf-desk-open="1"] .sf-desk-state::before { animation: none; }@@@ -354,7 +354,83 @@});}-- function run() { runMotif(); tv(); webForm(); }++ function webTask() {++ var root = document.querySelector('[data-sf-desk-task]');++ if (!root || root.dataset.ready) return;++ root.dataset.ready = '1';++ var id = root.getAttribute('data-sf-desk-task');++ var base = '/api/v1/web/tasks/' + encodeURIComponent(id);++ var steps = root.querySelector('[data-sf-desk-steps]');++ var seen = {};++ var timer = null;++ var stream = null;++ function safeUrl(value) {++ try { var u = new URL(value); return /^https?:$/.test(u.protocol) ? u.href : null; } catch (_) { return null; }++ }++ function botDesk(value) {++ 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;++ }++ function snapshot(t) {++ root.querySelector('[data-sf-desk-state]').textContent = t.state || 'Working';++ 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.';++ var links = root.querySelector('[data-sf-desk-links]');++ links.hidden = !desk;++ if (desk) {++ root.querySelector('[data-sf-desk-watch]').href = desk;++ root.querySelector('[data-sf-desk-takeover]').href = desk;++ }++ var needs = root.querySelector('[data-sf-desk-needs]');++ needs.hidden = !t.needs_you;++ needs.textContent = t.needs_you && t.needs_you.what || '';++ var list = root.querySelector('[data-sf-desk-sources]');++ list.replaceChildren();++ var sources = t.result && Array.isArray(t.result.sources) ? t.result.sources : [];++ 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';++ li.appendChild(a); list.appendChild(li);++ });++ root.querySelector('[data-sf-desk-sources-empty]').hidden = !!list.children.length;++ var answer = t.result && typeof t.result.answer === 'string' ? t.result.answer : '';++ root.querySelector('[data-sf-desk-report]').textContent = answer;++ root.querySelector('[data-sf-desk-report-empty]').hidden = !!answer;++ if (!['queued', 'running', 'verifying'].includes(t.state)) {++ 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.';++ }++ }++ function refresh() {++ fetch(base, { credentials: 'same-origin', headers: { accept: 'application/json' } })++ .then(function (r) { if (!r.ok) throw new Error('Task unavailable'); return r.json(); })++ .then(snapshot)++ .catch(function () { root.querySelector('[data-sf-desk-steps-note]').textContent = 'Connection lost. Trying again…'; });++ }++ function step(e) {++ try {++ var item = JSON.parse(e.data);++ if (item.kind === 'end') { if (stream) stream.close(); refresh(); return; }++ if (item.kind !== 'output' || seen[item.seq]) return;++ seen[item.seq] = true;++ var line = document.createElement('li');++ 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);++ root.querySelector('[data-sf-desk-steps-note]').textContent = 'Updating live as the agent works.';++ } catch (_) { /* ignore a malformed event */ }++ }++ if (window.EventSource) {++ stream = new EventSource(base + '/events');++ ['output', 'state', 'end'].forEach(function (name) { stream.addEventListener(name, step); });++ } else root.querySelector('[data-sf-desk-steps-note]').textContent = 'Open the full task in Actor to see its steps.';++ if (root.getAttribute('data-sf-desk-open') === '1') timer = setInterval(refresh, 3000);++ }++++ function run() { runMotif(); tv(); webForm(); webTask(); }if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run); else run();document.addEventListener('ov:boost:load', run);})();@@@ -3,7 +3,7 @@/*** OpenVibe.Surf — Express app factory; server/index.js listens, tests build their own instance with mock upstreams.*-- * /, /ride, /ride/:map, /watch, /web, /developers the pages (http/pages.js)++ * /, /ride, /ride/:map, /watch, /web, /web/t/:taskId, /developers the pages (http/pages.js)* /watch/party, /watch/p/:id, …/events watch together (http/party.js)* /web/go the no-JavaScript task form* /api/v1/watch/feed, /api/v1/ride/maps… Watch and Ride (http/api.js)@@@ -107,6 +107,12 @@ function createApi(ctx) {}});++ r.get('/v1/web/tasks/:id', requireToken, async (req, res) => {++ try { return res.json(await web.task({ token: req.webToken, id: req.params.id })); }++ catch (err) { return problem(req, res, err.status || 502, 'web.task.unavailable', err.message); }++ });++ r.get('/v1/web/tasks/:id/events', requireToken, (req, res) => web.events({ token: req.webToken, id: req.params.id, after: req.get('last-event-id'), res }));++return r;}@@@ -7,7 +7,8 @@* /ride/:map one map: name, tier, stages, records, the embed* /watch the merged Live feed as a list with thumbnails and play links* /web the Surf with Vibe form and what it does-- * /web/go?q= the no-JavaScript form: create the task, 303 to its Actor page++ * /web/go the no-JavaScript form: create the task, 303 to its Surf page++ * /web/t/:taskId a person's live desk, steps, sources and report* /developers the API, for people and agents** Every page is server-rendered and complete without JavaScript. A page for a signed-in person is private and@@@ -84,6 +85,18 @@ function createPageRoutes(ctx) {r.get('/web', (req, res) => page(res, 200, views.web({ me: meOf(req.viewer), query: typeof req.query.q === 'string' ? req.query.q.slice(0, 500) : '' }), req.viewer));++ r.get('/web/t/:taskId', async (req, res) => {++ res.set('Cache-Control', 'private, no-store');++ const token = req.viewer && req.viewer.kind === 'user' ? req.viewer.token : null;++ if (!token) return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);++ try {++ const task = await web.task({ token, id: req.params.taskId });++ return page(res, 200, views.webTask({ task, actorUrl: config.actorPublicUrl }), req.viewer);++ } catch (err) {++ return page(res, err.status === 404 ? 404 : 502, views.webTaskError({ notFound: err.status === 404 }), req.viewer);++ }++ });++// The no-JavaScript form: a POST from this site that creates one task and goes to it (303). The query is in the// body, never in a URL (so never in a log); another site's form is refused by its Origin.r.get('/web/go', (req, res) => res.redirect(303, '/web'));@@@ -248,12 +248,46 @@ function web({ me = {}, query = '' } = {}) {return layout({ title: 'Surf with Vibe', description: 'Ask an agent to surf the web for you: it searches, reads pages on a private desk when needed, keeps its sources and reports back with citations.', path: '/web', body, active: '/web' });}++const safeWebUrl = (value) => {++ try {++ const u = new URL(String(value));++ return /^https?:$/.test(u.protocol) ? u.href : null;++ } catch { return null; }++};++const deskUrl = (value) => {++ const url = safeWebUrl(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;++};++++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' });++}++++function webTaskError({ notFound = false } = {}) {++ return layout({ title: 'Surf task', path: '/web', robots: 'noindex, nofollow', body: `<div class="sf-wrap sf-page-pad sf-narrow"><h1>${notFound ? 'Task not found' : 'The task is unavailable'}</h1><p>${notFound ? 'This task is unavailable to your account.' : 'Actor did not answer. Try this page again in a moment.'}</p><p><a href="/web">Back to Surf with Vibe</a></p></div>`, active: '/web' });++}++function developers() {const rows = [['GET /api/v1/watch/feed?topic=all|live|clips|moments&cursor=', 'The merged OpenVibe.Live feed: who is live (by viewers), then clips and AI moments by freshness, never two from one channel in a row. Each item says why it is on. Cached 30 s; a source that did not answer is named in <code>sources</code>.'],['GET /api/v1/ride/maps', 'The surf maps from OpenVibe.Games: id, name, tier, stages, author, runs, the record. Cached 60 s.'],['GET /api/v1/ride/maps/:id', 'One map, its fastest runs and its <code>embed_url</code>.'],-- ['POST /api/v1/web/tasks', 'Signed in, from this site: <code>{ query }</code> (3–500 characters) starts an OpenVibe.Actor task as you and answers <code>{ task_id, page }</code>.'],++ ['POST /api/v1/web/tasks', 'Signed in, from this site: <code>{ query }</code> (3–500 characters) starts an OpenVibe.Actor task as you and answers <code>{ task_id, page }</code>; the page is in Surf.'],++ ['GET /api/v1/web/tasks/:id', 'Your Actor task state, desk link, sources and final report, fetched with your token.'],++ ['GET /api/v1/web/tasks/:id/events', 'Your live Actor steps as server-sent events.'],['POST /watch/party · GET /watch/p/:id/events', 'Watch together: signed in and same-site, opens an in-memory party at <code>/watch/p/<id></code> (303, or 201 JSON). The SSE stream carries the host\'s <code>{ channel_key, position_s, playing, at }</code> commands to every member; a restart ends the party.'],];const body = `<div class="sf-wrap sf-page-pad">@@@ -273,7 +307,7 @@ function notFound({ party = false } = {}) {}module.exports = {-- home, ride, map, watch, party, web, developers, notFound, runTime,++ home, ride, map, watch, party, web, webTask, webTaskError, developers, notFound, runTime,layout,setRelease: layoutLib.setRelease,configure: layoutLib.configure,@@@ -21,6 +21,7 @@ const crypto = require('crypto');const MIN_QUERY = 3;const MAX_QUERY = 500;const IDEMPOTENCY = /^[A-Za-z0-9._:-]{8,128}$/;++const TASK_ID = /^tsk_[A-Za-z0-9]{8,64}$/;const PREFIX = 'Surf the web for me with your tools. Use search_web to find pages, read_page to read them, and a desk when a page needs one. Keep every source you use and finish with a short report that cites each source.';function validQuery(value) {@@@ -39,8 +40,53 @@ function newIdempotencyKey() {}function createWeb({ config, fetchImpl = globalThis.fetch, log = console }) {++ const taskPath = (id) => `${config.actorInternalUrl}/api/v1/tasks/${encodeURIComponent(id)}`;++ const headers = (token) => ({ Accept: 'application/json', Authorization: `Bearer ${token}` });++++ async function task({ token, id }) {++ if (!token) throw Object.assign(new Error('Sign in to see your task.'), { status: 401 });++ if (!TASK_ID.test(String(id || ''))) throw Object.assign(new Error('Task not found.'), { status: 404 });++ let res;++ try {++ res = await fetchImpl(taskPath(id), { headers: headers(token), signal: AbortSignal.timeout(config.actorTimeoutMs || 15_000) });++ } catch {++ throw Object.assign(new Error('OpenVibe.Actor did not answer.'), { status: 502 });++ }++ if (!res.ok) throw Object.assign(new Error(res.status === 404 || res.status === 403 ? 'Task not found.' : 'OpenVibe.Actor did not answer.'), { status: res.status === 401 ? 401 : res.status === 403 || res.status === 404 ? 404 : 502 });++ let data;++ try { data = await res.json(); } catch { /* invalid Actor response */ }++ if (!data || data.id !== id) throw Object.assign(new Error('OpenVibe.Actor sent an invalid task.'), { status: 502 });++ return data;++ }++++ // Actor offers SSE. Proxy it server-side so the person's token stays in an httpOnly cookie and never enters JS.++ async function events({ token, id, after, res }) {++ if (!token) return res.status(401).end();++ if (!TASK_ID.test(String(id || ''))) return res.status(404).end();++ const controller = new AbortController();++ res.on('close', () => controller.abort());++ let upstream;++ try {++ upstream = await fetchImpl(`${taskPath(id)}/events`, {++ headers: { ...headers(token), Accept: 'text/event-stream', ...(after && /^\d{1,12}$/.test(after) ? { 'Last-Event-ID': after } : {}) }, signal: controller.signal,++ });++ if (!upstream.ok || !upstream.body) return res.status(upstream.status === 401 ? 401 : upstream.status === 403 || upstream.status === 404 ? 404 : 502).end();++ res.status(200).set({ 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'private, no-store', 'X-Accel-Buffering': 'no' });++ for await (const chunk of upstream.body) {++ if (res.destroyed) break;++ res.write(chunk);++ }++ res.end();++ } catch (err) {++ if (!controller.signal.aborted) {++ log.warn('[OpenVibe.Surf] Actor event stream ended:', (err && err.message) || err);++ if (!res.headersSent) res.status(502);++ res.end();++ }++ }++ }/**-- * Create one task for the person whose token this is. Resolves { task_id, page } or throws++ * Create one task for the person whose token this is. Resolves { task_id, page } (Surf's task page) or throws* { status, code, detail, actor } — a 4xx from Actor keeps its status and detail.*/async function createTask({ token, query, idempotencyKey = null } = {}) {@@@ -84,10 +130,10 @@ function createWeb({ config, fetchImpl = globalThis.fetch, log = console }) {log.warn('[OpenVibe.Surf] Actor answered without a task id');throw Object.assign(new Error('OpenVibe.Actor answered without a task id.'), { status: 502, code: 'actor.invalid_response' });}-- return { task_id: id, page: `${String(config.actorPublicUrl).replace(/\/+$/, '')}/tasks/${encodeURIComponent(id)}` };++ return { task_id: id, page: `/web/t/${encodeURIComponent(id)}` };}-- return { createTask, validQuery };++ return { createTask, task, events, validQuery };}--module.exports = { createWeb, taskText, validQuery, newIdempotencyKey, PREFIX, MIN_QUERY, MAX_QUERY };++module.exports = { createWeb, taskText, validQuery, newIdempotencyKey, PREFIX, MIN_QUERY, MAX_QUERY, TASK_ID };@@@ -70,12 +70,12 @@ async function startUpstreams() {dvr: { 9002: 'https://openvibe.media/v/9002/dvr.m3u8' },fail: { streams: null, clips: null, moments: null },delay: { streams: 0, clips: 0, moments: 0 },-- actor: { status: 201, id: 'tsk_01J8SURFEXAMPLE00000000', problem: null },++ actor: { status: 201, id: 'tsk_01J8SURFEXAMPLE00000000', problem: null, task: null, events: [] },requests: [],counts: { streams: 0, clips: 0, moments: 0, maps: 0, map: 0, actor: 0 },};-- const srv = await listen(async (req, raw, json) => {++ const srv = await listen(async (req, raw, json, res) => {const url = new URL(req.url, 'http://x');const p = url.pathname;const entry = { method: req.method, path: p, query: Object.fromEntries(url.searchParams), headers: { ...req.headers }, body: raw };@@@ -137,6 +137,14 @@ async function startUpstreams() {}return json(state.actor.status, { id: state.actor.id, state: 'queued', task: 'the task' });}++ if (p === `/api/v1/tasks/${state.actor.id}` && req.method === 'GET') {++ return json(200, state.actor.task || { id: state.actor.id, state: 'running', task: 'Surf the web', result: null });++ }++ if (p === `/api/v1/tasks/${state.actor.id}/events` && req.method === 'GET') {++ res.writeHead(200, { 'Content-Type': 'text/event-stream' });++ for (const event of state.actor.events) res.write(`id: ${event.seq}\nevent: ${event.kind}\ndata: ${JSON.stringify(event)}\n\n`);++ return res.end();++ }return json(404, { error: 'not found' });});@@@ -1,7 +1,7 @@'use strict';/*** Web: signed out 401, cross-site 403, the Actor call carries the person's Bearer and an Idempotency-Key and the-- * prefixed browsing brief, Actor's refusal passes through in words, and the plain form (POST /web/go, this site only) 303s to the Actor task page.++ * prefixed browsing brief, Actor's refusal passes through in words, and the plain form (POST /web/go, this site only) 303s to the Surf task page.*/const assert = require('assert');const { boot, check, done } = require('./helpers/boot');@@@ -45,7 +45,7 @@ const ORIGIN = 'https://openvibe.surf';assert.strictEqual(r.status, 201, r.text);const b = r.json();assert.strictEqual(b.task_id, 'tsk_01J8SURFEXAMPLE00000000');-- assert.strictEqual(b.page, 'https://openvibe.actor/tasks/tsk_01J8SURFEXAMPLE00000000');++ assert.strictEqual(b.page, '/web/t/tsk_01J8SURFEXAMPLE00000000');assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');assert.strictEqual(t.upstreams.state.counts.actor, 1);@@@ -100,11 +100,11 @@ const ORIGIN = 'https://openvibe.surf';});const own = { origin: 'https://openvibe.surf', 'sec-fetch-site': 'same-origin' };-- await check('POST /web/go (the plain form) creates the task and 303s to the Actor task page; the query is never in a URL', async () => {++ await check('POST /web/go (the plain form) creates the task and 303s to the Surf task page; the query is never in a URL', async () => {t.upstreams.resetCounts();const r = await t.get('/web/go', { cookie: signedIn, form: { q: 'find the best surf spots in Portugal' }, headers: own });assert.strictEqual(r.status, 303, r.text);-- assert.strictEqual(r.headers.get('location'), 'https://openvibe.actor/tasks/tsk_01J8SURFEXAMPLE00000000');++ assert.strictEqual(r.headers.get('location'), '/web/t/tsk_01J8SURFEXAMPLE00000000');assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');const sent = t.upstreams.lastFor('/api/v1/tasks');assert.strictEqual(sent.headers.authorization, `Bearer ${token}`);@@@ -134,6 +134,55 @@ const ORIGIN = 'https://openvibe.surf';assert.match(r.text, /free tier runs 5 tasks a day/);});++ await check('the Surf task page shows the private desk link, cited sources and report', async () => {++ const desk = `https://openvibe.bot/machines/dev_${'A'.repeat(26)}/screen?desk=desk-surf`;++ t.upstreams.state.actor.task = {++ id: t.upstreams.state.actor.id, state: 'succeeded', task: '<find a flight>',++ desk: { url: desk, kind: 'browser' }, needs_you: { what: 'Sign in on the desk.' },++ result: { answer: 'Found a flight < $500.', sources: ['https://example.com/flight', 'javascript:alert(1)'] },++ };++ const r = await t.get(`/web/t/${t.upstreams.state.actor.id}`, { cookie: signedIn });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');++ assert.match(r.text, /<find a flight>/);++ assert.match(r.text, /Found a flight < \$500/);++ assert.match(r.text, /https:\/\/example.com\/flight/);++ assert.doesNotMatch(r.text, /javascript:alert/);++ assert.match(r.text, /Take over/);++ assert.match(r.text, /openvibe\.bot\/machines/);++ assert.match(r.text, /openvibe\.actor\/tasks/);++ assert.ok(!r.text.includes(token));++ const sent = t.upstreams.lastFor(`/api/v1/tasks/${t.upstreams.state.actor.id}`);++ assert.strictEqual(sent.headers.authorization, `Bearer ${token}`);++ });++++ await check('task JSON and live events use the person’s token without exposing it to the browser', async () => {++ const id = t.upstreams.state.actor.id;++ t.upstreams.state.actor.events = [{ seq: 1, kind: 'output', step: 'tool_result', text: 'Read page evidence' }, { seq: 2, kind: 'end', state: 'succeeded' }];++ const info = await t.get(`/api/v1/web/tasks/${id}`, { cookie: signedIn });++ assert.strictEqual(info.status, 200, info.text);++ assert.strictEqual(info.json().id, id);++ assert.strictEqual(info.headers.get('cache-control'), 'private, no-store');++ const live = await t.get(`/api/v1/web/tasks/${id}/events`, { cookie: signedIn });++ assert.strictEqual(live.status, 200, live.text);++ assert.match(live.headers.get('content-type'), /text\/event-stream/);++ assert.match(live.text, /Read page evidence/);++ assert.ok(!live.text.includes(token));++ assert.strictEqual(t.upstreams.lastFor(`/api/v1/tasks/${id}/events`).headers.authorization, `Bearer ${token}`);++ assert.strictEqual((await t.get(`/api/v1/web/tasks/${id}`)).status, 401);++ assert.strictEqual((await t.get(`/api/v1/web/tasks/${id}/events`)).status, 401);++ });++++ await check('an unrelated task and an unsafe desk URL are never presented as a live desk', async () => {++ const id = t.upstreams.state.actor.id;++ t.upstreams.state.actor.task = { id, state: 'running', task: 'Surf', desk: { url: 'https://evil.example/machines/dev_x/screen' } };++ const r = await t.get(`/web/t/${id}`, { cookie: signedIn });++ assert.strictEqual(r.status, 200);++ assert.doesNotMatch(r.text, /evil\.example/);++ const other = await t.get('/web/t/tsk_other00000', { cookie: signedIn });++ assert.strictEqual(other.status, 404);++ });++await t.close();done();})().catch((err) => { console.error(err); process.exit(1); });
@@ -216,3 +216,36 @@ html.ov-motion .sf-party-playing .sf-party-dot { animation: sf-pulse 1.6s ease-o
@@ -216,3 +216,36 @@ html.ov-motion .sf-party-playing .sf-party-dot { animation: sf-pulse 1.6s ease-o
.sf-party-chat-url { flex: 1 1 240px; min-height: 38px; padding: 0 12px; border-radius: 10px; border: 1px solid var(--border, #1f2d47); background: var(--bg-input, var(--bg-primary, #0a0f1c)); color: inherit; font: inherit; }
.sf-party-chat-url { flex: 1 1 240px; min-height: 38px; padding: 0 12px; border-radius: 10px; border: 1px solid var(--border, #1f2d47); background: var(--bg-input, var(--bg-primary, #0a0f1c)); color: inherit; font: inherit; }
.sf-party-id { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .8rem; color: var(--text-muted, #7386a3); }
.sf-party-id { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .8rem; color: var(--text-muted, #7386a3); }
@media (prefers-reduced-motion: reduce) { .sf-party-dot { animation: none !important; } }
@media (prefers-reduced-motion: reduce) { .sf-party-dot { animation: none !important; } }
+
+/* Surf with Vibe: the task page (/web/t/:id): state, desk links, live steps as a timeline, sources, the report */
+.sf-desk-page { display: grid; gap: 14px; }
+.sf-desk-head h1 { margin-bottom: 6px; }
+.sf-desk-query { margin: 0; font-size: 1.05rem; line-height: 1.5; color: var(--text-primary, #e6edf7); overflow-wrap: anywhere; }
+.sf-desk-state { display: inline-flex; align-items: center; gap: 8px; margin: 12px 0 0; padding: 5px 12px 5px 10px; border: 1px solid var(--border, #1f2d47); border-radius: 999px; background: var(--bg-secondary, #101828); font-size: .8rem; font-weight: 800; letter-spacing: .03em; text-transform: capitalize; }
+.sf-desk-state::before { content: ''; width: 8px; height: 8px; border-radius: 50%; background: var(--text-muted, #7386a3); }
+.sf-desk-page[data-sf-desk-open="1"] .sf-desk-state::before { background: #22c55e; animation: sf-desk-pulse 1.6s ease-out infinite; }
+@keyframes sf-desk-pulse { from { box-shadow: 0 0 0 0 rgba(34, 197, 94, .55); } to { box-shadow: 0 0 0 8px rgba(34, 197, 94, 0); } }
+.sf-desk-page section h2 { margin: 0 0 10px; font-size: .76rem; letter-spacing: .08em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
+.sf-desk-view p { margin: 0; line-height: 1.5; }
+.sf-desk-view [data-sf-desk-links] { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; font-size: 0; }
+.sf-desk-view [data-sf-desk-links][hidden], .sf-desk-view [data-sf-desk-needs][hidden] { display: none; }
+.sf-desk-watch, .sf-desk-takeover { display: inline-flex; align-items: center; min-height: 40px; padding: 0 16px; border-radius: 999px; font-size: .9rem; font-weight: 800; text-decoration: none; }
+.sf-desk-watch { background: var(--accent-strong, var(--accent, #3b82f6)); color: var(--on-accent-strong, var(--on-accent, #fff)); }
+.sf-desk-takeover { border: 1px solid var(--border, #1f2d47); color: var(--text-primary, #e6edf7); }
+.sf-desk-watch:focus-visible, .sf-desk-takeover:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
+.sf-desk-view [data-sf-desk-needs] { margin-top: 12px; padding: 10px 12px; border: 1px solid color-mix(in srgb, #f59e0b 45%, transparent); border-radius: 10px; background: color-mix(in srgb, #f59e0b 14%, transparent); font-weight: 600; }
+.sf-desk-steps ol { list-style: none; margin: 0; padding: 0; max-height: 420px; overflow-y: auto; overscroll-behavior: contain; }
+.sf-desk-step { position: relative; padding: 0 0 12px 22px; font-size: .92rem; line-height: 1.45; color: var(--text-secondary, #96a7c2); overflow-wrap: anywhere; }
+.sf-desk-step::before { content: ''; position: absolute; left: 0; top: .42em; width: 9px; height: 9px; border-radius: 50%; background: var(--accent, #3b82f6); }
+.sf-desk-step::after { content: ''; position: absolute; left: 4px; top: calc(.42em + 13px); bottom: 2px; width: 1px; background: var(--border, #1f2d47); }
+.sf-desk-step:last-child { color: var(--text-primary, #e6edf7); padding-bottom: 4px; }
+.sf-desk-step:last-child::after { display: none; }
+.sf-desk-steps .sf-fine { margin: 8px 0 0; }
+.sf-desk-evidence ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
+.sf-desk-evidence li a { display: block; padding: 8px 10px; border-radius: 10px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 5%, transparent); font-size: .86rem; text-decoration: none; overflow-wrap: anywhere; }
+.sf-desk-evidence li a:hover { background: color-mix(in srgb, var(--text-primary, #e6edf7) 9%, transparent); }
+.sf-desk-report-text { white-space: pre-wrap; line-height: 1.6; overflow-wrap: anywhere; }
+.sf-desk-report p { margin: 0; color: var(--text-secondary, #96a7c2); }
+.sf-desk-actor { margin: 4px 0 0; text-align: center; }
+@media (prefers-reduced-motion: reduce) { .sf-desk-page[data-sf-desk-open="1"] .sf-desk-state::before { animation: none; } }
+html[data-ov-motion=reduced] .sf-desk-page[data-sf-desk-open="1"] .sf-desk-state::before { animation: none; }
@@ -354,7 +354,83 @@
@@ -354,7 +354,83 @@
});
});
}
}
- function run() { runMotif(); tv(); webForm(); }
+ function webTask() {
+ var root = document.querySelector('[data-sf-desk-task]');
+ if (!root || root.dataset.ready) return;
+ root.dataset.ready = '1';
+ var id = root.getAttribute('data-sf-desk-task');
+ var base = '/api/v1/web/tasks/' + encodeURIComponent(id);
+ var steps = root.querySelector('[data-sf-desk-steps]');
+ var seen = {};
+ var timer = null;
+ var stream = null;
+ function safeUrl(value) {
+ try { var u = new URL(value); return /^https?:$/.test(u.protocol) ? u.href : null; } catch (_) { return null; }
+ }
+ function botDesk(value) {
+ 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;
+ }
+ function snapshot(t) {
+ root.querySelector('[data-sf-desk-state]').textContent = t.state || 'Working';
+ 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.';
+ var links = root.querySelector('[data-sf-desk-links]');
+ links.hidden = !desk;
+ if (desk) {
+ root.querySelector('[data-sf-desk-watch]').href = desk;
+ root.querySelector('[data-sf-desk-takeover]').href = desk;
+ }
+ var needs = root.querySelector('[data-sf-desk-needs]');
+ needs.hidden = !t.needs_you;
+ needs.textContent = t.needs_you && t.needs_you.what || '';
+ var list = root.querySelector('[data-sf-desk-sources]');
+ list.replaceChildren();
+ var sources = t.result && Array.isArray(t.result.sources) ? t.result.sources : [];
+ 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';
+ li.appendChild(a); list.appendChild(li);
+ });
+ root.querySelector('[data-sf-desk-sources-empty]').hidden = !!list.children.length;
+ var answer = t.result && typeof t.result.answer === 'string' ? t.result.answer : '';
+ root.querySelector('[data-sf-desk-report]').textContent = answer;
+ root.querySelector('[data-sf-desk-report-empty]').hidden = !!answer;
+ if (!['queued', 'running', 'verifying'].includes(t.state)) {
+ 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.';
+ }
+ }
+ function refresh() {
+ fetch(base, { credentials: 'same-origin', headers: { accept: 'application/json' } })
+ .then(function (r) { if (!r.ok) throw new Error('Task unavailable'); return r.json(); })
+ .then(snapshot)
+ .catch(function () { root.querySelector('[data-sf-desk-steps-note]').textContent = 'Connection lost. Trying again…'; });
+ }
+ function step(e) {
+ try {
+ var item = JSON.parse(e.data);
+ if (item.kind === 'end') { if (stream) stream.close(); refresh(); return; }
+ if (item.kind !== 'output' || seen[item.seq]) return;
+ seen[item.seq] = true;
+ var line = document.createElement('li');
+ 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);
+ root.querySelector('[data-sf-desk-steps-note]').textContent = 'Updating live as the agent works.';
+ } catch (_) { /* ignore a malformed event */ }
+ }
+ if (window.EventSource) {
+ stream = new EventSource(base + '/events');
+ ['output', 'state', 'end'].forEach(function (name) { stream.addEventListener(name, step); });
+ } else root.querySelector('[data-sf-desk-steps-note]').textContent = 'Open the full task in Actor to see its steps.';
+ if (root.getAttribute('data-sf-desk-open') === '1') timer = setInterval(refresh, 3000);
+ }
+
+ function run() { runMotif(); tv(); webForm(); webTask(); }
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run); else run();
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run); else run();
document.addEventListener('ov:boost:load', run);
document.addEventListener('ov:boost:load', run);
})();
})();
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
/**
/**
* OpenVibe.Surf — Express app factory; server/index.js listens, tests build their own instance with mock upstreams.
* OpenVibe.Surf — Express app factory; server/index.js listens, tests build their own instance with mock upstreams.
*
*
- * /, /ride, /ride/:map, /watch, /web, /developers the pages (http/pages.js)
+ * /, /ride, /ride/:map, /watch, /web, /web/t/:taskId, /developers the pages (http/pages.js)
* /watch/party, /watch/p/:id, …/events watch together (http/party.js)
* /watch/party, /watch/p/:id, …/events watch together (http/party.js)
* /web/go the no-JavaScript task form
* /web/go the no-JavaScript task form
* /api/v1/watch/feed, /api/v1/ride/maps… Watch and Ride (http/api.js)
* /api/v1/watch/feed, /api/v1/ride/maps… Watch and Ride (http/api.js)
@@ -107,6 +107,12 @@ function createApi(ctx) {
@@ -107,6 +107,12 @@ function createApi(ctx) {
}
}
});
});
+ r.get('/v1/web/tasks/:id', requireToken, async (req, res) => {
+ try { return res.json(await web.task({ token: req.webToken, id: req.params.id })); }
+ catch (err) { return problem(req, res, err.status || 502, 'web.task.unavailable', err.message); }
+ });
+ r.get('/v1/web/tasks/:id/events', requireToken, (req, res) => web.events({ token: req.webToken, id: req.params.id, after: req.get('last-event-id'), res }));
+
return r;
return r;
}
}
@@ -7,7 +7,8 @@
@@ -7,7 +7,8 @@
* /ride/:map one map: name, tier, stages, records, the embed
* /ride/:map one map: name, tier, stages, records, the embed
* /watch the merged Live feed as a list with thumbnails and play links
* /watch the merged Live feed as a list with thumbnails and play links
* /web the Surf with Vibe form and what it does
* /web the Surf with Vibe form and what it does
- * /web/go?q= the no-JavaScript form: create the task, 303 to its Actor page
+ * /web/go the no-JavaScript form: create the task, 303 to its Surf page
+ * /web/t/:taskId a person's live desk, steps, sources and report
* /developers the API, for people and agents
* /developers the API, for people and agents
*
*
* Every page is server-rendered and complete without JavaScript. A page for a signed-in person is private and
* Every page is server-rendered and complete without JavaScript. A page for a signed-in person is private and
@@ -84,6 +85,18 @@ function createPageRoutes(ctx) {
@@ -84,6 +85,18 @@ function createPageRoutes(ctx) {
r.get('/web', (req, res) => page(res, 200, views.web({ me: meOf(req.viewer), query: typeof req.query.q === 'string' ? req.query.q.slice(0, 500) : '' }), req.viewer));
r.get('/web', (req, res) => page(res, 200, views.web({ me: meOf(req.viewer), query: typeof req.query.q === 'string' ? req.query.q.slice(0, 500) : '' }), req.viewer));
+ r.get('/web/t/:taskId', async (req, res) => {
+ res.set('Cache-Control', 'private, no-store');
+ const token = req.viewer && req.viewer.kind === 'user' ? req.viewer.token : null;
+ if (!token) return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
+ try {
+ const task = await web.task({ token, id: req.params.taskId });
+ return page(res, 200, views.webTask({ task, actorUrl: config.actorPublicUrl }), req.viewer);
+ } catch (err) {
+ return page(res, err.status === 404 ? 404 : 502, views.webTaskError({ notFound: err.status === 404 }), req.viewer);
+ }
+ });
+
// The no-JavaScript form: a POST from this site that creates one task and goes to it (303). The query is in the
// The no-JavaScript form: a POST from this site that creates one task and goes to it (303). The query is in the
// body, never in a URL (so never in a log); another site's form is refused by its Origin.
// body, never in a URL (so never in a log); another site's form is refused by its Origin.
r.get('/web/go', (req, res) => res.redirect(303, '/web'));
r.get('/web/go', (req, res) => res.redirect(303, '/web'));
@@ -248,12 +248,46 @@ function web({ me = {}, query = '' } = {}) {
@@ -248,12 +248,46 @@ function web({ me = {}, query = '' } = {}) {
return layout({ title: 'Surf with Vibe', description: 'Ask an agent to surf the web for you: it searches, reads pages on a private desk when needed, keeps its sources and reports back with citations.', path: '/web', body, active: '/web' });
return layout({ title: 'Surf with Vibe', description: 'Ask an agent to surf the web for you: it searches, reads pages on a private desk when needed, keeps its sources and reports back with citations.', path: '/web', body, active: '/web' });
}
}
+const safeWebUrl = (value) => {
+ try {
+ const u = new URL(String(value));
+ return /^https?:$/.test(u.protocol) ? u.href : null;
+ } catch { return null; }
+};
+const deskUrl = (value) => {
+ const url = safeWebUrl(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;
+};
+
+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' });
+}
+
+function webTaskError({ notFound = false } = {}) {
+ return layout({ title: 'Surf task', path: '/web', robots: 'noindex, nofollow', body: `<div class="sf-wrap sf-page-pad sf-narrow"><h1>${notFound ? 'Task not found' : 'The task is unavailable'}</h1><p>${notFound ? 'This task is unavailable to your account.' : 'Actor did not answer. Try this page again in a moment.'}</p><p><a href="/web">Back to Surf with Vibe</a></p></div>`, active: '/web' });
+}
+
function developers() {
function developers() {
const rows = [
const rows = [
['GET /api/v1/watch/feed?topic=all|live|clips|moments&cursor=', 'The merged OpenVibe.Live feed: who is live (by viewers), then clips and AI moments by freshness, never two from one channel in a row. Each item says why it is on. Cached 30 s; a source that did not answer is named in <code>sources</code>.'],
['GET /api/v1/watch/feed?topic=all|live|clips|moments&cursor=', 'The merged OpenVibe.Live feed: who is live (by viewers), then clips and AI moments by freshness, never two from one channel in a row. Each item says why it is on. Cached 30 s; a source that did not answer is named in <code>sources</code>.'],
['GET /api/v1/ride/maps', 'The surf maps from OpenVibe.Games: id, name, tier, stages, author, runs, the record. Cached 60 s.'],
['GET /api/v1/ride/maps', 'The surf maps from OpenVibe.Games: id, name, tier, stages, author, runs, the record. Cached 60 s.'],
['GET /api/v1/ride/maps/:id', 'One map, its fastest runs and its <code>embed_url</code>.'],
['GET /api/v1/ride/maps/:id', 'One map, its fastest runs and its <code>embed_url</code>.'],
- ['POST /api/v1/web/tasks', 'Signed in, from this site: <code>{ query }</code> (3–500 characters) starts an OpenVibe.Actor task as you and answers <code>{ task_id, page }</code>.'],
+ ['POST /api/v1/web/tasks', 'Signed in, from this site: <code>{ query }</code> (3–500 characters) starts an OpenVibe.Actor task as you and answers <code>{ task_id, page }</code>; the page is in Surf.'],
+ ['GET /api/v1/web/tasks/:id', 'Your Actor task state, desk link, sources and final report, fetched with your token.'],
+ ['GET /api/v1/web/tasks/:id/events', 'Your live Actor steps as server-sent events.'],
['POST /watch/party · GET /watch/p/:id/events', 'Watch together: signed in and same-site, opens an in-memory party at <code>/watch/p/<id></code> (303, or 201 JSON). The SSE stream carries the host\'s <code>{ channel_key, position_s, playing, at }</code> commands to every member; a restart ends the party.'],
['POST /watch/party · GET /watch/p/:id/events', 'Watch together: signed in and same-site, opens an in-memory party at <code>/watch/p/<id></code> (303, or 201 JSON). The SSE stream carries the host\'s <code>{ channel_key, position_s, playing, at }</code> commands to every member; a restart ends the party.'],
];
];
const body = `<div class="sf-wrap sf-page-pad">
const body = `<div class="sf-wrap sf-page-pad">
@@ -273,7 +307,7 @@ function notFound({ party = false } = {}) {
@@ -273,7 +307,7 @@ function notFound({ party = false } = {}) {
}
}
module.exports = {
module.exports = {
- home, ride, map, watch, party, web, developers, notFound, runTime,
+ home, ride, map, watch, party, web, webTask, webTaskError, developers, notFound, runTime,
layout,
layout,
setRelease: layoutLib.setRelease,
setRelease: layoutLib.setRelease,
configure: layoutLib.configure,
configure: layoutLib.configure,
@@ -21,6 +21,7 @@ const crypto = require('crypto');
@@ -21,6 +21,7 @@ const crypto = require('crypto');
const MIN_QUERY = 3;
const MIN_QUERY = 3;
const MAX_QUERY = 500;
const MAX_QUERY = 500;
const IDEMPOTENCY = /^[A-Za-z0-9._:-]{8,128}$/;
const IDEMPOTENCY = /^[A-Za-z0-9._:-]{8,128}$/;
+const TASK_ID = /^tsk_[A-Za-z0-9]{8,64}$/;
const PREFIX = 'Surf the web for me with your tools. Use search_web to find pages, read_page to read them, and a desk when a page needs one. Keep every source you use and finish with a short report that cites each source.';
const PREFIX = 'Surf the web for me with your tools. Use search_web to find pages, read_page to read them, and a desk when a page needs one. Keep every source you use and finish with a short report that cites each source.';
function validQuery(value) {
function validQuery(value) {
@@ -39,8 +40,53 @@ function newIdempotencyKey() {
@@ -39,8 +40,53 @@ function newIdempotencyKey() {
}
}
function createWeb({ config, fetchImpl = globalThis.fetch, log = console }) {
function createWeb({ config, fetchImpl = globalThis.fetch, log = console }) {
+ const taskPath = (id) => `${config.actorInternalUrl}/api/v1/tasks/${encodeURIComponent(id)}`;
+ const headers = (token) => ({ Accept: 'application/json', Authorization: `Bearer ${token}` });
+
+ async function task({ token, id }) {
+ if (!token) throw Object.assign(new Error('Sign in to see your task.'), { status: 401 });
+ if (!TASK_ID.test(String(id || ''))) throw Object.assign(new Error('Task not found.'), { status: 404 });
+ let res;
+ try {
+ res = await fetchImpl(taskPath(id), { headers: headers(token), signal: AbortSignal.timeout(config.actorTimeoutMs || 15_000) });
+ } catch {
+ throw Object.assign(new Error('OpenVibe.Actor did not answer.'), { status: 502 });
+ }
+ if (!res.ok) throw Object.assign(new Error(res.status === 404 || res.status === 403 ? 'Task not found.' : 'OpenVibe.Actor did not answer.'), { status: res.status === 401 ? 401 : res.status === 403 || res.status === 404 ? 404 : 502 });
+ let data;
+ try { data = await res.json(); } catch { /* invalid Actor response */ }
+ if (!data || data.id !== id) throw Object.assign(new Error('OpenVibe.Actor sent an invalid task.'), { status: 502 });
+ return data;
+ }
+
+ // Actor offers SSE. Proxy it server-side so the person's token stays in an httpOnly cookie and never enters JS.
+ async function events({ token, id, after, res }) {
+ if (!token) return res.status(401).end();
+ if (!TASK_ID.test(String(id || ''))) return res.status(404).end();
+ const controller = new AbortController();
+ res.on('close', () => controller.abort());
+ let upstream;
+ try {
+ upstream = await fetchImpl(`${taskPath(id)}/events`, {
+ headers: { ...headers(token), Accept: 'text/event-stream', ...(after && /^\d{1,12}$/.test(after) ? { 'Last-Event-ID': after } : {}) }, signal: controller.signal,
+ });
+ if (!upstream.ok || !upstream.body) return res.status(upstream.status === 401 ? 401 : upstream.status === 403 || upstream.status === 404 ? 404 : 502).end();
+ res.status(200).set({ 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'private, no-store', 'X-Accel-Buffering': 'no' });
+ for await (const chunk of upstream.body) {
+ if (res.destroyed) break;
+ res.write(chunk);
+ }
+ res.end();
+ } catch (err) {
+ if (!controller.signal.aborted) {
+ log.warn('[OpenVibe.Surf] Actor event stream ended:', (err && err.message) || err);
+ if (!res.headersSent) res.status(502);
+ res.end();
+ }
+ }
+ }
/**
/**
- * Create one task for the person whose token this is. Resolves { task_id, page } or throws
+ * Create one task for the person whose token this is. Resolves { task_id, page } (Surf's task page) or throws
* { status, code, detail, actor } — a 4xx from Actor keeps its status and detail.
* { status, code, detail, actor } — a 4xx from Actor keeps its status and detail.
*/
*/
async function createTask({ token, query, idempotencyKey = null } = {}) {
async function createTask({ token, query, idempotencyKey = null } = {}) {
@@ -84,10 +130,10 @@ function createWeb({ config, fetchImpl = globalThis.fetch, log = console }) {
@@ -84,10 +130,10 @@ function createWeb({ config, fetchImpl = globalThis.fetch, log = console }) {
log.warn('[OpenVibe.Surf] Actor answered without a task id');
log.warn('[OpenVibe.Surf] Actor answered without a task id');
throw Object.assign(new Error('OpenVibe.Actor answered without a task id.'), { status: 502, code: 'actor.invalid_response' });
throw Object.assign(new Error('OpenVibe.Actor answered without a task id.'), { status: 502, code: 'actor.invalid_response' });
}
}
- return { task_id: id, page: `${String(config.actorPublicUrl).replace(/\/+$/, '')}/tasks/${encodeURIComponent(id)}` };
+ return { task_id: id, page: `/web/t/${encodeURIComponent(id)}` };
}
}
- return { createTask, validQuery };
+ return { createTask, task, events, validQuery };
}
}
-module.exports = { createWeb, taskText, validQuery, newIdempotencyKey, PREFIX, MIN_QUERY, MAX_QUERY };
+module.exports = { createWeb, taskText, validQuery, newIdempotencyKey, PREFIX, MIN_QUERY, MAX_QUERY, TASK_ID };
@@ -70,12 +70,12 @@ async function startUpstreams() {
@@ -70,12 +70,12 @@ async function startUpstreams() {
dvr: { 9002: 'https://openvibe.media/v/9002/dvr.m3u8' },
dvr: { 9002: 'https://openvibe.media/v/9002/dvr.m3u8' },
fail: { streams: null, clips: null, moments: null },
fail: { streams: null, clips: null, moments: null },
delay: { streams: 0, clips: 0, moments: 0 },
delay: { streams: 0, clips: 0, moments: 0 },
- actor: { status: 201, id: 'tsk_01J8SURFEXAMPLE00000000', problem: null },
+ actor: { status: 201, id: 'tsk_01J8SURFEXAMPLE00000000', problem: null, task: null, events: [] },
requests: [],
requests: [],
counts: { streams: 0, clips: 0, moments: 0, maps: 0, map: 0, actor: 0 },
counts: { streams: 0, clips: 0, moments: 0, maps: 0, map: 0, actor: 0 },
};
};
- const srv = await listen(async (req, raw, json) => {
+ const srv = await listen(async (req, raw, json, res) => {
const url = new URL(req.url, 'http://x');
const url = new URL(req.url, 'http://x');
const p = url.pathname;
const p = url.pathname;
const entry = { method: req.method, path: p, query: Object.fromEntries(url.searchParams), headers: { ...req.headers }, body: raw };
const entry = { method: req.method, path: p, query: Object.fromEntries(url.searchParams), headers: { ...req.headers }, body: raw };
@@ -137,6 +137,14 @@ async function startUpstreams() {
@@ -137,6 +137,14 @@ async function startUpstreams() {
}
}
return json(state.actor.status, { id: state.actor.id, state: 'queued', task: 'the task' });
return json(state.actor.status, { id: state.actor.id, state: 'queued', task: 'the task' });
}
}
+ if (p === `/api/v1/tasks/${state.actor.id}` && req.method === 'GET') {
+ return json(200, state.actor.task || { id: state.actor.id, state: 'running', task: 'Surf the web', result: null });
+ }
+ if (p === `/api/v1/tasks/${state.actor.id}/events` && req.method === 'GET') {
+ res.writeHead(200, { 'Content-Type': 'text/event-stream' });
+ for (const event of state.actor.events) res.write(`id: ${event.seq}\nevent: ${event.kind}\ndata: ${JSON.stringify(event)}\n\n`);
+ return res.end();
+ }
return json(404, { error: 'not found' });
return json(404, { error: 'not found' });
});
});
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
/**
/**
* Web: signed out 401, cross-site 403, the Actor call carries the person's Bearer and an Idempotency-Key and the
* Web: signed out 401, cross-site 403, the Actor call carries the person's Bearer and an Idempotency-Key and the
- * prefixed browsing brief, Actor's refusal passes through in words, and the plain form (POST /web/go, this site only) 303s to the Actor task page.
+ * prefixed browsing brief, Actor's refusal passes through in words, and the plain form (POST /web/go, this site only) 303s to the Surf task page.
*/
*/
const assert = require('assert');
const assert = require('assert');
const { boot, check, done } = require('./helpers/boot');
const { boot, check, done } = require('./helpers/boot');
@@ -45,7 +45,7 @@ const ORIGIN = 'https://openvibe.surf';
@@ -45,7 +45,7 @@ const ORIGIN = 'https://openvibe.surf';
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual(r.status, 201, r.text);
const b = r.json();
const b = r.json();
assert.strictEqual(b.task_id, 'tsk_01J8SURFEXAMPLE00000000');
assert.strictEqual(b.task_id, 'tsk_01J8SURFEXAMPLE00000000');
- assert.strictEqual(b.page, 'https://openvibe.actor/tasks/tsk_01J8SURFEXAMPLE00000000');
+ assert.strictEqual(b.page, '/web/t/tsk_01J8SURFEXAMPLE00000000');
assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
assert.strictEqual(t.upstreams.state.counts.actor, 1);
assert.strictEqual(t.upstreams.state.counts.actor, 1);
@@ -100,11 +100,11 @@ const ORIGIN = 'https://openvibe.surf';
@@ -100,11 +100,11 @@ const ORIGIN = 'https://openvibe.surf';
});
});
const own = { origin: 'https://openvibe.surf', 'sec-fetch-site': 'same-origin' };
const own = { origin: 'https://openvibe.surf', 'sec-fetch-site': 'same-origin' };
- await check('POST /web/go (the plain form) creates the task and 303s to the Actor task page; the query is never in a URL', async () => {
+ await check('POST /web/go (the plain form) creates the task and 303s to the Surf task page; the query is never in a URL', async () => {
t.upstreams.resetCounts();
t.upstreams.resetCounts();
const r = await t.get('/web/go', { cookie: signedIn, form: { q: 'find the best surf spots in Portugal' }, headers: own });
const r = await t.get('/web/go', { cookie: signedIn, form: { q: 'find the best surf spots in Portugal' }, headers: own });
assert.strictEqual(r.status, 303, r.text);
assert.strictEqual(r.status, 303, r.text);
- assert.strictEqual(r.headers.get('location'), 'https://openvibe.actor/tasks/tsk_01J8SURFEXAMPLE00000000');
+ assert.strictEqual(r.headers.get('location'), '/web/t/tsk_01J8SURFEXAMPLE00000000');
assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
const sent = t.upstreams.lastFor('/api/v1/tasks');
const sent = t.upstreams.lastFor('/api/v1/tasks');
assert.strictEqual(sent.headers.authorization, `Bearer ${token}`);
assert.strictEqual(sent.headers.authorization, `Bearer ${token}`);
@@ -134,6 +134,55 @@ const ORIGIN = 'https://openvibe.surf';
@@ -134,6 +134,55 @@ const ORIGIN = 'https://openvibe.surf';
assert.match(r.text, /free tier runs 5 tasks a day/);
assert.match(r.text, /free tier runs 5 tasks a day/);
});
});
+ await check('the Surf task page shows the private desk link, cited sources and report', async () => {
+ const desk = `https://openvibe.bot/machines/dev_${'A'.repeat(26)}/screen?desk=desk-surf`;
+ t.upstreams.state.actor.task = {
+ id: t.upstreams.state.actor.id, state: 'succeeded', task: '<find a flight>',
+ desk: { url: desk, kind: 'browser' }, needs_you: { what: 'Sign in on the desk.' },
+ result: { answer: 'Found a flight < $500.', sources: ['https://example.com/flight', 'javascript:alert(1)'] },
+ };
+ const r = await t.get(`/web/t/${t.upstreams.state.actor.id}`, { cookie: signedIn });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
+ assert.match(r.text, /<find a flight>/);
+ assert.match(r.text, /Found a flight < \$500/);
+ assert.match(r.text, /https:\/\/example.com\/flight/);
+ assert.doesNotMatch(r.text, /javascript:alert/);
+ assert.match(r.text, /Take over/);
+ assert.match(r.text, /openvibe\.bot\/machines/);
+ assert.match(r.text, /openvibe\.actor\/tasks/);
+ assert.ok(!r.text.includes(token));
+ const sent = t.upstreams.lastFor(`/api/v1/tasks/${t.upstreams.state.actor.id}`);
+ assert.strictEqual(sent.headers.authorization, `Bearer ${token}`);
+ });
+
+ await check('task JSON and live events use the person’s token without exposing it to the browser', async () => {
+ const id = t.upstreams.state.actor.id;
+ t.upstreams.state.actor.events = [{ seq: 1, kind: 'output', step: 'tool_result', text: 'Read page evidence' }, { seq: 2, kind: 'end', state: 'succeeded' }];
+ const info = await t.get(`/api/v1/web/tasks/${id}`, { cookie: signedIn });
+ assert.strictEqual(info.status, 200, info.text);
+ assert.strictEqual(info.json().id, id);
+ assert.strictEqual(info.headers.get('cache-control'), 'private, no-store');
+ const live = await t.get(`/api/v1/web/tasks/${id}/events`, { cookie: signedIn });
+ assert.strictEqual(live.status, 200, live.text);
+ assert.match(live.headers.get('content-type'), /text\/event-stream/);
+ assert.match(live.text, /Read page evidence/);
+ assert.ok(!live.text.includes(token));
+ assert.strictEqual(t.upstreams.lastFor(`/api/v1/tasks/${id}/events`).headers.authorization, `Bearer ${token}`);
+ assert.strictEqual((await t.get(`/api/v1/web/tasks/${id}`)).status, 401);
+ assert.strictEqual((await t.get(`/api/v1/web/tasks/${id}/events`)).status, 401);
+ });
+
+ await check('an unrelated task and an unsafe desk URL are never presented as a live desk', async () => {
+ const id = t.upstreams.state.actor.id;
+ t.upstreams.state.actor.task = { id, state: 'running', task: 'Surf', desk: { url: 'https://evil.example/machines/dev_x/screen' } };
+ const r = await t.get(`/web/t/${id}`, { cookie: signedIn });
+ assert.strictEqual(r.status, 200);
+ assert.doesNotMatch(r.text, /evil\.example/);
+ const other = await t.get('/web/t/tsk_other00000', { cookie: signedIn });
+ assert.strictEqual(other.status, 404);
+ });
+
await t.close();
await t.close();
done();
done();
})().catch((err) => { console.error(err); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });