openvibers/openvibe.exchange Commits

Deep dive: the start, the live run and the report on /deep-dive (#222)

OP OpenVibers committed 2 days ago · 17da600b86 · 8 files, +577 −2

Changes

ddiff --git a/server/ai.js b/server/ai.js
iindex 487d5ff..8721a89 100644
---- a/server/ai.js
++++ b/server/ai.js
@@@ -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) };
}
/**
ddiff --git a/server/app.js b/server/app.js
iindex 7c2f8be..f1c64c5 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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));
ddiff --git a/server/config.js b/server/config.js
iindex b86fb38..dc34a80 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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)),
ddiff --git a/server/http/deep-dive.js b/server/http/deep-dive.js
nnew file mode 100644
iindex 0000000..5ea8459
---- /dev/null
++++ b/server/http/deep-dive.js
@@@ -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 };
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex a0fd172..5deb94d 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -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' },
ddiff --git a/server/views/deep-dive.js b/server/views/deep-dive.js
nnew file mode 100644
iindex 0000000..fb5eeb4
---- /dev/null
++++ b/server/views/deep-dive.js
@@@ -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 };
ddiff --git a/test/deep-dive.test.js b/test/deep-dive.test.js
nnew file mode 100644
iindex 0000000..1e3b9a4
---- /dev/null
++++ b/test/deep-dive.test.js
@@@ -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); });
ddiff --git a/test/helpers/ai.js b/test/helpers/ai.js
iindex 8cc7142..927a4d0 100644
---- a/test/helpers/ai.js
++++ b/test/helpers/ai.js
@@@ -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; },