openvibers/openvibe.network Commits
Admin: the guided setup of everything, one step at a time, with the setting we recommend (#895)
Changes
@@@ -28,6 +28,7 @@</script><link rel="stylesheet" href="/css/admin.css"><link rel="stylesheet" href="/css/ai-setup.css">++<link rel="stylesheet" href="/css/guide.css"><!-- OpenVibe's own form controls (4.109.0): every select in Platform AI is one, with data-badge/data-desc/data-state. --><link rel="stylesheet" href="/shared/ov-forms.css"></head>@@@ -49,6 +50,7 @@<div class="section-tabs-wrap"><div class="section-tabs" id="main-tabs"><button class="admin-tab-btn owner-only" onclick="showTab('setup',this)"><i class="fa-solid fa-list-check"></i> Setup</button>++ <button class="admin-tab-btn owner-only" onclick="showTab('guide',this)"><i class="fa-solid fa-route"></i> Guided setup</button><button class="admin-tab-btn active" onclick="showTab('dashboard',this)"><i class="fa-solid fa-chart-line"></i> Dashboard</button><button class="admin-tab-btn" onclick="location.href='/admin/review'"><i class="fa-solid fa-list-check"></i> Review queue</button><button class="admin-tab-btn" onclick="showTab('users',this)"><i class="fa-solid fa-users"></i> Users</button>@@@ -86,7 +88,13 @@</div><!-- Setup (owner only): everything only the owner can give the network, from every service (js/admin/setup.js) -->++ <!-- The guided setup (/admin/guide): everything, one step at a time, with the setting we recommend (js/admin/guide.js). -->++ <div class="section" id="sec-guide">++ <div id="guide-content" class="guide"></div>++ </div>++<div class="section" id="sec-setup">++ <div class="setup-guide-cta"><p><b>New here, or not sure what is left?</b> The guided setup walks through everything, one step at a time, with the setting we recommend.</p><a class="btn btn-primary" href="/admin/guide">Start the guided setup</a></div><div id="setup-content" class="setup"></div></div>@@@ -708,6 +716,8 @@<script src="/js/admin/glossary.js" defer></script><script src="/js/admin/ai.js" defer></script><script src="/js/admin/ai-setup.js" defer></script>++<script src="/js/admin/guide-rules.js" defer></script>++<script src="/js/admin/guide.js" defer></script><script src="/js/admin/inference.js" defer></script><script src="/js/admin/setup.js" defer></script><script src="/js/admin/content.js" defer></script>nnew file mode 100644@@@ -0,0 +1,54 @@++/* The owner's guided setup (/admin/guide, js/admin/guide.js): one step at a time, phone first.++ A rail of every step beside the card on a computer; on a phone the rail folds above the card. */++.guide-head { display: grid; gap: 10px; margin-bottom: 16px; }++.guide-head h2 { margin: 0; font-size: 22px; }++.guide-head p { margin: 2px 0 0; }++.guide-bar { height: 8px; border-radius: 999px; background: var(--bg-tertiary); overflow: hidden; }++.guide-bar > span { display: block; height: 100%; border-radius: inherit; background: var(--accent); transition: width .3s ease; }++.guide-body { display: grid; gap: 16px; }++.guide-fold > summary { cursor: pointer; padding: 10px 12px; border: 1px solid var(--border); border-radius: 12px; background: var(--bg-card); font-weight: 600; }++.guide-fold[open] > summary { margin-bottom: 8px; }++.guide-rail { display: grid; gap: 4px; }++.guide-rail ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; }++.guide-area { margin: 10px 0 2px; font-size: 11.5px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted); }++.guide-rail-step { display: flex; gap: 10px; align-items: center; min-height: 40px; padding: 6px 10px; border-radius: 10px; color: var(--text-secondary); text-decoration: none; }++.guide-rail-step:hover, .guide-rail-step:focus-visible { background: var(--bg-secondary); color: var(--text); }++.guide-rail-step.is-here { background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--text); font-weight: 600; }++.guide-dot { flex: none; width: 10px; height: 10px; border-radius: 50%; border: 2px solid var(--text-muted); }++.is-ok > .guide-dot { background: var(--success); border-color: var(--success); }++.is-you > .guide-dot { border-color: var(--warning); background: var(--warning); }++.is-todo > .guide-dot { border-color: var(--accent); }++.is-bad > .guide-dot { border-color: var(--danger); background: var(--danger); }++.guide-card { display: grid; gap: 12px; align-content: start; padding: 20px; border: 1px solid var(--border); border-radius: 16px; background: var(--bg-card); outline: none; min-width: 0; }++.guide-card h3 { margin: 0; font-size: 20px; line-height: 1.25; }++.guide-eyebrow { margin: 0; font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--accent-light); }++.guide-state { justify-self: start; display: inline-flex; gap: 6px; align-items: center; padding: 3px 10px; border-radius: 999px; font-size: 12.5px; font-weight: 600; background: var(--bg-secondary); color: var(--text-secondary); }++.guide-state.is-ok { color: var(--success-text, var(--success)); background: color-mix(in srgb, var(--success) 14%, transparent); }++.guide-state.is-you { color: var(--warning); background: color-mix(in srgb, var(--warning) 14%, transparent); }++.guide-state.is-bad { color: var(--danger); background: color-mix(in srgb, var(--danger) 12%, transparent); }++.guide-plain { margin: 0; font-size: 15px; line-height: 1.55; color: var(--text); max-width: 68ch; }++.guide-why { margin: 0; font-size: 13.5px; color: var(--text-secondary); }++.guide-items { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }++.guide-items li { display: grid; gap: 2px; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--border); background: var(--bg-secondary); font-size: 14px; }++.guide-items li.is-ok { border-color: color-mix(in srgb, var(--success) 40%, var(--border)); }++.guide-items li small { color: var(--text-muted); font-size: 12.5px; }++.guide-items a { color: var(--accent-light); }++.guide-rec { display: grid; gap: 8px; padding: 14px 16px; border-radius: 14px; border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border)); background: color-mix(in srgb, var(--accent) 7%, var(--bg-card)); }++.guide-rec-h { margin: 0; font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--accent-light); }++.guide-rec-l { margin: 0; font-size: 15.5px; font-weight: 600; }++.guide-rec ul { margin: 0; padding-left: 18px; display: grid; gap: 2px; font-size: 13.5px; color: var(--text-secondary); }++.guide-apply { justify-self: start; min-height: 44px; }++.guide-acts:empty { display: none; }++.guide-acts { display: flex; flex-wrap: wrap; gap: 8px; }++.guide-nav { display: flex; flex-wrap: wrap; gap: 8px; justify-content: space-between; padding-top: 12px; border-top: 1px solid var(--border); }++.guide-nav .btn { min-height: 44px; }++.guide-nav .btn-primary { margin-left: auto; }++.guide-bad { color: var(--danger); }++.setup-guide-cta { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: space-between; padding: 14px 16px; margin-bottom: 16px; border-radius: 14px; border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border)); background: color-mix(in srgb, var(--accent) 7%, var(--bg-card)); }++.setup-guide-cta p { margin: 0; }++@media (min-width: 900px) {++ .guide-body { grid-template-columns: 260px minmax(0, 1fr); align-items: start; }++ .guide-fold { position: sticky; top: 12px; }++ .guide-fold > summary { display: none; }++}++@media (prefers-reduced-motion: reduce) { .guide-bar > span { transition: none; } }@@@ -22,6 +22,7 @@ const tabLoaders = {payments: loadPayments,ai: loadAi,'ai-setup': loadAiSetup,++ guide: loadGuide,pastes: loadPastesAdmin,storage: loadStorage,notifications: () => {},@@@ -81,7 +81,7 @@ let currentTab = 'dashboard';// NOTE: 'ai' is NOT here — admins may use its analytics/explorer tools; only the// AI configuration form inside it is owner-gated (see loadAi).// 'setup' is the owner's checklist of keys and accounts (js/admin/setup.js); its API answers 403 to anyone else too.--const OWNER_ONLY_TABS = new Set(['setup', 'ai-setup', 'deploy', 'domains', 'payments', 'cashouts', 'storage', 'email', 'settings', 'grants', 'eventsops']);++const OWNER_ONLY_TABS = new Set(['guide', 'setup', 'ai-setup', 'deploy', 'domains', 'payments', 'cashouts', 'storage', 'email', 'settings', 'grants', 'eventsops']);function showTab(id, btn) {if (!tabLoaders[id]) id = 'dashboard';nnew file mode 100644@@@ -0,0 +1,236 @@++/* ═══════════════════════════════════════════════════════════════++ OpenVibe.Network — admin panel (/admin/guide): the guided setup's rules.++ The owner, 2026-11-12: "The guided setup should be built into admin panel and walks the owner rank through full++ config of everything entirely step by step and uses recommended settings that are most intelligent for our systems."++++ Pure functions over what the page reads (nothing here calls a network):++ setup GET /api/admin/setup every key and account only the owner can give, by group++ ai GET /api/admin/ai/setup every AI job: its key, model, spend and cap++ machines GET /api/v1/machines the owner's computers, what each can do and what is on++ access { principal_id: settings } each computer's access switches (GET /api/v1/me/nodes/:p/access-settings)++ progress GET /api/admin/guide the steps the owner skipped or saw++ build() returns the steps in order, each with its state and the setting we recommend, worked out from the live++ state: the keys that unlock the most jobs first, a cap from the real spend, each computer's roles from what it can++ actually do. "Use recommended" applies one step through the routes the rest of the admin panel already uses.++ Browser global OVGuideRules; require('…/guide-rules.js') in Node (test/admin-guide.test.js).++ ═══════════════════════════════════════════════════════════════ */++(function (root) {++ 'use strict';++++ const ROLE_WORDS = { 'codes.worker': 'run your coding tasks', 'desks.host': 'give your agents a browser', 'ai.host': 'serve its AI models', 'bot.robot': 'drive a robot' };++ const SWITCH_WORDS = {++ agent_computer: 'let your agents use it', ai_models: 'let OpenVibe AI use its models', watch_probe: 'let your watches check from it',++ shared_desks: 'lend browsers to other people’s agents',++ };++ const ACCESS_WORDS = { others_may_ask: 'let people ask to use it', auto_agent_send: 'your other computers use it without asking', agents_talk: 'its agents work with other agents' };++ const ROLE_ORDER = ['codes.worker', 'desks.host', 'ai.host', 'bot.robot'];++ const SKIP_GROUPS = new Set(['ai-providers']); // the AI steps cover them, job by job++++ const money = (n) => { const v = Number(n) || 0; return v ? `$${v < 10 ? v.toFixed(2) : Math.round(v)}` : '$0'; };++ const list = (words) => (words.length <= 1 ? words.join('') : `${words.slice(0, -1).join(', ')} and ${words[words.length - 1]}`);++ const arr = (v) => (Array.isArray(v) ? v : []);++++ /**++ * What we recommend for one computer, from what it says it can do: a role for each thing it has (browsers for++ * agents, AI models, the Codes runner), and the switches that let those be used. A computer that keeps each++ * browser in a locked box of its own is a shared node: it lends them to other people's agents too. A robot role++ * is kept when it is on (only its owner knows a robot is plugged in). → { roles, switches, why }++ */++ function recommendMachine(m) {++ const c = (m && m.controls) || {};++ const roles = new Set(arr(m && m.roles).filter((r) => r === 'bot.robot'));++ const switches = {};++ const why = [];++ const desk = c.desk || {};++ const ai = c.ai || {};++ const fleet = arr(m && m.fleet);++ if (desk.available) { roles.add('desks.host'); why.push(`it has browsers for agents${desk.max ? ` (up to ${desk.max})` : ''}`); }++ if (ai.available && ai.models > 0) { roles.add('ai.host'); switches.ai_models = true; why.push(`it serves ${ai.models} AI model${ai.models === 1 ? '' : 's'} for free`); }++ if (fleet.length || arr(m && m.roles).includes('codes.worker')) { roles.add('codes.worker'); why.push('it runs the Codes job runner'); }++ if (c.computer) { switches.agent_computer = true; why.push(`your agents may use it as far as its own level allows${c.level ? ` (${c.level})` : ''}`); }++ if (c.computer && c.probe) switches.watch_probe = true;++ if (desk.available && desk.sandboxed) { switches.shared_desks = true; why.push('each browser is locked in its own box, so it can lend them safely'); }++ return { roles: ROLE_ORDER.filter((r) => roles.has(r)), switches, why };++ }++++ /** What changes for one computer to reach the recommendation: roles to add, switches to turn on. */++ function machineChange(m) {++ const rec = recommendMachine(m);++ const have = new Set(arr(m.roles));++ const c = m.controls || {};++ const addRoles = rec.roles.filter((r) => !have.has(r));++ const turnOn = Object.keys(rec.switches).filter((k) => rec.switches[k] && c[k] !== true);++ if (!addRoles.length && !turnOn.length) return null;++ return {++ id: m.id, name: m.name || 'A computer', roles: [...new Set([...arr(m.roles), ...rec.roles])].sort((a, b) => ROLE_ORDER.indexOf(a) - ROLE_ORDER.indexOf(b)),++ switches: Object.fromEntries(turnOn.map((k) => [k, true])),++ words: [...addRoles.map((r) => ROLE_WORDS[r]), ...turnOn.map((k) => SWITCH_WORDS[k])].filter(Boolean),++ why: rec.why,++ };++ }++++ /** A monthly cap for one AI job: twice what it spent in the last 30 days, at least $5, in steps of $5. */++ function capFor(step) {++ const spent = Math.max(Number(step && step.estimated_month_usd) || 0, Number(step && step.month_usd) || 0);++ return Math.max(5, Math.ceil((spent * 2) / 5) * 5);++ }++++ /** The providers whose keys unlock the most AI jobs, first; each with the jobs it unlocks and where to get a key. */++ function keysWanted(ai) {++ const want = new Map();++ for (const s of arr(ai && ai.steps)) {++ if (s.ready) continue;++ const rec = s.recommended || {};++ if (!rec.provider || rec.configured === true) continue;++ const w = want.get(rec.provider) || { provider: rec.provider, name: rec.provider_name || rec.provider, jobs: [], key_page: s.key_page || rec.key_page || null };++ w.jobs.push(s.short || s.name || s.id);++ want.set(rec.provider, w);++ }++ return [...want.values()].sort((a, b) => b.jobs.length - a.jobs.length || a.provider.localeCompare(b.provider));++ }++++ const step = (id, area, title, plain, o = {}) => ({ id, area, title, plain, state: 'todo', recommended: null, open: null, items: [], ...o });++++ /** Every step, in order, with its state and what we recommend. */++ function build({ setup = null, ai = null, machines = null, access = {}, progress = {} } = {}) {++ const skipped = new Set(arr(progress.skipped));++ const seen = new Set(arr(progress.seen));++ const steps = [];++++ steps.push(step('welcome', 'Start', 'Set up OpenVibe',++ 'This walks you through everything OpenVibe needs from you, one step at a time. Each step says what it does and offers the setting we recommend. You can change any of it later, and skip what you do not need yet.',++ { state: seen.has('welcome') ? 'done' : 'todo' }));++++ // ── The services answer ──++ const reach = arr(setup && setup.services);++ const down = reach.filter((s) => !s.answered);++ steps.push(step('services', 'Start', 'Every service answers',++ 'The guide reads each service’s settings. A service that does not answer cannot be set up from here yet.',++ {++ state: !setup ? 'unknown' : down.length ? 'needs_you' : 'done',++ items: down.map((s) => ({ text: `${s.name} did not answer`, state: 'bad' })),++ why: down.length ? 'Try again in a minute; if it stays down, it needs a look on the server.' : `${reach.length} services answered.`,++ }));++++ // ── AI: keys, models, caps, Decisions, credits ──++ const aiSteps = arr(ai && ai.steps);++ const keys = keysWanted(ai);++ steps.push(step('ai-keys', 'AI', 'Add the AI keys that unlock the most',++ 'OpenVibe’s AI jobs (chat, code, pictures, speech, search …) each run on a provider. A key from a provider is what lets them run. Keys are sealed in OpenVibe.AI and never shown again.',++ {++ state: !ai ? 'unknown' : keys.length ? 'needs_you' : 'done',++ items: keys.map((k) => ({ text: `${k.name}: unlocks ${list(k.jobs)}`, href: k.key_page, state: 'todo' })),++ recommended: keys.length ? { label: `Add ${keys[0].name}’s key first`, detail: [`It unlocks ${keys[0].jobs.length} job${keys[0].jobs.length === 1 ? '' : 's'}: ${list(keys[0].jobs)}.`] } : null,++ open: { label: 'Add keys', href: '/admin/ai/setup' },++ }));++++ const unrouted = aiSteps.filter((s) => s.route && !s.current && s.recommended && s.recommended.configured === true);++ steps.push(step('ai-models', 'AI', 'Pick the model for each AI job',++ 'Each job uses one model. We recommend the cheapest model that does the job well, from Exchange’s catalogue and the keys you added.',++ {++ state: !ai ? 'unknown' : unrouted.length ? 'todo' : 'done',++ items: unrouted.map((s) => ({ text: `${s.name}: ${(s.recommended && (s.recommended.label || s.recommended.model)) || 'recommended'}`, state: 'todo' })),++ recommended: unrouted.length ? { label: 'Use the recommended model for every job', detail: unrouted.map((s) => `${s.name}: ${(s.recommended && (s.recommended.label || s.recommended.model)) || ''}`), apply: { type: 'ai-recommended' } } : null,++ open: { label: 'Choose by hand', href: '/admin/ai/setup' },++ }));++++ const uncapped = aiSteps.filter((s) => s.route && (s.cap_usd == null || s.cap_usd === ''));++ const caps = Object.fromEntries(uncapped.map((s) => [s.id, capFor(s)]));++ steps.push(step('ai-caps', 'AI', 'Set a monthly limit for each AI job',++ 'A limit stops a job from spending more than you planned in a month. We suggest twice what it spent in the last 30 days, at least $5.',++ {++ state: !ai ? 'unknown' : uncapped.length ? 'todo' : 'done',++ items: uncapped.map((s) => ({ text: `${s.name}: ${money(caps[s.id])} a month (spent ${money(s.estimated_month_usd)} in 30 days)`, state: 'todo' })),++ recommended: uncapped.length ? { label: 'Use these limits', detail: uncapped.map((s) => `${s.name}: ${money(caps[s.id])}`), apply: { type: 'ai-caps', caps } } : null,++ }));++++ const decide = aiSteps.find((s) => s.id === 'decide');++ steps.push(step('decisions', 'AI', 'Decisions: quick, measured yes/no and choose-one answers',++ 'Decisions answers routing, checks and grading questions with how sure it is, and it is what lets agents choose the right model and tool and measure themselves. With a Gemini key it uses Google’s calibrated Decisions model (public or test data only on Google’s free tier); without one, the cheapest structured model answers, marked not calibrated.',++ {++ state: !ai ? 'unknown' : !decide ? 'optional' : decide.ready ? 'done' : 'todo',++ recommended: decide && !decide.ready ? (decide.route && decide.current ? { label: 'Test Decisions now', apply: { type: 'ai-test', step: 'decide' } } : { label: 'Add a Gemini key, then test', detail: ['Free tier at aistudio.google.com.'] }) : null,++ open: { label: 'Open the Decisions step', href: '/admin/ai/setup' },++ }));++++ steps.push(step('credits', 'AI', 'Record the credits you already get',++ 'Some plans come with monthly API credits (for example ChatGPT Pro’s). Record them once, and OpenVibe spends them before it spends money.',++ { state: 'optional', open: { label: 'Open Providers on Exchange', href: 'https://openvibe.exchange/console/providers' } }));++++ // ── Computers ──++ const ms = arr(machines);++ const changes = ms.map(machineChange).filter(Boolean);++ steps.push(step('machines', 'Computers', 'Put your computers to work',++ 'Each computer you paired can run coding tasks, give your agents a browser and serve its own AI models. We turn on what each one can actually do.',++ {++ state: machines == null ? 'unknown' : !ms.length ? 'needs_you' : changes.length ? 'todo' : 'done',++ items: !ms.length ? [{ text: 'No computer is paired yet.', state: 'todo' }] : changes.map((ch) => ({ text: `${ch.name}: ${list(ch.words)}`, state: 'todo', why: ch.why.join('; ') })),++ recommended: changes.length ? { label: changes.length === 1 ? `Set up ${changes[0].name}` : `Set up all ${changes.length} computers`, detail: changes.map((ch) => `${ch.name}: ${list(ch.words)}`), apply: { type: 'machines', changes } } : null,++ open: { label: ms.length ? 'Open your computers' : 'Pair a computer', href: 'https://openvibe.link/machines' },++ }));++++ // ── Shared nodes: access for the computers that lend their browsers ──++ const shared = ms.filter((m) => m.principal_id && (m.controls || {}).desk && m.controls.desk.sandboxed);++ const accessChanges = shared.map((m) => {++ const now = access[m.principal_id] || {};++ const off = Object.keys(ACCESS_WORDS).filter((k) => now[k] !== true);++ return off.length ? { principal: m.principal_id, id: m.id, name: m.name || 'A computer', settings: Object.fromEntries(off.map((k) => [k, true])), words: off.map((k) => ACCESS_WORDS[k]) } : null;++ }).filter(Boolean);++ steps.push(step('shared', 'Computers', 'Open your shared computers to others',++ 'A shared computer serves people other than you: their agents borrow its browsers, each locked in its own box, and people can ask to use it. It never shows your files, your screen or your programs.',++ {++ state: machines == null ? 'unknown' : !shared.length ? 'optional' : accessChanges.length ? 'todo' : 'done',++ items: !shared.length ? [{ text: 'None of your computers is set up for sharing (Link’s --desk-host --shared).', state: 'optional' }] : accessChanges.map((a) => ({ text: `${a.name}: ${list(a.words)}`, state: 'todo' })),++ recommended: accessChanges.length ? { label: 'Open them to others', detail: accessChanges.map((a) => `${a.name}: ${list(a.words)}`), apply: { type: 'access', changes: accessChanges } } : null,++ }));++++ // ── Coding ──++ const workers = ms.filter((m) => arr(m.roles).includes('codes.worker'));++ const ready = workers.filter((m) => arr(m.fleet).some((a) => a && a.installed && a.signed_in));++ steps.push(step('coding', 'Coding', 'Let your computers do coding tasks',++ 'Coding tasks you start on openvibe.codes run on a computer with the Codes runner, using the coding apps signed in there (Claude Code, Codex, OpenCode …) or a model key.',++ {++ state: machines == null ? 'unknown' : ready.length ? 'done' : workers.length ? 'needs_you' : 'todo',++ items: workers.length && !ready.length ? workers.map((m) => ({ text: `${m.name || 'A computer'}: no coding app is signed in yet`, state: 'todo' })) : [],++ why: workers.length && !ready.length ? 'Sign in a coding app on that computer (for example: codex login), or add a model key for its runner.' : null,++ open: { label: 'Open Codes', href: 'https://openvibe.codes' },++ }));++++ // ── Agents ──++ steps.push(step('agents', 'Agents', 'Your agents',++ 'Vibe is your first agent. You can make more, share them, and install other people’s: each one says in plain words what it may do before you install it.',++ { state: 'optional', open: { label: 'Open your agents', href: 'https://openvibe.actor' } }));++++ // ── Everything else only the owner can give, a step per group ──++ for (const g of arr(setup && setup.groups)) {++ if (SKIP_GROUPS.has(g.id)) continue;++ const rows = arr(g.rows);++ const needed = rows.filter((r) => r.state === 'needed' || r.state === 'not_working');++ const optional = rows.filter((r) => r.state === 'optional');++ steps.push(step(`keys-${g.id}`, 'Keys and accounts', g.label,++ needed.length ? `${needed.length} thing${needed.length === 1 ? '' : 's'} here wait${needed.length === 1 ? 's' : ''} on you.` : optional.length ? 'Nothing waits on these; add them when you want what they unlock.' : 'All connected.',++ {++ state: needed.length ? 'needs_you' : optional.length ? 'optional' : 'done',++ items: [...needed, ...optional].slice(0, 12).map((r) => ({++ text: `${r.title} (${r.service_name})${r.state === 'not_working' ? ': not working' : r.required_for ? `: needed for ${r.required_for}` : r.unlocks ? `: ${r.unlocks}` : ''}`,++ href: r.get_it_at || null, state: r.state === 'optional' ? 'optional' : 'todo',++ })),++ open: { label: 'Open in Setup', href: `/admin/setup#${g.id}` },++ }));++ }++++ const counted = steps.filter((s) => s.id !== 'welcome');++ for (const s of steps) if (skipped.has(s.id) && s.state !== 'done') s.state = 'skipped';++ const done = counted.filter((s) => s.state === 'done').length;++ const open = counted.filter((s) => s.state === 'todo' || s.state === 'needs_you');++ steps.push(step('finish', 'Finish', open.length ? 'What is left' : 'All set',++ open.length ? `${done} of ${counted.length} steps are done. These still want you:` : 'Everything the guide covers is set up. Come back any time: it rereads the live state each time.',++ { state: open.length ? 'todo' : 'done', items: open.map((s) => ({ text: s.title, step: s.id, state: s.state })) }));++ const next = steps.find((s) => s.id !== 'welcome' && (s.state === 'todo' || s.state === 'needs_you')) || null;++ return { steps, done, total: counted.length, next: next ? next.id : 'finish' };++ }++++ const api = { build, recommendMachine, machineChange, capFor, keysWanted, ROLE_WORDS, SWITCH_WORDS, ACCESS_WORDS };++ if (typeof module !== 'undefined' && module.exports) module.exports = api;++ else root.OVGuideRules = api;++})(typeof globalThis !== 'undefined' ? globalThis : this);nnew file mode 100644@@@ -0,0 +1,188 @@++/* ═══════════════════════════════════════════════════════════════++ OpenVibe.Network — admin panel (/admin/guide): set up everything, one step at a time.++ It reads the live state (the Setup checklist, the AI setup, your computers and their access), works out each step and++ the setting we recommend (js/admin/guide-rules.js), and applies "Use recommended" through the routes the rest of the++ panel already uses. Progress (skipped and seen steps) is kept by /api/admin/guide (server/admin/guide.js).++ Classic script, global scope: admin/core.js's api() and toast(); built from DOM nodes.++ ═══════════════════════════════════════════════════════════════ */++let guideData = null; // { setup, ai, machines, access, progress }++let guideModel = null; // OVGuideRules.build(...)++let guideAt = 'welcome';++let guideBusy = false;++++const GUIDE_STATE = {++ done: ['Done', 'ok', 'fa-circle-check'], todo: ['To do', 'todo', 'fa-circle-dot'], needs_you: ['Needs you', 'you', 'fa-hand'],++ optional: ['Optional', 'opt', 'fa-circle'], skipped: ['Skipped', 'opt', 'fa-forward'], unknown: ['Could not read', 'bad', 'fa-triangle-exclamation'],++};++const GUIDE_SWITCH_ROUTE = { agent_computer: 'agent-computer', ai_models: 'ai-models', watch_probe: 'watch-probe', shared_desks: 'shared-desks' };++++function gEl(tag, cls, kids, attrs) {++ const n = document.createElement(tag);++ if (cls) n.className = cls;++ for (const k in attrs || {}) n.setAttribute(k, attrs[k]);++ (kids || []).forEach((c) => { if (c != null && c !== false) n.append(c); });++ return n;++}++const gIcon = (name) => gEl('i', `fa-solid ${name}`, [], { 'aria-hidden': 'true' });++++async function guideRead() {++ const quiet = (p) => p.catch(() => null);++ const [setup, ai, machinesOut, progress] = await Promise.all([++ quiet(api('/api/admin/setup')), quiet(api('/api/admin/ai/setup')), quiet(api('/api/v1/machines')), quiet(api('/api/admin/guide')),++ ]);++ const machines = machinesOut && Array.isArray(machinesOut.machines) ? machinesOut.machines : null;++ const access = {};++ await Promise.all((machines || []).filter((m) => m.principal_id).map(async (m) => {++ const out = await quiet(api(`/api/v1/me/nodes/${encodeURIComponent(m.principal_id)}/access-settings`));++ if (out && out.settings) access[m.principal_id] = out.settings;++ }));++ guideData = { setup, ai, machines, access, progress: progress || { skipped: [], seen: [] } };++ guideModel = OVGuideRules.build(guideData);++}++++async function loadGuide() {++ const box = document.getElementById('guide-content');++ if (!box) return;++ box.replaceChildren(gEl('p', 'muted', ['Reading what is set up…']));++ try { await guideRead(); } catch (e) { box.replaceChildren(gEl('p', 'guide-bad', [e.message || 'The guide could not read the live state.'])); return; }++ const want = (location.hash || '').replace(/^#/, '');++ guideAt = guideModel.steps.some((s) => s.id === want) ? want : (guideData.progress.seen || []).includes('welcome') ? guideModel.next : 'welcome';++ guideDraw();++}++++async function guideSaveProgress(change) {++ const p = guideData.progress;++ const next = { skipped: change.skipped || p.skipped || [], seen: change.seen || p.seen || [] };++ try { guideData.progress = { ...p, ...(await api('/api/admin/guide', { method: 'PUT', body: next })) }; } catch { guideData.progress = { ...p, ...next }; }++}++++function guideGo(id) {++ guideAt = id;++ try { history.replaceState(history.state, '', `/admin/guide#${id}`); } catch { /* */ }++ const seen = new Set(guideData.progress.seen || []);++ if (!seen.has(id)) { seen.add(id); guideSaveProgress({ seen: [...seen] }); }++ guideDraw();++ const card = document.querySelector('.guide-card');++ if (card && card.focus) card.focus();++}++++function guideDraw() {++ const box = document.getElementById('guide-content');++ const steps = guideModel.steps;++ const i = Math.max(0, steps.findIndex((s) => s.id === guideAt));++ const s = steps[i];++ const pct = guideModel.total ? Math.round((guideModel.done / guideModel.total) * 100) : 0;++++ const head = gEl('header', 'guide-head', [++ gEl('div', 'guide-head-t', [gEl('h2', '', ['Set up OpenVibe']), gEl('p', 'muted', [`${guideModel.done} of ${guideModel.total} steps done`])]),++ gEl('div', 'guide-bar', [gEl('span', '', [], { style: `width:${pct}%` })], { role: 'progressbar', 'aria-valuemin': '0', 'aria-valuemax': '100', 'aria-valuenow': String(pct), 'aria-label': 'Setup progress' }),++ ]);++++ // Every step, by area: a rail on a computer, a fold on a phone.++ const rail = gEl('nav', 'guide-rail', [], { 'aria-label': 'Every step' });++ let area = null; let ul = null;++ for (const st of steps) {++ if (st.area !== area) { area = st.area; ul = gEl('ul'); rail.append(gEl('p', 'guide-area', [area]), ul); }++ const [word, cls] = GUIDE_STATE[st.state] || GUIDE_STATE.todo;++ const a = gEl('a', `guide-rail-step is-${cls}${st.id === s.id ? ' is-here' : ''}`, [gEl('span', 'guide-dot', [], { 'aria-hidden': 'true' }), gEl('span', '', [st.title])], { href: `#${st.id}`, title: word, ...(st.id === s.id ? { 'aria-current': 'step' } : {}) });++ a.addEventListener('click', (e) => { e.preventDefault(); guideGo(st.id); });++ ul.append(gEl('li', '', [a]));++ }++ const fold = gEl('details', 'guide-fold', [gEl('summary', '', [`Step ${i + 1} of ${steps.length}: every step`]), rail]);++++ const [word, cls, icon] = GUIDE_STATE[s.state] || GUIDE_STATE.todo;++ const card = gEl('section', 'guide-card', [], { tabindex: '-1', 'aria-labelledby': 'guide-title' });++ card.append(gEl('p', 'guide-eyebrow', [s.area]), gEl('h3', '', [s.title], { id: 'guide-title' }), gEl('span', `guide-state is-${cls}`, [gIcon(icon), ' ', word]), gEl('p', 'guide-plain', [s.plain]));++ if (s.why) card.append(gEl('p', 'guide-why', [s.why]));++ if (s.items && s.items.length) {++ card.append(gEl('ul', 'guide-items', s.items.map((it) => {++ const li = gEl('li', `is-${(GUIDE_STATE[it.state] || GUIDE_STATE.todo)[1]}`, []);++ if (it.step) { const a = gEl('a', '', [it.text], { href: `#${it.step}` }); a.addEventListener('click', (e) => { e.preventDefault(); guideGo(it.step); }); li.append(a); }++ else if (it.href) li.append(gEl('a', '', [it.text, ' ', gIcon('fa-arrow-up-right-from-square')], { href: it.href, target: '_blank', rel: 'noopener' }));++ else li.append(it.text);++ if (it.why) li.append(gEl('small', '', [it.why]));++ return li;++ })));++ }++ if (s.recommended) {++ const rec = gEl('div', 'guide-rec', [gEl('p', 'guide-rec-h', [gIcon('fa-wand-magic-sparkles'), ' We recommend']), gEl('p', 'guide-rec-l', [s.recommended.label])]);++ if (s.recommended.detail && s.recommended.detail.length) rec.append(gEl('ul', '', s.recommended.detail.map((d) => gEl('li', '', [d]))));++ if (s.recommended.apply) {++ const b = gEl('button', 'btn btn-primary guide-apply', [gIcon('fa-check'), ' Use recommended'], { type: 'button' });++ b.addEventListener('click', () => guideApply(s, b));++ rec.append(b);++ }++ card.append(rec);++ }++ const acts = gEl('div', 'guide-acts');++ if (s.open) acts.append(gEl('a', 'btn btn-outline', [s.open.label, ' ', gIcon(s.open.href.startsWith('http') ? 'fa-arrow-up-right-from-square' : 'fa-arrow-right')], { href: s.open.href, ...(s.open.href.startsWith('http') ? { target: '_blank', rel: 'noopener' } : {}) }));++ card.append(acts);++++ const nav = gEl('div', 'guide-nav');++ const back = gEl('button', 'btn btn-outline', [gIcon('fa-arrow-left'), ' Back'], { type: 'button' });++ back.disabled = i === 0;++ back.addEventListener('click', () => guideGo(steps[i - 1].id));++ nav.append(back);++ if (s.id !== 'welcome' && s.id !== 'finish' && s.state !== 'done') {++ const skip = gEl('button', 'btn btn-quiet', [s.state === 'skipped' ? 'Unskip' : 'Skip for now'], { type: 'button' });++ skip.addEventListener('click', async () => {++ const set = new Set(guideData.progress.skipped || []);++ if (set.has(s.id)) set.delete(s.id); else set.add(s.id);++ await guideSaveProgress({ skipped: [...set] });++ guideModel = OVGuideRules.build(guideData);++ if (set.has(s.id) && i < steps.length - 1) guideGo(steps[i + 1].id); else guideDraw();++ });++ nav.append(skip);++ }++ if (i < steps.length - 1) {++ const next = gEl('button', 'btn btn-primary', [s.id === 'welcome' ? 'Start' : 'Next', ' ', gIcon('fa-arrow-right')], { type: 'button' });++ next.addEventListener('click', () => guideGo(steps[i + 1].id));++ nav.append(next);++ } else {++ const again = gEl('button', 'btn btn-outline', [gIcon('fa-rotate'), ' Read everything again'], { type: 'button' });++ again.addEventListener('click', () => loadGuide());++ nav.append(again);++ }++ card.append(nav);++ box.replaceChildren(head, gEl('div', 'guide-body', [fold, card]));++}++++/** "Use recommended": one step's setting, through the routes the rest of the panel uses; then everything is read again. */++async function guideApply(s, button) {++ if (guideBusy) return;++ guideBusy = true;++ button.disabled = true;++ const a = s.recommended.apply;++ const failed = [];++ try {++ if (a.type === 'ai-recommended') await api('/api/admin/ai/setup/recommended', { method: 'POST', body: {} });++ else if (a.type === 'ai-test') await api(`/api/admin/ai/setup/test/${encodeURIComponent(a.step)}`, { method: 'POST', body: {} });++ else if (a.type === 'ai-caps') {++ for (const [kind, usd] of Object.entries(a.caps || {})) {++ try { await api(`/api/admin/ai/setup/caps/${encodeURIComponent(kind)}`, { method: 'PUT', body: { cap_usd: usd } }); } catch (e) { failed.push(`${kind}: ${e.message}`); }++ }++ } else if (a.type === 'machines') {++ for (const ch of a.changes || []) {++ try {++ await api(`/api/v1/machines/${encodeURIComponent(ch.id)}/roles`, { method: 'PUT', body: { roles: ch.roles } });++ for (const key of Object.keys(ch.switches || {})) {++ const route = GUIDE_SWITCH_ROUTE[key];++ if (!route) continue;++ try { await api(`/api/v1/machines/${encodeURIComponent(ch.id)}/${route}`, { method: 'POST', body: { on: true } }); } catch (e) { failed.push(`${ch.name}: ${e.message}`); }++ }++ } catch (e) { failed.push(`${ch.name}: ${e.message}`); }++ }++ } else if (a.type === 'access') {++ for (const ch of a.changes || []) {++ try { await api(`/api/v1/me/nodes/${encodeURIComponent(ch.principal)}/access-settings`, { method: 'PUT', body: ch.settings }); } catch (e) { failed.push(`${ch.name}: ${e.message}`); }++ }++ }++ if (typeof toast === 'function') toast(failed.length ? `Partly saved. ${failed.join('; ')}` : 'Saved. The recommended setting is on.', failed.length ? 'error' : 'success');++ } catch (e) {++ if (typeof toast === 'function') toast(e.message || 'That could not be saved.', 'error');++ } finally {++ guideBusy = false;++ await guideRead().catch(() => {});++ guideDraw();++ }++}nnew file mode 100644@@@ -0,0 +1,86 @@++'use strict';++/**++ * The owner's guided setup (/admin/guide, 2026-11-12): what the guide remembers between visits. The steps themselves++ * are worked out each time from the live state (public/js/admin/guide-rules.js over the Setup checklist, the AI setup++ * and the owner's computers), and "Use recommended" goes through the routes the rest of the panel already uses, so++ * the only thing kept here is which steps the owner skipped and which they have seen.++ *++ * GET /api/admin/guide { skipped, seen }++ * PUT /api/admin/guide { skipped?, seen? } the whole lists (step ids)++ *++ * Owner only. Kept in Network's own namespace network.guide (openvibe-shared/config), so a change is a revision with++ * its actor and history like any other setting.++ */++const express = require('express');++const config = require('openvibe-shared/config');++const { requireOwner } = require('../auth/owner-guard');++const siteConfig = require('./site-config');++++const NAMESPACE = 'network.guide';++const STEP = /^[a-z][a-z0-9-]{0,40}$/;++const IDS = { type: 'array', maxItems: 60, uniqueItems: true, items: { type: 'string', pattern: STEP.source } };++const SCHEMA = Object.freeze({++ type: 'object',++ additionalProperties: false,++ properties: {++ skipped: { ...IDS, title: 'Skipped steps', description: 'The guided setup’s steps the owner chose to skip for now.' },++ seen: { ...IDS, title: 'Seen steps', description: 'The guided setup’s steps the owner has opened.' },++ },++});++++const byDb = new Map();++async function store(db, log = console) {++ if (byDb.has(db)) return byDb.get(db);++ const ready = config.createConfigStore({++ db, service: 'network', namespace: NAMESPACE, schema: SCHEMA,++ log: { info: () => {}, warn: (m) => (log.warn || log.log || (() => {})).call(log, `[Guide] ${m}`), error: (m) => (log.error || log.warn || (() => {})).call(log, `[Guide] ${m}`) },++ });++ byDb.set(db, ready);++ try { return await ready; } catch (err) { byDb.delete(db); throw err; }++}++++const clean = (v) => (Array.isArray(v) ? [...new Set(v.map(String).filter((id) => STEP.test(id)))].slice(0, 60) : null);++++function createGuideRoutes(opts = {}) {++ const r = express.Router();++ const log = opts.log || console;++ r.use(requireOwner);++ const dbOf = (req) => opts.db || req.app.locals.db;++ const send = (res, status, body) => { res.set('Cache-Control', 'no-store'); return res.status(status).json(body); };++++ r.get('/', async (req, res) => {++ try {++ const s = await store(dbOf(req), log);++ const v = s.get() || {};++ send(res, 200, { ok: true, skipped: v.skipped || [], seen: v.seen || [] });++ } catch (err) {++ log.error(`[Guide] ${err && err.message ? String(err.message).slice(0, 200) : 'failed'}`);++ send(res, 500, { ok: false, error: 'The guide could not be read', code: 'guide.internal' });++ }++ });++++ r.put('/', async (req, res) => {++ try {++ const body = req.body && typeof req.body === 'object' ? req.body : {};++ const next = {};++ for (const key of ['skipped', 'seen']) {++ if (body[key] === undefined) continue;++ const ids = clean(body[key]);++ if (!ids) return send(res, 400, { ok: false, error: `${key} is a list of step ids`, code: 'guide.bad_request' });++ next[key] = ids;++ }++ if (!Object.keys(next).length) return send(res, 400, { ok: false, error: 'Send skipped or seen', code: 'guide.bad_request' });++ const s = await store(dbOf(req), log);++ await s.apply(next, { merge: true, actor: siteConfig.actorOf(req.user), reason: 'Guided setup progress' });++ const v = s.get() || {};++ send(res, 200, { ok: true, skipped: v.skipped || [], seen: v.seen || [] });++ } catch (err) {++ log.error(`[Guide] ${err && err.message ? String(err.message).slice(0, 200) : 'failed'}`);++ send(res, 500, { ok: false, error: 'The guide could not be saved', code: 'guide.internal' });++ }++ });++++ return r;++}++++module.exports = { createGuideRoutes, SCHEMA, NAMESPACE };@@@ -1040,6 +1040,8 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {// The guided AI setup page (/admin/ai/setup): every task kind's key, route, test and monthly cap, through// OpenVibe.AI's own setup API. Owner only; Network keeps no key and the request body is dropped here.router.use('/ai/setup', requireOwner, require('./ai-setup').createAiSetup());++ // The guided setup's progress (server/admin/guide.js): the steps the owner skipped or saw.++ router.use('/guide', requireOwner, express.json({ limit: '16kb' }), require('./guide').createGuideRoutes());// The owner's Setup tab (server/admin/setup.js): every key and account only the owner can give, from every service's// config namespaces, plus Network's own to-dos. Owner only; saving goes through the proxy above.router.use('/setup', requireOwner, require('./setup').createSetupRoutes({ db }));nnew file mode 100644@@@ -0,0 +1,139 @@++'use strict';++// The owner's guided setup (/admin/guide, 2026-11-12): the rules that turn the live state into ordered steps with the++// setting we recommend (public/js/admin/guide-rules.js), and the route that keeps what the owner skipped or saw++// (server/admin/guide.js): owner only, step ids only, kept in network.guide. The admin page wires the tab, the++// section, the scripts and a call to action at the top of Setup.++// node test/admin-guide.test.js++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const jwt = require('jsonwebtoken');++const { buildWorld } = require('./security-world');++const rules = require('../public/js/admin/guide-rules.js');++++const out = (...a) => process.stdout.write(a.join(' ') + '\n');++const ROOT = path.join(__dirname, '..');++++// ── The rules ──++{++ // ov-node-01 as it was on 11-12: a shared desk host with two models and the Codes runner, everything off.++ const node = {++ id: 'dev_N', name: 'ov-node-01', kind: 'onboard', roles: [], principal_id: 'lnk_N',++ fleet: [{ agent: 'codex', installed: true, signed_in: false }],++ controls: { computer: true, level: 'trusted', probe: true, agent_computer: false, watch_probe: false, ai_models: false, shared_desks: false,++ desk: { available: true, sandboxed: true, max: 7 }, ai: { available: true, models: 2, offered: true } },++ };++ const rec = rules.recommendMachine(node);++ assert.deepStrictEqual(rec.roles, ['codes.worker', 'desks.host', 'ai.host'], 'a role for each thing it can do');++ assert.deepStrictEqual(rec.switches, { ai_models: true, agent_computer: true, watch_probe: true, shared_desks: true });++ assert.ok(rec.why.some((w) => /locked in its own box/.test(w)), 'says why it may lend browsers');++++ // A laptop: no sandbox, no models, no runner: only what it has; a robot role that is on is kept.++ const laptop = { id: 'dev_L', name: 'Laptop', roles: ['bot.robot'], controls: { computer: true, level: 'ask', probe: false, desk: { available: true, sandboxed: false } } };++ assert.deepStrictEqual(rules.recommendMachine(laptop).roles, ['desks.host', 'bot.robot']);++ assert.strictEqual(rules.recommendMachine(laptop).switches.shared_desks, undefined, 'never lends browsers that are not each in a box');++ assert.strictEqual(rules.machineChange({ ...node, roles: rec.roles, controls: { ...node.controls, ...rec.switches } }), null, 'nothing to change once it matches');++++ // Caps: twice the last 30 days, at least $5, in steps of $5.++ assert.strictEqual(rules.capFor({ estimated_month_usd: 0 }), 5);++ assert.strictEqual(rules.capFor({ estimated_month_usd: 3.2 }), 10);++ assert.strictEqual(rules.capFor({ estimated_month_usd: 12, month_usd: 14 }), 30);++++ // Keys: the provider that unlocks the most jobs first.++ const ai = { steps: [++ { id: 'chat', name: 'Chat and writing', short: 'Chat', route: 'default.chat', ready: false, recommended: { provider: 'deepseek', configured: false }, key_page: 'https://platform.deepseek.com' },++ { id: 'code', name: 'Code agents', short: 'Code', route: 'space.build', ready: false, recommended: { provider: 'deepseek', configured: false } },++ { id: 'image', name: 'Images', short: 'Images', route: 'default.image', ready: false, recommended: { provider: 'together', configured: false } },++ { id: 'stt', name: 'Speech to text', short: 'Speech in', route: 'live.stt', ready: false, current: null, recommended: { provider: 'groq', configured: true, label: 'Whisper large v3' }, estimated_month_usd: 3.2, cap_usd: null },++ { id: 'decide', name: 'Decisions', short: 'Decisions', route: 'decide.fallback', ready: false, current: 'deepseek/flash', recommended: { provider: 'gemini', configured: false }, cap_usd: 5 },++ ] };++ assert.deepStrictEqual(rules.keysWanted(ai).map((k) => [k.provider, k.jobs.length]), [['deepseek', 2], ['gemini', 1], ['together', 1]]);++++ const setup = {++ services: [{ service: 'ai', name: 'OpenVibe.AI', answered: true }, { service: 'chat', name: 'OpenVibe.Chat', answered: false }],++ groups: [++ { id: 'ai-providers', label: 'AI providers', rows: [{ state: 'needed', title: 'DeepSeek key', service_name: 'OpenVibe.AI' }] },++ { id: 'money', label: 'Money', rows: [{ state: 'needed', title: 'Stripe key', service_name: 'OpenVibe.Billing', required_for: 'checkout' }] },++ { id: 'voices', label: 'Voices', rows: [{ state: 'optional', title: 'Polly key', service_name: 'OpenVibe.Live', unlocks: 'more voices' }] },++ { id: 'domains', label: 'Domains', rows: [{ state: 'connected', title: 'Cloudflare token', service_name: 'OpenVibe.Domains' }] },++ ],++ };++ const g = rules.build({ setup, ai, machines: [node], access: { lnk_N: { others_may_ask: false, auto_agent_send: true, agents_talk: false } }, progress: { seen: ['welcome'], skipped: ['credits'] } });++ const by = Object.fromEntries(g.steps.map((s) => [s.id, s]));++ assert.deepStrictEqual(g.steps.map((s) => s.id), ['welcome', 'services', 'ai-keys', 'ai-models', 'ai-caps', 'decisions', 'credits', 'machines', 'shared', 'coding', 'agents', 'keys-money', 'keys-voices', 'keys-domains', 'finish'],++ 'in order: start, AI, computers, coding, agents, every other group (AI providers are the AI steps), finish');++ assert.strictEqual(by.services.state, 'needs_you');++ assert.match(by['ai-keys'].recommended.label, /deepseek/i);++ assert.deepStrictEqual(by['ai-models'].recommended.apply, { type: 'ai-recommended' }, 'a job whose key is in gets the recommended model');++ assert.deepStrictEqual(by['ai-caps'].recommended.apply, { type: 'ai-caps', caps: { chat: 5, code: 5, image: 5, stt: 10 } }, 'a cap for each routed job without one');++ assert.strictEqual(by.decisions.recommended.apply.type, 'ai-test', 'Decisions has a route: test it');++ assert.strictEqual(by.credits.state, 'skipped');++ assert.strictEqual(by.machines.recommended.apply.changes[0].id, 'dev_N');++ assert.deepStrictEqual(by.machines.recommended.apply.changes[0].roles, ['codes.worker', 'desks.host', 'ai.host']);++ assert.match(by.machines.items[0].text, /ov-node-01: run your coding tasks, give your agents a browser, serve its AI models, let OpenVibe AI use its models/);++ assert.deepStrictEqual(by.shared.recommended.apply.changes[0].settings, { others_may_ask: true, agents_talk: true }, 'a shared node opens to others');++ assert.strictEqual(by.coding.state, 'todo', 'the Codes runner is there but the role is not on yet');++ assert.strictEqual(by['keys-money'].state, 'needs_you');++ assert.strictEqual(by['keys-voices'].state, 'optional');++ assert.strictEqual(by['keys-domains'].state, 'done');++ assert.strictEqual(g.next, 'services', 'the first step that wants you');++ assert.match(by.finish.title, /What is left/);++++ // Nothing read (a service down): every step that depends on it says so instead of guessing.++ const blind = rules.build({});++ assert.ok(['services', 'ai-keys', 'machines', 'coding'].every((id) => blind.steps.find((s) => s.id === id).state === 'unknown'));++ // Nothing paired: the machines step asks for a computer.++ assert.strictEqual(rules.build({ machines: [] }).steps.find((s) => s.id === 'machines').state, 'needs_you');++ out('guide rules: computers from what they can do, caps from spend, keys by reach, ordered steps, skipped and unknown');++}++++// ── The page is wired ──++{++ const html = fs.readFileSync(path.join(ROOT, 'public', 'admin.html'), 'utf8');++ assert.ok(html.includes("showTab('guide',this)") && html.includes('id="sec-guide"') && html.includes('/js/admin/guide-rules.js') && html.includes('/js/admin/guide.js') && html.includes('/css/guide.css'));++ assert.ok(html.includes('href="/admin/guide">Start the guided setup</a>'), 'Setup points to it');++ assert.ok(/OWNER_ONLY_TABS = new Set\(\['guide',/.test(fs.readFileSync(path.join(ROOT, 'public', 'js', 'admin', 'core.js'), 'utf8')), 'owner only');++ assert.ok(/guide: loadGuide/.test(fs.readFileSync(path.join(ROOT, 'public', 'js', 'admin', 'boot.js'), 'utf8')));++ const page = fs.readFileSync(path.join(ROOT, 'public', 'js', 'admin', 'guide.js'), 'utf8');++ assert.ok(!/innerHTML/.test(page), 'built from DOM nodes');++ for (const route of ['/api/admin/ai/setup/recommended', '/api/admin/ai/setup/caps/', '/roles', '/access-settings']) assert.ok(page.includes(route), `applies through ${route}`);++ out('guide page: tab, section, scripts, owner only, the Setup call to action, applied through the existing routes');++}++++// ── The route ──++(async () => {++ let w = null;++ try {++ // Every service unreachable, as in test/setup.test.js: the guide's route reads none of them.++ const unreachable = 'http://127.0.0.1:9';++ const env = Object.fromEntries(['AI', 'CHAT', 'DEALS', 'BILLING', 'SERVICES', 'WATCH', 'BENCH', 'REPORT', 'HOST', 'CODES', 'ACTOR', 'CONNECT', 'WORK', 'SOURCES', 'DESIGN', 'DOMAINS', 'MEDIA']++ .map((s) => [`OV_${s}_INTERNAL_URL`, unreachable]));++ w = await buildWorld({ label: 'guide', env });++ const owner = w.users.owner;++ const fresh = jwt.sign({ sub: owner.id, id: owner.id, username: owner.username, subject_id: owner.subject, role: owner.role, auth_time: Math.floor(Date.now() / 1000) },++ w.keys.privateKey, { algorithm: 'RS256', issuer: w.issuer, expiresIn: '1h' });++ const ownerCall = (method, p, body) => w.call({ authorization: `Bearer ${fresh}`, cookie: `ov_token=${fresh}` }, method, p, body);++++ let r = await w.call('alice', 'GET', '/api/admin/guide');++ assert.strictEqual(r.status, 403, 'a user never gets the guide');++ r = await w.call('staff', 'PUT', '/api/admin/guide', { skipped: ['credits'] });++ assert.strictEqual(r.status, 403, 'a plain admin cannot change it');++ r = await ownerCall('GET', '/api/admin/guide');++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));++ assert.deepStrictEqual([r.body.skipped, r.body.seen], [[], []]);++ r = await ownerCall('PUT', '/api/admin/guide', { skipped: ['credits', 'credits', 'Not A Step!'], seen: ['welcome', 'ai-keys'] });++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));++ assert.deepStrictEqual([r.body.skipped, r.body.seen], [['credits'], ['welcome', 'ai-keys']], 'step ids only, once each');++ r = await ownerCall('PUT', '/api/admin/guide', { skipped: 'credits' });++ assert.strictEqual(r.status, 400, 'a list, not a string');++ r = await ownerCall('PUT', '/api/admin/guide', {});++ assert.strictEqual(r.status, 400, 'something to save');++ r = await ownerCall('GET', '/api/admin/guide');++ assert.deepStrictEqual(r.body.skipped, ['credits'], 'kept');++ const snap = await w.db.prepare("SELECT values_json FROM config_snapshots WHERE namespace = 'network.guide' AND state = 'active'").get();++ assert.ok(snap && JSON.parse(snap.values_json).seen.includes('ai-keys'), 'in network.guide, a revision like any setting');++ out('guide route: owner only, step ids only, kept in network.guide');++ } finally {++ if (w) await w.stop();++ }++})().then(() => process.exit(0)).catch((e) => { console.error(e); process.exit(1); });@@@ -12,7 +12,7 @@ for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) asconst files = fs.readdirSync(path.join(pub, 'js', 'admin')).filter((f) => f.endsWith('.js')).sort();// The module map, in load order (public/js/admin/…).--const modules = ['core.js', 'dashboard.js', 'users.js', 'moderation.js', 'settings.js', 'tools-workers.js', 'payments.js', 'glossary.js', 'ai.js', 'ai-setup.js', 'inference.js', 'setup.js', 'content.js', 'analytics.js', 'deploy.js', 'boot.js'];++const modules = ['core.js', 'dashboard.js', 'users.js', 'moderation.js', 'settings.js', 'tools-workers.js', 'payments.js', 'glossary.js', 'ai.js', 'ai-setup.js', 'guide-rules.js', 'guide.js', 'inference.js', 'setup.js', 'content.js', 'analytics.js', 'deploy.js', 'boot.js'];assert.deepStrictEqual(files, [...modules].sort(), 'each module file exists and there is no other');const tags = [...html.matchAll(/<script\b([^>]*)\bsrc="\/js\/admin\/([^"]+)"([^>]*)><\/script>/g)];const loaded = tags.map((m) => m[2]);@@@ -7,7 +7,7 @@ const fs = require('fs');const path = require('path');const pub = path.join(__dirname, '..', 'public');--for (const [page, sheets] of [['admin.html', ['admin.css', 'ai-setup.css']], ['my.html', ['my.css']], ['login.html', ['login.css']]]) {++for (const [page, sheets] of [['admin.html', ['admin.css', 'ai-setup.css', 'guide.css']], ['my.html', ['my.css']], ['login.html', ['login.css']]]) {const html = fs.readFileSync(path.join(pub, page), 'utf8');const links = [...html.matchAll(/<link\b[^>]*rel="stylesheet"[^>]*href="\/css\/([^"]+)"/g)].map((m) => m[1]);assert.deepStrictEqual(links, sheets, `${page} links ${sheets.map((s) => '/css/' + s).join(', ')} exactly once`);
@@ -28,6 +28,7 @@
@@ -28,6 +28,7 @@
</script>
</script>
<link rel="stylesheet" href="/css/admin.css">
<link rel="stylesheet" href="/css/admin.css">
<link rel="stylesheet" href="/css/ai-setup.css">
<link rel="stylesheet" href="/css/ai-setup.css">
+<link rel="stylesheet" href="/css/guide.css">
<!-- OpenVibe's own form controls (4.109.0): every select in Platform AI is one, with data-badge/data-desc/data-state. -->
<!-- OpenVibe's own form controls (4.109.0): every select in Platform AI is one, with data-badge/data-desc/data-state. -->
<link rel="stylesheet" href="/shared/ov-forms.css">
<link rel="stylesheet" href="/shared/ov-forms.css">
</head>
</head>
@@ -49,6 +50,7 @@
@@ -49,6 +50,7 @@
<div class="section-tabs-wrap">
<div class="section-tabs-wrap">
<div class="section-tabs" id="main-tabs">
<div class="section-tabs" id="main-tabs">
<button class="admin-tab-btn owner-only" onclick="showTab('setup',this)"><i class="fa-solid fa-list-check"></i> Setup</button>
<button class="admin-tab-btn owner-only" onclick="showTab('setup',this)"><i class="fa-solid fa-list-check"></i> Setup</button>
+ <button class="admin-tab-btn owner-only" onclick="showTab('guide',this)"><i class="fa-solid fa-route"></i> Guided setup</button>
<button class="admin-tab-btn active" onclick="showTab('dashboard',this)"><i class="fa-solid fa-chart-line"></i> Dashboard</button>
<button class="admin-tab-btn active" onclick="showTab('dashboard',this)"><i class="fa-solid fa-chart-line"></i> Dashboard</button>
<button class="admin-tab-btn" onclick="location.href='/admin/review'"><i class="fa-solid fa-list-check"></i> Review queue</button>
<button class="admin-tab-btn" onclick="location.href='/admin/review'"><i class="fa-solid fa-list-check"></i> Review queue</button>
<button class="admin-tab-btn" onclick="showTab('users',this)"><i class="fa-solid fa-users"></i> Users</button>
<button class="admin-tab-btn" onclick="showTab('users',this)"><i class="fa-solid fa-users"></i> Users</button>
@@ -86,7 +88,13 @@
@@ -86,7 +88,13 @@
</div>
</div>
<!-- Setup (owner only): everything only the owner can give the network, from every service (js/admin/setup.js) -->
<!-- Setup (owner only): everything only the owner can give the network, from every service (js/admin/setup.js) -->
+ <!-- The guided setup (/admin/guide): everything, one step at a time, with the setting we recommend (js/admin/guide.js). -->
+ <div class="section" id="sec-guide">
+ <div id="guide-content" class="guide"></div>
+ </div>
+
<div class="section" id="sec-setup">
<div class="section" id="sec-setup">
+ <div class="setup-guide-cta"><p><b>New here, or not sure what is left?</b> The guided setup walks through everything, one step at a time, with the setting we recommend.</p><a class="btn btn-primary" href="/admin/guide">Start the guided setup</a></div>
<div id="setup-content" class="setup"></div>
<div id="setup-content" class="setup"></div>
</div>
</div>
@@ -708,6 +716,8 @@
@@ -708,6 +716,8 @@
<script src="/js/admin/glossary.js" defer></script>
<script src="/js/admin/glossary.js" defer></script>
<script src="/js/admin/ai.js" defer></script>
<script src="/js/admin/ai.js" defer></script>
<script src="/js/admin/ai-setup.js" defer></script>
<script src="/js/admin/ai-setup.js" defer></script>
+<script src="/js/admin/guide-rules.js" defer></script>
+<script src="/js/admin/guide.js" defer></script>
<script src="/js/admin/inference.js" defer></script>
<script src="/js/admin/inference.js" defer></script>
<script src="/js/admin/setup.js" defer></script>
<script src="/js/admin/setup.js" defer></script>
<script src="/js/admin/content.js" defer></script>
<script src="/js/admin/content.js" defer></script>
new file mode 100644
new file mode 100644
@@ -0,0 +1,54 @@
@@ -0,0 +1,54 @@
+/* The owner's guided setup (/admin/guide, js/admin/guide.js): one step at a time, phone first.
+ A rail of every step beside the card on a computer; on a phone the rail folds above the card. */
+.guide-head { display: grid; gap: 10px; margin-bottom: 16px; }
+.guide-head h2 { margin: 0; font-size: 22px; }
+.guide-head p { margin: 2px 0 0; }
+.guide-bar { height: 8px; border-radius: 999px; background: var(--bg-tertiary); overflow: hidden; }
+.guide-bar > span { display: block; height: 100%; border-radius: inherit; background: var(--accent); transition: width .3s ease; }
+.guide-body { display: grid; gap: 16px; }
+.guide-fold > summary { cursor: pointer; padding: 10px 12px; border: 1px solid var(--border); border-radius: 12px; background: var(--bg-card); font-weight: 600; }
+.guide-fold[open] > summary { margin-bottom: 8px; }
+.guide-rail { display: grid; gap: 4px; }
+.guide-rail ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; }
+.guide-area { margin: 10px 0 2px; font-size: 11.5px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted); }
+.guide-rail-step { display: flex; gap: 10px; align-items: center; min-height: 40px; padding: 6px 10px; border-radius: 10px; color: var(--text-secondary); text-decoration: none; }
+.guide-rail-step:hover, .guide-rail-step:focus-visible { background: var(--bg-secondary); color: var(--text); }
+.guide-rail-step.is-here { background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--text); font-weight: 600; }
+.guide-dot { flex: none; width: 10px; height: 10px; border-radius: 50%; border: 2px solid var(--text-muted); }
+.is-ok > .guide-dot { background: var(--success); border-color: var(--success); }
+.is-you > .guide-dot { border-color: var(--warning); background: var(--warning); }
+.is-todo > .guide-dot { border-color: var(--accent); }
+.is-bad > .guide-dot { border-color: var(--danger); background: var(--danger); }
+.guide-card { display: grid; gap: 12px; align-content: start; padding: 20px; border: 1px solid var(--border); border-radius: 16px; background: var(--bg-card); outline: none; min-width: 0; }
+.guide-card h3 { margin: 0; font-size: 20px; line-height: 1.25; }
+.guide-eyebrow { margin: 0; font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--accent-light); }
+.guide-state { justify-self: start; display: inline-flex; gap: 6px; align-items: center; padding: 3px 10px; border-radius: 999px; font-size: 12.5px; font-weight: 600; background: var(--bg-secondary); color: var(--text-secondary); }
+.guide-state.is-ok { color: var(--success-text, var(--success)); background: color-mix(in srgb, var(--success) 14%, transparent); }
+.guide-state.is-you { color: var(--warning); background: color-mix(in srgb, var(--warning) 14%, transparent); }
+.guide-state.is-bad { color: var(--danger); background: color-mix(in srgb, var(--danger) 12%, transparent); }
+.guide-plain { margin: 0; font-size: 15px; line-height: 1.55; color: var(--text); max-width: 68ch; }
+.guide-why { margin: 0; font-size: 13.5px; color: var(--text-secondary); }
+.guide-items { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
+.guide-items li { display: grid; gap: 2px; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--border); background: var(--bg-secondary); font-size: 14px; }
+.guide-items li.is-ok { border-color: color-mix(in srgb, var(--success) 40%, var(--border)); }
+.guide-items li small { color: var(--text-muted); font-size: 12.5px; }
+.guide-items a { color: var(--accent-light); }
+.guide-rec { display: grid; gap: 8px; padding: 14px 16px; border-radius: 14px; border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border)); background: color-mix(in srgb, var(--accent) 7%, var(--bg-card)); }
+.guide-rec-h { margin: 0; font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--accent-light); }
+.guide-rec-l { margin: 0; font-size: 15.5px; font-weight: 600; }
+.guide-rec ul { margin: 0; padding-left: 18px; display: grid; gap: 2px; font-size: 13.5px; color: var(--text-secondary); }
+.guide-apply { justify-self: start; min-height: 44px; }
+.guide-acts:empty { display: none; }
+.guide-acts { display: flex; flex-wrap: wrap; gap: 8px; }
+.guide-nav { display: flex; flex-wrap: wrap; gap: 8px; justify-content: space-between; padding-top: 12px; border-top: 1px solid var(--border); }
+.guide-nav .btn { min-height: 44px; }
+.guide-nav .btn-primary { margin-left: auto; }
+.guide-bad { color: var(--danger); }
+.setup-guide-cta { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: space-between; padding: 14px 16px; margin-bottom: 16px; border-radius: 14px; border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border)); background: color-mix(in srgb, var(--accent) 7%, var(--bg-card)); }
+.setup-guide-cta p { margin: 0; }
+@media (min-width: 900px) {
+ .guide-body { grid-template-columns: 260px minmax(0, 1fr); align-items: start; }
+ .guide-fold { position: sticky; top: 12px; }
+ .guide-fold > summary { display: none; }
+}
+@media (prefers-reduced-motion: reduce) { .guide-bar > span { transition: none; } }
@@ -22,6 +22,7 @@ const tabLoaders = {
@@ -22,6 +22,7 @@ const tabLoaders = {
payments: loadPayments,
payments: loadPayments,
ai: loadAi,
ai: loadAi,
'ai-setup': loadAiSetup,
'ai-setup': loadAiSetup,
+ guide: loadGuide,
pastes: loadPastesAdmin,
pastes: loadPastesAdmin,
storage: loadStorage,
storage: loadStorage,
notifications: () => {},
notifications: () => {},
@@ -81,7 +81,7 @@ let currentTab = 'dashboard';
@@ -81,7 +81,7 @@ let currentTab = 'dashboard';
// NOTE: 'ai' is NOT here — admins may use its analytics/explorer tools; only the
// NOTE: 'ai' is NOT here — admins may use its analytics/explorer tools; only the
// AI configuration form inside it is owner-gated (see loadAi).
// AI configuration form inside it is owner-gated (see loadAi).
// 'setup' is the owner's checklist of keys and accounts (js/admin/setup.js); its API answers 403 to anyone else too.
// 'setup' is the owner's checklist of keys and accounts (js/admin/setup.js); its API answers 403 to anyone else too.
-const OWNER_ONLY_TABS = new Set(['setup', 'ai-setup', 'deploy', 'domains', 'payments', 'cashouts', 'storage', 'email', 'settings', 'grants', 'eventsops']);
+const OWNER_ONLY_TABS = new Set(['guide', 'setup', 'ai-setup', 'deploy', 'domains', 'payments', 'cashouts', 'storage', 'email', 'settings', 'grants', 'eventsops']);
function showTab(id, btn) {
function showTab(id, btn) {
if (!tabLoaders[id]) id = 'dashboard';
if (!tabLoaders[id]) id = 'dashboard';
new file mode 100644
new file mode 100644
@@ -0,0 +1,236 @@
@@ -0,0 +1,236 @@
+/* ═══════════════════════════════════════════════════════════════
+ OpenVibe.Network — admin panel (/admin/guide): the guided setup's rules.
+ The owner, 2026-11-12: "The guided setup should be built into admin panel and walks the owner rank through full
+ config of everything entirely step by step and uses recommended settings that are most intelligent for our systems."
+
+ Pure functions over what the page reads (nothing here calls a network):
+ setup GET /api/admin/setup every key and account only the owner can give, by group
+ ai GET /api/admin/ai/setup every AI job: its key, model, spend and cap
+ machines GET /api/v1/machines the owner's computers, what each can do and what is on
+ access { principal_id: settings } each computer's access switches (GET /api/v1/me/nodes/:p/access-settings)
+ progress GET /api/admin/guide the steps the owner skipped or saw
+ build() returns the steps in order, each with its state and the setting we recommend, worked out from the live
+ state: the keys that unlock the most jobs first, a cap from the real spend, each computer's roles from what it can
+ actually do. "Use recommended" applies one step through the routes the rest of the admin panel already uses.
+ Browser global OVGuideRules; require('…/guide-rules.js') in Node (test/admin-guide.test.js).
+ ═══════════════════════════════════════════════════════════════ */
+(function (root) {
+ 'use strict';
+
+ const ROLE_WORDS = { 'codes.worker': 'run your coding tasks', 'desks.host': 'give your agents a browser', 'ai.host': 'serve its AI models', 'bot.robot': 'drive a robot' };
+ const SWITCH_WORDS = {
+ agent_computer: 'let your agents use it', ai_models: 'let OpenVibe AI use its models', watch_probe: 'let your watches check from it',
+ shared_desks: 'lend browsers to other people’s agents',
+ };
+ const ACCESS_WORDS = { others_may_ask: 'let people ask to use it', auto_agent_send: 'your other computers use it without asking', agents_talk: 'its agents work with other agents' };
+ const ROLE_ORDER = ['codes.worker', 'desks.host', 'ai.host', 'bot.robot'];
+ const SKIP_GROUPS = new Set(['ai-providers']); // the AI steps cover them, job by job
+
+ const money = (n) => { const v = Number(n) || 0; return v ? `$${v < 10 ? v.toFixed(2) : Math.round(v)}` : '$0'; };
+ const list = (words) => (words.length <= 1 ? words.join('') : `${words.slice(0, -1).join(', ')} and ${words[words.length - 1]}`);
+ const arr = (v) => (Array.isArray(v) ? v : []);
+
+ /**
+ * What we recommend for one computer, from what it says it can do: a role for each thing it has (browsers for
+ * agents, AI models, the Codes runner), and the switches that let those be used. A computer that keeps each
+ * browser in a locked box of its own is a shared node: it lends them to other people's agents too. A robot role
+ * is kept when it is on (only its owner knows a robot is plugged in). → { roles, switches, why }
+ */
+ function recommendMachine(m) {
+ const c = (m && m.controls) || {};
+ const roles = new Set(arr(m && m.roles).filter((r) => r === 'bot.robot'));
+ const switches = {};
+ const why = [];
+ const desk = c.desk || {};
+ const ai = c.ai || {};
+ const fleet = arr(m && m.fleet);
+ if (desk.available) { roles.add('desks.host'); why.push(`it has browsers for agents${desk.max ? ` (up to ${desk.max})` : ''}`); }
+ if (ai.available && ai.models > 0) { roles.add('ai.host'); switches.ai_models = true; why.push(`it serves ${ai.models} AI model${ai.models === 1 ? '' : 's'} for free`); }
+ if (fleet.length || arr(m && m.roles).includes('codes.worker')) { roles.add('codes.worker'); why.push('it runs the Codes job runner'); }
+ if (c.computer) { switches.agent_computer = true; why.push(`your agents may use it as far as its own level allows${c.level ? ` (${c.level})` : ''}`); }
+ if (c.computer && c.probe) switches.watch_probe = true;
+ if (desk.available && desk.sandboxed) { switches.shared_desks = true; why.push('each browser is locked in its own box, so it can lend them safely'); }
+ return { roles: ROLE_ORDER.filter((r) => roles.has(r)), switches, why };
+ }
+
+ /** What changes for one computer to reach the recommendation: roles to add, switches to turn on. */
+ function machineChange(m) {
+ const rec = recommendMachine(m);
+ const have = new Set(arr(m.roles));
+ const c = m.controls || {};
+ const addRoles = rec.roles.filter((r) => !have.has(r));
+ const turnOn = Object.keys(rec.switches).filter((k) => rec.switches[k] && c[k] !== true);
+ if (!addRoles.length && !turnOn.length) return null;
+ return {
+ id: m.id, name: m.name || 'A computer', roles: [...new Set([...arr(m.roles), ...rec.roles])].sort((a, b) => ROLE_ORDER.indexOf(a) - ROLE_ORDER.indexOf(b)),
+ switches: Object.fromEntries(turnOn.map((k) => [k, true])),
+ words: [...addRoles.map((r) => ROLE_WORDS[r]), ...turnOn.map((k) => SWITCH_WORDS[k])].filter(Boolean),
+ why: rec.why,
+ };
+ }
+
+ /** A monthly cap for one AI job: twice what it spent in the last 30 days, at least $5, in steps of $5. */
+ function capFor(step) {
+ const spent = Math.max(Number(step && step.estimated_month_usd) || 0, Number(step && step.month_usd) || 0);
+ return Math.max(5, Math.ceil((spent * 2) / 5) * 5);
+ }
+
+ /** The providers whose keys unlock the most AI jobs, first; each with the jobs it unlocks and where to get a key. */
+ function keysWanted(ai) {
+ const want = new Map();
+ for (const s of arr(ai && ai.steps)) {
+ if (s.ready) continue;
+ const rec = s.recommended || {};
+ if (!rec.provider || rec.configured === true) continue;
+ const w = want.get(rec.provider) || { provider: rec.provider, name: rec.provider_name || rec.provider, jobs: [], key_page: s.key_page || rec.key_page || null };
+ w.jobs.push(s.short || s.name || s.id);
+ want.set(rec.provider, w);
+ }
+ return [...want.values()].sort((a, b) => b.jobs.length - a.jobs.length || a.provider.localeCompare(b.provider));
+ }
+
+ const step = (id, area, title, plain, o = {}) => ({ id, area, title, plain, state: 'todo', recommended: null, open: null, items: [], ...o });
+
+ /** Every step, in order, with its state and what we recommend. */
+ function build({ setup = null, ai = null, machines = null, access = {}, progress = {} } = {}) {
+ const skipped = new Set(arr(progress.skipped));
+ const seen = new Set(arr(progress.seen));
+ const steps = [];
+
+ steps.push(step('welcome', 'Start', 'Set up OpenVibe',
+ 'This walks you through everything OpenVibe needs from you, one step at a time. Each step says what it does and offers the setting we recommend. You can change any of it later, and skip what you do not need yet.',
+ { state: seen.has('welcome') ? 'done' : 'todo' }));
+
+ // ── The services answer ──
+ const reach = arr(setup && setup.services);
+ const down = reach.filter((s) => !s.answered);
+ steps.push(step('services', 'Start', 'Every service answers',
+ 'The guide reads each service’s settings. A service that does not answer cannot be set up from here yet.',
+ {
+ state: !setup ? 'unknown' : down.length ? 'needs_you' : 'done',
+ items: down.map((s) => ({ text: `${s.name} did not answer`, state: 'bad' })),
+ why: down.length ? 'Try again in a minute; if it stays down, it needs a look on the server.' : `${reach.length} services answered.`,
+ }));
+
+ // ── AI: keys, models, caps, Decisions, credits ──
+ const aiSteps = arr(ai && ai.steps);
+ const keys = keysWanted(ai);
+ steps.push(step('ai-keys', 'AI', 'Add the AI keys that unlock the most',
+ 'OpenVibe’s AI jobs (chat, code, pictures, speech, search …) each run on a provider. A key from a provider is what lets them run. Keys are sealed in OpenVibe.AI and never shown again.',
+ {
+ state: !ai ? 'unknown' : keys.length ? 'needs_you' : 'done',
+ items: keys.map((k) => ({ text: `${k.name}: unlocks ${list(k.jobs)}`, href: k.key_page, state: 'todo' })),
+ recommended: keys.length ? { label: `Add ${keys[0].name}’s key first`, detail: [`It unlocks ${keys[0].jobs.length} job${keys[0].jobs.length === 1 ? '' : 's'}: ${list(keys[0].jobs)}.`] } : null,
+ open: { label: 'Add keys', href: '/admin/ai/setup' },
+ }));
+
+ const unrouted = aiSteps.filter((s) => s.route && !s.current && s.recommended && s.recommended.configured === true);
+ steps.push(step('ai-models', 'AI', 'Pick the model for each AI job',
+ 'Each job uses one model. We recommend the cheapest model that does the job well, from Exchange’s catalogue and the keys you added.',
+ {
+ state: !ai ? 'unknown' : unrouted.length ? 'todo' : 'done',
+ items: unrouted.map((s) => ({ text: `${s.name}: ${(s.recommended && (s.recommended.label || s.recommended.model)) || 'recommended'}`, state: 'todo' })),
+ recommended: unrouted.length ? { label: 'Use the recommended model for every job', detail: unrouted.map((s) => `${s.name}: ${(s.recommended && (s.recommended.label || s.recommended.model)) || ''}`), apply: { type: 'ai-recommended' } } : null,
+ open: { label: 'Choose by hand', href: '/admin/ai/setup' },
+ }));
+
+ const uncapped = aiSteps.filter((s) => s.route && (s.cap_usd == null || s.cap_usd === ''));
+ const caps = Object.fromEntries(uncapped.map((s) => [s.id, capFor(s)]));
+ steps.push(step('ai-caps', 'AI', 'Set a monthly limit for each AI job',
+ 'A limit stops a job from spending more than you planned in a month. We suggest twice what it spent in the last 30 days, at least $5.',
+ {
+ state: !ai ? 'unknown' : uncapped.length ? 'todo' : 'done',
+ items: uncapped.map((s) => ({ text: `${s.name}: ${money(caps[s.id])} a month (spent ${money(s.estimated_month_usd)} in 30 days)`, state: 'todo' })),
+ recommended: uncapped.length ? { label: 'Use these limits', detail: uncapped.map((s) => `${s.name}: ${money(caps[s.id])}`), apply: { type: 'ai-caps', caps } } : null,
+ }));
+
+ const decide = aiSteps.find((s) => s.id === 'decide');
+ steps.push(step('decisions', 'AI', 'Decisions: quick, measured yes/no and choose-one answers',
+ 'Decisions answers routing, checks and grading questions with how sure it is, and it is what lets agents choose the right model and tool and measure themselves. With a Gemini key it uses Google’s calibrated Decisions model (public or test data only on Google’s free tier); without one, the cheapest structured model answers, marked not calibrated.',
+ {
+ state: !ai ? 'unknown' : !decide ? 'optional' : decide.ready ? 'done' : 'todo',
+ recommended: decide && !decide.ready ? (decide.route && decide.current ? { label: 'Test Decisions now', apply: { type: 'ai-test', step: 'decide' } } : { label: 'Add a Gemini key, then test', detail: ['Free tier at aistudio.google.com.'] }) : null,
+ open: { label: 'Open the Decisions step', href: '/admin/ai/setup' },
+ }));
+
+ steps.push(step('credits', 'AI', 'Record the credits you already get',
+ 'Some plans come with monthly API credits (for example ChatGPT Pro’s). Record them once, and OpenVibe spends them before it spends money.',
+ { state: 'optional', open: { label: 'Open Providers on Exchange', href: 'https://openvibe.exchange/console/providers' } }));
+
+ // ── Computers ──
+ const ms = arr(machines);
+ const changes = ms.map(machineChange).filter(Boolean);
+ steps.push(step('machines', 'Computers', 'Put your computers to work',
+ 'Each computer you paired can run coding tasks, give your agents a browser and serve its own AI models. We turn on what each one can actually do.',
+ {
+ state: machines == null ? 'unknown' : !ms.length ? 'needs_you' : changes.length ? 'todo' : 'done',
+ items: !ms.length ? [{ text: 'No computer is paired yet.', state: 'todo' }] : changes.map((ch) => ({ text: `${ch.name}: ${list(ch.words)}`, state: 'todo', why: ch.why.join('; ') })),
+ recommended: changes.length ? { label: changes.length === 1 ? `Set up ${changes[0].name}` : `Set up all ${changes.length} computers`, detail: changes.map((ch) => `${ch.name}: ${list(ch.words)}`), apply: { type: 'machines', changes } } : null,
+ open: { label: ms.length ? 'Open your computers' : 'Pair a computer', href: 'https://openvibe.link/machines' },
+ }));
+
+ // ── Shared nodes: access for the computers that lend their browsers ──
+ const shared = ms.filter((m) => m.principal_id && (m.controls || {}).desk && m.controls.desk.sandboxed);
+ const accessChanges = shared.map((m) => {
+ const now = access[m.principal_id] || {};
+ const off = Object.keys(ACCESS_WORDS).filter((k) => now[k] !== true);
+ return off.length ? { principal: m.principal_id, id: m.id, name: m.name || 'A computer', settings: Object.fromEntries(off.map((k) => [k, true])), words: off.map((k) => ACCESS_WORDS[k]) } : null;
+ }).filter(Boolean);
+ steps.push(step('shared', 'Computers', 'Open your shared computers to others',
+ 'A shared computer serves people other than you: their agents borrow its browsers, each locked in its own box, and people can ask to use it. It never shows your files, your screen or your programs.',
+ {
+ state: machines == null ? 'unknown' : !shared.length ? 'optional' : accessChanges.length ? 'todo' : 'done',
+ items: !shared.length ? [{ text: 'None of your computers is set up for sharing (Link’s --desk-host --shared).', state: 'optional' }] : accessChanges.map((a) => ({ text: `${a.name}: ${list(a.words)}`, state: 'todo' })),
+ recommended: accessChanges.length ? { label: 'Open them to others', detail: accessChanges.map((a) => `${a.name}: ${list(a.words)}`), apply: { type: 'access', changes: accessChanges } } : null,
+ }));
+
+ // ── Coding ──
+ const workers = ms.filter((m) => arr(m.roles).includes('codes.worker'));
+ const ready = workers.filter((m) => arr(m.fleet).some((a) => a && a.installed && a.signed_in));
+ steps.push(step('coding', 'Coding', 'Let your computers do coding tasks',
+ 'Coding tasks you start on openvibe.codes run on a computer with the Codes runner, using the coding apps signed in there (Claude Code, Codex, OpenCode …) or a model key.',
+ {
+ state: machines == null ? 'unknown' : ready.length ? 'done' : workers.length ? 'needs_you' : 'todo',
+ items: workers.length && !ready.length ? workers.map((m) => ({ text: `${m.name || 'A computer'}: no coding app is signed in yet`, state: 'todo' })) : [],
+ why: workers.length && !ready.length ? 'Sign in a coding app on that computer (for example: codex login), or add a model key for its runner.' : null,
+ open: { label: 'Open Codes', href: 'https://openvibe.codes' },
+ }));
+
+ // ── Agents ──
+ steps.push(step('agents', 'Agents', 'Your agents',
+ 'Vibe is your first agent. You can make more, share them, and install other people’s: each one says in plain words what it may do before you install it.',
+ { state: 'optional', open: { label: 'Open your agents', href: 'https://openvibe.actor' } }));
+
+ // ── Everything else only the owner can give, a step per group ──
+ for (const g of arr(setup && setup.groups)) {
+ if (SKIP_GROUPS.has(g.id)) continue;
+ const rows = arr(g.rows);
+ const needed = rows.filter((r) => r.state === 'needed' || r.state === 'not_working');
+ const optional = rows.filter((r) => r.state === 'optional');
+ steps.push(step(`keys-${g.id}`, 'Keys and accounts', g.label,
+ needed.length ? `${needed.length} thing${needed.length === 1 ? '' : 's'} here wait${needed.length === 1 ? 's' : ''} on you.` : optional.length ? 'Nothing waits on these; add them when you want what they unlock.' : 'All connected.',
+ {
+ state: needed.length ? 'needs_you' : optional.length ? 'optional' : 'done',
+ items: [...needed, ...optional].slice(0, 12).map((r) => ({
+ text: `${r.title} (${r.service_name})${r.state === 'not_working' ? ': not working' : r.required_for ? `: needed for ${r.required_for}` : r.unlocks ? `: ${r.unlocks}` : ''}`,
+ href: r.get_it_at || null, state: r.state === 'optional' ? 'optional' : 'todo',
+ })),
+ open: { label: 'Open in Setup', href: `/admin/setup#${g.id}` },
+ }));
+ }
+
+ const counted = steps.filter((s) => s.id !== 'welcome');
+ for (const s of steps) if (skipped.has(s.id) && s.state !== 'done') s.state = 'skipped';
+ const done = counted.filter((s) => s.state === 'done').length;
+ const open = counted.filter((s) => s.state === 'todo' || s.state === 'needs_you');
+ steps.push(step('finish', 'Finish', open.length ? 'What is left' : 'All set',
+ open.length ? `${done} of ${counted.length} steps are done. These still want you:` : 'Everything the guide covers is set up. Come back any time: it rereads the live state each time.',
+ { state: open.length ? 'todo' : 'done', items: open.map((s) => ({ text: s.title, step: s.id, state: s.state })) }));
+ const next = steps.find((s) => s.id !== 'welcome' && (s.state === 'todo' || s.state === 'needs_you')) || null;
+ return { steps, done, total: counted.length, next: next ? next.id : 'finish' };
+ }
+
+ const api = { build, recommendMachine, machineChange, capFor, keysWanted, ROLE_WORDS, SWITCH_WORDS, ACCESS_WORDS };
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
+ else root.OVGuideRules = api;
+})(typeof globalThis !== 'undefined' ? globalThis : this);
new file mode 100644
new file mode 100644
@@ -0,0 +1,188 @@
@@ -0,0 +1,188 @@
+/* ═══════════════════════════════════════════════════════════════
+ OpenVibe.Network — admin panel (/admin/guide): set up everything, one step at a time.
+ It reads the live state (the Setup checklist, the AI setup, your computers and their access), works out each step and
+ the setting we recommend (js/admin/guide-rules.js), and applies "Use recommended" through the routes the rest of the
+ panel already uses. Progress (skipped and seen steps) is kept by /api/admin/guide (server/admin/guide.js).
+ Classic script, global scope: admin/core.js's api() and toast(); built from DOM nodes.
+ ═══════════════════════════════════════════════════════════════ */
+let guideData = null; // { setup, ai, machines, access, progress }
+let guideModel = null; // OVGuideRules.build(...)
+let guideAt = 'welcome';
+let guideBusy = false;
+
+const GUIDE_STATE = {
+ done: ['Done', 'ok', 'fa-circle-check'], todo: ['To do', 'todo', 'fa-circle-dot'], needs_you: ['Needs you', 'you', 'fa-hand'],
+ optional: ['Optional', 'opt', 'fa-circle'], skipped: ['Skipped', 'opt', 'fa-forward'], unknown: ['Could not read', 'bad', 'fa-triangle-exclamation'],
+};
+const GUIDE_SWITCH_ROUTE = { agent_computer: 'agent-computer', ai_models: 'ai-models', watch_probe: 'watch-probe', shared_desks: 'shared-desks' };
+
+function gEl(tag, cls, kids, attrs) {
+ const n = document.createElement(tag);
+ if (cls) n.className = cls;
+ for (const k in attrs || {}) n.setAttribute(k, attrs[k]);
+ (kids || []).forEach((c) => { if (c != null && c !== false) n.append(c); });
+ return n;
+}
+const gIcon = (name) => gEl('i', `fa-solid ${name}`, [], { 'aria-hidden': 'true' });
+
+async function guideRead() {
+ const quiet = (p) => p.catch(() => null);
+ const [setup, ai, machinesOut, progress] = await Promise.all([
+ quiet(api('/api/admin/setup')), quiet(api('/api/admin/ai/setup')), quiet(api('/api/v1/machines')), quiet(api('/api/admin/guide')),
+ ]);
+ const machines = machinesOut && Array.isArray(machinesOut.machines) ? machinesOut.machines : null;
+ const access = {};
+ await Promise.all((machines || []).filter((m) => m.principal_id).map(async (m) => {
+ const out = await quiet(api(`/api/v1/me/nodes/${encodeURIComponent(m.principal_id)}/access-settings`));
+ if (out && out.settings) access[m.principal_id] = out.settings;
+ }));
+ guideData = { setup, ai, machines, access, progress: progress || { skipped: [], seen: [] } };
+ guideModel = OVGuideRules.build(guideData);
+}
+
+async function loadGuide() {
+ const box = document.getElementById('guide-content');
+ if (!box) return;
+ box.replaceChildren(gEl('p', 'muted', ['Reading what is set up…']));
+ try { await guideRead(); } catch (e) { box.replaceChildren(gEl('p', 'guide-bad', [e.message || 'The guide could not read the live state.'])); return; }
+ const want = (location.hash || '').replace(/^#/, '');
+ guideAt = guideModel.steps.some((s) => s.id === want) ? want : (guideData.progress.seen || []).includes('welcome') ? guideModel.next : 'welcome';
+ guideDraw();
+}
+
+async function guideSaveProgress(change) {
+ const p = guideData.progress;
+ const next = { skipped: change.skipped || p.skipped || [], seen: change.seen || p.seen || [] };
+ try { guideData.progress = { ...p, ...(await api('/api/admin/guide', { method: 'PUT', body: next })) }; } catch { guideData.progress = { ...p, ...next }; }
+}
+
+function guideGo(id) {
+ guideAt = id;
+ try { history.replaceState(history.state, '', `/admin/guide#${id}`); } catch { /* */ }
+ const seen = new Set(guideData.progress.seen || []);
+ if (!seen.has(id)) { seen.add(id); guideSaveProgress({ seen: [...seen] }); }
+ guideDraw();
+ const card = document.querySelector('.guide-card');
+ if (card && card.focus) card.focus();
+}
+
+function guideDraw() {
+ const box = document.getElementById('guide-content');
+ const steps = guideModel.steps;
+ const i = Math.max(0, steps.findIndex((s) => s.id === guideAt));
+ const s = steps[i];
+ const pct = guideModel.total ? Math.round((guideModel.done / guideModel.total) * 100) : 0;
+
+ const head = gEl('header', 'guide-head', [
+ gEl('div', 'guide-head-t', [gEl('h2', '', ['Set up OpenVibe']), gEl('p', 'muted', [`${guideModel.done} of ${guideModel.total} steps done`])]),
+ gEl('div', 'guide-bar', [gEl('span', '', [], { style: `width:${pct}%` })], { role: 'progressbar', 'aria-valuemin': '0', 'aria-valuemax': '100', 'aria-valuenow': String(pct), 'aria-label': 'Setup progress' }),
+ ]);
+
+ // Every step, by area: a rail on a computer, a fold on a phone.
+ const rail = gEl('nav', 'guide-rail', [], { 'aria-label': 'Every step' });
+ let area = null; let ul = null;
+ for (const st of steps) {
+ if (st.area !== area) { area = st.area; ul = gEl('ul'); rail.append(gEl('p', 'guide-area', [area]), ul); }
+ const [word, cls] = GUIDE_STATE[st.state] || GUIDE_STATE.todo;
+ const a = gEl('a', `guide-rail-step is-${cls}${st.id === s.id ? ' is-here' : ''}`, [gEl('span', 'guide-dot', [], { 'aria-hidden': 'true' }), gEl('span', '', [st.title])], { href: `#${st.id}`, title: word, ...(st.id === s.id ? { 'aria-current': 'step' } : {}) });
+ a.addEventListener('click', (e) => { e.preventDefault(); guideGo(st.id); });
+ ul.append(gEl('li', '', [a]));
+ }
+ const fold = gEl('details', 'guide-fold', [gEl('summary', '', [`Step ${i + 1} of ${steps.length}: every step`]), rail]);
+
+ const [word, cls, icon] = GUIDE_STATE[s.state] || GUIDE_STATE.todo;
+ const card = gEl('section', 'guide-card', [], { tabindex: '-1', 'aria-labelledby': 'guide-title' });
+ card.append(gEl('p', 'guide-eyebrow', [s.area]), gEl('h3', '', [s.title], { id: 'guide-title' }), gEl('span', `guide-state is-${cls}`, [gIcon(icon), ' ', word]), gEl('p', 'guide-plain', [s.plain]));
+ if (s.why) card.append(gEl('p', 'guide-why', [s.why]));
+ if (s.items && s.items.length) {
+ card.append(gEl('ul', 'guide-items', s.items.map((it) => {
+ const li = gEl('li', `is-${(GUIDE_STATE[it.state] || GUIDE_STATE.todo)[1]}`, []);
+ if (it.step) { const a = gEl('a', '', [it.text], { href: `#${it.step}` }); a.addEventListener('click', (e) => { e.preventDefault(); guideGo(it.step); }); li.append(a); }
+ else if (it.href) li.append(gEl('a', '', [it.text, ' ', gIcon('fa-arrow-up-right-from-square')], { href: it.href, target: '_blank', rel: 'noopener' }));
+ else li.append(it.text);
+ if (it.why) li.append(gEl('small', '', [it.why]));
+ return li;
+ })));
+ }
+ if (s.recommended) {
+ const rec = gEl('div', 'guide-rec', [gEl('p', 'guide-rec-h', [gIcon('fa-wand-magic-sparkles'), ' We recommend']), gEl('p', 'guide-rec-l', [s.recommended.label])]);
+ if (s.recommended.detail && s.recommended.detail.length) rec.append(gEl('ul', '', s.recommended.detail.map((d) => gEl('li', '', [d]))));
+ if (s.recommended.apply) {
+ const b = gEl('button', 'btn btn-primary guide-apply', [gIcon('fa-check'), ' Use recommended'], { type: 'button' });
+ b.addEventListener('click', () => guideApply(s, b));
+ rec.append(b);
+ }
+ card.append(rec);
+ }
+ const acts = gEl('div', 'guide-acts');
+ if (s.open) acts.append(gEl('a', 'btn btn-outline', [s.open.label, ' ', gIcon(s.open.href.startsWith('http') ? 'fa-arrow-up-right-from-square' : 'fa-arrow-right')], { href: s.open.href, ...(s.open.href.startsWith('http') ? { target: '_blank', rel: 'noopener' } : {}) }));
+ card.append(acts);
+
+ const nav = gEl('div', 'guide-nav');
+ const back = gEl('button', 'btn btn-outline', [gIcon('fa-arrow-left'), ' Back'], { type: 'button' });
+ back.disabled = i === 0;
+ back.addEventListener('click', () => guideGo(steps[i - 1].id));
+ nav.append(back);
+ if (s.id !== 'welcome' && s.id !== 'finish' && s.state !== 'done') {
+ const skip = gEl('button', 'btn btn-quiet', [s.state === 'skipped' ? 'Unskip' : 'Skip for now'], { type: 'button' });
+ skip.addEventListener('click', async () => {
+ const set = new Set(guideData.progress.skipped || []);
+ if (set.has(s.id)) set.delete(s.id); else set.add(s.id);
+ await guideSaveProgress({ skipped: [...set] });
+ guideModel = OVGuideRules.build(guideData);
+ if (set.has(s.id) && i < steps.length - 1) guideGo(steps[i + 1].id); else guideDraw();
+ });
+ nav.append(skip);
+ }
+ if (i < steps.length - 1) {
+ const next = gEl('button', 'btn btn-primary', [s.id === 'welcome' ? 'Start' : 'Next', ' ', gIcon('fa-arrow-right')], { type: 'button' });
+ next.addEventListener('click', () => guideGo(steps[i + 1].id));
+ nav.append(next);
+ } else {
+ const again = gEl('button', 'btn btn-outline', [gIcon('fa-rotate'), ' Read everything again'], { type: 'button' });
+ again.addEventListener('click', () => loadGuide());
+ nav.append(again);
+ }
+ card.append(nav);
+ box.replaceChildren(head, gEl('div', 'guide-body', [fold, card]));
+}
+
+/** "Use recommended": one step's setting, through the routes the rest of the panel uses; then everything is read again. */
+async function guideApply(s, button) {
+ if (guideBusy) return;
+ guideBusy = true;
+ button.disabled = true;
+ const a = s.recommended.apply;
+ const failed = [];
+ try {
+ if (a.type === 'ai-recommended') await api('/api/admin/ai/setup/recommended', { method: 'POST', body: {} });
+ else if (a.type === 'ai-test') await api(`/api/admin/ai/setup/test/${encodeURIComponent(a.step)}`, { method: 'POST', body: {} });
+ else if (a.type === 'ai-caps') {
+ for (const [kind, usd] of Object.entries(a.caps || {})) {
+ try { await api(`/api/admin/ai/setup/caps/${encodeURIComponent(kind)}`, { method: 'PUT', body: { cap_usd: usd } }); } catch (e) { failed.push(`${kind}: ${e.message}`); }
+ }
+ } else if (a.type === 'machines') {
+ for (const ch of a.changes || []) {
+ try {
+ await api(`/api/v1/machines/${encodeURIComponent(ch.id)}/roles`, { method: 'PUT', body: { roles: ch.roles } });
+ for (const key of Object.keys(ch.switches || {})) {
+ const route = GUIDE_SWITCH_ROUTE[key];
+ if (!route) continue;
+ try { await api(`/api/v1/machines/${encodeURIComponent(ch.id)}/${route}`, { method: 'POST', body: { on: true } }); } catch (e) { failed.push(`${ch.name}: ${e.message}`); }
+ }
+ } catch (e) { failed.push(`${ch.name}: ${e.message}`); }
+ }
+ } else if (a.type === 'access') {
+ for (const ch of a.changes || []) {
+ try { await api(`/api/v1/me/nodes/${encodeURIComponent(ch.principal)}/access-settings`, { method: 'PUT', body: ch.settings }); } catch (e) { failed.push(`${ch.name}: ${e.message}`); }
+ }
+ }
+ if (typeof toast === 'function') toast(failed.length ? `Partly saved. ${failed.join('; ')}` : 'Saved. The recommended setting is on.', failed.length ? 'error' : 'success');
+ } catch (e) {
+ if (typeof toast === 'function') toast(e.message || 'That could not be saved.', 'error');
+ } finally {
+ guideBusy = false;
+ await guideRead().catch(() => {});
+ guideDraw();
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
+'use strict';
+/**
+ * The owner's guided setup (/admin/guide, 2026-11-12): what the guide remembers between visits. The steps themselves
+ * are worked out each time from the live state (public/js/admin/guide-rules.js over the Setup checklist, the AI setup
+ * and the owner's computers), and "Use recommended" goes through the routes the rest of the panel already uses, so
+ * the only thing kept here is which steps the owner skipped and which they have seen.
+ *
+ * GET /api/admin/guide { skipped, seen }
+ * PUT /api/admin/guide { skipped?, seen? } the whole lists (step ids)
+ *
+ * Owner only. Kept in Network's own namespace network.guide (openvibe-shared/config), so a change is a revision with
+ * its actor and history like any other setting.
+ */
+const express = require('express');
+const config = require('openvibe-shared/config');
+const { requireOwner } = require('../auth/owner-guard');
+const siteConfig = require('./site-config');
+
+const NAMESPACE = 'network.guide';
+const STEP = /^[a-z][a-z0-9-]{0,40}$/;
+const IDS = { type: 'array', maxItems: 60, uniqueItems: true, items: { type: 'string', pattern: STEP.source } };
+const SCHEMA = Object.freeze({
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ skipped: { ...IDS, title: 'Skipped steps', description: 'The guided setup’s steps the owner chose to skip for now.' },
+ seen: { ...IDS, title: 'Seen steps', description: 'The guided setup’s steps the owner has opened.' },
+ },
+});
+
+const byDb = new Map();
+async function store(db, log = console) {
+ if (byDb.has(db)) return byDb.get(db);
+ const ready = config.createConfigStore({
+ db, service: 'network', namespace: NAMESPACE, schema: SCHEMA,
+ log: { info: () => {}, warn: (m) => (log.warn || log.log || (() => {})).call(log, `[Guide] ${m}`), error: (m) => (log.error || log.warn || (() => {})).call(log, `[Guide] ${m}`) },
+ });
+ byDb.set(db, ready);
+ try { return await ready; } catch (err) { byDb.delete(db); throw err; }
+}
+
+const clean = (v) => (Array.isArray(v) ? [...new Set(v.map(String).filter((id) => STEP.test(id)))].slice(0, 60) : null);
+
+function createGuideRoutes(opts = {}) {
+ const r = express.Router();
+ const log = opts.log || console;
+ r.use(requireOwner);
+ const dbOf = (req) => opts.db || req.app.locals.db;
+ const send = (res, status, body) => { res.set('Cache-Control', 'no-store'); return res.status(status).json(body); };
+
+ r.get('/', async (req, res) => {
+ try {
+ const s = await store(dbOf(req), log);
+ const v = s.get() || {};
+ send(res, 200, { ok: true, skipped: v.skipped || [], seen: v.seen || [] });
+ } catch (err) {
+ log.error(`[Guide] ${err && err.message ? String(err.message).slice(0, 200) : 'failed'}`);
+ send(res, 500, { ok: false, error: 'The guide could not be read', code: 'guide.internal' });
+ }
+ });
+
+ r.put('/', async (req, res) => {
+ try {
+ const body = req.body && typeof req.body === 'object' ? req.body : {};
+ const next = {};
+ for (const key of ['skipped', 'seen']) {
+ if (body[key] === undefined) continue;
+ const ids = clean(body[key]);
+ if (!ids) return send(res, 400, { ok: false, error: `${key} is a list of step ids`, code: 'guide.bad_request' });
+ next[key] = ids;
+ }
+ if (!Object.keys(next).length) return send(res, 400, { ok: false, error: 'Send skipped or seen', code: 'guide.bad_request' });
+ const s = await store(dbOf(req), log);
+ await s.apply(next, { merge: true, actor: siteConfig.actorOf(req.user), reason: 'Guided setup progress' });
+ const v = s.get() || {};
+ send(res, 200, { ok: true, skipped: v.skipped || [], seen: v.seen || [] });
+ } catch (err) {
+ log.error(`[Guide] ${err && err.message ? String(err.message).slice(0, 200) : 'failed'}`);
+ send(res, 500, { ok: false, error: 'The guide could not be saved', code: 'guide.internal' });
+ }
+ });
+
+ return r;
+}
+
+module.exports = { createGuideRoutes, SCHEMA, NAMESPACE };
@@ -1040,6 +1040,8 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -1040,6 +1040,8 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
// The guided AI setup page (/admin/ai/setup): every task kind's key, route, test and monthly cap, through
// The guided AI setup page (/admin/ai/setup): every task kind's key, route, test and monthly cap, through
// OpenVibe.AI's own setup API. Owner only; Network keeps no key and the request body is dropped here.
// OpenVibe.AI's own setup API. Owner only; Network keeps no key and the request body is dropped here.
router.use('/ai/setup', requireOwner, require('./ai-setup').createAiSetup());
router.use('/ai/setup', requireOwner, require('./ai-setup').createAiSetup());
+ // The guided setup's progress (server/admin/guide.js): the steps the owner skipped or saw.
+ router.use('/guide', requireOwner, express.json({ limit: '16kb' }), require('./guide').createGuideRoutes());
// The owner's Setup tab (server/admin/setup.js): every key and account only the owner can give, from every service's
// The owner's Setup tab (server/admin/setup.js): every key and account only the owner can give, from every service's
// config namespaces, plus Network's own to-dos. Owner only; saving goes through the proxy above.
// config namespaces, plus Network's own to-dos. Owner only; saving goes through the proxy above.
router.use('/setup', requireOwner, require('./setup').createSetupRoutes({ db }));
router.use('/setup', requireOwner, require('./setup').createSetupRoutes({ db }));
new file mode 100644
new file mode 100644
@@ -0,0 +1,139 @@
@@ -0,0 +1,139 @@
+'use strict';
+// The owner's guided setup (/admin/guide, 2026-11-12): the rules that turn the live state into ordered steps with the
+// setting we recommend (public/js/admin/guide-rules.js), and the route that keeps what the owner skipped or saw
+// (server/admin/guide.js): owner only, step ids only, kept in network.guide. The admin page wires the tab, the
+// section, the scripts and a call to action at the top of Setup.
+// node test/admin-guide.test.js
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const jwt = require('jsonwebtoken');
+const { buildWorld } = require('./security-world');
+const rules = require('../public/js/admin/guide-rules.js');
+
+const out = (...a) => process.stdout.write(a.join(' ') + '\n');
+const ROOT = path.join(__dirname, '..');
+
+// ── The rules ──
+{
+ // ov-node-01 as it was on 11-12: a shared desk host with two models and the Codes runner, everything off.
+ const node = {
+ id: 'dev_N', name: 'ov-node-01', kind: 'onboard', roles: [], principal_id: 'lnk_N',
+ fleet: [{ agent: 'codex', installed: true, signed_in: false }],
+ controls: { computer: true, level: 'trusted', probe: true, agent_computer: false, watch_probe: false, ai_models: false, shared_desks: false,
+ desk: { available: true, sandboxed: true, max: 7 }, ai: { available: true, models: 2, offered: true } },
+ };
+ const rec = rules.recommendMachine(node);
+ assert.deepStrictEqual(rec.roles, ['codes.worker', 'desks.host', 'ai.host'], 'a role for each thing it can do');
+ assert.deepStrictEqual(rec.switches, { ai_models: true, agent_computer: true, watch_probe: true, shared_desks: true });
+ assert.ok(rec.why.some((w) => /locked in its own box/.test(w)), 'says why it may lend browsers');
+
+ // A laptop: no sandbox, no models, no runner: only what it has; a robot role that is on is kept.
+ const laptop = { id: 'dev_L', name: 'Laptop', roles: ['bot.robot'], controls: { computer: true, level: 'ask', probe: false, desk: { available: true, sandboxed: false } } };
+ assert.deepStrictEqual(rules.recommendMachine(laptop).roles, ['desks.host', 'bot.robot']);
+ assert.strictEqual(rules.recommendMachine(laptop).switches.shared_desks, undefined, 'never lends browsers that are not each in a box');
+ assert.strictEqual(rules.machineChange({ ...node, roles: rec.roles, controls: { ...node.controls, ...rec.switches } }), null, 'nothing to change once it matches');
+
+ // Caps: twice the last 30 days, at least $5, in steps of $5.
+ assert.strictEqual(rules.capFor({ estimated_month_usd: 0 }), 5);
+ assert.strictEqual(rules.capFor({ estimated_month_usd: 3.2 }), 10);
+ assert.strictEqual(rules.capFor({ estimated_month_usd: 12, month_usd: 14 }), 30);
+
+ // Keys: the provider that unlocks the most jobs first.
+ const ai = { steps: [
+ { id: 'chat', name: 'Chat and writing', short: 'Chat', route: 'default.chat', ready: false, recommended: { provider: 'deepseek', configured: false }, key_page: 'https://platform.deepseek.com' },
+ { id: 'code', name: 'Code agents', short: 'Code', route: 'space.build', ready: false, recommended: { provider: 'deepseek', configured: false } },
+ { id: 'image', name: 'Images', short: 'Images', route: 'default.image', ready: false, recommended: { provider: 'together', configured: false } },
+ { id: 'stt', name: 'Speech to text', short: 'Speech in', route: 'live.stt', ready: false, current: null, recommended: { provider: 'groq', configured: true, label: 'Whisper large v3' }, estimated_month_usd: 3.2, cap_usd: null },
+ { id: 'decide', name: 'Decisions', short: 'Decisions', route: 'decide.fallback', ready: false, current: 'deepseek/flash', recommended: { provider: 'gemini', configured: false }, cap_usd: 5 },
+ ] };
+ assert.deepStrictEqual(rules.keysWanted(ai).map((k) => [k.provider, k.jobs.length]), [['deepseek', 2], ['gemini', 1], ['together', 1]]);
+
+ const setup = {
+ services: [{ service: 'ai', name: 'OpenVibe.AI', answered: true }, { service: 'chat', name: 'OpenVibe.Chat', answered: false }],
+ groups: [
+ { id: 'ai-providers', label: 'AI providers', rows: [{ state: 'needed', title: 'DeepSeek key', service_name: 'OpenVibe.AI' }] },
+ { id: 'money', label: 'Money', rows: [{ state: 'needed', title: 'Stripe key', service_name: 'OpenVibe.Billing', required_for: 'checkout' }] },
+ { id: 'voices', label: 'Voices', rows: [{ state: 'optional', title: 'Polly key', service_name: 'OpenVibe.Live', unlocks: 'more voices' }] },
+ { id: 'domains', label: 'Domains', rows: [{ state: 'connected', title: 'Cloudflare token', service_name: 'OpenVibe.Domains' }] },
+ ],
+ };
+ const g = rules.build({ setup, ai, machines: [node], access: { lnk_N: { others_may_ask: false, auto_agent_send: true, agents_talk: false } }, progress: { seen: ['welcome'], skipped: ['credits'] } });
+ const by = Object.fromEntries(g.steps.map((s) => [s.id, s]));
+ assert.deepStrictEqual(g.steps.map((s) => s.id), ['welcome', 'services', 'ai-keys', 'ai-models', 'ai-caps', 'decisions', 'credits', 'machines', 'shared', 'coding', 'agents', 'keys-money', 'keys-voices', 'keys-domains', 'finish'],
+ 'in order: start, AI, computers, coding, agents, every other group (AI providers are the AI steps), finish');
+ assert.strictEqual(by.services.state, 'needs_you');
+ assert.match(by['ai-keys'].recommended.label, /deepseek/i);
+ assert.deepStrictEqual(by['ai-models'].recommended.apply, { type: 'ai-recommended' }, 'a job whose key is in gets the recommended model');
+ assert.deepStrictEqual(by['ai-caps'].recommended.apply, { type: 'ai-caps', caps: { chat: 5, code: 5, image: 5, stt: 10 } }, 'a cap for each routed job without one');
+ assert.strictEqual(by.decisions.recommended.apply.type, 'ai-test', 'Decisions has a route: test it');
+ assert.strictEqual(by.credits.state, 'skipped');
+ assert.strictEqual(by.machines.recommended.apply.changes[0].id, 'dev_N');
+ assert.deepStrictEqual(by.machines.recommended.apply.changes[0].roles, ['codes.worker', 'desks.host', 'ai.host']);
+ assert.match(by.machines.items[0].text, /ov-node-01: run your coding tasks, give your agents a browser, serve its AI models, let OpenVibe AI use its models/);
+ assert.deepStrictEqual(by.shared.recommended.apply.changes[0].settings, { others_may_ask: true, agents_talk: true }, 'a shared node opens to others');
+ assert.strictEqual(by.coding.state, 'todo', 'the Codes runner is there but the role is not on yet');
+ assert.strictEqual(by['keys-money'].state, 'needs_you');
+ assert.strictEqual(by['keys-voices'].state, 'optional');
+ assert.strictEqual(by['keys-domains'].state, 'done');
+ assert.strictEqual(g.next, 'services', 'the first step that wants you');
+ assert.match(by.finish.title, /What is left/);
+
+ // Nothing read (a service down): every step that depends on it says so instead of guessing.
+ const blind = rules.build({});
+ assert.ok(['services', 'ai-keys', 'machines', 'coding'].every((id) => blind.steps.find((s) => s.id === id).state === 'unknown'));
+ // Nothing paired: the machines step asks for a computer.
+ assert.strictEqual(rules.build({ machines: [] }).steps.find((s) => s.id === 'machines').state, 'needs_you');
+ out('guide rules: computers from what they can do, caps from spend, keys by reach, ordered steps, skipped and unknown');
+}
+
+// ── The page is wired ──
+{
+ const html = fs.readFileSync(path.join(ROOT, 'public', 'admin.html'), 'utf8');
+ assert.ok(html.includes("showTab('guide',this)") && html.includes('id="sec-guide"') && html.includes('/js/admin/guide-rules.js') && html.includes('/js/admin/guide.js') && html.includes('/css/guide.css'));
+ assert.ok(html.includes('href="/admin/guide">Start the guided setup</a>'), 'Setup points to it');
+ assert.ok(/OWNER_ONLY_TABS = new Set\(\['guide',/.test(fs.readFileSync(path.join(ROOT, 'public', 'js', 'admin', 'core.js'), 'utf8')), 'owner only');
+ assert.ok(/guide: loadGuide/.test(fs.readFileSync(path.join(ROOT, 'public', 'js', 'admin', 'boot.js'), 'utf8')));
+ const page = fs.readFileSync(path.join(ROOT, 'public', 'js', 'admin', 'guide.js'), 'utf8');
+ assert.ok(!/innerHTML/.test(page), 'built from DOM nodes');
+ for (const route of ['/api/admin/ai/setup/recommended', '/api/admin/ai/setup/caps/', '/roles', '/access-settings']) assert.ok(page.includes(route), `applies through ${route}`);
+ out('guide page: tab, section, scripts, owner only, the Setup call to action, applied through the existing routes');
+}
+
+// ── The route ──
+(async () => {
+ let w = null;
+ try {
+ // Every service unreachable, as in test/setup.test.js: the guide's route reads none of them.
+ const unreachable = 'http://127.0.0.1:9';
+ const env = Object.fromEntries(['AI', 'CHAT', 'DEALS', 'BILLING', 'SERVICES', 'WATCH', 'BENCH', 'REPORT', 'HOST', 'CODES', 'ACTOR', 'CONNECT', 'WORK', 'SOURCES', 'DESIGN', 'DOMAINS', 'MEDIA']
+ .map((s) => [`OV_${s}_INTERNAL_URL`, unreachable]));
+ w = await buildWorld({ label: 'guide', env });
+ const owner = w.users.owner;
+ const fresh = jwt.sign({ sub: owner.id, id: owner.id, username: owner.username, subject_id: owner.subject, role: owner.role, auth_time: Math.floor(Date.now() / 1000) },
+ w.keys.privateKey, { algorithm: 'RS256', issuer: w.issuer, expiresIn: '1h' });
+ const ownerCall = (method, p, body) => w.call({ authorization: `Bearer ${fresh}`, cookie: `ov_token=${fresh}` }, method, p, body);
+
+ let r = await w.call('alice', 'GET', '/api/admin/guide');
+ assert.strictEqual(r.status, 403, 'a user never gets the guide');
+ r = await w.call('staff', 'PUT', '/api/admin/guide', { skipped: ['credits'] });
+ assert.strictEqual(r.status, 403, 'a plain admin cannot change it');
+ r = await ownerCall('GET', '/api/admin/guide');
+ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
+ assert.deepStrictEqual([r.body.skipped, r.body.seen], [[], []]);
+ r = await ownerCall('PUT', '/api/admin/guide', { skipped: ['credits', 'credits', 'Not A Step!'], seen: ['welcome', 'ai-keys'] });
+ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
+ assert.deepStrictEqual([r.body.skipped, r.body.seen], [['credits'], ['welcome', 'ai-keys']], 'step ids only, once each');
+ r = await ownerCall('PUT', '/api/admin/guide', { skipped: 'credits' });
+ assert.strictEqual(r.status, 400, 'a list, not a string');
+ r = await ownerCall('PUT', '/api/admin/guide', {});
+ assert.strictEqual(r.status, 400, 'something to save');
+ r = await ownerCall('GET', '/api/admin/guide');
+ assert.deepStrictEqual(r.body.skipped, ['credits'], 'kept');
+ const snap = await w.db.prepare("SELECT values_json FROM config_snapshots WHERE namespace = 'network.guide' AND state = 'active'").get();
+ assert.ok(snap && JSON.parse(snap.values_json).seen.includes('ai-keys'), 'in network.guide, a revision like any setting');
+ out('guide route: owner only, step ids only, kept in network.guide');
+ } finally {
+ if (w) await w.stop();
+ }
+})().then(() => process.exit(0)).catch((e) => { console.error(e); process.exit(1); });
@@ -12,7 +12,7 @@ for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) as
@@ -12,7 +12,7 @@ for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) as
const files = fs.readdirSync(path.join(pub, 'js', 'admin')).filter((f) => f.endsWith('.js')).sort();
const files = fs.readdirSync(path.join(pub, 'js', 'admin')).filter((f) => f.endsWith('.js')).sort();
// The module map, in load order (public/js/admin/…).
// The module map, in load order (public/js/admin/…).
-const modules = ['core.js', 'dashboard.js', 'users.js', 'moderation.js', 'settings.js', 'tools-workers.js', 'payments.js', 'glossary.js', 'ai.js', 'ai-setup.js', 'inference.js', 'setup.js', 'content.js', 'analytics.js', 'deploy.js', 'boot.js'];
+const modules = ['core.js', 'dashboard.js', 'users.js', 'moderation.js', 'settings.js', 'tools-workers.js', 'payments.js', 'glossary.js', 'ai.js', 'ai-setup.js', 'guide-rules.js', 'guide.js', 'inference.js', 'setup.js', 'content.js', 'analytics.js', 'deploy.js', 'boot.js'];
assert.deepStrictEqual(files, [...modules].sort(), 'each module file exists and there is no other');
assert.deepStrictEqual(files, [...modules].sort(), 'each module file exists and there is no other');
const tags = [...html.matchAll(/<script\b([^>]*)\bsrc="\/js\/admin\/([^"]+)"([^>]*)><\/script>/g)];
const tags = [...html.matchAll(/<script\b([^>]*)\bsrc="\/js\/admin\/([^"]+)"([^>]*)><\/script>/g)];
const loaded = tags.map((m) => m[2]);
const loaded = tags.map((m) => m[2]);
@@ -7,7 +7,7 @@ const fs = require('fs');
@@ -7,7 +7,7 @@ const fs = require('fs');
const path = require('path');
const path = require('path');
const pub = path.join(__dirname, '..', 'public');
const pub = path.join(__dirname, '..', 'public');
-for (const [page, sheets] of [['admin.html', ['admin.css', 'ai-setup.css']], ['my.html', ['my.css']], ['login.html', ['login.css']]]) {
+for (const [page, sheets] of [['admin.html', ['admin.css', 'ai-setup.css', 'guide.css']], ['my.html', ['my.css']], ['login.html', ['login.css']]]) {
const html = fs.readFileSync(path.join(pub, page), 'utf8');
const html = fs.readFileSync(path.join(pub, page), 'utf8');
const links = [...html.matchAll(/<link\b[^>]*rel="stylesheet"[^>]*href="\/css\/([^"]+)"/g)].map((m) => m[1]);
const links = [...html.matchAll(/<link\b[^>]*rel="stylesheet"[^>]*href="\/css\/([^"]+)"/g)].map((m) => m[1]);
assert.deepStrictEqual(links, sheets, `${page} links ${sheets.map((s) => '/css/' + s).join(', ')} exactly once`);
assert.deepStrictEqual(links, sheets, `${page} links ${sheets.map((s) => '/css/' + s).join(', ')} exactly once`);