openvibers/openvibe.exchange Commits
Deep dive: the start, the live run and the report on /deep-dive (#222)
Changes
@@@ -372,6 +372,62 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = (async function ownCreate(token, body) { return ownCall('POST', '/api/v1/own-endpoints', { token, body }); }async function ownStop(token, id) { return ownCall('POST', `/api/v1/own-endpoints/${encodeURIComponent(id)}/stop`, { token }); }++ /**++ * Deep dives (AI workflow design.deep_dive, server/deep-dive/): Exchange calls AI as the service, for the++ * signed-in person (on_behalf_of), and shows the run, its live steps and its report. The price is AI's own++ * answer (GET /api/v1/deep-dives/depths), never a number copied into this site; the shot bytes come back++ * through AI because the screenshots are private to the run's owner.++ */++ let depthsHit = { at: 0, value: null };++ async function deepDiveDepths() {++ if (depthsHit.value && now() - depthsHit.at < CATALOG_MS) return depthsHit.value;++ try {++ const body = await get('/api/v1/deep-dives/depths');++ const value = { depths: Array.isArray(body.depths) ? body.depths : [], capture: body.capture || null };++ depthsHit = { at: now(), value };++ return value;++ } catch (err) { warned('deep dive depths', err); return depthsHit.value || { depths: [], unavailable: true }; }++ }++ async function deepDiveCall(method, path, { body = undefined } = {}) {++ if (!tokens) throw Object.assign(new Error('AI is not configured for this service'), { code: 'ai.not_configured', status: 503 });++ const token = await tokens.getToken();++ let res;++ try {++ res = await fetchImpl(`${config.ai.url}${path}`, {++ method,++ headers: { Accept: 'application/json', Authorization: `Bearer ${token}`, ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}) },++ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),++ signal: AbortSignal.timeout(Math.max(config.ai.timeoutMs, 30000)),++ });++ } catch (err) {++ throw Object.assign(new Error('OpenVibe.AI is unreachable right now'), { code: 'ai.unavailable', status: 503 });++ }++ const out = await jsonOf(res);++ if (!res.ok) throw Object.assign(new Error((out && (out.detail || out.title)) || `AI answered ${res.status}`), { status: res.status, code: out && out.code });++ return out || {};++ }++ async function deepDiveCreate({ question, depth, sites = [], subject }) {++ return deepDiveCall('POST', '/api/v1/deep-dives', { body: {++ question, depth, ...(sites.length ? { sites } : {}),++ ...(subject ? { on_behalf_of: { type: 'user', id: subject } } : {}),++ } });++ }++ async function deepDiveGet(id) { return deepDiveCall('GET', `/api/v1/deep-dives/${encodeURIComponent(id)}`); }++ async function deepDiveState(id) { return deepDiveCall('GET', `/api/v1/deep-dives/${encodeURIComponent(id)}/state`); }++ async function deepDiveSites(id, { add = [], drop = [], start = false } = {}) {++ return deepDiveCall('POST', `/api/v1/deep-dives/${encodeURIComponent(id)}/sites`, { body: { ...(add.length ? { add } : {}), ...(drop.length ? { drop } : {}), ...(start ? { start: true } : {}) } });++ }++ async function deepDiveCancel(id) { return deepDiveCall('POST', `/api/v1/deep-dives/${encodeURIComponent(id)}/cancel`, { body: {} }); }++ async function deepDiveShot(id, shot) {++ if (!tokens) throw Object.assign(new Error('AI is not configured for this service'), { code: 'ai.not_configured', status: 503 });++ const token = await tokens.getToken();++ const res = await fetchImpl(`${config.ai.url}/api/v1/deep-dives/${encodeURIComponent(id)}/shots/${encodeURIComponent(shot)}`, {++ headers: { Authorization: `Bearer ${token}`, Accept: 'image/png' }, signal: AbortSignal.timeout(config.ai.timeoutMs),++ });++ if (!res.ok) throw Object.assign(new Error(`AI answered ${res.status}`), { status: res.status, code: 'deep_dive.shot_not_found' });++ return { buffer: Buffer.from(await res.arrayBuffer()), contentType: res.headers.get('content-type') || 'image/png' };++ }++/** The person's own AI credentials: pass only their signed Network session, never the Exchange service token. */async function meRequest(token, path, { method = 'GET', body = undefined } = {}) {if (!token && path !== '/api/v1/tasks' && !path.startsWith('/api/v1/choices?')) throw Object.assign(new Error('Sign in is required'), { status: 401 });@@@ -390,7 +446,7 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = (return createCredentialsClient(client, { baseUrl: config.ai.url });}-- return { catalog, directory, modelEvents, searchEngines, usage, communityOffers, chat, presetsFor, setKeyPrivacy, ownedProjects, gpuQuote, ownStatus, ownList, ownCreate, ownStop, adminRequest, meRequest, credentialsFor, configured: Boolean(tokens) };++ return { catalog, directory, modelEvents, searchEngines, usage, communityOffers, chat, presetsFor, setKeyPrivacy, ownedProjects, gpuQuote, ownStatus, ownList, ownCreate, ownStop, adminRequest, meRequest, credentialsFor, deepDiveDepths, deepDiveCreate, deepDiveGet, deepDiveState, deepDiveSites, deepDiveCancel, deepDiveShot, configured: Boolean(tokens) };}/**@@@ -354,6 +354,7 @@ async function createApp(opts = {}) {app.use(createArenaRoutes(ctx));app.use(require('./http/dashboard').createDashboardRoutes(ctx));app.use(require('./http/search').createSearchRoutes(ctx));++ app.use(require('./http/deep-dive').createDeepDiveRoutes(ctx));app.use(createPageRoutes(ctx));app.use(createConsoleRoutes(ctx));@@@ -67,7 +67,7 @@ function load(env = process.env) {ai: {url: trim(env.OV_AI_URL || 'https://ai.openvibe.services'),audience: env.OV_AI_AUDIENCE || 'openvibe.ai',-- scope: env.OV_AI_SCOPE || 'ai.run.create',++ scope: env.OV_AI_SCOPE || 'ai.run.create ai.run.read',consoleScope: env.OV_AI_CONSOLE_SCOPE || 'ai.usage.read ai.workflow.manage ai.provider.manage',timeoutMs: Math.max(2000, int(env.EXCHANGE_AI_TIMEOUT_MS, 30_000)),waitMs: Math.max(1000, int(env.EXCHANGE_AI_WAIT_MS, 25_000)),nnew file mode 100644@@@ -0,0 +1,140 @@++'use strict';++/**++ * /deep-dive: start a run, follow its steps, choose sites, read the report, export it, and stream a private++ * screenshot back (AI holds it; Exchange never stores someone else's captures).++ *++ * The person's session is the only way in: POST needs the signed-in viewer (AI gets them as on_behalf_of), every++ * read is AI's owner-checked answer, and a signed-out visitor gets the explanation and a sign-in button. Same-origin++ * on every POST. JavaScript is optional: the forms are plain POSTs and the run page still says what every step is;++ * a small poll keeps the progress current while a run is live.++ */++const express = require('express');++const views = require('../views/deep-dive');++const { raw } = require('../render/html');++const { send } = require('../render/layout');++const { sameOrigin } = require('./principal');++++function createDeepDiveRoutes({ config, ai, log = console }) {++ const r = express.Router();++ r.use(express.urlencoded({ extended: false, limit: '32kb' }));++++ const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });++ const signedIn = (req) => Boolean(req.viewer && req.viewer.kind === 'user' && req.viewer.subject);++ const errorOf = (err) => {++ if (!err) return null;++ if (err.status === 401) return 'Your session has expired. Sign in again.';++ if (err.status === 402) return err.message || 'Your allowance cannot pay for this run.';++ if (err.status === 422) return err.message || 'That input was not accepted.';++ if (err.status === 503) return err.message || 'OpenVibe.AI is not available right now.';++ return err.message || 'OpenVibe.AI could not answer.';++ };++++ async function stateOf(id) {++ const out = await ai.deepDiveState(id);++ return out.deep_dive || null;++ }++++ r.get('/deep-dive', async (req, res) => {++ let depths = [];++ let capture = null;++ try { const d = await ai.deepDiveDepths(); depths = d.depths || []; capture = d.capture || null; } catch (err) { log.warn('[deep-dive] depths:', String(err && err.message || err).slice(0, 160)); }++ page(req, res, { title: 'Deep dive', description: 'Expert design research: search everywhere, capture the best sites at phone and computer sizes, and read what works and why.', body: raw(views.homePage({ viewer: req.viewer, depths, capture })) });++ });++++ r.post('/deep-dive', async (req, res) => {++ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent('/deep-dive')}`);++ if (!sameOrigin(req, config.baseUrl)) return res.sendStatus(403);++ const question = String(req.body.question || '').replace(/\s+/g, ' ').trim().slice(0, 500);++ const depth = ['quick', 'thorough', 'expert'].includes(req.body.depth) ? req.body.depth : 'quick';++ const sites = String(req.body.sites || '').split(/\r?\n/).map((s) => s.trim()).filter((s) => /^https?:\/\//i.test(s)).slice(0, 30);++ if (!question) {++ const d = await ai.deepDiveDepths().catch(() => ({ depths: [] }));++ return page(req, res, { title: 'Deep dive', index: false, body: raw(views.homePage({ viewer: req.viewer, depths: d.depths, error: 'Say what you are designing first.' })) }, 422);++ }++ try {++ const out = await ai.deepDiveCreate({ question, depth, sites, subject: req.viewer.subject });++ const id = out.deep_dive && out.deep_dive.id;++ if (!id) throw Object.assign(new Error('AI did not return a deep dive id'), { status: 502 });++ return res.redirect(303, `/deep-dive/${encodeURIComponent(id)}`);++ } catch (err) {++ const d = await ai.deepDiveDepths().catch(() => ({ depths: [] }));++ const status = err.status === 402 ? 402 : err.status === 422 ? 422 : 502;++ return page(req, res, { title: 'Deep dive', index: false, body: raw(views.homePage({ viewer: req.viewer, depths: d.depths, error: errorOf(err) })) }, status);++ }++ });++++ r.get('/deep-dive/:id', async (req, res) => {++ if (!signedIn(req)) return page(req, res, { title: 'Deep dive', body: raw(views.homePage({ viewer: null, depths: [], error: null })) }, 401);++ try {++ const out = await ai.deepDiveGet(req.params.id);++ const row = out.deep_dive;++ if (!row) throw Object.assign(new Error('No such deep dive'), { status: 404 });++ const title = row.question || 'Deep dive';++ if (row.status === 'succeeded' && row.report) return page(req, res, { title, description: 'A deep dive report with its sources, captures, synthesis and receipts.', body: raw(views.reportPage({ viewer: req.viewer, row, id: row.id })) });++ return page(req, res, { title, index: false, body: raw(views.runPage({ viewer: req.viewer, row, id: row.id })) });++ } catch (err) {++ const status = err.status === 404 ? 404 : 502;++ return page(req, res, { title: status === 404 ? 'Not found' : 'Deep dive', index: false, body: raw(`<div class="ex-wrap ex-narrow">${status === 404 ? require('openvibe-shared/kit').empty({ icon: 'search', title: 'No such deep dive', text: 'It may belong to another account, or the link is old.', action: { href: '/deep-dive', label: 'Deep dive' } }) : require('openvibe-shared/kit').empty({ title: 'OpenVibe.AI is unavailable', text: errorOf(err), action: { href: '/deep-dive', label: 'Try again' } })}</div>`) }, status);++ }++ });++++ r.get('/deep-dive/:id/state', async (req, res) => {++ if (!signedIn(req)) return res.status(401).json({ error: 'sign in' });++ try {++ const out = await ai.deepDiveState(req.params.id);++ res.set('Cache-Control', 'no-store');++ res.json(out);++ } catch (err) { res.status(err.status === 404 ? 404 : 502).json({ error: errorOf(err) }); }++ });++++ r.post('/deep-dive/:id/sites', async (req, res) => {++ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/deep-dive/${req.params.id}`)}`);++ if (!sameOrigin(req, config.baseUrl)) return res.sendStatus(403);++ const add = String(req.body.sites || '').split(/\r?\n/).map((s) => s.trim()).filter((s) => /^https?:\/\//i.test(s)).slice(0, 30);++ const drop = req.body.drop ? [String(req.body.drop)] : [];++ try { await ai.deepDiveSites(req.params.id, { add, drop, start: true }); } catch (err) { log.warn('[deep-dive] sites:', String(err && err.message || err).slice(0, 160)); }++ res.redirect(303, `/deep-dive/${encodeURIComponent(req.params.id)}`);++ });++++ r.post('/deep-dive/:id/cancel', async (req, res) => {++ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/deep-dive/${req.params.id}`)}`);++ if (!sameOrigin(req, config.baseUrl)) return res.sendStatus(403);++ try { await ai.deepDiveCancel(req.params.id); } catch (err) { log.warn('[deep-dive] cancel:', String(err && err.message || err).slice(0, 160)); }++ res.redirect(303, `/deep-dive/${encodeURIComponent(req.params.id)}`);++ });++++ r.get('/deep-dive/:id/shot/:shot', async (req, res) => {++ if (!signedIn(req)) return res.sendStatus(401);++ try {++ const got = await ai.deepDiveShot(req.params.id, req.params.shot);++ res.set({ 'Content-Type': got.contentType, 'Cache-Control': 'private, max-age=60', 'X-Robots-Tag': 'noindex' });++ res.send(got.buffer);++ } catch (err) { res.status(err.status === 404 ? 404 : 502).end(); }++ });++++ r.get('/deep-dive/:id/export/json', async (req, res) => {++ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/deep-dive/${req.params.id}`)}`);++ try {++ const out = await ai.deepDiveGet(req.params.id);++ const report = out.deep_dive && out.deep_dive.report;++ if (!report) return res.status(409).send('The deep dive has not written its report yet.');++ res.set({ 'Content-Type': 'application/json; charset=utf-8', 'Content-Disposition': `attachment; filename="deep-dive-${req.params.id}.json"`, 'Cache-Control': 'private, no-store' });++ res.send(JSON.stringify(report, null, 2));++ } catch (err) { res.status(err.status === 404 ? 404 : 502).send(errorOf(err)); }++ });++++ r.get('/deep-dive/:id/export/markdown', async (req, res) => {++ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/deep-dive/${req.params.id}`)}`);++ try {++ const out = await ai.deepDiveGet(req.params.id);++ const report = out.deep_dive && out.deep_dive.report;++ if (!report) return res.status(409).send('The deep dive has not written its report yet.');++ res.set({ 'Content-Type': 'text/markdown; charset=utf-8', 'Content-Disposition': `attachment; filename="deep-dive-${req.params.id}.md"`, 'Cache-Control': 'private, no-store' });++ res.send(views.exportMarkdown(report));++ } catch (err) { res.status(err.status === 404 ? 404 : 502).send(errorOf(err)); }++ });++++ return r;++}++++module.exports = { createDeepDiveRoutes };@@@ -23,6 +23,7 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');const NAV = [{ label: 'Models', href: '/models', icon: 'fa-microchip' },{ label: 'Search', href: '/search', icon: 'fa-magnifying-glass' },++ { label: 'Deep dive', href: '/deep-dive', icon: 'fa-binoculars' },{ label: 'Pricing', href: '/pricing', icon: 'fa-tag' },{ label: 'Docs', href: '/docs', icon: 'fa-code' },{ label: 'Status', href: '/status', icon: 'fa-gauge' },nnew file mode 100644@@@ -0,0 +1,212 @@++'use strict';++/**++ * /deep-dive: the start, a run's live steps and its report (plain kit markup; the look is the main session's).++ *++ * The words are the owner's (deep-dive.md): "Deep dive", "Expert design research: we search everywhere, capture++ * the best sites at phone and computer sizes, and tell you what works and why.", "What are you designing?", the++ * three depths with their prices, and the step names Planning, Searching, Choosing sites, Capturing, Analysing,++ * Writing it up. Nothing here restyles another page; it uses kit.head, kit.section, kit.badge, kit.button,++ * kit.empty and kit.table, and plain lists and forms.++ *++ * Every screenshot is private to the run's owner and is shown through /deep-dive/:id/shot/:shot with its source++ * URL and capture date as commentary, never republished wholesale.++ */++const kit = require('openvibe-shared/kit');++const { esc, raw } = require('../render/html');++++const STEP_LABELS = { plan: 'Planning', search: 'Searching', choose: 'Choosing sites', capture: 'Capturing', analyse: 'Analysing', synthesise: 'Writing it up', report: 'Writing it up' };++const STEP_NOTES = { plan: 'The questions an expert would ask, and the searches that answer them.', search: 'MegaSource, the search engines, GitHub and our own rules and components.', choose: 'The sites worth capturing, with a reason each. Add or drop any before Capture.', capture: 'Each site at 390 and 1280 px, light and dark where it supports dark, full page after a slow scroll.', analyse: 'A vision model reads each screenshot; a text model reads the DOM facts and the words.', synthesise: 'Patterns across sites, the gap, reference tokens, and recommendations checked against our rules.', report: 'The report, its evidence and the receipt.' };++const price = (n) => `$${Number(n || 0).toFixed(2)}`;++const when = (iso) => { if (!iso) return '—'; try { return new Date(iso).toISOString().slice(0, 16).replace('T', ' ') + ' UTC'; } catch { return String(iso); } };++const host = (u) => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return String(u || ''); } };++const pct = (n) => Math.max(0, Math.min(100, Math.round((Number(n) || 0) * 100)));++++function stepList(steps, current = null) {++ const given = new Map((steps || []).map((s) => [s.key, s]));++ const list = Object.keys(STEP_LABELS).map((key) => ({ key, status: 'pending', ...(given.get(key) || {}) }));++ return `<ol class="dd-steps">${list.map((s) => {++ const label = STEP_LABELS[s.key] || s.key;++ const detail = s.detail || (s.status === 'pending' ? 'Waiting' : '');++ return `<li class="dd-step is-${esc(s.status || 'pending')}" data-step="${esc(s.key)}"><strong>${esc(label)}</strong>${s.key === current ? ' <span class="ovk-badge">now</span>' : ''}<span>${esc(detail || '')}</span></li>`;++ }).join('')}</ol>`;++}++++function progressBar(progress) {++ return `<div class="dd-progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${pct(progress)}"><span style="display:block;width:${pct(progress)}%"></span></div>`;++}++++function depthCards(depths, selected = 'quick') {++ return `<fieldset class="dd-depths"><legend>Depth</legend>${(depths || []).map((d) => `<label class="dd-depth"><input type="radio" name="depth" value="${esc(d.key)}"${d.key === selected ? ' checked' : ''}> <strong>${esc(d.label || d.key)}</strong> <span>${esc(String(d.sites || ''))} sites · about ${esc(String(d.minutes || '?'))} min · ${price(d.price_usd)}</span><small>${esc(d.analysis || '')}</small></label>`).join('')}</fieldset>`;++}++++function depthList(depths) {++ return `<ul class="dd-depth-list">${(depths || []).map((d) => `<li><strong>${esc(d.label || d.key)}</strong> <span>${esc(String(d.sites || ''))} sites · about ${esc(String(d.minutes || '?'))} min · ${price(d.price_usd)}</span> <small>${esc(d.analysis || '')}</small></li>`).join('')}</ul>`;++}++++function signIn(next = '/deep-dive') {++ return kit.empty({ icon: 'person', title: 'Sign in to run a deep dive', text: 'A deep dive keeps private screenshots and spends your allowance, so it needs your account. You can read what it does below first.', action: { href: `/auth/login?next=${encodeURIComponent(next)}`, label: 'Sign in with OpenVibe' } });++}++++function whatItDoes() {++ return kit.section({ title: 'What a deep dive does', body: `<ol class="dd-what">++<li><strong>Search every way we have.</strong> Our research sources, Exchange's web, picture and icon engines, open-source projects on GitHub, design write-ups, and our own rules register and component registry.</li>++<li><strong>Choose the sites worth a look.</strong> A model ranks them with a reason each; you can add or drop any before Capture (Quick goes straight on).</li>++<li><strong>Capture them properly.</strong> 390 and 1280 px, dark and light where the site supports it, the whole page after a slow scroll so lazy sections load, and the DOM facts: type scale, palette, spacing, radii, shadows, grid, navigation, heading outline, button and link words, components. Expert adds open menus, hover and focus states, a transition frame strip, axe-core findings and LCP/CLS/INP.</li>++<li><strong>Read them.</strong> A vision model reads each screenshot and a text model reads the measured facts and the words: hierarchy, density, what draws the eye, what is good and what fails.</li>++<li><strong>Synthesise.</strong> The patterns and how many sites use each, a comparison, the best version, what everyone gets wrong, the gap nobody fills, reference tokens as inspiration, do and don't, accessibility and speed, and recommendations checked against OpenVibe's own rules.</li>++<li><strong>Show the evidence.</strong> Every claim names its site, capture and the date it was read. Screenshots stay private to you and are shown as attributed commentary, never republished.</li>++</ol>++<p class="ovk-note">The price is shown before it starts and held; cancelling stops the spend. No paid screenshot service: our own Chrome, one page budget at a time.</p>` });++}++++function homePage({ viewer, depths = [], capture = null, error = null }) {++ const signedIn = Boolean(viewer && viewer.kind === 'user');++ const cards = (depths && depths.length ? depths : [++ { key: 'quick', label: 'Quick', sites: 6, minutes: 3, price_usd: 0.2, analysis: 'captures and reads each site' },++ { key: 'thorough', label: 'Thorough', sites: 15, minutes: 8, price_usd: 0.5, analysis: 'more sites' },++ { key: 'expert', label: 'Expert', sites: 30, minutes: 18, price_usd: 1.2, analysis: 'plus interactions, accessibility and speed' },++ ]).map((d) => ({ ...d, key: d.key || d.depth, price_usd: d.price_usd ?? d.price }));++ const form = signedIn++ ? `<form method="post" action="/deep-dive" class="dd-form">++<label class="dd-field"><span>What are you designing?</span><textarea name="question" rows="3" maxlength="500" required placeholder="a phone-first robot control panel"></textarea></label>++${depthCards(cards)}++<label class="dd-field"><span>Sites you already know (optional, one per line)</span><textarea name="sites" rows="2" maxlength="2048" placeholder="https://example.com/panel"></textarea></label>++<p class="dd-go">${kit.button({ label: 'Start the deep dive', type: 'submit' })}<span class="ovk-note">The price is held before it starts; you can cancel at any step.</span></p>++</form>${error ? `<p class="dd-error" role="alert">${esc(error)}</p>` : ''}`++ : signIn();++ return `<div class="ex-wrap dd">${kit.head({ title: 'Deep dive', sub: 'Expert design research: we search everywhere, capture the best sites at phone and computer sizes, and tell you what works and why.' })}${form}${signedIn ? '' : `<section class="ovk-section"><h2>The depths and their prices</h2>${depthList(cards)}</section>`}`++ + (capture && capture.enabled === false ? `<p class="dd-error">Capture is off on this host right now (drill mode or Chrome missing), so a deep dive cannot start.</p>` : '')++ + whatItDoes() + `</div>`;++}++++function choosing(row, id) {++ const candidates = row.candidates || [];++ return kit.section({ title: 'Choose the sites to capture', note: 'Add or drop any site now. Capture starts when you say so (or when the wait ends).', body:++ `<ul class="dd-candidates">${candidates.map((c) => `<li><a href="${esc(c.url)}" rel="noopener noreferrer">${esc(c.title || host(c.url))}</a><span>${esc(c.reason || '')}</span><form method="post" action="/deep-dive/${encodeURIComponent(id)}/sites" class="dd-inline"><input type="hidden" name="drop" value="${esc(c.url)}">${kit.button({ label: 'Drop', type: 'submit', kind: 'quiet', small: true })}</form></li>`).join('')}</ul>++<form method="post" action="/deep-dive/${encodeURIComponent(id)}/sites" class="dd-form"><label class="dd-field"><span>Add a site (one per line)</span><textarea name="sites" rows="2" maxlength="2048"></textarea></label><input type="hidden" name="start" value="1"><p class="dd-go">${kit.button({ label: 'Start capture', type: 'submit' })}</p></form>` });++}++++function evidenceList(list, id) {++ if (!list || !list.length) return '';++ return `<ul class="dd-evidence">${list.map((e) => `<li>${esc(e.claim)}${e.capture_id ? ` <a href="/deep-dive/${encodeURIComponent(id)}/shot/${encodeURIComponent(e.crop_id || e.capture_id)}" rel="noopener noreferrer">capture</a>` : ''} <a href="${esc(e.url)}" rel="noopener noreferrer">${esc(host(e.url))}</a> <span class="ovk-note">read ${esc(when(e.read_at))}</span></li>`).join('')}</ul>`;++}++++function capturesSection(report, id, limit = 8) {++ const caps = (report.captures || []).filter((c) => c.media_id && !c.skip).slice(0, limit);++ if (!caps.length) return kit.section({ title: 'Captures', body: '<p>No site could be captured.</p>' });++ return kit.section({ title: 'Captures', count: caps.length, note: 'Private to you. Each shot keeps its source and the date it was read; it is shown as commentary, never republished.', body:++ `<div class="dd-shots">${caps.map((c) => {++ const crop = (c.crops || [])[0];++ const src = crop || c;++ return `<figure class="dd-shot"><a href="/deep-dive/${encodeURIComponent(id)}/shot/${encodeURIComponent(src.id || c.id)}" rel="noopener noreferrer"><img src="/deep-dive/${encodeURIComponent(id)}/shot/${encodeURIComponent(src.id || c.id)}" alt="Capture of ${esc(host(c.url))} at ${c.viewport} px, ${esc(c.theme)}" loading="lazy"></a><figcaption><a href="${esc(c.url)}" rel="noopener noreferrer">${esc(host(c.url))}</a> · ${c.viewport} px · ${esc(c.theme)} · ${esc(when(c.captured_at))}${c.cached ? ' · from the 7-day cache' : ''}${c.skip ? ` · ${esc(c.skip)}` : ''}</figcaption></figure>`;++ }).join('')}</div>` });++}++++function analysisSection(report, id) {++ const rows = (report.analysis || []).map((a) => `<article class="dd-analysis"><h3><a href="${esc(a.url)}" rel="noopener noreferrer">${esc(host(a.url))}</a></h3><p>${esc(a.summary)}</p>`++ + (a.first_impression ? `<p><strong>First impression:</strong> ${esc(a.first_impression)}</p>` : '')++ + `<p class="ovk-note">${[a.hierarchy && `Hierarchy: ${a.hierarchy}`, a.density && `Density: ${a.density}`, a.type && `Type: ${a.type}`, a.colour && `Colour: ${a.colour}`, a.motion && `Motion: ${a.motion}`, a.words && `Words: ${a.words}`].filter(Boolean).map(esc).join(' · ')}</p>`++ + (a.strengths && a.strengths.length ? `<p><strong>Good:</strong> ${a.strengths.map(esc).join('; ')}</p>` : '')++ + (a.weaknesses && a.weaknesses.length ? `<p><strong>Fails:</strong> ${a.weaknesses.map(esc).join('; ')}</p>` : '')++ + (a.accessibility ? `<p><strong>Accessibility:</strong> ${esc(a.accessibility)}</p>` : '')++ + (a.speed ? `<p><strong>Speed:</strong> ${esc(a.speed)}</p>` : '')++ + evidenceList(a.evidence, id) + `</article>`).join('') || '<p>No site was analysed.</p>';++ return kit.section({ title: 'What each site does', body: rows });++}++++function synthesisSection(report, id) {++ const s = report.synthesis || {};++ const patterns = (s.patterns || []).length ? `<ul class="dd-patterns">${s.patterns.map((p) => `<li><strong>${esc(p.name)}</strong> <span class="ovk-badge">${esc(String(p.usage_count || (p.sites || []).length))} sites</span><p>${esc(p.what)}</p>${evidenceList(p.evidence, id)}</li>`).join('')}</ul>` : '<p>No pattern was found.</p>';++ const table = s.comparison && (s.comparison.columns || []).length++ ? kit.table({ columns: s.comparison.columns.map((c, i) => ({ key: `c${i}`, label: c })), rows: (s.comparison.rows || []).map((r) => Object.fromEntries((s.comparison.columns || []).map((c, i) => [`c${i}`, r[i] == null ? '—' : String(r[i])]))) })++ : '';++ const tokens = s.reference_tokens || {};++ const tokenList = (list, label, render) => (list || []).length ? `<h3>${esc(label)}</h3><ul>${list.slice(0, 12).map((t) => `<li>${esc(t.source_id || '')}: ${render(t)}</li>`).join('')}</ul>` : '';++ const recs = (report.recommendations || []).length ? `<ol class="dd-recs">${report.recommendations.map((r) => `<li><strong>${esc(r.what)}</strong><p>${esc(r.why)}</p><p class="ovk-note">${r.rule && r.rule.id ? `Rule ${esc(r.rule.id)} (${esc(r.rule.status)}): ${esc(r.rule.text)}` : 'No existing rule: a proposed new one.'}</p>${evidenceList(r.evidence, id)}</li>`).join('')}</ol>` : '<p>No recommendation was made.</p>';++ return kit.section({ title: 'What the sites agree on', body: patterns + (s.gap ? `<p><strong>The gap nobody fills:</strong> ${esc(s.gap)}</p>` : '') + (table ? `<div class="dd-table">${table}</div>` : '') })++ + (s.best && s.best.what ? kit.section({ title: 'The best version', body: `<p><a href="${esc((report.sources || []).find((x) => x.id === s.best.source_id)?.url || '#')}" rel="noopener noreferrer">${esc(host((report.sources || []).find((x) => x.id === s.best.source_id)?.url || ''))}</a> ${esc(s.best.what)}</p>${evidenceList(s.best.evidence, id)}` }) : '')++ + ((s.mistakes || []).length ? kit.section({ title: 'What everyone gets wrong', body: `<ul class="dd-mistakes">${s.mistakes.map((m) => `<li>${esc(m.what)}${evidenceList(m.evidence, id)}</li>`).join('')}</ul>` }) : '')++ + kit.section({ title: 'Reference tokens', note: 'Inspiration, never copied: palettes, type scales and spacing read from the captured pages.', body: tokenList(tokens.palettes, 'Palettes', (t) => (t.colours || []).map(esc).join(', ')) + tokenList(tokens.type_scales, 'Type scales', (t) => `${(t.families || []).map(esc).join(', ')} — ${(t.sizes || []).map(esc).join(', ')}`) + tokenList(tokens.spacing, 'Spacing', (t) => (t.steps || []).map(esc).join(', ')) })++ + kit.section({ title: 'Do and do not', body: `<div class="dd-two"><div><h3>Do</h3><ul>${(s.do || []).map((x) => `<li>${esc(x)}</li>`).join('') || '<li>—</li>'}</ul></div><div><h3>Do not</h3><ul>${(s.dont || []).map((x) => `<li>${esc(x)}</li>`).join('') || '<li>—</li>'}</ul></div></div>` })++ + (s.accessibility && s.accessibility.length ? kit.section({ title: 'Accessibility', body: `<ul>${s.accessibility.map((x) => `<li>${esc(x)}</li>`).join('')}</ul>` }) : '')++ + (s.speed && s.speed.length ? kit.section({ title: 'Speed', body: `<ul>${s.speed.map((x) => `<li>${esc(x)}</li>`).join('')}</ul>` }) : '')++ + kit.section({ title: 'For OpenVibe', note: 'Each recommendation says which of our rules it follows, or which it would change. Nothing is added to the register by itself.', body: recs });++}++++function receiptSection(report, moreUrl) {++ const r = report.receipt || {};++ const models = (r.models || []).length ? kit.table({ caption: 'Every model call on the receipt', columns: [++ { key: 'step', label: 'Step' }, { key: 'provider', label: 'Provider' }, { key: 'model', label: 'Model' },++ { key: 'tokens_in', label: 'In', num: true }, { key: 'tokens_out', label: 'Out', num: true }, { key: 'ms', label: 'ms', num: true }, { key: 'cost_usd', label: 'USD', num: true, fmt: (v) => Number(v || 0).toFixed(6) },++ ], rows: r.models.map((m) => ({ step: m.step, provider: m.provider || '—', model: m.model || '—', tokens_in: m.tokens_in, tokens_out: m.tokens_out, ms: m.ms, cost_usd: m.cost_usd })) }) : '';++ return kit.section({ title: 'The receipt', body: `<p>Price shown before the run: <strong>${price(r.price_usd)}</strong>. Held: ${price(r.held_usd)}. Spent: <strong>${price(r.cost_usd)}</strong>. Captures: ${esc(String(r.captures_count || 0))}.</p>${models}${moreUrl ? `<p><a href="${esc(moreUrl)}" rel="noopener noreferrer">The run's own receipt</a> lists every call and capture.</p>` : ''}` });++}++++function reportPage({ viewer, row, id }) {++ const report = row.report;++ const title = row.question || 'Deep dive';++ const actions = [kit.button({ href: `/deep-dive/${encodeURIComponent(id)}/export/markdown`, label: 'Markdown', kind: 'quiet' }), kit.button({ href: `/deep-dive/${encodeURIComponent(id)}/export/json`, label: 'JSON', kind: 'quiet' }), kit.button({ href: `https://openvibe.design/create?research=${encodeURIComponent(id)}`, label: 'Use on OpenVibe.Design', kind: 'quiet' })];++ if (!report) {++ return `<div class="ex-wrap dd">${kit.head({ title, sub: 'The run ended, but its report is not available.', back: { href: '/deep-dive', label: 'Deep dive' } })}${kit.empty({ title: row.error ? 'The deep dive failed' : 'No report', text: (row.error && row.error.detail) || 'Nothing was written.' })}</div>`;++ }++ const s = report.synthesis || {};++ return `<div class="ex-wrap dd">${kit.head({ title, sub: `${report.input && report.input.depth || row.depth} · ${String((report.sources || []).length)} sources · ${String(report.receipt && report.receipt.captures_count || 0)} captures · written ${when(report.finished_at)}`, back: { href: '/deep-dive', label: 'Deep dive' }, actions })}`++ + `<p class="dd-lede">${esc(s.gap || '')}</p>`++ + capturesSection(report, id)++ + synthesisSection(report, id)++ + analysisSection(report, id)++ + (report.gaps && report.gaps.length ? kit.section({ title: 'What this run could not do', body: `<ul>${report.gaps.map((g) => `<li>${esc(g)}</li>`).join('')}</ul>` }) : '')++ + receiptSection(report, `/runs/${encodeURIComponent(row.run_id || id)}`)++ + `<p class="ovk-note">Screenshots are private to you and are shown as commentary with their source and read date; no site's code or assets are copied into a design.</p></div>`;++}++++function runPage({ viewer, row, id }) {++ if (row.status === 'succeeded') return reportPage({ viewer, row, id });++ const title = row.question || 'Deep dive';++ const live = ['queued', 'running', 'choosing'].includes(row.status);++ const head = kit.head({ title, sub: `${row.depth} deep dive · ${price(row.price_usd)} held · started ${when(row.created_at)}`, back: { href: '/deep-dive', label: 'Deep dive' } });++ const body = `<section class="dd-live"><div class="dd-live-head"><span class="ovk-badge">${esc(row.status)}</span>${row.step ? `<span class="ovk-note">${esc(STEP_LABELS[row.step] || row.step)}</span>` : ''}<span class="ovk-note" data-progress-text>${pct(row.progress)}%</span></div>`++ + progressBar(row.progress) + stepList(row.steps, row.step)++ + (row.status === 'choosing' ? choosing(row, id) : `<p class="ovk-note">${esc((row.steps || []).find((s) => s.key === row.step)?.detail || '')}</p>`)++ + (row.error ? `<p class="dd-error" role="alert">${esc(row.error.detail || row.error.code)}</p>` : '')++ + (live ? `<form method="post" action="/deep-dive/${encodeURIComponent(id)}/cancel" class="dd-cancel">${kit.button({ label: 'Cancel the deep dive', type: 'submit', kind: 'danger' })}<span class="ovk-note">Cancelling stops the next model call and the next capture.</span></form>` : '')++ + (row.status === 'failed' || row.status === 'cancelled' ? `<p>${kit.button({ href: '/deep-dive', label: 'Start a new deep dive' })}</p>` : '')++ + `</section>`;++ const poll = live ? `<script>(function(){var id=${JSON.stringify(id)};var status=${JSON.stringify(row.status)};var ticks=0;var t=setInterval(function(){ticks++;if(ticks>900){clearInterval(t);return;}fetch('/deep-dive/'+encodeURIComponent(id)+'/state',{headers:{Accept:'application/json'}}).then(function(r){if(!r.ok)return null;return r.json();}).then(function(j){if(!j||!j.deep_dive)return;var d=j.deep_dive;if(d.status!==status){location.reload();return;}var bar=document.querySelector('[role=progressbar]');if(bar){bar.setAttribute('aria-valuenow',Math.round((d.progress||0)*100));var fill=bar.firstElementChild;if(fill)fill.style.width=Math.round((d.progress||0)*100)+'%';}var txt=document.querySelector('[data-progress-text]');if(txt)txt.textContent=Math.round((d.progress||0)*100)+'%';}).catch(function(){});},2500);})();</script>` : '';++ return `<div class="ex-wrap dd">${head}${body}${poll}</div>`;++}++++function exportMarkdown(report) {++ const lines = [];++ const s = report.synthesis || {};++ lines.push(`# ${report.input && report.input.question || 'Deep dive'}`, '');++ lines.push(`Depth: ${report.input && report.input.depth || ''} · ${(report.sources || []).length} sources · ${(report.receipt && report.receipt.captures_count) || 0} captures · ${when(report.finished_at)}`, '');++ if (s.gap) lines.push(`**The gap:** ${s.gap}`, '');++ lines.push('## What the sites agree on', '');++ for (const p of s.patterns || []) lines.push(`- **${p.name}** (${p.usage_count || (p.sites || []).length} sites): ${p.what}`);++ if (s.comparison && (s.comparison.columns || []).length) {++ lines.push('', `| ${s.comparison.columns.join(' | ')} |`, `| ${s.comparison.columns.map(() => '---').join(' | ')} |`);++ for (const r of s.comparison.rows || []) lines.push(`| ${r.map((x) => String(x).replace(/\|/g, '\\|')).join(' | ')} |`);++ }++ if (s.best && s.best.what) lines.push('', '## The best version', '', s.best.what);++ if ((s.mistakes || []).length) { lines.push('', '## What everyone gets wrong', ''); for (const m of s.mistakes) lines.push(`- ${m.what}`); }++ if ((s.do || []).length) { lines.push('', '## Do', ''); for (const x of s.do) lines.push(`- ${x}`); }++ if ((s.dont || []).length) { lines.push('', '## Do not', ''); for (const x of s.dont) lines.push(`- ${x}`); }++ if (s.accessibility && s.accessibility.length) { lines.push('', '## Accessibility', ''); for (const x of s.accessibility) lines.push(`- ${x}`); }++ if (s.speed && s.speed.length) { lines.push('', '## Speed', ''); for (const x of s.speed) lines.push(`- ${x}`); }++ lines.push('', '## What each site does', '');++ for (const a of report.analysis || []) {++ lines.push(`### ${a.url}`, '', a.summary, '');++ if (a.strengths && a.strengths.length) lines.push(`Good: ${a.strengths.join('; ')}`);++ if (a.weaknesses && a.weaknesses.length) lines.push(`Fails: ${a.weaknesses.join('; ')}`);++ lines.push('');++ }++ if ((report.recommendations || []).length) { lines.push('## For OpenVibe', ''); for (const r of report.recommendations) lines.push(`- **${r.what}** ${r.why}${r.rule && r.rule.id ? ` (rule ${r.rule.id}, ${r.rule.status})` : ''}`); }++ lines.push('', '## Evidence', '', 'Every claim above cites its site, capture and read date in the JSON export and on the report page.', '');++ lines.push('Screenshots are private to the run\'s owner and are shown there as attributed commentary; no site\'s code or assets are copied into a design.', '');++ lines.push('## The receipt', '', `Price shown: $${Number(report.receipt && report.receipt.price_usd || 0).toFixed(2)}. Spent: $${Number(report.receipt && report.receipt.cost_usd || 0).toFixed(6)}. Captures: ${report.receipt && report.receipt.captures_count || 0}.`, '');++ for (const m of (report.receipt && report.receipt.models) || []) lines.push(`- ${m.step}: ${m.provider || '?'}/${m.model || '?'} — ${m.tokens_in} in, ${m.tokens_out} out, $${Number(m.cost_usd || 0).toFixed(6)}, ${m.ms} ms`);++ return lines.join('\n');++}++++module.exports = { homePage, runPage, reportPage, exportMarkdown, STEP_LABELS };nnew file mode 100644@@@ -0,0 +1,145 @@++'use strict';++// /deep-dive: the signed-out explanation, the start form with its prices, a live run's steps, choosing sites,++// the report (with its captures and evidence), the Markdown and JSON exports, the private shot, and cancel.++const assert = require('assert');++const { boot } = require('./helpers/boot');++const { createFakeAi } = require('./helpers/ai');++++const ID = 'run_TEST';++const API = 'https://openvibe.exchange';++const USER = { subject: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ', username: 'probe' };++++const REPORT = {++ id: ID, schema: 'ai.deep-dive@1', run_id: ID, status: 'succeeded',++ input: { question: 'a phone-first robot control panel', depth: 'quick', max_sites: 6 },++ steps: [++ { key: 'plan', status: 'done', detail: '9 questions' }, { key: 'search', status: 'done', detail: '12 sources' },++ { key: 'choose', status: 'done', detail: '6 sites' }, { key: 'capture', status: 'done', detail: '12 captures' },++ { key: 'analyse', status: 'done', detail: '6 sites' }, { key: 'synthesise', status: 'done', detail: '8 patterns' },++ { key: 'report', status: 'done', detail: 'ready' },++ ],++ sources: [{ id: 'src_1', kind: 'web', title: 'A control panel', url: 'https://example.com/robot', publisher: 'example.com', engine: 'openvibe', snippet: 'A panel', license: null, read_at: '2026-11-11T10:00:00.000Z' }],++ captures: [{ id: 'cap_1', source_id: 'src_1', url: 'https://example.com/robot', final_url: 'https://example.com/robot', title: 'A control panel', captured_at: '2026-11-11T10:00:45.000Z', viewport: 390, theme: 'light', full_page: true, media_id: 'med_01JAB2C3D4E5F6G7H8J9K0MNPQ', width: 390, height: 2000, crops: [{ id: 'crop_1_1', media_id: 'med_01JAB2C3D4E5F6G7H8J9K0MNPR', y: 0, height: 844 }], dom: {}, extras: null, skip: null, cached: false }],++ analysis: [{ source_id: 'src_1', url: 'https://example.com/robot', summary: 'One thumb reaches every control.', first_impression: 'The camera fills the first screen.', strengths: ['Reachable'], weaknesses: ['Stop is not separated'], evidence: [{ claim: 'Reachable', source_id: 'src_1', capture_id: 'cap_1', crop_id: 'crop_1_1', url: 'https://example.com/robot', read_at: '2026-11-11T10:00:45.000Z' }] }],++ synthesis: {++ patterns: [{ name: 'Live view first', what: 'The camera is the page.', sites: ['src_1'], usage_count: 1, evidence: [{ claim: 'Camera first', source_id: 'src_1', capture_id: 'cap_1', crop_id: 'crop_1_1', url: 'https://example.com/robot', read_at: '2026-11-11T10:00:45.000Z' }] }],++ comparison: { columns: ['Site', 'First screen'], rows: [['example.com', 'live frame']] },++ best: { source_id: 'src_1', what: 'The frame carries the state.', evidence: [{ claim: 'State in the frame', source_id: 'src_1', capture_id: 'cap_1', crop_id: null, url: 'https://example.com/robot', read_at: '2026-11-11T10:00:45.000Z' }] },++ mistakes: [{ what: 'The stop control is not separate.', evidence: [{ claim: 'One row', source_id: 'src_1', capture_id: 'cap_1', crop_id: 'crop_1_1', url: 'https://example.com/robot', read_at: '2026-11-11T10:00:45.000Z' }] }],++ gap: 'No panel shows what the robot is about to do.', reference_tokens: { palettes: [{ source_id: 'src_1', colours: ['#0c0e12', '#ffffff'] }], type_scales: [{ source_id: 'src_1', families: ['Inter'], sizes: ['14px'] }], spacing: [{ source_id: 'src_1', steps: ['4px'] }] },++ do: ['Keep controls in one hand'], dont: ['Do not share the stop row'], accessibility: ['Name every icon'], speed: ['Show the frame first'],++ },++ recommendations: [{ id: 'rec_1', what: 'Put the stop alone.', why: 'The best panel suggests it.', rule: { id: 'tap-targets', status: 'follows', text: 'Controls are big enough for a thumb.' }, evidence: [{ claim: 'The stop shares a row', source_id: 'src_1', capture_id: 'cap_1', crop_id: 'crop_1_1', url: 'https://example.com/robot', read_at: '2026-11-11T10:00:45.000Z' }] }],++ receipt: { price_usd: 0.2, held_usd: 0.2, cost_usd: 0.014, captures_count: 1, synthetic: false, models: [{ step: 'plan', provider: 'deepseek', model: 'deepseek-v4-flash', tokens_in: 900, tokens_out: 320, cost_usd: 0.0004, ms: 1800 }] },++ gaps: ['No Expert extras at Quick depth.'],++ created_at: '2026-11-11T10:00:00.000Z', updated_at: '2026-11-11T10:03:21.000Z', finished_at: '2026-11-11T10:03:21.000Z',++};++++(async () => {++ const ai = createFakeAi();++ const t = await boot({ ai });++ try {++ // Signed out: the page explains and asks for sign-in, and still names the price of every depth.++ let r = await t.get('/deep-dive');++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /Deep dive/);++ assert.match(r.text, /Expert design research/);++ assert.match(r.text, /Sign in to run a deep dive/);++ assert.match(r.text, /\$0\.20/);++ assert.match(r.text, /Search every way we have/);++++ // Signed in: the field, the depths and their prices.++ r = await t.get('/deep-dive', { as: USER });++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /What are you designing\?/);++ assert.match(r.text, /name="depth" value="quick" checked/);++ assert.match(r.text, /\$0\.50/);++ assert.match(r.text, /Start the deep dive/);++++ // Start: AI is called for the person and the page redirects to the run.++ r = await t.get('/deep-dive', { as: USER, form: { question: 'a phone-first robot control panel', depth: 'quick', sites: 'https://example.com/robot' } });++ assert.strictEqual(r.status, 303);++ assert.strictEqual(r.headers.get('location'), `/deep-dive/${ID}`);++ const row = ai.deepDives.get(ID);++ assert.strictEqual(row.question, 'a phone-first robot control panel');++ assert.strictEqual(row.owner.id, USER.subject);++++ // A running run: the six step names, the progress and Cancel.++ row.status = 'running'; row.step = 'capture'; row.progress = 0.5;++ row.steps = [{ key: 'plan', status: 'done' }, { key: 'search', status: 'done' }, { key: 'choose', status: 'done' }, { key: 'capture', status: 'running', detail: '3/6 sites' }, { key: 'analyse', status: 'pending' }];++ r = await t.get(`/deep-dive/${ID}`, { as: USER });++ assert.strictEqual(r.status, 200);++ for (const label of ['Planning', 'Searching', 'Choosing sites', 'Capturing', 'Analysing', 'Writing it up']) assert.ok(r.text.includes(label), `step ${label} is named`);++ assert.match(r.text, /3\/6 sites/);++ assert.match(r.text, /Cancel the deep dive/);++ assert.match(r.text, /role="progressbar"/);++++ // Choosing: the candidates, a Drop form and Start capture.++ row.status = 'choosing'; row.step = 'choose'; row.candidates = [{ id: 'site_1', url: 'https://example.com/robot', title: 'example.com', reason: 'a phone-first panel' }];++ r = await t.get(`/deep-dive/${ID}`, { as: USER });++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /Choose the sites to capture/);++ assert.match(r.text, /a phone-first panel/);++ assert.match(r.text, /Start capture/);++ assert.match(r.text, /name="drop" value="https:\/\/example.com\/robot"/);++++ // Sites: add one, drop one, and the person's start is sent.++ r = await t.get(`/deep-dive/${ID}/sites`, { as: USER, form: { sites: 'https://example.org/other', drop: 'https://example.com/robot', start: '1' } });++ assert.strictEqual(r.status, 303);++ const call = ai.deepDiveCalls.find((c) => c.body && c.body.start);++ assert.deepStrictEqual(call.body.add, ['https://example.org/other']);++ assert.deepStrictEqual(call.body.drop, ['https://example.com/robot']);++++ // Succeeded: the report page with captures, patterns, recommendations, receipt and both exports.++ row.status = 'succeeded'; row.report = REPORT; row.step = 'report'; row.progress = 1;++ r = await t.get(`/deep-dive/${ID}`, { as: USER });++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /What the sites agree on/);++ assert.match(r.text, /Live view first/);++ assert.match(r.text, /The gap nobody fills/);++ assert.match(r.text, /Put the stop alone/);++ assert.match(r.text, /Rule tap-targets \(follows\)/);++ assert.match(r.text, /The receipt/);++ assert.match(r.text, /deepseek-v4-flash/);++ assert.match(r.text, /Use on OpenVibe.Design/);++ assert.match(r.text, /\/deep-dive\/run_TEST\/shot\/crop_1_1/);++ assert.match(r.text, /example\.com/);++++ // Exports.++ r = await t.get(`/deep-dive/${ID}/export/markdown`, { as: USER });++ assert.strictEqual(r.status, 200);++ assert.match(r.headers.get('content-type'), /text\/markdown/);++ assert.match(r.text, /^# a phone-first robot control panel/);++ assert.match(r.text, /Live view first/);++ r = await t.get(`/deep-dive/${ID}/export/json`, { as: USER });++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.json().schema, 'ai.deep-dive@1');++ assert.strictEqual(r.json().recommendations[0].rule.id, 'tap-targets');++++ // The private shot is proxied from AI.++ r = await t.get(`/deep-dive/${ID}/shot/crop_1_1`, { as: USER });++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('content-type'), 'image/png');++ assert.ok(r.buffer.length > 10);++ assert.match(r.headers.get('cache-control'), /private/);++++ // Cancel is a POST and goes to AI.++ r = await t.get(`/deep-dive/${ID}/cancel`, { as: USER, form: { confirm: '1' } });++ assert.strictEqual(r.status, 303);++ assert.ok(ai.deepDiveCalls.some((c) => c.cancel && c.id === ID));++++ // A signed-out run page asks for sign-in; a run AI does not know is a 404.++ r = await t.get(`/deep-dive/${ID}`);++ assert.strictEqual(r.status, 401);++ assert.match(r.text, /Sign in to run a deep dive/);++ r = await t.get('/deep-dive/run_UNKNOWN', { as: USER });++ assert.strictEqual(r.status, 404);++ assert.match(r.text, /No such deep dive/);++++ // A cross-site POST is refused.++ r = await t.get(`/deep-dive/${ID}/cancel`, { as: USER, form: { confirm: '1' }, origin: 'https://evil.example' });++ assert.strictEqual(r.status, 403);++ assert.ok(API);++ process.stdout.write('deep dive: 34 checks passed\n');++ } finally { await t.close(); }++})().catch((e) => { process.stderr.write(String(e && e.stack || e) + '\n'); process.exit(1); });@@@ -69,6 +69,8 @@ function createFakeAi({ chat = null, community = [] } = {}) {by_product: [{ product: 'live', requests: 30, tokens_in: 3000, tokens_out: 800, cost_usd: 0.005, models: [{ provider: 'deepseek', model: 'deepseek-chat', requests: 30 }] }],};const communityData = { generated_at: '2026-11-06T09:00:00.000Z', offers: community };++ const deepDives = new Map();++ const deepDiveCalls = [];return {calls,configured: true,@@@ -91,6 +93,24 @@ function createFakeAi({ chat = null, community = [] } = {}) {{ id: 'iconify', kinds: ['icons'], price_per_1000: 0, byok: false, available: true, healthy: true },];},++ // Deep dives (AI workflow design.deep_dive): a small in-memory run the page tests drive.++ async deepDiveDepths() {++ return { depths: [++ { key: 'quick', label: 'Quick', sites: 6, price_usd: 0.2, minutes: 3, analysis: 'captures and reads each site' },++ { key: 'thorough', label: 'Thorough', sites: 15, price_usd: 0.5, minutes: 8, analysis: 'more sites' },++ { key: 'expert', label: 'Expert', sites: 30, price_usd: 1.2, minutes: 18, analysis: 'plus interactions, accessibility and speed' },++ ], capture: { enabled: true } };++ },++ async deepDiveCreate({ question, depth, sites = [], subject }) {++ deepDives.set('run_TEST', { id: 'run_TEST', run_id: 'run_TEST', question, depth, price_usd: 0.2, status: 'running', step: 'plan', progress: 0.03, steps: [], candidates: [], captures: [], report: null, error: null, owner: { type: 'user', id: subject || 'usr_TEST' }, created_at: new Date().toISOString(), updated_at: new Date().toISOString() });++ return { deep_dive: deepDives.get('run_TEST') };++ },++ async deepDiveGet(id) { const row = deepDives.get(id); if (!row) throw Object.assign(new Error('not found'), { status: 404 }); return { deep_dive: row }; },++ async deepDiveState(id) { const row = deepDives.get(id); if (!row) throw Object.assign(new Error('not found'), { status: 404 }); return { deep_dive: { id: row.id, status: row.status, step: row.step, progress: row.progress, steps: row.steps, candidates: row.candidates, captures: row.captures, error: row.error } }; },++ async deepDiveSites(id, body) { const row = deepDives.get(id); if (!row) throw Object.assign(new Error('not found'), { status: 404 }); deepDiveCalls.push({ id, body }); row.candidates = row.candidates.filter((c) => !(body.drop || []).includes(c.url)); for (const url of body.add || []) row.candidates.push({ id: 'site_x', url, title: url, reason: 'added' }); row.status = 'running'; return { deep_dive: row }; },++ async deepDiveCancel(id) { const row = deepDives.get(id); if (row) { row.status = 'cancelled'; } deepDiveCalls.push({ id, cancel: true }); return { deep_dive: row }; },++ async deepDiveShot() { return { buffer: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64'), contentType: 'image/png' }; },++ deepDives, deepDiveCalls,async catalog() { return catalog; },async usage() { return usage; },async communityOffers() { return communityData; },
@@ -372,6 +372,62 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = (
@@ -372,6 +372,62 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = (
async function ownCreate(token, body) { return ownCall('POST', '/api/v1/own-endpoints', { token, body }); }
async function ownCreate(token, body) { return ownCall('POST', '/api/v1/own-endpoints', { token, body }); }
async function ownStop(token, id) { return ownCall('POST', `/api/v1/own-endpoints/${encodeURIComponent(id)}/stop`, { token }); }
async function ownStop(token, id) { return ownCall('POST', `/api/v1/own-endpoints/${encodeURIComponent(id)}/stop`, { token }); }
+ /**
+ * Deep dives (AI workflow design.deep_dive, server/deep-dive/): Exchange calls AI as the service, for the
+ * signed-in person (on_behalf_of), and shows the run, its live steps and its report. The price is AI's own
+ * answer (GET /api/v1/deep-dives/depths), never a number copied into this site; the shot bytes come back
+ * through AI because the screenshots are private to the run's owner.
+ */
+ let depthsHit = { at: 0, value: null };
+ async function deepDiveDepths() {
+ if (depthsHit.value && now() - depthsHit.at < CATALOG_MS) return depthsHit.value;
+ try {
+ const body = await get('/api/v1/deep-dives/depths');
+ const value = { depths: Array.isArray(body.depths) ? body.depths : [], capture: body.capture || null };
+ depthsHit = { at: now(), value };
+ return value;
+ } catch (err) { warned('deep dive depths', err); return depthsHit.value || { depths: [], unavailable: true }; }
+ }
+ async function deepDiveCall(method, path, { body = undefined } = {}) {
+ if (!tokens) throw Object.assign(new Error('AI is not configured for this service'), { code: 'ai.not_configured', status: 503 });
+ const token = await tokens.getToken();
+ let res;
+ try {
+ res = await fetchImpl(`${config.ai.url}${path}`, {
+ method,
+ headers: { Accept: 'application/json', Authorization: `Bearer ${token}`, ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}) },
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
+ signal: AbortSignal.timeout(Math.max(config.ai.timeoutMs, 30000)),
+ });
+ } catch (err) {
+ throw Object.assign(new Error('OpenVibe.AI is unreachable right now'), { code: 'ai.unavailable', status: 503 });
+ }
+ const out = await jsonOf(res);
+ if (!res.ok) throw Object.assign(new Error((out && (out.detail || out.title)) || `AI answered ${res.status}`), { status: res.status, code: out && out.code });
+ return out || {};
+ }
+ async function deepDiveCreate({ question, depth, sites = [], subject }) {
+ return deepDiveCall('POST', '/api/v1/deep-dives', { body: {
+ question, depth, ...(sites.length ? { sites } : {}),
+ ...(subject ? { on_behalf_of: { type: 'user', id: subject } } : {}),
+ } });
+ }
+ async function deepDiveGet(id) { return deepDiveCall('GET', `/api/v1/deep-dives/${encodeURIComponent(id)}`); }
+ async function deepDiveState(id) { return deepDiveCall('GET', `/api/v1/deep-dives/${encodeURIComponent(id)}/state`); }
+ async function deepDiveSites(id, { add = [], drop = [], start = false } = {}) {
+ return deepDiveCall('POST', `/api/v1/deep-dives/${encodeURIComponent(id)}/sites`, { body: { ...(add.length ? { add } : {}), ...(drop.length ? { drop } : {}), ...(start ? { start: true } : {}) } });
+ }
+ async function deepDiveCancel(id) { return deepDiveCall('POST', `/api/v1/deep-dives/${encodeURIComponent(id)}/cancel`, { body: {} }); }
+ async function deepDiveShot(id, shot) {
+ if (!tokens) throw Object.assign(new Error('AI is not configured for this service'), { code: 'ai.not_configured', status: 503 });
+ const token = await tokens.getToken();
+ const res = await fetchImpl(`${config.ai.url}/api/v1/deep-dives/${encodeURIComponent(id)}/shots/${encodeURIComponent(shot)}`, {
+ headers: { Authorization: `Bearer ${token}`, Accept: 'image/png' }, signal: AbortSignal.timeout(config.ai.timeoutMs),
+ });
+ if (!res.ok) throw Object.assign(new Error(`AI answered ${res.status}`), { status: res.status, code: 'deep_dive.shot_not_found' });
+ return { buffer: Buffer.from(await res.arrayBuffer()), contentType: res.headers.get('content-type') || 'image/png' };
+ }
+
/** The person's own AI credentials: pass only their signed Network session, never the Exchange service token. */
/** The person's own AI credentials: pass only their signed Network session, never the Exchange service token. */
async function meRequest(token, path, { method = 'GET', body = undefined } = {}) {
async function meRequest(token, path, { method = 'GET', body = undefined } = {}) {
if (!token && path !== '/api/v1/tasks' && !path.startsWith('/api/v1/choices?')) throw Object.assign(new Error('Sign in is required'), { status: 401 });
if (!token && path !== '/api/v1/tasks' && !path.startsWith('/api/v1/choices?')) throw Object.assign(new Error('Sign in is required'), { status: 401 });
@@ -390,7 +446,7 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = (
@@ -390,7 +446,7 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = (
return createCredentialsClient(client, { baseUrl: config.ai.url });
return createCredentialsClient(client, { baseUrl: config.ai.url });
}
}
- return { catalog, directory, modelEvents, searchEngines, usage, communityOffers, chat, presetsFor, setKeyPrivacy, ownedProjects, gpuQuote, ownStatus, ownList, ownCreate, ownStop, adminRequest, meRequest, credentialsFor, configured: Boolean(tokens) };
+ return { catalog, directory, modelEvents, searchEngines, usage, communityOffers, chat, presetsFor, setKeyPrivacy, ownedProjects, gpuQuote, ownStatus, ownList, ownCreate, ownStop, adminRequest, meRequest, credentialsFor, deepDiveDepths, deepDiveCreate, deepDiveGet, deepDiveState, deepDiveSites, deepDiveCancel, deepDiveShot, configured: Boolean(tokens) };
}
}
/**
/**
@@ -354,6 +354,7 @@ async function createApp(opts = {}) {
@@ -354,6 +354,7 @@ async function createApp(opts = {}) {
app.use(createArenaRoutes(ctx));
app.use(createArenaRoutes(ctx));
app.use(require('./http/dashboard').createDashboardRoutes(ctx));
app.use(require('./http/dashboard').createDashboardRoutes(ctx));
app.use(require('./http/search').createSearchRoutes(ctx));
app.use(require('./http/search').createSearchRoutes(ctx));
+ app.use(require('./http/deep-dive').createDeepDiveRoutes(ctx));
app.use(createPageRoutes(ctx));
app.use(createPageRoutes(ctx));
app.use(createConsoleRoutes(ctx));
app.use(createConsoleRoutes(ctx));
@@ -67,7 +67,7 @@ function load(env = process.env) {
@@ -67,7 +67,7 @@ function load(env = process.env) {
ai: {
ai: {
url: trim(env.OV_AI_URL || 'https://ai.openvibe.services'),
url: trim(env.OV_AI_URL || 'https://ai.openvibe.services'),
audience: env.OV_AI_AUDIENCE || 'openvibe.ai',
audience: env.OV_AI_AUDIENCE || 'openvibe.ai',
- scope: env.OV_AI_SCOPE || 'ai.run.create',
+ scope: env.OV_AI_SCOPE || 'ai.run.create ai.run.read',
consoleScope: env.OV_AI_CONSOLE_SCOPE || 'ai.usage.read ai.workflow.manage ai.provider.manage',
consoleScope: env.OV_AI_CONSOLE_SCOPE || 'ai.usage.read ai.workflow.manage ai.provider.manage',
timeoutMs: Math.max(2000, int(env.EXCHANGE_AI_TIMEOUT_MS, 30_000)),
timeoutMs: Math.max(2000, int(env.EXCHANGE_AI_TIMEOUT_MS, 30_000)),
waitMs: Math.max(1000, int(env.EXCHANGE_AI_WAIT_MS, 25_000)),
waitMs: Math.max(1000, int(env.EXCHANGE_AI_WAIT_MS, 25_000)),
new file mode 100644
new file mode 100644
@@ -0,0 +1,140 @@
@@ -0,0 +1,140 @@
+'use strict';
+/**
+ * /deep-dive: start a run, follow its steps, choose sites, read the report, export it, and stream a private
+ * screenshot back (AI holds it; Exchange never stores someone else's captures).
+ *
+ * The person's session is the only way in: POST needs the signed-in viewer (AI gets them as on_behalf_of), every
+ * read is AI's owner-checked answer, and a signed-out visitor gets the explanation and a sign-in button. Same-origin
+ * on every POST. JavaScript is optional: the forms are plain POSTs and the run page still says what every step is;
+ * a small poll keeps the progress current while a run is live.
+ */
+const express = require('express');
+const views = require('../views/deep-dive');
+const { raw } = require('../render/html');
+const { send } = require('../render/layout');
+const { sameOrigin } = require('./principal');
+
+function createDeepDiveRoutes({ config, ai, log = console }) {
+ const r = express.Router();
+ r.use(express.urlencoded({ extended: false, limit: '32kb' }));
+
+ const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
+ const signedIn = (req) => Boolean(req.viewer && req.viewer.kind === 'user' && req.viewer.subject);
+ const errorOf = (err) => {
+ if (!err) return null;
+ if (err.status === 401) return 'Your session has expired. Sign in again.';
+ if (err.status === 402) return err.message || 'Your allowance cannot pay for this run.';
+ if (err.status === 422) return err.message || 'That input was not accepted.';
+ if (err.status === 503) return err.message || 'OpenVibe.AI is not available right now.';
+ return err.message || 'OpenVibe.AI could not answer.';
+ };
+
+ async function stateOf(id) {
+ const out = await ai.deepDiveState(id);
+ return out.deep_dive || null;
+ }
+
+ r.get('/deep-dive', async (req, res) => {
+ let depths = [];
+ let capture = null;
+ try { const d = await ai.deepDiveDepths(); depths = d.depths || []; capture = d.capture || null; } catch (err) { log.warn('[deep-dive] depths:', String(err && err.message || err).slice(0, 160)); }
+ page(req, res, { title: 'Deep dive', description: 'Expert design research: search everywhere, capture the best sites at phone and computer sizes, and read what works and why.', body: raw(views.homePage({ viewer: req.viewer, depths, capture })) });
+ });
+
+ r.post('/deep-dive', async (req, res) => {
+ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent('/deep-dive')}`);
+ if (!sameOrigin(req, config.baseUrl)) return res.sendStatus(403);
+ const question = String(req.body.question || '').replace(/\s+/g, ' ').trim().slice(0, 500);
+ const depth = ['quick', 'thorough', 'expert'].includes(req.body.depth) ? req.body.depth : 'quick';
+ const sites = String(req.body.sites || '').split(/\r?\n/).map((s) => s.trim()).filter((s) => /^https?:\/\//i.test(s)).slice(0, 30);
+ if (!question) {
+ const d = await ai.deepDiveDepths().catch(() => ({ depths: [] }));
+ return page(req, res, { title: 'Deep dive', index: false, body: raw(views.homePage({ viewer: req.viewer, depths: d.depths, error: 'Say what you are designing first.' })) }, 422);
+ }
+ try {
+ const out = await ai.deepDiveCreate({ question, depth, sites, subject: req.viewer.subject });
+ const id = out.deep_dive && out.deep_dive.id;
+ if (!id) throw Object.assign(new Error('AI did not return a deep dive id'), { status: 502 });
+ return res.redirect(303, `/deep-dive/${encodeURIComponent(id)}`);
+ } catch (err) {
+ const d = await ai.deepDiveDepths().catch(() => ({ depths: [] }));
+ const status = err.status === 402 ? 402 : err.status === 422 ? 422 : 502;
+ return page(req, res, { title: 'Deep dive', index: false, body: raw(views.homePage({ viewer: req.viewer, depths: d.depths, error: errorOf(err) })) }, status);
+ }
+ });
+
+ r.get('/deep-dive/:id', async (req, res) => {
+ if (!signedIn(req)) return page(req, res, { title: 'Deep dive', body: raw(views.homePage({ viewer: null, depths: [], error: null })) }, 401);
+ try {
+ const out = await ai.deepDiveGet(req.params.id);
+ const row = out.deep_dive;
+ if (!row) throw Object.assign(new Error('No such deep dive'), { status: 404 });
+ const title = row.question || 'Deep dive';
+ if (row.status === 'succeeded' && row.report) return page(req, res, { title, description: 'A deep dive report with its sources, captures, synthesis and receipts.', body: raw(views.reportPage({ viewer: req.viewer, row, id: row.id })) });
+ return page(req, res, { title, index: false, body: raw(views.runPage({ viewer: req.viewer, row, id: row.id })) });
+ } catch (err) {
+ const status = err.status === 404 ? 404 : 502;
+ return page(req, res, { title: status === 404 ? 'Not found' : 'Deep dive', index: false, body: raw(`<div class="ex-wrap ex-narrow">${status === 404 ? require('openvibe-shared/kit').empty({ icon: 'search', title: 'No such deep dive', text: 'It may belong to another account, or the link is old.', action: { href: '/deep-dive', label: 'Deep dive' } }) : require('openvibe-shared/kit').empty({ title: 'OpenVibe.AI is unavailable', text: errorOf(err), action: { href: '/deep-dive', label: 'Try again' } })}</div>`) }, status);
+ }
+ });
+
+ r.get('/deep-dive/:id/state', async (req, res) => {
+ if (!signedIn(req)) return res.status(401).json({ error: 'sign in' });
+ try {
+ const out = await ai.deepDiveState(req.params.id);
+ res.set('Cache-Control', 'no-store');
+ res.json(out);
+ } catch (err) { res.status(err.status === 404 ? 404 : 502).json({ error: errorOf(err) }); }
+ });
+
+ r.post('/deep-dive/:id/sites', async (req, res) => {
+ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/deep-dive/${req.params.id}`)}`);
+ if (!sameOrigin(req, config.baseUrl)) return res.sendStatus(403);
+ const add = String(req.body.sites || '').split(/\r?\n/).map((s) => s.trim()).filter((s) => /^https?:\/\//i.test(s)).slice(0, 30);
+ const drop = req.body.drop ? [String(req.body.drop)] : [];
+ try { await ai.deepDiveSites(req.params.id, { add, drop, start: true }); } catch (err) { log.warn('[deep-dive] sites:', String(err && err.message || err).slice(0, 160)); }
+ res.redirect(303, `/deep-dive/${encodeURIComponent(req.params.id)}`);
+ });
+
+ r.post('/deep-dive/:id/cancel', async (req, res) => {
+ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/deep-dive/${req.params.id}`)}`);
+ if (!sameOrigin(req, config.baseUrl)) return res.sendStatus(403);
+ try { await ai.deepDiveCancel(req.params.id); } catch (err) { log.warn('[deep-dive] cancel:', String(err && err.message || err).slice(0, 160)); }
+ res.redirect(303, `/deep-dive/${encodeURIComponent(req.params.id)}`);
+ });
+
+ r.get('/deep-dive/:id/shot/:shot', async (req, res) => {
+ if (!signedIn(req)) return res.sendStatus(401);
+ try {
+ const got = await ai.deepDiveShot(req.params.id, req.params.shot);
+ res.set({ 'Content-Type': got.contentType, 'Cache-Control': 'private, max-age=60', 'X-Robots-Tag': 'noindex' });
+ res.send(got.buffer);
+ } catch (err) { res.status(err.status === 404 ? 404 : 502).end(); }
+ });
+
+ r.get('/deep-dive/:id/export/json', async (req, res) => {
+ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/deep-dive/${req.params.id}`)}`);
+ try {
+ const out = await ai.deepDiveGet(req.params.id);
+ const report = out.deep_dive && out.deep_dive.report;
+ if (!report) return res.status(409).send('The deep dive has not written its report yet.');
+ res.set({ 'Content-Type': 'application/json; charset=utf-8', 'Content-Disposition': `attachment; filename="deep-dive-${req.params.id}.json"`, 'Cache-Control': 'private, no-store' });
+ res.send(JSON.stringify(report, null, 2));
+ } catch (err) { res.status(err.status === 404 ? 404 : 502).send(errorOf(err)); }
+ });
+
+ r.get('/deep-dive/:id/export/markdown', async (req, res) => {
+ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/deep-dive/${req.params.id}`)}`);
+ try {
+ const out = await ai.deepDiveGet(req.params.id);
+ const report = out.deep_dive && out.deep_dive.report;
+ if (!report) return res.status(409).send('The deep dive has not written its report yet.');
+ res.set({ 'Content-Type': 'text/markdown; charset=utf-8', 'Content-Disposition': `attachment; filename="deep-dive-${req.params.id}.md"`, 'Cache-Control': 'private, no-store' });
+ res.send(views.exportMarkdown(report));
+ } catch (err) { res.status(err.status === 404 ? 404 : 502).send(errorOf(err)); }
+ });
+
+ return r;
+}
+
+module.exports = { createDeepDiveRoutes };
@@ -23,6 +23,7 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
@@ -23,6 +23,7 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const NAV = [
const NAV = [
{ label: 'Models', href: '/models', icon: 'fa-microchip' },
{ label: 'Models', href: '/models', icon: 'fa-microchip' },
{ label: 'Search', href: '/search', icon: 'fa-magnifying-glass' },
{ label: 'Search', href: '/search', icon: 'fa-magnifying-glass' },
+ { label: 'Deep dive', href: '/deep-dive', icon: 'fa-binoculars' },
{ label: 'Pricing', href: '/pricing', icon: 'fa-tag' },
{ label: 'Pricing', href: '/pricing', icon: 'fa-tag' },
{ label: 'Docs', href: '/docs', icon: 'fa-code' },
{ label: 'Docs', href: '/docs', icon: 'fa-code' },
{ label: 'Status', href: '/status', icon: 'fa-gauge' },
{ label: 'Status', href: '/status', icon: 'fa-gauge' },
new file mode 100644
new file mode 100644
@@ -0,0 +1,212 @@
@@ -0,0 +1,212 @@
+'use strict';
+/**
+ * /deep-dive: the start, a run's live steps and its report (plain kit markup; the look is the main session's).
+ *
+ * The words are the owner's (deep-dive.md): "Deep dive", "Expert design research: we search everywhere, capture
+ * the best sites at phone and computer sizes, and tell you what works and why.", "What are you designing?", the
+ * three depths with their prices, and the step names Planning, Searching, Choosing sites, Capturing, Analysing,
+ * Writing it up. Nothing here restyles another page; it uses kit.head, kit.section, kit.badge, kit.button,
+ * kit.empty and kit.table, and plain lists and forms.
+ *
+ * Every screenshot is private to the run's owner and is shown through /deep-dive/:id/shot/:shot with its source
+ * URL and capture date as commentary, never republished wholesale.
+ */
+const kit = require('openvibe-shared/kit');
+const { esc, raw } = require('../render/html');
+
+const STEP_LABELS = { plan: 'Planning', search: 'Searching', choose: 'Choosing sites', capture: 'Capturing', analyse: 'Analysing', synthesise: 'Writing it up', report: 'Writing it up' };
+const STEP_NOTES = { plan: 'The questions an expert would ask, and the searches that answer them.', search: 'MegaSource, the search engines, GitHub and our own rules and components.', choose: 'The sites worth capturing, with a reason each. Add or drop any before Capture.', capture: 'Each site at 390 and 1280 px, light and dark where it supports dark, full page after a slow scroll.', analyse: 'A vision model reads each screenshot; a text model reads the DOM facts and the words.', synthesise: 'Patterns across sites, the gap, reference tokens, and recommendations checked against our rules.', report: 'The report, its evidence and the receipt.' };
+const price = (n) => `$${Number(n || 0).toFixed(2)}`;
+const when = (iso) => { if (!iso) return '—'; try { return new Date(iso).toISOString().slice(0, 16).replace('T', ' ') + ' UTC'; } catch { return String(iso); } };
+const host = (u) => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return String(u || ''); } };
+const pct = (n) => Math.max(0, Math.min(100, Math.round((Number(n) || 0) * 100)));
+
+function stepList(steps, current = null) {
+ const given = new Map((steps || []).map((s) => [s.key, s]));
+ const list = Object.keys(STEP_LABELS).map((key) => ({ key, status: 'pending', ...(given.get(key) || {}) }));
+ return `<ol class="dd-steps">${list.map((s) => {
+ const label = STEP_LABELS[s.key] || s.key;
+ const detail = s.detail || (s.status === 'pending' ? 'Waiting' : '');
+ return `<li class="dd-step is-${esc(s.status || 'pending')}" data-step="${esc(s.key)}"><strong>${esc(label)}</strong>${s.key === current ? ' <span class="ovk-badge">now</span>' : ''}<span>${esc(detail || '')}</span></li>`;
+ }).join('')}</ol>`;
+}
+
+function progressBar(progress) {
+ return `<div class="dd-progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${pct(progress)}"><span style="display:block;width:${pct(progress)}%"></span></div>`;
+}
+
+function depthCards(depths, selected = 'quick') {
+ return `<fieldset class="dd-depths"><legend>Depth</legend>${(depths || []).map((d) => `<label class="dd-depth"><input type="radio" name="depth" value="${esc(d.key)}"${d.key === selected ? ' checked' : ''}> <strong>${esc(d.label || d.key)}</strong> <span>${esc(String(d.sites || ''))} sites · about ${esc(String(d.minutes || '?'))} min · ${price(d.price_usd)}</span><small>${esc(d.analysis || '')}</small></label>`).join('')}</fieldset>`;
+}
+
+function depthList(depths) {
+ return `<ul class="dd-depth-list">${(depths || []).map((d) => `<li><strong>${esc(d.label || d.key)}</strong> <span>${esc(String(d.sites || ''))} sites · about ${esc(String(d.minutes || '?'))} min · ${price(d.price_usd)}</span> <small>${esc(d.analysis || '')}</small></li>`).join('')}</ul>`;
+}
+
+function signIn(next = '/deep-dive') {
+ return kit.empty({ icon: 'person', title: 'Sign in to run a deep dive', text: 'A deep dive keeps private screenshots and spends your allowance, so it needs your account. You can read what it does below first.', action: { href: `/auth/login?next=${encodeURIComponent(next)}`, label: 'Sign in with OpenVibe' } });
+}
+
+function whatItDoes() {
+ return kit.section({ title: 'What a deep dive does', body: `<ol class="dd-what">
+<li><strong>Search every way we have.</strong> Our research sources, Exchange's web, picture and icon engines, open-source projects on GitHub, design write-ups, and our own rules register and component registry.</li>
+<li><strong>Choose the sites worth a look.</strong> A model ranks them with a reason each; you can add or drop any before Capture (Quick goes straight on).</li>
+<li><strong>Capture them properly.</strong> 390 and 1280 px, dark and light where the site supports it, the whole page after a slow scroll so lazy sections load, and the DOM facts: type scale, palette, spacing, radii, shadows, grid, navigation, heading outline, button and link words, components. Expert adds open menus, hover and focus states, a transition frame strip, axe-core findings and LCP/CLS/INP.</li>
+<li><strong>Read them.</strong> A vision model reads each screenshot and a text model reads the measured facts and the words: hierarchy, density, what draws the eye, what is good and what fails.</li>
+<li><strong>Synthesise.</strong> The patterns and how many sites use each, a comparison, the best version, what everyone gets wrong, the gap nobody fills, reference tokens as inspiration, do and don't, accessibility and speed, and recommendations checked against OpenVibe's own rules.</li>
+<li><strong>Show the evidence.</strong> Every claim names its site, capture and the date it was read. Screenshots stay private to you and are shown as attributed commentary, never republished.</li>
+</ol>
+<p class="ovk-note">The price is shown before it starts and held; cancelling stops the spend. No paid screenshot service: our own Chrome, one page budget at a time.</p>` });
+}
+
+function homePage({ viewer, depths = [], capture = null, error = null }) {
+ const signedIn = Boolean(viewer && viewer.kind === 'user');
+ const cards = (depths && depths.length ? depths : [
+ { key: 'quick', label: 'Quick', sites: 6, minutes: 3, price_usd: 0.2, analysis: 'captures and reads each site' },
+ { key: 'thorough', label: 'Thorough', sites: 15, minutes: 8, price_usd: 0.5, analysis: 'more sites' },
+ { key: 'expert', label: 'Expert', sites: 30, minutes: 18, price_usd: 1.2, analysis: 'plus interactions, accessibility and speed' },
+ ]).map((d) => ({ ...d, key: d.key || d.depth, price_usd: d.price_usd ?? d.price }));
+ const form = signedIn
+ ? `<form method="post" action="/deep-dive" class="dd-form">
+<label class="dd-field"><span>What are you designing?</span><textarea name="question" rows="3" maxlength="500" required placeholder="a phone-first robot control panel"></textarea></label>
+${depthCards(cards)}
+<label class="dd-field"><span>Sites you already know (optional, one per line)</span><textarea name="sites" rows="2" maxlength="2048" placeholder="https://example.com/panel"></textarea></label>
+<p class="dd-go">${kit.button({ label: 'Start the deep dive', type: 'submit' })}<span class="ovk-note">The price is held before it starts; you can cancel at any step.</span></p>
+</form>${error ? `<p class="dd-error" role="alert">${esc(error)}</p>` : ''}`
+ : signIn();
+ return `<div class="ex-wrap dd">${kit.head({ title: 'Deep dive', sub: 'Expert design research: we search everywhere, capture the best sites at phone and computer sizes, and tell you what works and why.' })}${form}${signedIn ? '' : `<section class="ovk-section"><h2>The depths and their prices</h2>${depthList(cards)}</section>`}`
+ + (capture && capture.enabled === false ? `<p class="dd-error">Capture is off on this host right now (drill mode or Chrome missing), so a deep dive cannot start.</p>` : '')
+ + whatItDoes() + `</div>`;
+}
+
+function choosing(row, id) {
+ const candidates = row.candidates || [];
+ return kit.section({ title: 'Choose the sites to capture', note: 'Add or drop any site now. Capture starts when you say so (or when the wait ends).', body:
+ `<ul class="dd-candidates">${candidates.map((c) => `<li><a href="${esc(c.url)}" rel="noopener noreferrer">${esc(c.title || host(c.url))}</a><span>${esc(c.reason || '')}</span><form method="post" action="/deep-dive/${encodeURIComponent(id)}/sites" class="dd-inline"><input type="hidden" name="drop" value="${esc(c.url)}">${kit.button({ label: 'Drop', type: 'submit', kind: 'quiet', small: true })}</form></li>`).join('')}</ul>
+<form method="post" action="/deep-dive/${encodeURIComponent(id)}/sites" class="dd-form"><label class="dd-field"><span>Add a site (one per line)</span><textarea name="sites" rows="2" maxlength="2048"></textarea></label><input type="hidden" name="start" value="1"><p class="dd-go">${kit.button({ label: 'Start capture', type: 'submit' })}</p></form>` });
+}
+
+function evidenceList(list, id) {
+ if (!list || !list.length) return '';
+ return `<ul class="dd-evidence">${list.map((e) => `<li>${esc(e.claim)}${e.capture_id ? ` <a href="/deep-dive/${encodeURIComponent(id)}/shot/${encodeURIComponent(e.crop_id || e.capture_id)}" rel="noopener noreferrer">capture</a>` : ''} <a href="${esc(e.url)}" rel="noopener noreferrer">${esc(host(e.url))}</a> <span class="ovk-note">read ${esc(when(e.read_at))}</span></li>`).join('')}</ul>`;
+}
+
+function capturesSection(report, id, limit = 8) {
+ const caps = (report.captures || []).filter((c) => c.media_id && !c.skip).slice(0, limit);
+ if (!caps.length) return kit.section({ title: 'Captures', body: '<p>No site could be captured.</p>' });
+ return kit.section({ title: 'Captures', count: caps.length, note: 'Private to you. Each shot keeps its source and the date it was read; it is shown as commentary, never republished.', body:
+ `<div class="dd-shots">${caps.map((c) => {
+ const crop = (c.crops || [])[0];
+ const src = crop || c;
+ return `<figure class="dd-shot"><a href="/deep-dive/${encodeURIComponent(id)}/shot/${encodeURIComponent(src.id || c.id)}" rel="noopener noreferrer"><img src="/deep-dive/${encodeURIComponent(id)}/shot/${encodeURIComponent(src.id || c.id)}" alt="Capture of ${esc(host(c.url))} at ${c.viewport} px, ${esc(c.theme)}" loading="lazy"></a><figcaption><a href="${esc(c.url)}" rel="noopener noreferrer">${esc(host(c.url))}</a> · ${c.viewport} px · ${esc(c.theme)} · ${esc(when(c.captured_at))}${c.cached ? ' · from the 7-day cache' : ''}${c.skip ? ` · ${esc(c.skip)}` : ''}</figcaption></figure>`;
+ }).join('')}</div>` });
+}
+
+function analysisSection(report, id) {
+ const rows = (report.analysis || []).map((a) => `<article class="dd-analysis"><h3><a href="${esc(a.url)}" rel="noopener noreferrer">${esc(host(a.url))}</a></h3><p>${esc(a.summary)}</p>`
+ + (a.first_impression ? `<p><strong>First impression:</strong> ${esc(a.first_impression)}</p>` : '')
+ + `<p class="ovk-note">${[a.hierarchy && `Hierarchy: ${a.hierarchy}`, a.density && `Density: ${a.density}`, a.type && `Type: ${a.type}`, a.colour && `Colour: ${a.colour}`, a.motion && `Motion: ${a.motion}`, a.words && `Words: ${a.words}`].filter(Boolean).map(esc).join(' · ')}</p>`
+ + (a.strengths && a.strengths.length ? `<p><strong>Good:</strong> ${a.strengths.map(esc).join('; ')}</p>` : '')
+ + (a.weaknesses && a.weaknesses.length ? `<p><strong>Fails:</strong> ${a.weaknesses.map(esc).join('; ')}</p>` : '')
+ + (a.accessibility ? `<p><strong>Accessibility:</strong> ${esc(a.accessibility)}</p>` : '')
+ + (a.speed ? `<p><strong>Speed:</strong> ${esc(a.speed)}</p>` : '')
+ + evidenceList(a.evidence, id) + `</article>`).join('') || '<p>No site was analysed.</p>';
+ return kit.section({ title: 'What each site does', body: rows });
+}
+
+function synthesisSection(report, id) {
+ const s = report.synthesis || {};
+ const patterns = (s.patterns || []).length ? `<ul class="dd-patterns">${s.patterns.map((p) => `<li><strong>${esc(p.name)}</strong> <span class="ovk-badge">${esc(String(p.usage_count || (p.sites || []).length))} sites</span><p>${esc(p.what)}</p>${evidenceList(p.evidence, id)}</li>`).join('')}</ul>` : '<p>No pattern was found.</p>';
+ const table = s.comparison && (s.comparison.columns || []).length
+ ? kit.table({ columns: s.comparison.columns.map((c, i) => ({ key: `c${i}`, label: c })), rows: (s.comparison.rows || []).map((r) => Object.fromEntries((s.comparison.columns || []).map((c, i) => [`c${i}`, r[i] == null ? '—' : String(r[i])]))) })
+ : '';
+ const tokens = s.reference_tokens || {};
+ const tokenList = (list, label, render) => (list || []).length ? `<h3>${esc(label)}</h3><ul>${list.slice(0, 12).map((t) => `<li>${esc(t.source_id || '')}: ${render(t)}</li>`).join('')}</ul>` : '';
+ const recs = (report.recommendations || []).length ? `<ol class="dd-recs">${report.recommendations.map((r) => `<li><strong>${esc(r.what)}</strong><p>${esc(r.why)}</p><p class="ovk-note">${r.rule && r.rule.id ? `Rule ${esc(r.rule.id)} (${esc(r.rule.status)}): ${esc(r.rule.text)}` : 'No existing rule: a proposed new one.'}</p>${evidenceList(r.evidence, id)}</li>`).join('')}</ol>` : '<p>No recommendation was made.</p>';
+ return kit.section({ title: 'What the sites agree on', body: patterns + (s.gap ? `<p><strong>The gap nobody fills:</strong> ${esc(s.gap)}</p>` : '') + (table ? `<div class="dd-table">${table}</div>` : '') })
+ + (s.best && s.best.what ? kit.section({ title: 'The best version', body: `<p><a href="${esc((report.sources || []).find((x) => x.id === s.best.source_id)?.url || '#')}" rel="noopener noreferrer">${esc(host((report.sources || []).find((x) => x.id === s.best.source_id)?.url || ''))}</a> ${esc(s.best.what)}</p>${evidenceList(s.best.evidence, id)}` }) : '')
+ + ((s.mistakes || []).length ? kit.section({ title: 'What everyone gets wrong', body: `<ul class="dd-mistakes">${s.mistakes.map((m) => `<li>${esc(m.what)}${evidenceList(m.evidence, id)}</li>`).join('')}</ul>` }) : '')
+ + kit.section({ title: 'Reference tokens', note: 'Inspiration, never copied: palettes, type scales and spacing read from the captured pages.', body: tokenList(tokens.palettes, 'Palettes', (t) => (t.colours || []).map(esc).join(', ')) + tokenList(tokens.type_scales, 'Type scales', (t) => `${(t.families || []).map(esc).join(', ')} — ${(t.sizes || []).map(esc).join(', ')}`) + tokenList(tokens.spacing, 'Spacing', (t) => (t.steps || []).map(esc).join(', ')) })
+ + kit.section({ title: 'Do and do not', body: `<div class="dd-two"><div><h3>Do</h3><ul>${(s.do || []).map((x) => `<li>${esc(x)}</li>`).join('') || '<li>—</li>'}</ul></div><div><h3>Do not</h3><ul>${(s.dont || []).map((x) => `<li>${esc(x)}</li>`).join('') || '<li>—</li>'}</ul></div></div>` })
+ + (s.accessibility && s.accessibility.length ? kit.section({ title: 'Accessibility', body: `<ul>${s.accessibility.map((x) => `<li>${esc(x)}</li>`).join('')}</ul>` }) : '')
+ + (s.speed && s.speed.length ? kit.section({ title: 'Speed', body: `<ul>${s.speed.map((x) => `<li>${esc(x)}</li>`).join('')}</ul>` }) : '')
+ + kit.section({ title: 'For OpenVibe', note: 'Each recommendation says which of our rules it follows, or which it would change. Nothing is added to the register by itself.', body: recs });
+}
+
+function receiptSection(report, moreUrl) {
+ const r = report.receipt || {};
+ const models = (r.models || []).length ? kit.table({ caption: 'Every model call on the receipt', columns: [
+ { key: 'step', label: 'Step' }, { key: 'provider', label: 'Provider' }, { key: 'model', label: 'Model' },
+ { key: 'tokens_in', label: 'In', num: true }, { key: 'tokens_out', label: 'Out', num: true }, { key: 'ms', label: 'ms', num: true }, { key: 'cost_usd', label: 'USD', num: true, fmt: (v) => Number(v || 0).toFixed(6) },
+ ], rows: r.models.map((m) => ({ step: m.step, provider: m.provider || '—', model: m.model || '—', tokens_in: m.tokens_in, tokens_out: m.tokens_out, ms: m.ms, cost_usd: m.cost_usd })) }) : '';
+ return kit.section({ title: 'The receipt', body: `<p>Price shown before the run: <strong>${price(r.price_usd)}</strong>. Held: ${price(r.held_usd)}. Spent: <strong>${price(r.cost_usd)}</strong>. Captures: ${esc(String(r.captures_count || 0))}.</p>${models}${moreUrl ? `<p><a href="${esc(moreUrl)}" rel="noopener noreferrer">The run's own receipt</a> lists every call and capture.</p>` : ''}` });
+}
+
+function reportPage({ viewer, row, id }) {
+ const report = row.report;
+ const title = row.question || 'Deep dive';
+ const actions = [kit.button({ href: `/deep-dive/${encodeURIComponent(id)}/export/markdown`, label: 'Markdown', kind: 'quiet' }), kit.button({ href: `/deep-dive/${encodeURIComponent(id)}/export/json`, label: 'JSON', kind: 'quiet' }), kit.button({ href: `https://openvibe.design/create?research=${encodeURIComponent(id)}`, label: 'Use on OpenVibe.Design', kind: 'quiet' })];
+ if (!report) {
+ return `<div class="ex-wrap dd">${kit.head({ title, sub: 'The run ended, but its report is not available.', back: { href: '/deep-dive', label: 'Deep dive' } })}${kit.empty({ title: row.error ? 'The deep dive failed' : 'No report', text: (row.error && row.error.detail) || 'Nothing was written.' })}</div>`;
+ }
+ const s = report.synthesis || {};
+ return `<div class="ex-wrap dd">${kit.head({ title, sub: `${report.input && report.input.depth || row.depth} · ${String((report.sources || []).length)} sources · ${String(report.receipt && report.receipt.captures_count || 0)} captures · written ${when(report.finished_at)}`, back: { href: '/deep-dive', label: 'Deep dive' }, actions })}`
+ + `<p class="dd-lede">${esc(s.gap || '')}</p>`
+ + capturesSection(report, id)
+ + synthesisSection(report, id)
+ + analysisSection(report, id)
+ + (report.gaps && report.gaps.length ? kit.section({ title: 'What this run could not do', body: `<ul>${report.gaps.map((g) => `<li>${esc(g)}</li>`).join('')}</ul>` }) : '')
+ + receiptSection(report, `/runs/${encodeURIComponent(row.run_id || id)}`)
+ + `<p class="ovk-note">Screenshots are private to you and are shown as commentary with their source and read date; no site's code or assets are copied into a design.</p></div>`;
+}
+
+function runPage({ viewer, row, id }) {
+ if (row.status === 'succeeded') return reportPage({ viewer, row, id });
+ const title = row.question || 'Deep dive';
+ const live = ['queued', 'running', 'choosing'].includes(row.status);
+ const head = kit.head({ title, sub: `${row.depth} deep dive · ${price(row.price_usd)} held · started ${when(row.created_at)}`, back: { href: '/deep-dive', label: 'Deep dive' } });
+ const body = `<section class="dd-live"><div class="dd-live-head"><span class="ovk-badge">${esc(row.status)}</span>${row.step ? `<span class="ovk-note">${esc(STEP_LABELS[row.step] || row.step)}</span>` : ''}<span class="ovk-note" data-progress-text>${pct(row.progress)}%</span></div>`
+ + progressBar(row.progress) + stepList(row.steps, row.step)
+ + (row.status === 'choosing' ? choosing(row, id) : `<p class="ovk-note">${esc((row.steps || []).find((s) => s.key === row.step)?.detail || '')}</p>`)
+ + (row.error ? `<p class="dd-error" role="alert">${esc(row.error.detail || row.error.code)}</p>` : '')
+ + (live ? `<form method="post" action="/deep-dive/${encodeURIComponent(id)}/cancel" class="dd-cancel">${kit.button({ label: 'Cancel the deep dive', type: 'submit', kind: 'danger' })}<span class="ovk-note">Cancelling stops the next model call and the next capture.</span></form>` : '')
+ + (row.status === 'failed' || row.status === 'cancelled' ? `<p>${kit.button({ href: '/deep-dive', label: 'Start a new deep dive' })}</p>` : '')
+ + `</section>`;
+ const poll = live ? `<script>(function(){var id=${JSON.stringify(id)};var status=${JSON.stringify(row.status)};var ticks=0;var t=setInterval(function(){ticks++;if(ticks>900){clearInterval(t);return;}fetch('/deep-dive/'+encodeURIComponent(id)+'/state',{headers:{Accept:'application/json'}}).then(function(r){if(!r.ok)return null;return r.json();}).then(function(j){if(!j||!j.deep_dive)return;var d=j.deep_dive;if(d.status!==status){location.reload();return;}var bar=document.querySelector('[role=progressbar]');if(bar){bar.setAttribute('aria-valuenow',Math.round((d.progress||0)*100));var fill=bar.firstElementChild;if(fill)fill.style.width=Math.round((d.progress||0)*100)+'%';}var txt=document.querySelector('[data-progress-text]');if(txt)txt.textContent=Math.round((d.progress||0)*100)+'%';}).catch(function(){});},2500);})();</script>` : '';
+ return `<div class="ex-wrap dd">${head}${body}${poll}</div>`;
+}
+
+function exportMarkdown(report) {
+ const lines = [];
+ const s = report.synthesis || {};
+ lines.push(`# ${report.input && report.input.question || 'Deep dive'}`, '');
+ lines.push(`Depth: ${report.input && report.input.depth || ''} · ${(report.sources || []).length} sources · ${(report.receipt && report.receipt.captures_count) || 0} captures · ${when(report.finished_at)}`, '');
+ if (s.gap) lines.push(`**The gap:** ${s.gap}`, '');
+ lines.push('## What the sites agree on', '');
+ for (const p of s.patterns || []) lines.push(`- **${p.name}** (${p.usage_count || (p.sites || []).length} sites): ${p.what}`);
+ if (s.comparison && (s.comparison.columns || []).length) {
+ lines.push('', `| ${s.comparison.columns.join(' | ')} |`, `| ${s.comparison.columns.map(() => '---').join(' | ')} |`);
+ for (const r of s.comparison.rows || []) lines.push(`| ${r.map((x) => String(x).replace(/\|/g, '\\|')).join(' | ')} |`);
+ }
+ if (s.best && s.best.what) lines.push('', '## The best version', '', s.best.what);
+ if ((s.mistakes || []).length) { lines.push('', '## What everyone gets wrong', ''); for (const m of s.mistakes) lines.push(`- ${m.what}`); }
+ if ((s.do || []).length) { lines.push('', '## Do', ''); for (const x of s.do) lines.push(`- ${x}`); }
+ if ((s.dont || []).length) { lines.push('', '## Do not', ''); for (const x of s.dont) lines.push(`- ${x}`); }
+ if (s.accessibility && s.accessibility.length) { lines.push('', '## Accessibility', ''); for (const x of s.accessibility) lines.push(`- ${x}`); }
+ if (s.speed && s.speed.length) { lines.push('', '## Speed', ''); for (const x of s.speed) lines.push(`- ${x}`); }
+ lines.push('', '## What each site does', '');
+ for (const a of report.analysis || []) {
+ lines.push(`### ${a.url}`, '', a.summary, '');
+ if (a.strengths && a.strengths.length) lines.push(`Good: ${a.strengths.join('; ')}`);
+ if (a.weaknesses && a.weaknesses.length) lines.push(`Fails: ${a.weaknesses.join('; ')}`);
+ lines.push('');
+ }
+ if ((report.recommendations || []).length) { lines.push('## For OpenVibe', ''); for (const r of report.recommendations) lines.push(`- **${r.what}** ${r.why}${r.rule && r.rule.id ? ` (rule ${r.rule.id}, ${r.rule.status})` : ''}`); }
+ lines.push('', '## Evidence', '', 'Every claim above cites its site, capture and read date in the JSON export and on the report page.', '');
+ lines.push('Screenshots are private to the run\'s owner and are shown there as attributed commentary; no site\'s code or assets are copied into a design.', '');
+ lines.push('## The receipt', '', `Price shown: $${Number(report.receipt && report.receipt.price_usd || 0).toFixed(2)}. Spent: $${Number(report.receipt && report.receipt.cost_usd || 0).toFixed(6)}. Captures: ${report.receipt && report.receipt.captures_count || 0}.`, '');
+ for (const m of (report.receipt && report.receipt.models) || []) lines.push(`- ${m.step}: ${m.provider || '?'}/${m.model || '?'} — ${m.tokens_in} in, ${m.tokens_out} out, $${Number(m.cost_usd || 0).toFixed(6)}, ${m.ms} ms`);
+ return lines.join('\n');
+}
+
+module.exports = { homePage, runPage, reportPage, exportMarkdown, STEP_LABELS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,145 @@
@@ -0,0 +1,145 @@
+'use strict';
+// /deep-dive: the signed-out explanation, the start form with its prices, a live run's steps, choosing sites,
+// the report (with its captures and evidence), the Markdown and JSON exports, the private shot, and cancel.
+const assert = require('assert');
+const { boot } = require('./helpers/boot');
+const { createFakeAi } = require('./helpers/ai');
+
+const ID = 'run_TEST';
+const API = 'https://openvibe.exchange';
+const USER = { subject: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ', username: 'probe' };
+
+const REPORT = {
+ id: ID, schema: 'ai.deep-dive@1', run_id: ID, status: 'succeeded',
+ input: { question: 'a phone-first robot control panel', depth: 'quick', max_sites: 6 },
+ steps: [
+ { key: 'plan', status: 'done', detail: '9 questions' }, { key: 'search', status: 'done', detail: '12 sources' },
+ { key: 'choose', status: 'done', detail: '6 sites' }, { key: 'capture', status: 'done', detail: '12 captures' },
+ { key: 'analyse', status: 'done', detail: '6 sites' }, { key: 'synthesise', status: 'done', detail: '8 patterns' },
+ { key: 'report', status: 'done', detail: 'ready' },
+ ],
+ sources: [{ id: 'src_1', kind: 'web', title: 'A control panel', url: 'https://example.com/robot', publisher: 'example.com', engine: 'openvibe', snippet: 'A panel', license: null, read_at: '2026-11-11T10:00:00.000Z' }],
+ captures: [{ id: 'cap_1', source_id: 'src_1', url: 'https://example.com/robot', final_url: 'https://example.com/robot', title: 'A control panel', captured_at: '2026-11-11T10:00:45.000Z', viewport: 390, theme: 'light', full_page: true, media_id: 'med_01JAB2C3D4E5F6G7H8J9K0MNPQ', width: 390, height: 2000, crops: [{ id: 'crop_1_1', media_id: 'med_01JAB2C3D4E5F6G7H8J9K0MNPR', y: 0, height: 844 }], dom: {}, extras: null, skip: null, cached: false }],
+ analysis: [{ source_id: 'src_1', url: 'https://example.com/robot', summary: 'One thumb reaches every control.', first_impression: 'The camera fills the first screen.', strengths: ['Reachable'], weaknesses: ['Stop is not separated'], evidence: [{ claim: 'Reachable', source_id: 'src_1', capture_id: 'cap_1', crop_id: 'crop_1_1', url: 'https://example.com/robot', read_at: '2026-11-11T10:00:45.000Z' }] }],
+ synthesis: {
+ patterns: [{ name: 'Live view first', what: 'The camera is the page.', sites: ['src_1'], usage_count: 1, evidence: [{ claim: 'Camera first', source_id: 'src_1', capture_id: 'cap_1', crop_id: 'crop_1_1', url: 'https://example.com/robot', read_at: '2026-11-11T10:00:45.000Z' }] }],
+ comparison: { columns: ['Site', 'First screen'], rows: [['example.com', 'live frame']] },
+ best: { source_id: 'src_1', what: 'The frame carries the state.', evidence: [{ claim: 'State in the frame', source_id: 'src_1', capture_id: 'cap_1', crop_id: null, url: 'https://example.com/robot', read_at: '2026-11-11T10:00:45.000Z' }] },
+ mistakes: [{ what: 'The stop control is not separate.', evidence: [{ claim: 'One row', source_id: 'src_1', capture_id: 'cap_1', crop_id: 'crop_1_1', url: 'https://example.com/robot', read_at: '2026-11-11T10:00:45.000Z' }] }],
+ gap: 'No panel shows what the robot is about to do.', reference_tokens: { palettes: [{ source_id: 'src_1', colours: ['#0c0e12', '#ffffff'] }], type_scales: [{ source_id: 'src_1', families: ['Inter'], sizes: ['14px'] }], spacing: [{ source_id: 'src_1', steps: ['4px'] }] },
+ do: ['Keep controls in one hand'], dont: ['Do not share the stop row'], accessibility: ['Name every icon'], speed: ['Show the frame first'],
+ },
+ recommendations: [{ id: 'rec_1', what: 'Put the stop alone.', why: 'The best panel suggests it.', rule: { id: 'tap-targets', status: 'follows', text: 'Controls are big enough for a thumb.' }, evidence: [{ claim: 'The stop shares a row', source_id: 'src_1', capture_id: 'cap_1', crop_id: 'crop_1_1', url: 'https://example.com/robot', read_at: '2026-11-11T10:00:45.000Z' }] }],
+ receipt: { price_usd: 0.2, held_usd: 0.2, cost_usd: 0.014, captures_count: 1, synthetic: false, models: [{ step: 'plan', provider: 'deepseek', model: 'deepseek-v4-flash', tokens_in: 900, tokens_out: 320, cost_usd: 0.0004, ms: 1800 }] },
+ gaps: ['No Expert extras at Quick depth.'],
+ created_at: '2026-11-11T10:00:00.000Z', updated_at: '2026-11-11T10:03:21.000Z', finished_at: '2026-11-11T10:03:21.000Z',
+};
+
+(async () => {
+ const ai = createFakeAi();
+ const t = await boot({ ai });
+ try {
+ // Signed out: the page explains and asks for sign-in, and still names the price of every depth.
+ let r = await t.get('/deep-dive');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /Deep dive/);
+ assert.match(r.text, /Expert design research/);
+ assert.match(r.text, /Sign in to run a deep dive/);
+ assert.match(r.text, /\$0\.20/);
+ assert.match(r.text, /Search every way we have/);
+
+ // Signed in: the field, the depths and their prices.
+ r = await t.get('/deep-dive', { as: USER });
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /What are you designing\?/);
+ assert.match(r.text, /name="depth" value="quick" checked/);
+ assert.match(r.text, /\$0\.50/);
+ assert.match(r.text, /Start the deep dive/);
+
+ // Start: AI is called for the person and the page redirects to the run.
+ r = await t.get('/deep-dive', { as: USER, form: { question: 'a phone-first robot control panel', depth: 'quick', sites: 'https://example.com/robot' } });
+ assert.strictEqual(r.status, 303);
+ assert.strictEqual(r.headers.get('location'), `/deep-dive/${ID}`);
+ const row = ai.deepDives.get(ID);
+ assert.strictEqual(row.question, 'a phone-first robot control panel');
+ assert.strictEqual(row.owner.id, USER.subject);
+
+ // A running run: the six step names, the progress and Cancel.
+ row.status = 'running'; row.step = 'capture'; row.progress = 0.5;
+ row.steps = [{ key: 'plan', status: 'done' }, { key: 'search', status: 'done' }, { key: 'choose', status: 'done' }, { key: 'capture', status: 'running', detail: '3/6 sites' }, { key: 'analyse', status: 'pending' }];
+ r = await t.get(`/deep-dive/${ID}`, { as: USER });
+ assert.strictEqual(r.status, 200);
+ for (const label of ['Planning', 'Searching', 'Choosing sites', 'Capturing', 'Analysing', 'Writing it up']) assert.ok(r.text.includes(label), `step ${label} is named`);
+ assert.match(r.text, /3\/6 sites/);
+ assert.match(r.text, /Cancel the deep dive/);
+ assert.match(r.text, /role="progressbar"/);
+
+ // Choosing: the candidates, a Drop form and Start capture.
+ row.status = 'choosing'; row.step = 'choose'; row.candidates = [{ id: 'site_1', url: 'https://example.com/robot', title: 'example.com', reason: 'a phone-first panel' }];
+ r = await t.get(`/deep-dive/${ID}`, { as: USER });
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /Choose the sites to capture/);
+ assert.match(r.text, /a phone-first panel/);
+ assert.match(r.text, /Start capture/);
+ assert.match(r.text, /name="drop" value="https:\/\/example.com\/robot"/);
+
+ // Sites: add one, drop one, and the person's start is sent.
+ r = await t.get(`/deep-dive/${ID}/sites`, { as: USER, form: { sites: 'https://example.org/other', drop: 'https://example.com/robot', start: '1' } });
+ assert.strictEqual(r.status, 303);
+ const call = ai.deepDiveCalls.find((c) => c.body && c.body.start);
+ assert.deepStrictEqual(call.body.add, ['https://example.org/other']);
+ assert.deepStrictEqual(call.body.drop, ['https://example.com/robot']);
+
+ // Succeeded: the report page with captures, patterns, recommendations, receipt and both exports.
+ row.status = 'succeeded'; row.report = REPORT; row.step = 'report'; row.progress = 1;
+ r = await t.get(`/deep-dive/${ID}`, { as: USER });
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /What the sites agree on/);
+ assert.match(r.text, /Live view first/);
+ assert.match(r.text, /The gap nobody fills/);
+ assert.match(r.text, /Put the stop alone/);
+ assert.match(r.text, /Rule tap-targets \(follows\)/);
+ assert.match(r.text, /The receipt/);
+ assert.match(r.text, /deepseek-v4-flash/);
+ assert.match(r.text, /Use on OpenVibe.Design/);
+ assert.match(r.text, /\/deep-dive\/run_TEST\/shot\/crop_1_1/);
+ assert.match(r.text, /example\.com/);
+
+ // Exports.
+ r = await t.get(`/deep-dive/${ID}/export/markdown`, { as: USER });
+ assert.strictEqual(r.status, 200);
+ assert.match(r.headers.get('content-type'), /text\/markdown/);
+ assert.match(r.text, /^# a phone-first robot control panel/);
+ assert.match(r.text, /Live view first/);
+ r = await t.get(`/deep-dive/${ID}/export/json`, { as: USER });
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.json().schema, 'ai.deep-dive@1');
+ assert.strictEqual(r.json().recommendations[0].rule.id, 'tap-targets');
+
+ // The private shot is proxied from AI.
+ r = await t.get(`/deep-dive/${ID}/shot/crop_1_1`, { as: USER });
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('content-type'), 'image/png');
+ assert.ok(r.buffer.length > 10);
+ assert.match(r.headers.get('cache-control'), /private/);
+
+ // Cancel is a POST and goes to AI.
+ r = await t.get(`/deep-dive/${ID}/cancel`, { as: USER, form: { confirm: '1' } });
+ assert.strictEqual(r.status, 303);
+ assert.ok(ai.deepDiveCalls.some((c) => c.cancel && c.id === ID));
+
+ // A signed-out run page asks for sign-in; a run AI does not know is a 404.
+ r = await t.get(`/deep-dive/${ID}`);
+ assert.strictEqual(r.status, 401);
+ assert.match(r.text, /Sign in to run a deep dive/);
+ r = await t.get('/deep-dive/run_UNKNOWN', { as: USER });
+ assert.strictEqual(r.status, 404);
+ assert.match(r.text, /No such deep dive/);
+
+ // A cross-site POST is refused.
+ r = await t.get(`/deep-dive/${ID}/cancel`, { as: USER, form: { confirm: '1' }, origin: 'https://evil.example' });
+ assert.strictEqual(r.status, 403);
+ assert.ok(API);
+ process.stdout.write('deep dive: 34 checks passed\n');
+ } finally { await t.close(); }
+})().catch((e) => { process.stderr.write(String(e && e.stack || e) + '\n'); process.exit(1); });
@@ -69,6 +69,8 @@ function createFakeAi({ chat = null, community = [] } = {}) {
@@ -69,6 +69,8 @@ function createFakeAi({ chat = null, community = [] } = {}) {
by_product: [{ product: 'live', requests: 30, tokens_in: 3000, tokens_out: 800, cost_usd: 0.005, models: [{ provider: 'deepseek', model: 'deepseek-chat', requests: 30 }] }],
by_product: [{ product: 'live', requests: 30, tokens_in: 3000, tokens_out: 800, cost_usd: 0.005, models: [{ provider: 'deepseek', model: 'deepseek-chat', requests: 30 }] }],
};
};
const communityData = { generated_at: '2026-11-06T09:00:00.000Z', offers: community };
const communityData = { generated_at: '2026-11-06T09:00:00.000Z', offers: community };
+ const deepDives = new Map();
+ const deepDiveCalls = [];
return {
return {
calls,
calls,
configured: true,
configured: true,
@@ -91,6 +93,24 @@ function createFakeAi({ chat = null, community = [] } = {}) {
@@ -91,6 +93,24 @@ function createFakeAi({ chat = null, community = [] } = {}) {
{ id: 'iconify', kinds: ['icons'], price_per_1000: 0, byok: false, available: true, healthy: true },
{ id: 'iconify', kinds: ['icons'], price_per_1000: 0, byok: false, available: true, healthy: true },
];
];
},
},
+ // Deep dives (AI workflow design.deep_dive): a small in-memory run the page tests drive.
+ async deepDiveDepths() {
+ return { depths: [
+ { key: 'quick', label: 'Quick', sites: 6, price_usd: 0.2, minutes: 3, analysis: 'captures and reads each site' },
+ { key: 'thorough', label: 'Thorough', sites: 15, price_usd: 0.5, minutes: 8, analysis: 'more sites' },
+ { key: 'expert', label: 'Expert', sites: 30, price_usd: 1.2, minutes: 18, analysis: 'plus interactions, accessibility and speed' },
+ ], capture: { enabled: true } };
+ },
+ async deepDiveCreate({ question, depth, sites = [], subject }) {
+ deepDives.set('run_TEST', { id: 'run_TEST', run_id: 'run_TEST', question, depth, price_usd: 0.2, status: 'running', step: 'plan', progress: 0.03, steps: [], candidates: [], captures: [], report: null, error: null, owner: { type: 'user', id: subject || 'usr_TEST' }, created_at: new Date().toISOString(), updated_at: new Date().toISOString() });
+ return { deep_dive: deepDives.get('run_TEST') };
+ },
+ async deepDiveGet(id) { const row = deepDives.get(id); if (!row) throw Object.assign(new Error('not found'), { status: 404 }); return { deep_dive: row }; },
+ async deepDiveState(id) { const row = deepDives.get(id); if (!row) throw Object.assign(new Error('not found'), { status: 404 }); return { deep_dive: { id: row.id, status: row.status, step: row.step, progress: row.progress, steps: row.steps, candidates: row.candidates, captures: row.captures, error: row.error } }; },
+ async deepDiveSites(id, body) { const row = deepDives.get(id); if (!row) throw Object.assign(new Error('not found'), { status: 404 }); deepDiveCalls.push({ id, body }); row.candidates = row.candidates.filter((c) => !(body.drop || []).includes(c.url)); for (const url of body.add || []) row.candidates.push({ id: 'site_x', url, title: url, reason: 'added' }); row.status = 'running'; return { deep_dive: row }; },
+ async deepDiveCancel(id) { const row = deepDives.get(id); if (row) { row.status = 'cancelled'; } deepDiveCalls.push({ id, cancel: true }); return { deep_dive: row }; },
+ async deepDiveShot() { return { buffer: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64'), contentType: 'image/png' }; },
+ deepDives, deepDiveCalls,
async catalog() { return catalog; },
async catalog() { return catalog; },
async usage() { return usage; },
async usage() { return usage; },
async communityOffers() { return communityData; },
async communityOffers() { return communityData; },