openvibers/openvibe.actor Commits

The openvibe.actor home gains task templates that fill the task box, the tools it can run from OpenVibe.Tools, live numbers, API samples and the questions people ask (#98)

OP OpenVibers committed 22 days ago · be7ca0fbe2 · 8 files, +211 −15

Changes

ddiff --git a/README.md b/README.md
iindex 9b4ca71..e2d7f3b 100644
---- a/README.md
++++ b/README.md
@@@ -94,6 +94,7 @@ The resource index is for OpenVibe.Services on loopback and is blocked at the pu
- Tasks, their event logs and daily spend (`migrations/0001_tasks.sql`). Tasks are kept 30 days, then pruned with their events. Nothing stored is a credential, a key or a token.
- Request telemetry (`migrations/0010_telemetry.sql`): each HTTP request is aggregated by route template, method and status class, never by raw URL. Once a minute one `http.request` sample per key, plus the active-requests, p95 latency and event-loop-lag gauges, is written to this service's own `telemetry_samples` table (openvibe-sdk 0.46 telemetry, the `platform.telemetry-sample@1` shape); rows older than 14 days are deleted daily. `/shared` files, static assets, health, readiness and `/metrics` are not counted. `server/app.js` builds the collector (`createHttpTelemetry`) and mounts its middleware; `server/index.js` flushes it every minute and on stop, and runs the 14-day retention.
- The agent catalog and its rate cards; the routing policy over them (placement itself is `openvibe-sdk/placement`).
++- The home page (`server/http/pages.js`, `public/css/home.css`, `test/home.test.js`): the task box, numbers read from the catalog, the running config and OpenVibe.Tools' catalog (the runtime's own cached copy, waited for at most a second), task templates that fill the task box (`/?task=…&mode=…`, kept by no shared cache; signed out, the task waits for sign-in), the Tools tools the runtime can run, the agents, the API samples and the questions people ask first, with JSON-LD WebSite, WebApplication, WebPage, ItemList (the agents) and FAQPage (exactly the questions shown).
## Does not own
ddiff --git a/public/css/actor.css b/public/css/actor.css
iindex d6f2cb4..2a80054 100644
---- a/public/css/actor.css
++++ b/public/css/actor.css
@@@ -2,7 +2,8 @@
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--text-primary, #e6edf7); font: 16px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
--a { color: var(--accent-light, #60a5fa); }
++/* accent-light is drawn for dark themes: links mix it toward the text colour so they read on light themes too (WCAG AA). */
++a { color: color-mix(in srgb, var(--accent-light, #60a5fa) 55%, var(--text-primary, #e6edf7)); }
a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible, summary:focus-visible { outline: 3px solid var(--accent, #3b82f6); outline-offset: 2px; }
.skip { position: absolute; left: -9999px; }
.skip:focus { left: 16px; top: 8px; background: var(--bg-card, #131c2e); padding: 8px 12px; z-index: 10; }
@@@ -11,7 +12,7 @@ a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visib
h1 { font-size: 1.8rem; line-height: 1.2; margin: 1rem 0 .6rem; overflow-wrap: anywhere; }
h2 { font-size: 1.3rem; margin: 1.8rem 0 .5rem; }
h3 { font-size: 1.08rem; margin: 1.2rem 0 .4rem; }
--.muted { color: var(--text-muted, #7386a3); }
++.muted { color: var(--text-secondary, #96a7c2); }
.small { font-size: .85rem; }
.crumbs { font-size: .9rem; color: var(--text-muted, #7386a3); margin: 8px 0; overflow-wrap: anywhere; }
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .88em; }
ddiff --git a/public/css/home.css b/public/css/home.css
nnew file mode 100644
iindex 0000000..c6643cf
---- /dev/null
++++ b/public/css/home.css
@@@ -0,0 +1,42 @@
++/* openvibe.actor's home page only (server/http/pages.js): the numbers, the task templates, the tools Actor can run and
++ the questions. Colours come from the Frame's theme tokens, so it follows the person's theme, light or dark. */
++.page { --hm-accent-text: color-mix(in srgb, var(--accent-light, #60a5fa) 45%, var(--text-primary, #e6edf7)); }
++.sc-eyebrow { color: var(--hm-accent-text); }
++.sc-table thead th { color: var(--text-secondary, #96a7c2); }
++
++/* Numbers from the catalog and the running config. */
++.hm-stats-sec { margin-top: 40px; }
++.hm-stats { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(min(128px, 100%), 1fr)); }
++.hm-stats a { display: grid; gap: 2px; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, rgba(255, 255, 255, .08)); background: var(--bg-secondary, #101828); color: inherit; text-decoration: none; transition: border-color .15s; }
++.hm-stats a:hover, .hm-stats a:focus-visible { border-color: var(--accent, #3b82f6); }
++.hm-stats b { font-size: 30px; line-height: 1.1; letter-spacing: -.02em; }
++.hm-stats span { font-size: 13.5px; color: var(--text-secondary, #96a7c2); }
++
++/* Task templates, grouped by what they need. */
++.hm-templates { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr)); }
++.hm-tgroup { display: flex; flex-direction: column; gap: 8px; min-width: 0; padding: 14px; border-radius: 16px; border: 1px solid var(--border, rgba(255, 255, 255, .08)); background: var(--bg-secondary, #101828); }
++.hm-tgroup-head { margin: 0 0 2px; font-size: 13px; letter-spacing: .04em; text-transform: uppercase; color: var(--text-secondary, #96a7c2); }
++.hm-template { display: grid; gap: 4px; padding: 10px 12px; border-radius: 12px; border: 1px solid var(--border-light, var(--border, #2c3d5c)); background: var(--bg-primary, #0a0f1c); color: var(--text-primary, #e6edf7); text-decoration: none; font-size: 14.5px; line-height: 1.45; transition: border-color .15s; }
++.hm-template:hover, .hm-template:focus-visible { border-color: var(--accent, #3b82f6); }
++.hm-template small { font-size: 12.5px; font-weight: 700; color: var(--hm-accent-text); }
++.hm-needs { margin: auto 0 0; font-size: 13px; color: var(--text-secondary, #96a7c2); }
++.hm-chosen { margin: 0 0 12px; padding: 10px 14px; border-left: 3px solid var(--accent, #3b82f6); border-radius: 0 10px 10px 0; background: var(--bg-primary, #0a0f1c); font-size: 15px; overflow-wrap: anywhere; }
++
++/* The tools the runtime can run, as chips. */
++.hm-chips { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }
++.hm-chips li { min-width: 0; }
++.hm-chips a, .hm-chips li:not(:has(a)) { display: inline-block; padding: 5px 12px; border-radius: 999px; border: 1px solid var(--border-light, var(--border, #2c3d5c)); background: var(--bg-secondary, #101828); color: var(--text-primary, #e6edf7); font-size: 13.5px; text-decoration: none; overflow-wrap: anywhere; }
++.hm-chips a:hover, .hm-chips a:focus-visible { border-color: var(--accent, #3b82f6); }
++
++/* Questions, answered on the page. */
++.hm-faq { display: grid; gap: 12px 28px; grid-template-columns: repeat(auto-fill, minmax(min(380px, 100%), 1fr)); }
++.hm-qa { padding: 4px 0 14px; border-bottom: 1px solid var(--border, rgba(255, 255, 255, .08)); }
++.hm-qa h3 { margin: 0 0 6px; font-size: 16px; }
++.hm-qa p { margin: 0; font-size: 14.5px; line-height: 1.6; color: var(--text-secondary, #96a7c2); }
++
++/* The shared steps and code grids ask for 280–320 px; a 320 px phone has less. */
++.sc-steps { grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr)); }
++.sc-code { grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr)); }
++@media (max-width: 520px) { .sc-code pre { white-space: pre-wrap; overflow-wrap: anywhere; } }
++@media (max-width: 420px) { .sc-hero, .sc-sec, .sc-cta { padding-left: 16px; padding-right: 16px; } }
++@media (prefers-reduced-motion: reduce) { .hm-stats a, .hm-template { transition: none; } }
ddiff --git a/server/agents/catalog.js b/server/agents/catalog.js
iindex e73b435..b9c07cc 100644
---- a/server/agents/catalog.js
++++ b/server/agents/catalog.js
@@@ -41,7 +41,7 @@ const AGENTS = [
quality: 'standard',
can: ['task:answer', 'task:lookup', 'task:research', 'task:robot', 'task:computer', 'tool:openvibe-search', 'tool:openvibe-tools', 'tool:openvibe-bot'],
rate_card: { input_usd_per_mtok: 0.30, output_usd_per_mtok: 1.20, source: DEEPSEEK_PRICES, verified_at: VERIFIED },
-- blurb: 'OpenVibe\'s own agent: a DeepSeek model that acts through OpenVibe services — search across the network, 160+ tools (DNS, mail, certificates, headers, conversions), a page reader, your own robots on OpenVibe.Bot when you let it drive them, and your own computers when you let it use them.',
++ blurb: 'OpenVibe\'s own agent: a DeepSeek model that acts through OpenVibe services — search across the network, the OpenVibe.Tools tools anyone can run (DNS, mail records, certificates, headers, WHOIS), a page reader, your own robots on OpenVibe.Bot when you let it drive them, and your own computers when you let it use them.',
},
{
id: 'openai-agent',
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex aee03db..cfcf524 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -23,6 +23,7 @@ const store = require('../tasks/store');
const { createTask } = require('../tasks/create');
const catalog = require('../agents/catalog');
const { MODES } = require('../agents/router');
++const seo = require('openvibe-shared/seo');
const TASK_ID = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
@@@ -42,6 +43,35 @@ const EXAMPLES = [
'Is the TLS certificate of example.com valid, and when does it expire?',
];
++// Tasks to start from, by what they need. Each one works today; the robot, computer and private ones say what they need.
++const TEMPLATES = [
++ { group: 'Look it up', items: [
++ { text: EXAMPLES[0] },
++ { text: EXAMPLES[3] },
++ ] },
++ { group: 'Research with sources', items: [
++ { text: EXAMPLES[1] },
++ { text: 'Which open-source licences let me sell hosted copies of the software? Compare MIT, Apache-2.0, GPL-3.0 and AGPL-3.0, with sources.' },
++ ] },
++ { group: 'Write and explain', items: [
++ { text: EXAMPLES[2] },
++ { text: 'Write a short, friendly note telling my viewers the stream starts an hour later on Friday.', mode: 'cheapest' },
++ ] },
++ { group: 'Across OpenVibe', items: [
++ { text: 'Find recent posts and clips on OpenVibe about Raspberry Pi robots, and link the best three.' },
++ ] },
++ { group: 'Your robot', needs: 'Turn on Your agent on the robot\'s panel first.', items: [
++ { text: 'Drive my rover forward for two seconds, turn on its headlights, then say hello.' },
++ ] },
++ { group: 'Your computer', needs: 'Needs the OpenVibe Node\'s computer plugin on that machine.', items: [
++ { text: 'On my computer, list the ten largest files in my Downloads folder.' },
++ ] },
++ { group: 'Private', needs: 'Answered on OpenVibe\'s own model; nothing leaves OpenVibe.', items: [
++ { text: 'Summarize the following note in three bullet points, without it leaving OpenVibe:', mode: 'private' },
++ ] },
++];
++const templateHref = (t) => `/?${new URLSearchParams({ task: t.text, ...(t.mode ? { mode: t.mode } : {}) })}#task`;
++
const usd = (n) => (n == null ? '—' : n === 0 ? '$0' : n < 0.01 ? `$${Number(n).toFixed(4)}` : `$${Number(n).toFixed(2)}`);
const perM = (n) => (n === 0 ? 'free' : `$${Number(n).toFixed(2)}`);
const priceText = (rc) => (rc.input_usd_per_mtok === 0 && rc.output_usd_per_mtok === 0 ? 'Free (OpenVibe hardware)' : `${perM(rc.input_usd_per_mtok)} in · ${perM(rc.output_usd_per_mtok)} out per 1M tokens${rc.per_call_usd ? ` · ${usd(rc.per_call_usd)} a search` : ''}`);
@@@ -58,10 +88,12 @@ function createPageRoutes(ctx) {
// ── The task box ─────────────────────────────────────────
function taskForm(req, { text = '', mode = 'balanced', problem = null } = {}) {
if (!signedIn(req)) {
++ // A template picked while signed out waits in the box and comes back after sign-in.
++ const next = text ? `/?${new URLSearchParams({ task: text, mode })}` : '/#task';
return html`<div class="task-box task-box-guest">
--<p class="task-box-lead">Sign in with your OpenVibe account and ask anything like:</p>
--<ul class="examples">${EXAMPLES.map((e) => html`<li>“${e}”</li>`)}</ul>
--<div class="task-actions"><a class="sc-btn sc-primary" href="/auth/login?next=%2F%23task">Sign in to start</a><span class="muted small">Free: ${tier.tasksPerDay} tasks a day, no card.</span></div></div>`;
++${text ? html`<p class="task-box-lead">Your task, ready to run once you sign in:</p><blockquote class="hm-chosen">${text}</blockquote>`
++ : html`<p class="task-box-lead">Sign in with your OpenVibe account and ask in plain words. Not sure what to ask? Start from one of the tasks below.</p>`}
++<div class="task-actions"><a class="sc-btn sc-primary" href="/auth/login?next=${encodeURIComponent(next)}">${text ? 'Sign in to run it' : 'Sign in to start'}</a><span class="muted small">Free: ${tier.tasksPerDay} tasks a day, no card.</span></div></div>`;
}
return html`<form class="task-box" method="post" action="/tasks">
${problem ? notice(problem, 'warn') : ''}
@@@ -75,8 +107,21 @@ ${MODES.map((m) => html`<label class="mode"><input type="radio" name="mode" valu
}
// ── Home ─────────────────────────────────────────────────
-- function home(req, res, { status = 200, form = {} } = {}) {
++ // The OpenVibe.Tools tools the runtime can call (the adapters' own cached catalog), waited for at most a second.
++ async function callableTools() {
++ const ov = ctx.adapters && ctx.adapters.tools;
++ if (!ov || typeof ov.tools !== 'function') return [];
++ const list = await Promise.race([ov.tools().catch(() => []), new Promise((resolve) => setTimeout(() => resolve([]), 1000).unref())]);
++ return (Array.isArray(list) ? list : []).filter((t) => t && t.id && t.name);
++ }
++
++ async function home(req, res, { status = 200, form = {} } = {}) {
const agents = catalog.list(config);
++ const tools = await callableTools();
++ // A template link (/?task=…&mode=…#task) fills the task box; the page itself stays the canonical /.
++ if (!form.text && typeof req.query.task === 'string') form = { text: req.query.task.slice(0, 2000), mode: MODES.includes(req.query.mode) ? req.query.mode : 'balanced' };
++ const working = agents.filter((a) => a.available).length;
++ const faq = faqItems(agents);
const hero = showcase.hero({
eyebrow: 'OpenVibe.Actor · your agent for everything · alpha',
title: 'Ask once.', accent: 'The right agent does it.',
@@@ -85,13 +130,28 @@ ${MODES.map((m) => html`<label class="mode"><input type="radio" name="mode" valu
note: `Open source (AGPL-3.0). Free tier: ${tier.tasksPerDay} tasks a day, up to ${usd(tier.perTaskUsd)} each. The list below says exactly what works today.`,
aside: { html: String(routerCard(agents)) },
});
++ const stats = [
++ [working, working === 1 ? 'agent working now' : 'agents working now', '#h-agents'],
++ [tools.length, 'tools it can run', '#h-tools'],
++ [MODES.length, 'ways to choose', '#route'],
++ [tier.tasksPerDay, 'free tasks a day', '#faq'],
++ ].filter(([n]) => n > 0);
page(req, res, {
-- index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
-- jsonLd: homeJsonLd(config),
++ index: true, cache: signedIn(req) && !req.query.task ? null : req.query.task ? cache.htmlHeaders({ private: true }) : PUBLIC_CACHE,
++ jsonLd: [...homeJsonLd(config),
++ seo.jsonLd.itemList('Agents OpenVibe.Actor routes tasks to', agents.map((a) => ({ name: a.name, url: `${config.baseUrl}/agents#${a.id}`, description: (catalog.byId(a.id) || {}).blurb }))),
++ seo.jsonLd.faq(faq.map(([q, a]) => ({ q, a })))],
styles: [showcase.STYLESHEET],
++ css: ['css/home.css'],
body: html`${raw(hero)}
++<section class="sc-sec hm-stats-sec" aria-label="OpenVibe.Actor in numbers"><ul class="hm-stats">${stats.map(([n, label, href]) => html`<li><a href="${href}"><b>${n}</b><span>${label}</span></a></li>`)}</ul></section>
<section class="sc-sec" id="task" aria-labelledby="h-task"><h2 id="h-task">Give Actor a task</h2>
${taskForm(req, form)}</section>
++<section class="sc-sec" aria-labelledby="h-templates"><h2 id="h-templates">Start from one of these</h2>
++<p class="sc-lede">Tasks that work today. Pick one and it lands in the task box, ready to run or change.</p>
++<ul class="hm-templates">${TEMPLATES.map((g) => html`<li class="hm-tgroup"><p class="hm-tgroup-head"><b>${g.group}</b></p>
++${g.items.map((t) => html`<a class="hm-template" href="${templateHref(t)}"><span>${t.text}</span><small>${t.mode ? `${MODE_LABEL[t.mode]} mode · ` : ''}Use this</small></a>`)}
++${g.needs ? html`<p class="hm-needs">${g.needs}</p>` : ''}</li>`)}</ul></section>
${raw(showcase.features({
title: 'What it can do today',
lede: 'Each of these is a real agent working through a real API, not a demo.',
@@@ -105,7 +165,11 @@ ${raw(showcase.features({
{ icon: 'ov:check', title: 'Checked before you see it', text: 'A second model reads every answer first, from another model family where one is available (private mode checks on OpenVibe\'s own model); a failed check goes to the next agent.' },
],
}))}
++${tools.length ? html`<section class="sc-sec" aria-labelledby="h-tools"><h2 id="h-tools">Tools it can run</h2>
++<p class="sc-lede">For exact answers, OpenVibe's own agent runs these ${tools.length} OpenVibe.Tools tools itself instead of guessing: the same public tools anyone can use on openvibe.tools.</p>
++<ul class="hm-chips hm-tools">${tools.map((t) => html`<li>${/^https:\/\//.test(t.docs || '') ? html`<a href="${t.docs}">${t.name}</a>` : t.name}</li>`)}</ul></section>` : ''}
${raw(showcase.steps({
++ id: 'route',
title: 'How a task finds its agent',
items: [
{ title: 'Sort the task', text: 'An answer, a lookup, research, a live web search, code, your own robot or your own computer: that decides what an agent must be able to do.' },
@@@ -149,10 +213,37 @@ ${table(['Piece', 'State'], [
[html`Ask in chat: write <code>@actor</code> and your question in OpenVibe's global chat or your own channel, and the answer comes back in the room`, 'Live: public tools only (never your computers, robots or code), 5 an hour, on your free tier.'],
[html`Run your own: Actor on your server with your own model (<a href="https://github.com/OpenVibers/OpenVibe.Actor/blob/main/docs/self-host.md">how</a>), signed in with your password, data on your disk`, 'Live: Docker or plain Node, Ollama or any OpenAI-compatible model.'],
])}</section>
++${raw(showcase.code({
++ id: 'api',
++ title: 'Hand it tasks from your own code',
++ lede: 'Every task, its live stream and its webhooks are an API, and the router answers a dry run with no account at all.',
++ samples: [
++ { label: 'Which agent, what cost (curl, no token)', lang: 'sh', code: `curl -X POST ${config.baseUrl}/api/v1/route \\\n -H 'Content-Type: application/json' \\\n -d '{"task": "Is the certificate of example.com valid?",\n "mode": "cheapest"}'` },
++ { label: 'JavaScript (openvibe-sdk)', lang: 'js', code: "import { createClient } from 'openvibe-sdk/core';\nimport { createActorClient } from 'openvibe-sdk/actor';\n\nconst actor = createActorClient(\n createClient({ credentials: 'include' }));\nconst task = await actor.tasks.create({\n task: 'Summarize the latest Node.js release',\n});\nfor await (const e of actor.tasks.events(task.id)) {\n console.log(e.event, e.data);\n}" },
++ ],
++ }))}
++<section class="sc-sec" id="faq" aria-labelledby="h-faq"><h2 id="h-faq">Questions</h2>
++<div class="hm-faq">${faq.map(([q, a]) => html`<div class="hm-qa"><h3>${q}</h3><p>${a}</p></div>`)}</div></section>
${raw(showcase.cta({ title: 'Build on it', text: 'Every task, its live stream, its webhooks and the router are an API. Apps get actor.task.* grants on OpenVibe.Services.', actions: [{ label: 'Read the API', href: '/docs', primary: true }, { label: 'See the agents', href: '/agents' }] }))}`,
}, status);
}
++ /** Questions people ask first, answered from the running config and the catalog. */
++ function faqItems(agents) {
++ const names = agents.filter((a) => a.available).map((a) => a.name);
++ const list = names.length > 1 ? `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}` : names[0] || '';
++ return [
++ ['What does OpenVibe.Actor do?', 'You describe a task in plain words. Actor sorts it, picks the agent that can do it best within your budget and mode, runs it while you watch its steps, has a second model check the answer, and shows you the answer with its sources, what it cost and why that agent.'],
++ ['What does it cost?', `The free tier gives ${tier.tasksPerDay} tasks a day, up to ${usd(tier.perTaskUsd)} each, with no card. Every task shows its real cost to a fraction of a cent.`],
++ list ? ['Which agents does it use?', `${list} are working now. The agents page lists each one with its price per million tokens and what it can do.`] : null,
++ ['Is the answer checked?', 'Yes. A second model reads every answer before you see it, from another model family where one is available. An answer that fails its check goes to the next agent that can do the task.'],
++ ['What is private mode?', 'Private mode uses only an open model on OpenVibe\'s own server, so the task never leaves OpenVibe. It is for short answers; tasks that need search or tools go to the other agents in the other modes.'],
++ ['Can it use my computer or my robot?', 'Only if you turn it on. On openvibe.bot you let your agent use a computer or drive a robot, one at a time. On a computer at the ask level, each program or write waits for your approval on that computer.'],
++ ['Can I run it myself?', 'Yes. Actor is open source under the AGPL-3.0 and runs on your own server with Docker or plain Node and your own model, such as Ollama or any OpenAI-compatible one.'],
++ ['Can my app use it?', 'Yes. Apps get actor.task.* grants on OpenVibe.Services and use the same API: create a task, follow its live stream, and receive signed webhooks when it ends.'],
++ ].filter(Boolean);
++ }
++
function routerCard(agents) {
const shown = agents.filter((a) => a.id !== 'codes');
return html`<figure class="router-card" aria-label="How Actor picked an agent for a sample task">
ddiff --git a/test/home.test.js b/test/home.test.js
nnew file mode 100644
iindex 0000000..1bf7d15
---- /dev/null
++++ b/test/home.test.js
@@@ -0,0 +1,60 @@
++'use strict';
++/**
++ * The home page's data sections (server/http/pages.js): the numbers come from the catalog, the running config and
++ * OpenVibe.Tools' catalog; a template fills the task box (signed out, it waits for sign-in); the tools Actor can run
++ * are only the callable ones; the FAQPage JSON-LD holds exactly the questions the page shows.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ const user = t.network.addUser('kim');
++ const section = (html, id) => (new RegExp(`<section class="sc-sec[^"]*"[^>]*aria-labelledby="${id}"[\\s\\S]*?</section>`).exec(html) || [''])[0];
++
++ await check('the stat row: agents working, tools it can run, modes, the free tier', async () => {
++ const r = await t.get('/');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /<b>1<\/b><span>tools it can run<\/span>/, 'only the callable tool (mx), not the job tool');
++ assert.match(r.text, /<b>5<\/b><span>ways to choose<\/span>/);
++ assert.match(r.text, /<b>\d+<\/b><span>free tasks a day<\/span>/);
++ assert.match(r.text, /<b>\d+<\/b><span>agents? working now<\/span>/);
++ const tools = section(r.text, 'h-tools');
++ assert.match(tools, />MX Lookup</);
++ assert.ok(!tools.includes('Video download'), 'a job tool is not offered');
++ assert.match(r.text, /<link rel="stylesheet" href="\/css\/home\.css\?v=[0-9a-f]{10}">/);
++ });
++
++ await check('templates fill the task box; signed out, the chosen task waits for sign-in', async () => {
++ const home = await t.get('/');
++ const templates = section(home.text, 'h-templates');
++ const links = [...templates.matchAll(/class="hm-template" href="([^"]+)"/g)].map((m) => m[1].replace(/&amp;/g, '&'));
++ assert.ok(links.length >= 8, 'one link per template');
++ const privateOne = links.find((l) => l.includes('mode=private'));
++ assert.ok(privateOne, 'the private template names its mode');
++ const signedIn = await t.get(privateOne.replace(/#.*$/, ''), { as: user });
++ assert.match(signedIn.text, /<textarea id="task"[^>]*>Summarize the following note in three bullet points, without it leaving OpenVibe:<\/textarea>/);
++ assert.match(signedIn.text, /<input type="radio" name="mode" value="private" checked>/);
++ const guest = await t.get('/?task=Explain%20DNS&mode=nope');
++ assert.match(guest.text, /<blockquote class="hm-chosen">Explain DNS<\/blockquote>/);
++ assert.match(guest.text, /href="\/auth\/login\?next=%2F%3Ftask%3DExplain%2BDNS%26mode%3Dbalanced">Sign in to run it</);
++ assert.match(guest.text, /<link rel="canonical" href="https:\/\/openvibe\.actor\/">/, 'a template link is still the home page');
++ assert.match(guest.headers.get('cache-control') || '', /private/, 'a prefilled page is never kept by a shared cache');
++ });
++
++ await check('JSON-LD: the agents as an ItemList and exactly the questions shown as a FAQPage', async () => {
++ const r = await t.get('/');
++ const ld = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
++ const list = ld.find((x) => x['@type'] === 'ItemList');
++ const agents = (await t.get('/api/v1/agents')).json().agents;
++ assert.strictEqual(list.numberOfItems, agents.length);
++ const faq = section(r.text, 'h-faq');
++ const shown = [...faq.matchAll(/<h3>([^<]+)<\/h3>/g)].map((m) => m[1].replace(/&#39;/g, "'"));
++ assert.ok(shown.length >= 6);
++ assert.deepStrictEqual(ld.find((x) => x['@type'] === 'FAQPage').mainEntity.map((q) => q.name), shown);
++ assert.strictEqual((r.text.match(/<h1[ >]/g) || []).length, 1, 'one h1');
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/layout.test.js b/test/layout.test.js
iindex 53dd227..ed9a391 100644
---- a/test/layout.test.js
++++ b/test/layout.test.js
@@@ -42,7 +42,7 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#mai
assert.ok(head.includes('<link rel="canonical" href="https://openvibe.actor/">'), 'canonical');
assert.ok(head.includes('<meta name="robots" content="index, follow">'), 'robots');
const ld = [...head.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1])['@type']);
-- assert.deepStrictEqual(ld, ['WebSite', 'WebApplication', 'WebPage'], 'the home JSON-LD');
++ assert.deepStrictEqual(ld, ['WebSite', 'WebApplication', 'WebPage', 'ItemList', 'FAQPage'], 'the home JSON-LD');
for (const tag of ['<meta property="og:title"', 'data-ov-icon="actor"', '/css/actor.css?v=', '/shared/theme-loader.js', '/shared/navbar.js', '/shared/footer.js', '<meta name="referrer"']) {
assert.ok(head.includes(tag), `head has ${tag}`);
}
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex 526e2b5..b89c171 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -14,14 +14,15 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
// Budgets are set from the numbers measured on 2026-10-08 (the real ones, fresh database), rounded up with about 10%
// headroom; file counts are the measured counts and external files stay at 0.
const BUDGETS = {
-- htmlRawKB: 40.5, // measured 35.8 (the guest task box lists example tasks); 39.6 with the home's "Ask in chat" row (10-15)
-- htmlBrotliKB: 10, // 8.6; 9.6 with "Ask in chat"
++ htmlRawKB: 58.5, // measured 35.8 (the guest task box lists example tasks); 39.6 with the home's "Ask in chat" row (10-15);
++ // 53.2 with the rich home (10-22): numbers, task templates, the 34 Tools chips, the API samples, the questions
++ htmlBrotliKB: 13.5, // 8.6; 9.6 with "Ask in chat"; 12.1 with the rich home
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
jsRawKB: 264, // 239.8
jsBrotliKB: 62.5, // 56.5
-- cssFiles: 2, // 2 (actor.css + the cached /shared/showcase.css)
-- cssRawKB: 26.5, // 24.2 (the task page's outcome card and timeline)
-- cssBrotliKB: 6, // 5.4
++ cssFiles: 3, // 2 (actor.css + the cached /shared/showcase.css); 3 with home.css, the home's own sections (10-22)
++ cssRawKB: 32, // 24.2 (the task page's outcome card and timeline); 28.9 with home.css
++ cssBrotliKB: 7.3, // 5.4; 6.6 with home.css
externalFiles: 0, // 0
};