openvibers/openvibe.network Commits
Setup: the owner's guided AI setup page at /admin/ai/setup (#871)
Changes
@@@ -27,6 +27,7 @@})();</script><link rel="stylesheet" href="/css/admin.css">++<link rel="stylesheet" href="/css/ai-setup.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>@@@ -203,12 +204,30 @@<!-- AI: configure the vision/text provider + usage cost breakdown --><div class="section" id="sec-ai">++ <!-- The guided setup's first card: every task kind's key, route and spend on one page. -->++ <div id="ai-setup-card"></div><!-- Platform AI (AI Exchange D11 S3, owner only): one long page, loaded by js/admin/ai.js. --><div id="platform-ai" class="platform-ai pai" hidden></div><div id="ai-staff-room"></div><div id="ai-content"><div class="loading">Loading...</div></div></div>++ <!-- Set up AI: the owner's guided setup (/admin/ai/setup), loaded by js/admin/ai-setup.js. -->++ <div class="section" id="sec-ai-setup">++ <div class="setup-head">++ <div class="setup-head-text">++ <a class="setup-back" href="/admin/ai"><i class="fa-solid fa-arrow-left" aria-hidden="true"></i> AI</a>++ <h2>Set up AI</h2>++ <p class="muted">Every AI job the network does, one step at a time. Add a key, run a tiny real request, and the route is ready. Keys are sealed in OpenVibe.AI and never shown again.</p>++ </div>++ <div class="setup-progress" id="setup-progress" aria-live="polite"><span class="muted">Loading…</span></div>++ </div>++ <div class="setup-layout">++ <nav class="setup-steps" id="setup-steps" aria-label="Setup steps"></nav>++ <div class="setup-main" id="setup-main"><div class="loading">Loading…</div></div>++ </div>++ </div>++<!-- Pastes --><div class="section admin-clarity" id="sec-pastes"><p class="admin-intro">These are the limits and maintenance tools for shared text and image pastes. Check limits when reviewing capacity, and delete copies only when you intend permanent removal.</p>@@@ -688,6 +707,7 @@<script src="/js/admin/payments.js" defer></script><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/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,145 @@++/* OpenVibe.Network — /admin/ai/setup (the guided AI setup). Loaded after admin.css; uses its tokens. */++.setup-head{display:flex;align-items:flex-start;justify-content:space-between;gap:24px;margin:4px 0 22px;flex-wrap:wrap}++.setup-head-text{max-width:680px}++.setup-back{display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:600;color:var(--text-muted);text-decoration:none;margin-bottom:8px}++.setup-back:hover{color:var(--accent-light)}++.setup-head h2{font-size:clamp(24px,3.4vw,34px);letter-spacing:-.02em;margin:0 0 8px}++.setup-head p{line-height:1.55;max-width:64ch}++.setup-progress{min-width:260px;background:var(--bg-card);border:1px solid var(--border);border-radius:12px;padding:14px 16px;flex:1;max-width:420px}++.setup-progress-top{display:flex;align-items:center;justify-content:space-between;gap:10px;font-size:13px;margin-bottom:10px}++.setup-progress-top strong{font-size:17px}++.setup-bar{height:8px;border-radius:999px;background:var(--bg-tertiary);overflow:hidden}++.setup-bar span{display:block;height:100%;border-radius:999px;background:linear-gradient(90deg,var(--accent),var(--accent-light));transition:width .35s ease}++.setup-layout{display:grid;grid-template-columns:236px minmax(0,1fr);gap:22px;align-items:start}++.setup-steps{display:flex;flex-direction:column;gap:4px;position:sticky;top:14px}++.setup-step{display:flex;align-items:center;gap:10px;width:100%;text-align:left;padding:9px 10px;border:1px solid transparent;border-radius:10px;background:transparent;color:var(--text);cursor:pointer;font:inherit;transition:background .15s,border-color .15s}++.setup-step:hover{background:var(--bg-secondary)}++.setup-step.active{background:var(--bg-card);border-color:var(--border)}++.setup-step-dot{flex:none;display:grid;place-items:center;width:24px;height:24px;border-radius:50%;font-size:11px;font-weight:800;background:var(--bg-tertiary);color:var(--text-muted);border:1px solid var(--border)}++.setup-step.ready .setup-step-dot{background:color-mix(in srgb,var(--success) 18%,transparent);border-color:color-mix(in srgb,var(--success) 45%,transparent);color:var(--success)}++.setup-step.failing .setup-step-dot{background:color-mix(in srgb,var(--danger) 18%,transparent);border-color:color-mix(in srgb,var(--danger) 45%,transparent);color:var(--danger)}++.setup-step-text{display:flex;flex-direction:column;min-width:0}++.setup-step-name{font-size:12.5px;font-weight:700;line-height:1.2}++.setup-step-state{font-size:10.5px;color:var(--text-muted);margin-top:2px}++.setup-step.active .setup-step-state{color:var(--accent-light)}++.setup-step.defaults{margin-top:8px;border-top:1px solid var(--border);border-radius:0 0 10px 10px;padding-top:12px}++.setup-main{min-width:0}++.setup-card{background:var(--bg-card);border:1px solid var(--border);border-radius:14px;padding:24px;box-shadow:0 8px 30px rgba(0,0,0,.12)}++.setup-card-head{display:flex;justify-content:space-between;gap:16px;align-items:flex-start;margin-bottom:18px}++.setup-card-head h3{font-size:24px;letter-spacing:-.02em;margin:2px 0 6px}++.setup-kicker{font-size:10.5px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--text-muted)}++.setup-what{font-size:15px;font-weight:600;line-height:1.4;margin:0 0 8px}++.setup-uses{font-size:12.5px;color:var(--text-muted);line-height:1.5;margin:0;display:flex;gap:8px;align-items:flex-start}++.setup-uses i{color:var(--accent-light);margin-top:2px}++.setup-badge{flex:none;font-size:10.5px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;border-radius:999px;padding:5px 10px;border:1px solid var(--border)}++.setup-badge.ok,.setup-pill.ok{color:var(--success);background:color-mix(in srgb,var(--success) 12%,transparent);border-color:color-mix(in srgb,var(--success) 35%,transparent)}++.setup-badge.todo,.setup-pill.todo{color:var(--warning);background:color-mix(in srgb,var(--warning) 12%,transparent);border-color:color-mix(in srgb,var(--warning) 35%,transparent)}++.setup-badge.bad,.setup-pill.bad{color:var(--danger);background:color-mix(in srgb,var(--danger) 12%,transparent);border-color:color-mix(in srgb,var(--danger) 35%,transparent)}++.setup-pill{display:inline-flex;align-items:center;gap:5px;font-size:10.5px;font-weight:800;padding:3px 8px;border-radius:999px;border:1px solid var(--border)}++.setup-rec{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:18px;padding:16px 18px;border-radius:12px;border:1px solid color-mix(in srgb,var(--accent) 35%,var(--border));background:linear-gradient(135deg,color-mix(in srgb,var(--accent) 10%,transparent),transparent 70%)}++.setup-rec-main{min-width:0}++.setup-rec-top{display:flex;align-items:center;gap:8px;flex-wrap:wrap}++.setup-rec-flag{display:inline-flex;align-items:center;gap:6px;font-size:10.5px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--accent-light)}++.setup-rec h4{font-size:17px;font-weight:800;margin:6px 0 4px;line-height:1.25}++.setup-model{font-weight:500;color:var(--text-secondary);font-size:14px}++.setup-why{font-size:13px;font-weight:700;color:var(--accent-light);margin:0 0 4px}++.setup-note,.setup-help,.setup-cap-note,.setup-route{font-size:11.5px;color:var(--text-muted);line-height:1.5;margin:2px 0 0}++.setup-rec-price{text-align:right;min-width:150px;display:flex;flex-direction:column;gap:4px;justify-content:center}++.setup-price-label{font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--text-muted)}++.setup-rec-price strong{font-size:15px;line-height:1.35}++.setup-rec-price a{font-size:10.5px;color:var(--text-muted)}++.setup-others{margin:14px 0 0;border:1px solid var(--border);border-radius:10px;padding:0 14px;background:var(--bg-secondary)}++.setup-others summary{cursor:pointer;font-size:12px;font-weight:700;padding:11px 0;list-style:none;display:flex;align-items:center;gap:8px;color:var(--text-secondary)}++.setup-others summary::-webkit-details-marker{display:none}++.setup-others[open] summary i{transform:rotate(90deg)}++.setup-others summary i{font-size:9px;transition:transform .15s}++.setup-other-list{border-top:1px solid var(--border);padding:4px 0 10px}++.setup-other{display:flex;justify-content:space-between;gap:12px;align-items:center;padding:9px 0;border-bottom:1px solid color-mix(in srgb,var(--border) 55%,transparent)}++.setup-other:last-child{border-bottom:0}++.setup-other-price{font-size:11.5px;color:var(--text-muted);margin-top:2px}++.setup-other-actions{display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end}++.setup-do{margin-top:20px;display:flex;flex-direction:column;gap:2px}++.setup-do-head{display:grid;grid-template-columns:28px minmax(0,1fr);gap:12px;padding:14px 0;border-top:1px solid var(--border)}++.setup-num{display:grid;place-items:center;width:26px;height:26px;border-radius:50%;font-size:12px;font-weight:800;color:var(--on-accent,#fff);background:var(--accent);margin-top:1px}++.setup-do-head h4{font-size:14px;margin:0 0 4px}++.setup-do-head p{margin:0 0 8px}++.setup-do-head .btn{margin-bottom:6px}++.setup-how{font-size:12px;color:var(--text-secondary);line-height:1.5;max-width:70ch}++.setup-saved{font-size:12px;color:var(--success);margin:0 0 8px}++.setup-key-row{display:flex;gap:8px;align-items:center;max-width:620px}++.setup-key-row input{flex:1;min-width:0;padding:10px 12px;border-radius:8px;border:1px solid var(--border);background:var(--bg-input);color:var(--text);font-size:13px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}++.setup-key-row input:focus{outline:none;border-color:var(--accent)}++.setup-test-row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}++.setup-test-out{margin-top:12px}++.setup-result{border:1px solid var(--border);border-radius:10px;overflow:hidden;background:var(--bg-secondary)}++.setup-result.bad{border-color:color-mix(in srgb,var(--danger) 45%,var(--border))}++.setup-result-head{display:flex;gap:10px;align-items:center;flex-wrap:wrap;padding:10px 12px;background:var(--bg-tertiary);font-size:12px}++.setup-result-head strong{font-size:12px}++.setup-cost{font-weight:800;color:var(--accent-light)}++.setup-result-body{padding:12px}++.setup-result-body p{margin:0;line-height:1.55}++.setup-result-body blockquote{margin:0;padding:10px 12px;border-left:3px solid var(--accent);background:var(--bg-card);border-radius:4px;font-size:13px;line-height:1.55}++.setup-picture{display:block;width:256px;height:256px;max-width:100%;border-radius:10px;border:1px solid var(--border);object-fit:cover;background:var(--bg-card)}++.setup-vector{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--text-secondary);word-break:break-all}++.setup-card-foot{display:flex;justify-content:space-between;gap:10px;align-items:center;margin-top:20px;padding-top:16px;border-top:1px solid var(--border);flex-wrap:wrap}++.setup-foot-next{display:flex;gap:8px;margin-left:auto}++.setup-table-wrap{overflow-x:auto;border:1px solid var(--border);border-radius:10px}++.setup-table{width:100%;border-collapse:collapse;font-size:12.5px;min-width:760px}++.setup-table th{text-align:left;font-size:10px;text-transform:uppercase;letter-spacing:.06em;color:var(--text-muted);padding:10px;border-bottom:1px solid var(--border);white-space:nowrap}++.setup-table td{padding:10px;border-bottom:1px solid color-mix(in srgb,var(--border) 65%,transparent);vertical-align:top}++.setup-table tr:last-child td{border-bottom:0}++.setup-table select{max-width:280px;width:100%;padding:8px 10px;border-radius:8px;border:1px solid var(--border);background:var(--bg-input);color:var(--text);font:inherit}++.setup-cap{display:flex;align-items:center;gap:4px}++.setup-cap span{color:var(--text-muted);font-size:12px}++.setup-cap input{width:92px;padding:7px 8px;border-radius:8px;border:1px solid var(--border);background:var(--bg-input);color:var(--text);font:inherit}++.setup-error{display:flex;gap:12px;padding:20px;border:1px solid color-mix(in srgb,var(--danger) 40%,var(--border));border-radius:12px;background:var(--bg-card)}++.setup-error i{color:var(--danger);font-size:18px;margin-top:2px}++.setup-error p{margin:6px 0 10px}++++@media(max-width:800px){++ .setup-head{flex-direction:column;gap:14px}++ .setup-progress{max-width:none;width:100%;min-width:0}++ .setup-layout{grid-template-columns:1fr;gap:14px}++ .setup-steps{position:static;flex-direction:row;overflow-x:auto;gap:6px;padding-bottom:4px;scrollbar-width:thin}++ .setup-step{flex:0 0 auto;width:auto;padding:8px 10px;border:1px solid var(--border);background:var(--bg-secondary)}++ .setup-step.active{border-color:var(--accent)}++ .setup-step-text{max-width:110px}++ .setup-step-name{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}++ .setup-step.defaults{margin-top:0;border-top:1px solid var(--border);border-radius:10px;padding-top:8px}++ .setup-card{padding:16px}++ .setup-card-head{flex-direction:column;gap:10px}++ .setup-rec{grid-template-columns:1fr}++ .setup-rec-price{text-align:left;min-width:0;flex-direction:row;align-items:baseline;gap:8px;flex-wrap:wrap}++ .setup-other{flex-direction:column;align-items:flex-start}++ .setup-other-actions{justify-content:flex-start}++ .setup-key-row{flex-direction:column;align-items:stretch}++ .setup-key-row .btn{justify-content:center}++ .setup-table{min-width:0}++ .setup-table thead{display:none}++ .setup-table tr{display:grid;grid-template-columns:1fr;gap:2px;padding:12px 10px;border-bottom:1px solid var(--border)}++ .setup-table td{display:grid;grid-template-columns:108px minmax(0,1fr);gap:8px;align-items:center;padding:5px 0;border-bottom:0}++ .setup-table td::before{content:attr(data-label);font-size:10px;text-transform:uppercase;letter-spacing:.05em;color:var(--text-muted);font-weight:700}++ .setup-table td:first-child{grid-template-columns:1fr}++ .setup-table td:first-child::before{display:none}++ .setup-table select{max-width:none}++}++@media(max-width:420px){++ .setup-card{padding:14px}++ .setup-card-head h3{font-size:21px}++ .setup-do-head{grid-template-columns:24px minmax(0,1fr);gap:10px}++}++++/* The first card on /admin#ai */++.setup-cta{border-color:color-mix(in srgb,var(--accent) 35%,var(--border));background:linear-gradient(135deg,color-mix(in srgb,var(--accent) 9%,transparent),transparent 65%),var(--bg-card)}++.setup-cta-head{display:flex;align-items:flex-start;gap:14px}++.setup-cta-icon{flex:none;display:grid;place-items:center;width:42px;height:42px;border-radius:12px;background:color-mix(in srgb,var(--accent) 16%,transparent);color:var(--accent-light);font-size:18px}++.setup-cta-head h3{font-size:17px;margin:0 0 4px}++.setup-cta-head p{margin:0;line-height:1.55;max-width:74ch}++.setup-cta-count{margin-left:auto;text-align:right;flex:none}++.setup-cta-count strong{display:block;font-size:22px;line-height:1}++.setup-cta-count span{font-size:10.5px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--text-muted)}++.setup-cta-foot{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:14px;padding-top:12px;border-top:1px solid var(--border);flex-wrap:wrap}++@media(max-width:600px){++ .setup-cta-head{flex-wrap:wrap}++ .setup-cta-count{margin-left:0;text-align:left}++}nnew file mode 100644@@@ -0,0 +1,394 @@++/* ═══════════════════════════════════════════════════════════════++ OpenVibe.Network — admin panel (/admin/ai/setup): the guided AI setup.++ One stepper (left on a computer, across the top on a phone), one card per job the estate++ needs, and a last Defaults step for every task kind's model, estimated spend and monthly cap.++ The API is /api/admin/ai/setup (server/admin/ai-setup.js) → OpenVibe.AI's own setup API.++ Classic script, global scope: it relies on admin/core.js's api(), esc() and toast().++ ═══════════════════════════════════════════════════════════════ */++let aiSetupState = null;++let aiSetupStep = 'chat';++let aiSetupChoice = {}; // step id -> { provider, model }++let aiSetupBusy = false;++++const AI_SETUP_DEFAULT = 'defaults';++const AI_SETUP_STATE_WORD = { ready: 'Ready', not_set: 'Not set', failing: 'Failing' };++const AI_SETUP_STATE_CLASS = { ready: 'ok', not_set: 'todo', failing: 'bad' };++++function aiSetupMoney(n) {++ const v = Number(n) || 0;++ if (!v) return 'free';++ return v < 0.01 ? '$' + v.toFixed(4) : '$' + v.toFixed(2);++}++++function aiSetupShortPrice(price) {++ if (!price) return 'price not published';++ return price.text || 'price not published';++}++++function aiSetupStepById(id) {++ return (aiSetupState && aiSetupState.steps || []).find((s) => s.id === id) || null;++}++++function aiSetupSelected(step) {++ const chosen = aiSetupChoice[step.id];++ if (chosen) {++ const all = [step.recommended, ...(step.alternatives || [])];++ const hit = all.find((o) => o.provider === chosen.provider && o.model === chosen.model);++ if (hit) return hit;++ }++ return step.recommended;++}++++function aiSetupIsChoice(step, offer) {++ const selected = aiSetupSelected(step);++ return selected.provider === offer.provider && selected.model === offer.model;++}++++async function loadAiSetup(force) {++ const main = document.getElementById('setup-main');++ if (!main) return;++ if (!force && aiSetupState) { renderAiSetup(); return; }++ main.innerHTML = '<div class="loading">Loading the setup…</div>';++ try {++ aiSetupState = await api('/api/admin/ai/setup');++ if (!aiSetupState || !Array.isArray(aiSetupState.steps)) throw new Error('OpenVibe.AI did not answer with the setup');++ if (!aiSetupState.steps.some((s) => s.id === aiSetupStep) && aiSetupStep !== AI_SETUP_DEFAULT) aiSetupStep = 'chat';++ renderAiSetup();++ } catch (err) {++ main.innerHTML = `<div class="setup-error"><i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i>++ <div><strong>The setup could not be read.</strong><p class="muted">${esc(err.message || 'OpenVibe.AI did not answer')}</p>++ <button class="btn btn-outline btn-sm" onclick="loadAiSetup(true)">Try again</button></div></div>`;++ }++}++++function renderAiSetup() {++ if (!aiSetupState) return;++ renderAiSetupStepper();++ renderAiSetupProgress();++ if (aiSetupStep === AI_SETUP_DEFAULT) renderAiSetupDefaults();++ else renderAiSetupStep();++}++++function renderAiSetupStepper() {++ const nav = document.getElementById('setup-steps');++ if (!nav) return;++ const rows = aiSetupState.steps.map((s, i) => {++ const active = aiSetupStep === s.id;++ const state = s.ready ? 'ready' : s.state;++ return `<button class="setup-step ${esc(state)} ${active ? 'active' : ''}" onclick="selectAiSetupStep('${esc(s.id)}')" aria-current="${active ? 'step' : 'false'}">++ <span class="setup-step-dot" aria-hidden="true">${s.ready ? '<i class="fa-solid fa-check"></i>' : (i + 1)}</span>++ <span class="setup-step-text"><span class="setup-step-name">${esc(s.name)}</span><span class="setup-step-state">${esc(AI_SETUP_STATE_WORD[state] || state)}</span></span>++ </button>`;++ });++ rows.push(`<button class="setup-step defaults ${aiSetupStep === AI_SETUP_DEFAULT ? 'active' : ''}" onclick="selectAiSetupStep('${AI_SETUP_DEFAULT}')" aria-current="${aiSetupStep === AI_SETUP_DEFAULT ? 'step' : 'false'}">++ <span class="setup-step-dot" aria-hidden="true"><i class="fa-solid fa-sliders"></i></span>++ <span class="setup-step-text"><span class="setup-step-name">Defaults</span><span class="setup-step-state">Models, spend and caps</span></span>++ </button>`);++ nav.innerHTML = rows.join('');++}++++function renderAiSetupProgress() {++ const el = document.getElementById('setup-progress');++ if (!el) return;++ const total = aiSetupState.summary.total;++ const ready = aiSetupState.summary.ready;++ const pct = total ? Math.round((ready / total) * 100) : 0;++ el.innerHTML = `<div class="setup-progress-top"><span><strong>${ready} of ${total}</strong> ready</span>++ <button class="btn btn-primary btn-sm" onclick="nextAiSetupNotReady()">${ready === total ? 'Review defaults' : 'Continue setup'} <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></button></div>++ <div class="setup-bar" role="progressbar" aria-valuemin="0" aria-valuemax="${total}" aria-valuenow="${ready}"><span style="width:${pct}%"></span></div>`;++}++++function selectAiSetupStep(id) {++ aiSetupStep = id;++ renderAiSetup();++ const main = document.getElementById('setup-main');++ if (main && window.matchMedia && window.matchMedia('(max-width: 800px)').matches) main.scrollIntoView({ behavior: 'smooth', block: 'start' });++}++++function nextAiSetupNotReady() {++ if (!aiSetupState) return;++ const ids = aiSetupState.steps.map((s) => s.id);++ const from = ids.indexOf(aiSetupStep);++ const order = from >= 0 ? [...ids.slice(from + 1), ...ids.slice(0, from + 1)] : ids;++ const next = order.find((id) => { const s = aiSetupStepById(id); return s && !s.ready; });++ selectAiSetupStep(next || AI_SETUP_DEFAULT);++}++++function skipAiSetupStep() {++ const step = aiSetupStepById(aiSetupStep);++ if (step) toast(`Skipped ${step.name}. ${step.uses} will not work until it is set.`, 'warning');++ nextAiSetupNotReady();++}++++async function aiSetupChoose(stepId, provider, model) {++ aiSetupChoice[stepId] = { provider, model };++ const step = aiSetupStepById(stepId);++ const offer = step && [step.recommended, ...(step.alternatives || [])].find((o) => o.provider === provider && o.model === model);++ renderAiSetup();++ const card = document.getElementById('setup-main');++ if (card) card.scrollIntoView({ behavior: 'smooth', block: 'start' });++ if (step && step.route && offer && offer.configured) {++ try {++ await api(`/api/admin/ai/setup/routes/${encodeURIComponent(step.id)}`, { method: 'PUT', body: { provider, model } });++ toast('Route saved for ' + step.name);++ aiSetupState = null;++ await loadAiSetup(true);++ } catch (err) { toast(err.message || 'The route was not saved', 'error'); }++ }++}++++function aiSetupKeyPage(offer) {++ if (offer && offer.key_page && offer.key_page.url) return offer.key_page;++ return null;++}++++function renderAiSetupStep() {++ const step = aiSetupStepById(aiSetupStep) || (aiSetupState.steps[0] || null);++ const main = document.getElementById('setup-main');++ if (!step || !main) return;++ const selected = aiSetupSelected(step);++ const index = aiSetupState.steps.findIndex((s) => s.id === step.id);++ const state = step.ready ? 'ready' : step.state;++ const page = aiSetupKeyPage(selected);++ const keyless = selected.keyless || (selected.provider === 'stt' || selected.provider === 'local' || selected.provider === 'whisper');++ const hint = (step.key_hint && step.key_hint.last4) || (selected.key_hint && selected.key_hint.last4);++ const configured = selected.configured;++ const alternatives = (step.alternatives || []).filter((o) => !aiSetupIsChoice(step, o));++++ main.innerHTML = `++ <article class="setup-card" aria-labelledby="setup-step-title">++ <header class="setup-card-head">++ <div>++ <span class="setup-kicker">Step ${index + 1} of ${aiSetupState.steps.length}</span>++ <h3 id="setup-step-title">${esc(step.name)}</h3>++ <p class="setup-what">${esc(step.what)}</p>++ <p class="setup-uses"><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i> ${esc(step.uses)}</p>++ </div>++ <span class="setup-badge ${AI_SETUP_STATE_CLASS[state] || 'todo'}">${esc(AI_SETUP_STATE_WORD[state] || state)}</span>++ </header>++++ <section class="setup-rec" aria-label="Recommended provider">++ <div class="setup-rec-main">++ <div class="setup-rec-top"><span class="setup-rec-flag"><i class="fa-solid fa-star" aria-hidden="true"></i> ${aiSetupIsChoice(step, step.recommended) ? 'Recommended' : 'Chosen'}</span>++ ${selected.configured ? '<span class="setup-pill ok"><i class="fa-solid fa-check" aria-hidden="true"></i> Key saved</span>' : '<span class="setup-pill todo">Key needed</span>'}</div>++ <h4>${esc(selected.provider_name)}<span class="setup-model"> · ${esc(selected.label || selected.model)}</span></h4>++ <p class="setup-why">${esc(step.recommended.why || selected.why || '')}</p>++ ${selected.note ? `<p class="setup-note">${esc(selected.note)}</p>` : ''}++ </div>++ <div class="setup-rec-price">++ <span class="setup-price-label">Price</span>++ <strong>${esc(aiSetupShortPrice(selected.price))}</strong>++ ${selected.source ? `<a href="${esc(selected.source)}" target="_blank" rel="noopener noreferrer">seen ${esc(selected.checked || 'today')}</a>` : ''}++ </div>++ </section>++++ ${alternatives.length ? `<details class="setup-others">++ <summary><i class="fa-solid fa-chevron-right" aria-hidden="true"></i> Other choices</summary>++ <div class="setup-other-list">++ ${alternatives.map((o) => `<div class="setup-other">++ <div><strong>${esc(o.provider_name)}</strong> <span class="muted">· ${esc(o.label || o.model)}</span>++ <div class="setup-other-price">${esc(aiSetupShortPrice(o.price))}</div></div>++ <div class="setup-other-actions">++ ${o.key_page && o.key_page.url ? `<a class="btn btn-outline btn-sm" href="${esc(o.key_page.url)}" target="_blank" rel="noopener noreferrer">Get a key</a>` : ''}++ <button class="btn btn-outline btn-sm" onclick="aiSetupChoose('${esc(step.id)}','${esc(o.provider)}','${esc(o.model)}')">Choose</button>++ </div>++ </div>`).join('')}++ </div>++ </details>` : ''}++++ <section class="setup-do">++ <div class="setup-do-head"><span class="setup-num">1</span><div><h4>Get a key</h4>++ ${keyless ? '<p class="muted">No key needed: this runs on our own machine or the network\'s own index.</p>'++ : page ? `<p class="muted">Open ${esc(selected.provider_name)}'s key page, sign in, and create a key.</p>++ <p><a class="btn btn-primary" href="${esc(page.url)}" target="_blank" rel="noopener noreferrer"><i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i> Open ${esc(selected.provider_name)}'s key page</a></p>++ <p class="setup-how">${esc(page.how || '')}</p>`++ : '<p class="muted">This provider has no key page; paste the key its owner gave you.</p>'}++ </div></div>++++ ${keyless ? '' : `<div class="setup-do-head"><span class="setup-num">2</span><div><h4>Paste it</h4>++ ${hint && configured ? `<p class="setup-saved">Saved, ending in <strong>${esc(hint)}</strong>. It is never shown in full again.</p>` : ''}++ <div class="setup-key-row">++ <input id="setup-key-input" type="password" autocomplete="off" spellcheck="false" aria-label="${esc(selected.provider_name)} API key" placeholder="${configured ? 'Paste a new key to replace it' : 'Paste the key here'}" ${aiSetupBusy ? 'disabled' : ''}>++ <button class="btn btn-primary" onclick="saveAiSetupKey('${esc(step.id)}')" ${aiSetupBusy ? 'disabled' : ''}><i class="fa-solid fa-lock" aria-hidden="true"></i> Save</button>++ </div>++ <p class="setup-help">The key goes straight to OpenVibe.AI, is sealed there, and is never logged or shown again. Only the last four characters stay visible.</p>++ </div></div>`}++++ <div class="setup-do-head"><span class="setup-num">${keyless ? 2 : 3}</span><div><h4>Test</h4>++ <p class="muted">${step.id === 'image' ? 'Makes one small picture, shows it at 256 px, and reports what it cost.' : step.id === 'stt' ? 'Transcribes a built-in two-second clip and reports what it cost.' : step.id === 'speech' ? 'Speaks one short sentence and reports what it cost.' : step.id === 'embedding' ? 'Embeds one short sentence and reports what it cost.' : step.id === 'search' ? 'Runs one small web search and reports what it cost.' : 'Asks one tiny real question through the route and reports what it cost.'}</p>++ <div class="setup-test-row">++ <button class="btn btn-outline" onclick="runAiSetupTest('${esc(step.id)}')" ${aiSetupBusy ? 'disabled' : ''}><i class="fa-solid fa-play" aria-hidden="true"></i> Run the test</button>++ <span class="muted" id="setup-test-status"></span>++ </div>++ <div id="setup-test-out" class="setup-test-out" hidden></div>++ </div></div>++ </section>++++ <footer class="setup-card-foot">++ <button class="btn btn-outline" onclick="selectAiSetupStep('${AI_SETUP_DEFAULT}')">Defaults</button>++ <div class="setup-foot-next">++ <button class="btn btn-outline" onclick="skipAiSetupStep()">Skip</button>++ <button class="btn btn-primary" onclick="nextAiSetupNotReady()">Next <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></button>++ </div>++ </footer>++ </article>`;++}++++async function saveAiSetupKey(stepId) {++ if (aiSetupBusy) return;++ const step = aiSetupStepById(stepId);++ const selected = step && aiSetupSelected(step);++ const input = document.getElementById('setup-key-input');++ if (!step || !selected || !input) return;++ const key = String(input.value || '').trim();++ if (key.length < 8) { toast('Paste the whole key first', 'error'); input.focus(); return; }++ aiSetupBusy = true; input.disabled = true;++ const status = document.getElementById('setup-test-status');++ if (status) status.textContent = 'Saving…';++ try {++ await api(`/api/admin/ai/setup/providers/${encodeURIComponent(selected.provider)}/key`, { method: 'PUT', body: { key } });++ input.value = '';++ if (step.route) {++ await api(`/api/admin/ai/setup/routes/${encodeURIComponent(step.id)}`, { method: 'PUT', body: { provider: selected.provider, model: selected.model } });++ }++ toast('Key saved. It will never be shown in full again.');++ aiSetupBusy = false;++ await loadAiSetup(true);++ } catch (err) {++ aiSetupBusy = false; input.disabled = false;++ if (status) status.textContent = '';++ toast(err.message || 'The key was not saved', 'error');++ }++}++++function aiSetupTestHtml(step, out) {++ const a = out.answer || {};++ let answer = '';++ if (a.image) answer = `<img class="setup-picture" src="${esc(a.image)}" width="256" height="256" alt="The picture the test made">`;++ else if (a.text) answer = `<blockquote>${esc(a.text)}</blockquote>`;++ else if (a.title) answer = `<p><a href="${esc(a.url || '#')}" target="_blank" rel="noopener noreferrer">${esc(a.title)}</a> <span class="muted">· ${Number(a.count) || 0} results</span></p>`;++ else if (step.id === 'embedding') answer = `<p class="setup-vector">${Number(a.dimensions) || 0} numbers: ${(a.sample || []).map((n) => Number(n).toFixed(3)).join(', ')} …</p>`;++ else if (step.id === 'speech') answer = `<p>${Number(a.characters) || 0} characters spoken (${esc(a.mime_type || 'audio')}).</p>`;++ return `<div class="setup-result">++ <div class="setup-result-head"><strong>It works</strong>++ <span>${esc(out.provider)}${out.model ? ' · ' + esc(out.model) : ''}</span>++ <span class="setup-cost">${aiSetupMoney(out.cost_usd)}</span>++ <span class="muted">${Number(out.latency_ms) || 0} ms</span>++ ${out.synthetic ? '<span class="setup-pill todo">stub answer</span>' : ''}++ </div>++ <div class="setup-result-body">${answer || '<p class="muted">The test answered.</p>'}</div>++ </div>`;++}++++async function runAiSetupTest(stepId) {++ if (aiSetupBusy) return;++ const step = aiSetupStepById(stepId);++ const selected = step && aiSetupSelected(step);++ const out = document.getElementById('setup-test-out');++ const status = document.getElementById('setup-test-status');++ if (!step || !selected || !out) return;++ aiSetupBusy = true;++ out.hidden = true; out.innerHTML = '';++ if (status) status.textContent = 'Running one tiny request…';++ document.querySelectorAll('#sev-ai-setup button, #sec-ai-setup button').forEach((b) => { if (b.textContent.includes('Run the test') || b.textContent.includes('Save')) b.disabled = true; });++ try {++ const body = { provider: selected.provider };++ if (step.route) body.model = selected.model;++ const result = await api(`/api/admin/ai/setup/test/${encodeURIComponent(step.id)}`, { method: 'POST', body });++ out.innerHTML = aiSetupTestHtml(step, result);++ out.hidden = false;++ if (status) status.textContent = '';++ toast('The test answered');++ aiSetupState = null;++ await loadAiSetup(true);++ const freshOut = document.getElementById('setup-test-out');++ if (freshOut) { freshOut.innerHTML = aiSetupTestHtml(step, result); freshOut.hidden = false; }++ } catch (err) {++ if (status) status.textContent = '';++ out.innerHTML = `<div class="setup-result bad"><div class="setup-result-head"><strong>It did not work</strong></div><div class="setup-result-body"><p>${esc(err.message || 'The test did not answer')}</p><p class="muted">Check the key, that the provider has the model, and that the route can reach it.</p></div></div>`;++ out.hidden = false;++ } finally {++ aiSetupBusy = false;++ document.querySelectorAll('#sec-ai-setup button').forEach((b) => { b.disabled = false; });++ }++}++++function renderAiSetupDefaults() {++ const main = document.getElementById('setup-main');++ if (!main || !aiSetupState) return;++ const rows = (aiSetupState.defaults || []).map((d) => {++ const options = (d.options || []).map((o) => {++ const value = `${o.provider}|${o.model}`;++ const selected = d.current && d.current.provider === o.provider && d.current.model === o.model;++ return `<option value="${esc(value)}" ${selected ? 'selected' : ''}>${esc(o.provider_name)} · ${esc(o.label || o.model)}${o.recommended ? ' (recommended)' : ''}${o.configured ? '' : ' — key needed'}</option>`;++ }).join('');++ const auto = `<option value="" ${!d.current || !d.current.model ? 'selected' : ''}>OpenVibe picks the best offer</option>`;++ return `<tr>++ <td data-label="Task kind"><strong>${esc(d.name)}</strong>${d.route ? `<div class="muted setup-route">${esc(d.route)}</div>` : '<div class="muted setup-route">OpenVibe.Search</div>'}</td>++ <td data-label="Model"><select aria-label="Model for ${esc(d.name)}" onchange="saveAiSetupRoute('${esc(d.id)}', this.value)">${auto}${options}</select></td>++ <td data-label="Calls (30 days)">${Number(d.calls_30d) || 0}</td>++ <td data-label="Est. monthly">${aiSetupMoney(d.estimated_month_usd)}</td>++ <td data-label="This month">${aiSetupMoney(d.month_usd)}</td>++ <td data-label="Monthly cap"><div class="setup-cap"><span>$</span><input type="number" min="0" max="100000" step="0.01" value="${Number(d.cap_usd) || 0}" aria-label="Monthly cap for ${esc(d.name)}" onchange="saveAiSetupCap('${esc(d.id)}', this.value)"></div><div class="setup-cap-note">${Number(d.cap_usd) ? 'Refuses above the cap' : 'No cap'}</div></td>++ </tr>`;++ }).join('');++ main.innerHTML = `<article class="setup-card">++ <header class="setup-card-head">++ <div><span class="setup-kicker">Last step</span><h3>Defaults</h3>++ <p class="setup-what">Every task kind, the model it uses, and what it has cost.</p>++ <p class="setup-uses"><i class="fa-solid fa-coins" aria-hidden="true"></i> Estimated monthly is the last 30 days of real calls at their own prices; spend this month is what has been used so far.</p></div>++ <button class="btn btn-primary" onclick="applyAiSetupRecommended()"><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i> Use the recommended set</button>++ </header>++ <div class="setup-table-wrap">++ <table class="setup-table">++ <thead><tr><th>Task kind</th><th>Model</th><th>Calls (30d)</th><th>Est. monthly</th><th>This month</th><th>Monthly cap</th></tr></thead>++ <tbody>${rows}</tbody>++ </table>++ </div>++ <p class="setup-help">A model listed with “key needed” still works after its step's key is saved. “OpenVibe picks” lets the route choose the best offer by price and health.</p>++ <footer class="setup-card-foot">++ <button class="btn btn-outline" onclick="selectAiSetupStep('chat')"><i class="fa-solid fa-arrow-left" aria-hidden="true"></i> Back to the steps</button>++ <div class="setup-foot-next"><button class="btn btn-primary" onclick="nextAiSetupNotReady()">Next <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></button></div>++ </footer>++ </article>`;++}++++async function saveAiSetupRoute(kind, value) {++ const [provider, model] = String(value || '').split('|');++ try {++ await api(`/api/admin/ai/setup/routes/${encodeURIComponent(kind)}`, { method: 'PUT', body: provider ? { provider, model } : { provider: null, model: null } });++ toast(provider ? 'Model saved for ' + kind : 'The route picks for itself again');++ aiSetupState = null;++ await loadAiSetup(true);++ } catch (err) {++ toast(err.message || 'The model was not saved', 'error');++ }++}++++async function saveAiSetupCap(kind, value) {++ const cap = Number(value);++ if (!Number.isFinite(cap) || cap < 0) { toast('The cap is 0 or more', 'error'); return; }++ try {++ await api(`/api/admin/ai/setup/caps/${encodeURIComponent(kind)}`, { method: 'PUT', body: { cap_usd: cap } });++ toast(cap ? 'Cap saved for ' + kind : 'Cap removed for ' + kind);++ aiSetupState = null;++ await loadAiSetup(true);++ } catch (err) {++ toast(err.message || 'The cap was not saved', 'error');++ }++}++++async function applyAiSetupRecommended() {++ if (aiSetupBusy) return;++ aiSetupBusy = true;++ try {++ const out = await api('/api/admin/ai/setup/recommended', { method: 'POST', body: {} });++ const ok = (out.applied || []).filter((x) => x.ok).length;++ const bad = (out.applied || []).filter((x) => !x.ok);++ toast(bad.length ? `Recommended set saved for ${ok}; ${bad.length} needs a key first` : `Recommended set saved for all ${ok} task kinds`);++ aiSetupState = null;++ await loadAiSetup(true);++ } catch (err) {++ toast(err.message || 'The recommended set was not saved', 'error');++ } finally { aiSetupBusy = false; }++}@@@ -65,7 +65,40 @@ async function loadStaffRoom() {staffRoomTimer = setTimeout(() => { if (document.getElementById('sec-ai') && document.getElementById('sec-ai').classList.contains('active') && !document.hidden) loadStaffRoom(); }, 15000);}++// ── The guided setup's first card on /admin#ai ──++// One page for every key, route, test and cap: /admin/ai/setup (js/admin/ai-setup.js).++async function loadAiSetupCard() {++ const el = document.getElementById('ai-setup-card');++ if (!el) return;++ if (!currentUser || !currentUser.is_owner) { el.innerHTML = ''; return; }++ try {++ const s = await api('/api/admin/ai/setup');++ const ready = (s.summary && s.summary.ready) || 0;++ const total = (s.summary && s.summary.total) || 0;++ const pending = (s.steps || []).filter((x) => !x.ready);++ const next = pending[0] || null;++ el.innerHTML = `<div class="card setup-cta">++ <div class="setup-cta-head">++ <div class="setup-cta-icon"><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i></div>++ <div><h3>Set up AI</h3>++ <p class="muted">Add a key for each job the network does — chat, pictures, speech, search — and test it with one real request. Everything routes through OpenVibe Exchange; no other service keeps a provider key.</p></div>++ <div class="setup-cta-count"><strong>${ready} of ${total}</strong><span>ready</span></div>++ </div>++ <div class="setup-cta-foot">++ <span class="muted">${next ? 'Next: ' + esc(next.name) : (total && ready === total ? 'Every job has a key. Review the defaults and caps.' : 'The setup could not be read.')}</span>++ <a class="btn btn-primary" href="/admin/ai/setup">${total && ready === total ? 'Review setup' : 'Continue setup'} <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a>++ </div>++ </div>`;++ } catch (err) {++ el.innerHTML = `<div class="card setup-cta"><div class="setup-cta-head"><div class="setup-cta-icon"><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i></div>++ <div><h3>Set up AI</h3><p class="muted">Every AI job on one page: add a key, test it, choose the default model and set a cap.</p></div></div>++ <div class="setup-cta-foot"><span class="muted">The setup page could not be read: ${esc(err.message || 'OpenVibe.AI did not answer')}</span>++ <a class="btn btn-primary" href="/admin/ai/setup">Open setup <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div></div>`;++ }++}++async function loadAi() {++ loadAiSetupCard();loadPlatformAi();loadStaffRoom();const c = document.getElementById('ai-content');@@@ -21,6 +21,7 @@ const tabLoaders = {cashouts: () => {}, // a pointer to OpenVibe.Billing's consolepayments: loadPayments,ai: loadAi,++ 'ai-setup': loadAiSetup,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', 'deploy', 'domains', 'payments', 'cashouts', 'storage', 'email', 'settings', 'grants', 'eventsops']);++const OWNER_ONLY_TABS = new Set(['setup', 'ai-setup', 'deploy', 'domains', 'payments', 'cashouts', 'storage', 'email', 'settings', 'grants', 'eventsops']);function showTab(id, btn) {if (!tabLoaders[id]) id = 'dashboard';@@@ -134,6 +134,7 @@ function urlParamToPeriod(p) {}function _adminPath() {++ if (currentTab === 'ai-setup') return '/admin/ai/setup';if (currentTab === 'analytics') return '/admin/analytics/' + (analyticsCurrentSubTab || 'overview');if (currentTab === 'deploy') return '/admin/deploy/' + (deployCurrentSubTab || 'overview');if (currentTab && currentTab !== 'dashboard') return '/admin/' + currentTab;@@@ -172,6 +173,8 @@ function routeFromUrl() {}let tab = parts[0];const sub = parts[1] || null;++ // /admin/ai/setup is its own page, not the AI tab: the guided setup walks one job at a time.++ if (tab === 'ai' && sub === 'setup') tab = 'ai-setup';if (!tab || !tabLoaders[tab]) tab = 'dashboard';const per = urlParamToPeriod(new URLSearchParams(search).get('last'));if (tab === 'analytics' && per) {nnew file mode 100644@@@ -0,0 +1,68 @@++'use strict';++/**++ * The owner's guided AI setup (Network's /admin/ai/setup), a thin proxy to OpenVibe.AI.++ *++ * GET /api/admin/ai/setup steps, prices, key pages, spend and caps++ * PUT /api/admin/ai/setup/providers/:p/key write a provider key (AI seals it; it never comes back)++ * POST /api/admin/ai/setup/providers/:p/test one tiny request with that provider++ * POST /api/admin/ai/setup/test/:step one tiny request through the step's route++ * PUT /api/admin/ai/setup/routes/:step pin the model a route uses++ * PUT /api/admin/ai/setup/caps/:step the monthly cap per kind++ * POST /api/admin/ai/setup/recommended fill every route with its recommended model++ *++ * Every route is owner only: API keys and money are the owner's, never any admin's. Network keeps++ * no value: the request body goes to AI and is dropped. AI is called with Network's own service++ * token (ai.provider.manage), and its answer is passed through as JSON. The page is++ * public/js/admin/ai-setup.js; server/admin/routes.js mounts this behind requireOwner.++ */++const express = require('express');++const { requireOwner } = require('../auth/owner-guard');++++const AI_CAP = 'ai.provider.manage';++const JSON_TYPE = /^application\/([a-z.+-]+\+)?json\b/i;++const TIMEOUT_MS = 120000; // a test makes one real provider call, and the speech test runs ffmpeg++++function createAiSetup({ fetchImpl = globalThis.fetch, log = console } = {}) {++ const r = express.Router();++ r.use(requireOwner);++++ function target(req) {++ const config = req.app.locals.config || {};++ const base = (config.services && config.services.ai && config.services.ai.internalUrl) || 'http://127.0.0.1:4700';++ return String(base).replace(/\/+$/, '');++ }++++ async function forward(req, res, path, { method = req.method, body = undefined } = {}) {++ const app = req.app;++ const token = app.locals.selfToken ? app.locals.selfToken('openvibe.ai', [AI_CAP]) : null;++ if (!token) return res.status(503).json({ error: 'Network cannot sign a service token for OpenVibe.AI' });++ let upstream;++ try {++ upstream = await fetchImpl(target(req) + path, {++ method,++ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },++ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),++ signal: AbortSignal.timeout(TIMEOUT_MS),++ });++ } catch (err) {++ (log.warn || log.log).call(log, `[ai-setup] ${method} ${path}: ${err && err.code ? err.code : 'unreachable'}`);++ return res.status(502).json({ error: 'OpenVibe.AI did not answer; try again in a moment' });++ }++ const text = await upstream.text().catch(() => '');++ res.status(upstream.status);++ if (JSON_TYPE.test(upstream.headers.get('content-type') || '')) return res.type('application/json').send(text || '{}');++ return res.type('text/plain').send(text);++ }++++ r.get('/', (req, res) => forward(req, res, '/api/v1/setup'));++ r.get('/state', (req, res) => forward(req, res, '/api/v1/setup'));++ r.put('/providers/:provider/key', (req, res) => forward(req, res, `/api/v1/setup/providers/${encodeURIComponent(req.params.provider)}/key`, { body: req.body || {} }));++ r.post('/providers/:provider/test', (req, res) => forward(req, res, `/api/v1/setup/providers/${encodeURIComponent(req.params.provider)}/test`, { body: req.body || {} }));++ r.post('/test/:step', (req, res) => forward(req, res, `/api/v1/setup/test/${encodeURIComponent(req.params.step)}`, { body: req.body || {} }));++ r.put('/routes/:step', (req, res) => forward(req, res, `/api/v1/setup/routes/${encodeURIComponent(req.params.step)}`, { body: req.body || {} }));++ r.put('/caps/:step', (req, res) => forward(req, res, `/api/v1/setup/caps/${encodeURIComponent(req.params.step)}`, { body: req.body || {} }));++ r.post('/recommended', (req, res) => forward(req, res, '/api/v1/setup/recommended', { body: req.body || {} }));++ return r;++}++++module.exports = { createAiSetup };@@@ -1037,6 +1037,9 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {// owner (server/admin/ai-config-proxy.js). Owner only on every route; the proxy re-checks, and a write carries the// owner in X-OV-Subject, with a fresh sign-in for keys and x-danger money settings.router.use('/ai/config', requireOwner, require('./ai-config-proxy').createAiConfigProxy({ db, notificationService }));++ // 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 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,103 @@++'use strict';++// The owner's guided AI setup (/admin/ai/setup): every route is owner only, Network signs its own++// service token for OpenVibe.AI's setup API, and a provider key travels through once and is never++// kept or echoed by Network.++// node test/admin-ai-setup.test.js++const assert = require('assert');++const fs = require('fs');++const http = require('http');++const path = require('path');++const express = require('express');++const { createAiSetup } = require('../server/admin/ai-setup');++++function upstream() {++ const seen = [];++ const srv = http.createServer((req, res) => {++ let raw = '';++ req.on('data', (d) => { raw += d; });++ req.on('end', () => {++ const body = raw ? JSON.parse(raw) : null;++ seen.push({ method: req.method, url: req.url, auth: req.headers.authorization || null, body });++ res.writeHead(200, { 'content-type': 'application/json' });++ if (/\/key$/.test(req.url)) return res.end(JSON.stringify({ ok: true, provider: 'deepseek', hint: { last4: '1234' }, configured: true }));++ if (/\/test\//.test(req.url) || /\/test$/.test(req.url)) return res.end(JSON.stringify({ ok: true, step: 'chat', answer: { text: 'ready' }, cost_usd: 0.0001 }));++ if (/\/recommended$/.test(req.url)) return res.end(JSON.stringify({ ok: true, applied: [{ step: 'chat', ok: true }] }));++ return res.end(JSON.stringify({ ok: true, summary: { ready: 3, total: 8 }, steps: [{ id: 'chat', name: 'Chat and writing', ready: false }] }));++ });++ });++ return new Promise((resolve) => srv.listen(0, '127.0.0.1', () => resolve({ srv, seen, url: `http://127.0.0.1:${srv.address().port}` })));++}++++(async () => {++ const ai = await upstream();++ const app = express();++ app.locals.config = { services: { ai: { internalUrl: ai.url } } };++ app.locals.selfToken = (aud, caps) => { assert.strictEqual(aud, 'openvibe.ai'); assert.deepStrictEqual(caps, ['ai.provider.manage']); return 'network-signed-token'; };++ app.use(express.json());++ // The signed-in person: X-Test-User, or none for a 401. requireOwner reads req.user.++ app.use((req, _res, next) => { const raw = req.headers['x-test-user']; if (raw) req.user = JSON.parse(raw); next(); });++ app.use('/api/admin/ai/setup', createAiSetup({ log: { warn() {}, log() {} } }));++ const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });++ const base = `http://127.0.0.1:${server.address().port}`;++++ const owner = { id: 1, role: 'admin', username: 'goosely', is_owner: true };++ const admin = { id: 2, role: 'admin', username: 'someone', is_owner: false };++ async function call(who, method, p, body) {++ const headers = { 'content-type': 'application/json' };++ if (who) headers['x-test-user'] = JSON.stringify(who);++ const r = await fetch(base + p, { method, headers, body: body ? JSON.stringify(body) : undefined });++ const text = await r.text();++ let json = null; try { json = JSON.parse(text); } catch { /* not JSON */ }++ return { status: r.status, text, json };++ }++ try {++ // ── owner only ───────────────────────────────────────────────++ let r = await call(admin, 'GET', '/api/admin/ai/setup');++ assert.strictEqual(r.status, 403, 'a plain admin may not see the keys');++ r = await call(null, 'GET', '/api/admin/ai/setup');++ assert.strictEqual(r.status, 403, 'no person is not the owner');++++ // ── the state ────────────────────────────────────────────────++ r = await call(owner, 'GET', '/api/admin/ai/setup');++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json.summary.total, 8);++ assert.strictEqual(ai.seen.at(-1).url, '/api/v1/setup', 'the state comes from AI setup API');++ assert.strictEqual(ai.seen.at(-1).auth, 'Bearer network-signed-token', 'Network signs its own service token');++++ // ── a key goes through once and is never kept ────────────────++ r = await call(owner, 'PUT', '/api/admin/ai/setup/providers/deepseek/key', { key: 'forwarded-once-9999' });++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual(ai.seen.at(-1).body, { key: 'forwarded-once-9999' }, 'the key is forwarded, not rewritten');++ assert.strictEqual(r.json.hint.last4, '1234', 'only the last four come back');++ assert.ok(!fs.existsSync(path.join(__dirname, '..', 'data', 'ai-setup-keys.json')), 'Network kept no key file');++++ // ── tests and writes ─────────────────────────────────────────++ r = await call(owner, 'POST', '/api/admin/ai/setup/test/chat', { provider: 'deepseek', model: 'deepseek-flash' });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(ai.seen.at(-1).url, '/api/v1/setup/test/chat');++ r = await call(owner, 'PUT', '/api/admin/ai/setup/routes/chat', { provider: 'deepseek', model: 'deepseek-flash' });++ assert.strictEqual(ai.seen.at(-1).url, '/api/v1/setup/routes/chat');++ r = await call(owner, 'PUT', '/api/admin/ai/setup/caps/chat', { cap_usd: 5 });++ assert.strictEqual(ai.seen.at(-1).url, '/api/v1/setup/caps/chat');++ r = await call(owner, 'POST', '/api/admin/ai/setup/recommended', {});++ assert.strictEqual(ai.seen.at(-1).url, '/api/v1/setup/recommended');++++ // ── the page exists and is wired ─────────────────────────────++ const html = fs.readFileSync(path.join(__dirname, '..', 'public', 'admin.html'), 'utf8');++ assert.ok(html.includes('id="sec-ai-setup"'), 'the setup section is in the admin page');++ assert.ok(html.includes('/js/admin/ai-setup.js'), 'the setup script is loaded');++ const core = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'admin', 'core.js'), 'utf8');++ assert.ok(core.includes("'/admin/ai/setup'"), 'the client route is /admin/ai/setup');++ const aiJs = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'admin', 'ai.js'), 'utf8');++ assert.ok(aiJs.includes('loadAiSetupCard()'), 'the AI tab shows the setup card first');++++ // ── no signer: a clear 503, not an unhandled failure ─────────++ app.locals.selfToken = () => null;++ r = await call(owner, 'GET', '/api/admin/ai/setup');++ assert.strictEqual(r.status, 503, r.text);++ } finally {++ await new Promise((resolve) => server.close(resolve));++ await new Promise((resolve) => ai.srv.close(resolve));++ }++ process.stdout.write('admin-ai-setup: owner proxy, key forwarding and page wiring passed\n');++})().catch((err) => { process.stderr.write(`${err.stack || err}\n`); process.exitCode = 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', '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', '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,13 +7,15 @@ const fs = require('fs');const path = require('path');const pub = path.join(__dirname, '..', 'public');--for (const [page, css] of [['admin.html', 'admin.css'], ['my.html', 'my.css'], ['login.html', 'login.css']]) {++for (const [page, sheets] of [['admin.html', ['admin.css', 'ai-setup.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, [css], `${page} links /css/${css} exactly once`);++ assert.deepStrictEqual(links, sheets, `${page} links ${sheets.map((s) => '/css/' + s).join(', ')} exactly once`);assert.ok(!/<style\b/.test(html), `${page} keeps no inline <style> block`);-- const body = fs.readFileSync(path.join(pub, 'css', css), 'utf8');-- assert.ok(body.length > 1000, `public/css/${css} carries the page styles`);++ for (const css of sheets) {++ const body = fs.readFileSync(path.join(pub, 'css', css), 'utf8');++ assert.ok(body.length > 1000, `public/css/${css} carries the page styles`);++ }}for (const page of ['admin.html', 'my.html']) {const html = fs.readFileSync(path.join(pub, page), 'utf8');@@@ -142,6 +142,7 @@ const REVIEWED = {'server/stats/open.js fetch': [1, 'open stats (/stats): fixed first-party loopback URLs only — Live (OV_LIVE_INTERNAL_URL, its public home numbers and service-token analytics totals) and Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; the request only picks 7, 30 or 90 days'],'server/admin/service-proxies.js fetch': [1, 'the admin panel proxies: Live\'s and Chat\'s internal URLs from config, fixed path prefixes, staff only'],'server/admin/review-queue.js fetch': [2, 'the reviewer queue: Wiki/Blog/News/Trade internal URLs from operator config (loopback defaults), fixed /api/v1/review-queue and review paths, ids percent-encoded, the signed-in staff member\'s own token, staff only'],++ 'server/admin/ai-setup.js fetch': [1, 'the guided AI setup proxy: OpenVibe.AI internal URL from config.services (operator-set, loopback default), fixed /api/v1/setup paths, owner only, no caller-selected host or URL, 120 s timeout'],'server/admin/ai-config-proxy.js fetch': [1, 'the owner settings proxy and the Setup tab (server/admin/setup.js calls through it): the reachable services\' internal URLs from config.services (operator-set, loopback defaults), fixed /internal/config and AI /api/v1/providers paths, service, namespace and key checked against the known names, owner only'],'server/integrations/github.js fetch': [1, 'fixed host api.github.com (asserted below)'],'server/link/send.js fetch': [8, 'browser-only Link Send requests to fixed same-origin routes (the session read, machine keys, the resumable transfer parts and the delivery list); page input supplies file bytes, never a URL or destination host'],
@@ -27,6 +27,7 @@
@@ -27,6 +27,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">
<!-- 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>
@@ -203,12 +204,30 @@
@@ -203,12 +204,30 @@
<!-- AI: configure the vision/text provider + usage cost breakdown -->
<!-- AI: configure the vision/text provider + usage cost breakdown -->
<div class="section" id="sec-ai">
<div class="section" id="sec-ai">
+ <!-- The guided setup's first card: every task kind's key, route and spend on one page. -->
+ <div id="ai-setup-card"></div>
<!-- Platform AI (AI Exchange D11 S3, owner only): one long page, loaded by js/admin/ai.js. -->
<!-- Platform AI (AI Exchange D11 S3, owner only): one long page, loaded by js/admin/ai.js. -->
<div id="platform-ai" class="platform-ai pai" hidden></div>
<div id="platform-ai" class="platform-ai pai" hidden></div>
<div id="ai-staff-room"></div>
<div id="ai-staff-room"></div>
<div id="ai-content"><div class="loading">Loading...</div></div>
<div id="ai-content"><div class="loading">Loading...</div></div>
</div>
</div>
+ <!-- Set up AI: the owner's guided setup (/admin/ai/setup), loaded by js/admin/ai-setup.js. -->
+ <div class="section" id="sec-ai-setup">
+ <div class="setup-head">
+ <div class="setup-head-text">
+ <a class="setup-back" href="/admin/ai"><i class="fa-solid fa-arrow-left" aria-hidden="true"></i> AI</a>
+ <h2>Set up AI</h2>
+ <p class="muted">Every AI job the network does, one step at a time. Add a key, run a tiny real request, and the route is ready. Keys are sealed in OpenVibe.AI and never shown again.</p>
+ </div>
+ <div class="setup-progress" id="setup-progress" aria-live="polite"><span class="muted">Loading…</span></div>
+ </div>
+ <div class="setup-layout">
+ <nav class="setup-steps" id="setup-steps" aria-label="Setup steps"></nav>
+ <div class="setup-main" id="setup-main"><div class="loading">Loading…</div></div>
+ </div>
+ </div>
+
<!-- Pastes -->
<!-- Pastes -->
<div class="section admin-clarity" id="sec-pastes">
<div class="section admin-clarity" id="sec-pastes">
<p class="admin-intro">These are the limits and maintenance tools for shared text and image pastes. Check limits when reviewing capacity, and delete copies only when you intend permanent removal.</p>
<p class="admin-intro">These are the limits and maintenance tools for shared text and image pastes. Check limits when reviewing capacity, and delete copies only when you intend permanent removal.</p>
@@ -688,6 +707,7 @@
@@ -688,6 +707,7 @@
<script src="/js/admin/payments.js" defer></script>
<script src="/js/admin/payments.js" defer></script>
<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/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,145 @@
@@ -0,0 +1,145 @@
+/* OpenVibe.Network — /admin/ai/setup (the guided AI setup). Loaded after admin.css; uses its tokens. */
+.setup-head{display:flex;align-items:flex-start;justify-content:space-between;gap:24px;margin:4px 0 22px;flex-wrap:wrap}
+.setup-head-text{max-width:680px}
+.setup-back{display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:600;color:var(--text-muted);text-decoration:none;margin-bottom:8px}
+.setup-back:hover{color:var(--accent-light)}
+.setup-head h2{font-size:clamp(24px,3.4vw,34px);letter-spacing:-.02em;margin:0 0 8px}
+.setup-head p{line-height:1.55;max-width:64ch}
+.setup-progress{min-width:260px;background:var(--bg-card);border:1px solid var(--border);border-radius:12px;padding:14px 16px;flex:1;max-width:420px}
+.setup-progress-top{display:flex;align-items:center;justify-content:space-between;gap:10px;font-size:13px;margin-bottom:10px}
+.setup-progress-top strong{font-size:17px}
+.setup-bar{height:8px;border-radius:999px;background:var(--bg-tertiary);overflow:hidden}
+.setup-bar span{display:block;height:100%;border-radius:999px;background:linear-gradient(90deg,var(--accent),var(--accent-light));transition:width .35s ease}
+.setup-layout{display:grid;grid-template-columns:236px minmax(0,1fr);gap:22px;align-items:start}
+.setup-steps{display:flex;flex-direction:column;gap:4px;position:sticky;top:14px}
+.setup-step{display:flex;align-items:center;gap:10px;width:100%;text-align:left;padding:9px 10px;border:1px solid transparent;border-radius:10px;background:transparent;color:var(--text);cursor:pointer;font:inherit;transition:background .15s,border-color .15s}
+.setup-step:hover{background:var(--bg-secondary)}
+.setup-step.active{background:var(--bg-card);border-color:var(--border)}
+.setup-step-dot{flex:none;display:grid;place-items:center;width:24px;height:24px;border-radius:50%;font-size:11px;font-weight:800;background:var(--bg-tertiary);color:var(--text-muted);border:1px solid var(--border)}
+.setup-step.ready .setup-step-dot{background:color-mix(in srgb,var(--success) 18%,transparent);border-color:color-mix(in srgb,var(--success) 45%,transparent);color:var(--success)}
+.setup-step.failing .setup-step-dot{background:color-mix(in srgb,var(--danger) 18%,transparent);border-color:color-mix(in srgb,var(--danger) 45%,transparent);color:var(--danger)}
+.setup-step-text{display:flex;flex-direction:column;min-width:0}
+.setup-step-name{font-size:12.5px;font-weight:700;line-height:1.2}
+.setup-step-state{font-size:10.5px;color:var(--text-muted);margin-top:2px}
+.setup-step.active .setup-step-state{color:var(--accent-light)}
+.setup-step.defaults{margin-top:8px;border-top:1px solid var(--border);border-radius:0 0 10px 10px;padding-top:12px}
+.setup-main{min-width:0}
+.setup-card{background:var(--bg-card);border:1px solid var(--border);border-radius:14px;padding:24px;box-shadow:0 8px 30px rgba(0,0,0,.12)}
+.setup-card-head{display:flex;justify-content:space-between;gap:16px;align-items:flex-start;margin-bottom:18px}
+.setup-card-head h3{font-size:24px;letter-spacing:-.02em;margin:2px 0 6px}
+.setup-kicker{font-size:10.5px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--text-muted)}
+.setup-what{font-size:15px;font-weight:600;line-height:1.4;margin:0 0 8px}
+.setup-uses{font-size:12.5px;color:var(--text-muted);line-height:1.5;margin:0;display:flex;gap:8px;align-items:flex-start}
+.setup-uses i{color:var(--accent-light);margin-top:2px}
+.setup-badge{flex:none;font-size:10.5px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;border-radius:999px;padding:5px 10px;border:1px solid var(--border)}
+.setup-badge.ok,.setup-pill.ok{color:var(--success);background:color-mix(in srgb,var(--success) 12%,transparent);border-color:color-mix(in srgb,var(--success) 35%,transparent)}
+.setup-badge.todo,.setup-pill.todo{color:var(--warning);background:color-mix(in srgb,var(--warning) 12%,transparent);border-color:color-mix(in srgb,var(--warning) 35%,transparent)}
+.setup-badge.bad,.setup-pill.bad{color:var(--danger);background:color-mix(in srgb,var(--danger) 12%,transparent);border-color:color-mix(in srgb,var(--danger) 35%,transparent)}
+.setup-pill{display:inline-flex;align-items:center;gap:5px;font-size:10.5px;font-weight:800;padding:3px 8px;border-radius:999px;border:1px solid var(--border)}
+.setup-rec{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:18px;padding:16px 18px;border-radius:12px;border:1px solid color-mix(in srgb,var(--accent) 35%,var(--border));background:linear-gradient(135deg,color-mix(in srgb,var(--accent) 10%,transparent),transparent 70%)}
+.setup-rec-main{min-width:0}
+.setup-rec-top{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
+.setup-rec-flag{display:inline-flex;align-items:center;gap:6px;font-size:10.5px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--accent-light)}
+.setup-rec h4{font-size:17px;font-weight:800;margin:6px 0 4px;line-height:1.25}
+.setup-model{font-weight:500;color:var(--text-secondary);font-size:14px}
+.setup-why{font-size:13px;font-weight:700;color:var(--accent-light);margin:0 0 4px}
+.setup-note,.setup-help,.setup-cap-note,.setup-route{font-size:11.5px;color:var(--text-muted);line-height:1.5;margin:2px 0 0}
+.setup-rec-price{text-align:right;min-width:150px;display:flex;flex-direction:column;gap:4px;justify-content:center}
+.setup-price-label{font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--text-muted)}
+.setup-rec-price strong{font-size:15px;line-height:1.35}
+.setup-rec-price a{font-size:10.5px;color:var(--text-muted)}
+.setup-others{margin:14px 0 0;border:1px solid var(--border);border-radius:10px;padding:0 14px;background:var(--bg-secondary)}
+.setup-others summary{cursor:pointer;font-size:12px;font-weight:700;padding:11px 0;list-style:none;display:flex;align-items:center;gap:8px;color:var(--text-secondary)}
+.setup-others summary::-webkit-details-marker{display:none}
+.setup-others[open] summary i{transform:rotate(90deg)}
+.setup-others summary i{font-size:9px;transition:transform .15s}
+.setup-other-list{border-top:1px solid var(--border);padding:4px 0 10px}
+.setup-other{display:flex;justify-content:space-between;gap:12px;align-items:center;padding:9px 0;border-bottom:1px solid color-mix(in srgb,var(--border) 55%,transparent)}
+.setup-other:last-child{border-bottom:0}
+.setup-other-price{font-size:11.5px;color:var(--text-muted);margin-top:2px}
+.setup-other-actions{display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end}
+.setup-do{margin-top:20px;display:flex;flex-direction:column;gap:2px}
+.setup-do-head{display:grid;grid-template-columns:28px minmax(0,1fr);gap:12px;padding:14px 0;border-top:1px solid var(--border)}
+.setup-num{display:grid;place-items:center;width:26px;height:26px;border-radius:50%;font-size:12px;font-weight:800;color:var(--on-accent,#fff);background:var(--accent);margin-top:1px}
+.setup-do-head h4{font-size:14px;margin:0 0 4px}
+.setup-do-head p{margin:0 0 8px}
+.setup-do-head .btn{margin-bottom:6px}
+.setup-how{font-size:12px;color:var(--text-secondary);line-height:1.5;max-width:70ch}
+.setup-saved{font-size:12px;color:var(--success);margin:0 0 8px}
+.setup-key-row{display:flex;gap:8px;align-items:center;max-width:620px}
+.setup-key-row input{flex:1;min-width:0;padding:10px 12px;border-radius:8px;border:1px solid var(--border);background:var(--bg-input);color:var(--text);font-size:13px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
+.setup-key-row input:focus{outline:none;border-color:var(--accent)}
+.setup-test-row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
+.setup-test-out{margin-top:12px}
+.setup-result{border:1px solid var(--border);border-radius:10px;overflow:hidden;background:var(--bg-secondary)}
+.setup-result.bad{border-color:color-mix(in srgb,var(--danger) 45%,var(--border))}
+.setup-result-head{display:flex;gap:10px;align-items:center;flex-wrap:wrap;padding:10px 12px;background:var(--bg-tertiary);font-size:12px}
+.setup-result-head strong{font-size:12px}
+.setup-cost{font-weight:800;color:var(--accent-light)}
+.setup-result-body{padding:12px}
+.setup-result-body p{margin:0;line-height:1.55}
+.setup-result-body blockquote{margin:0;padding:10px 12px;border-left:3px solid var(--accent);background:var(--bg-card);border-radius:4px;font-size:13px;line-height:1.55}
+.setup-picture{display:block;width:256px;height:256px;max-width:100%;border-radius:10px;border:1px solid var(--border);object-fit:cover;background:var(--bg-card)}
+.setup-vector{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--text-secondary);word-break:break-all}
+.setup-card-foot{display:flex;justify-content:space-between;gap:10px;align-items:center;margin-top:20px;padding-top:16px;border-top:1px solid var(--border);flex-wrap:wrap}
+.setup-foot-next{display:flex;gap:8px;margin-left:auto}
+.setup-table-wrap{overflow-x:auto;border:1px solid var(--border);border-radius:10px}
+.setup-table{width:100%;border-collapse:collapse;font-size:12.5px;min-width:760px}
+.setup-table th{text-align:left;font-size:10px;text-transform:uppercase;letter-spacing:.06em;color:var(--text-muted);padding:10px;border-bottom:1px solid var(--border);white-space:nowrap}
+.setup-table td{padding:10px;border-bottom:1px solid color-mix(in srgb,var(--border) 65%,transparent);vertical-align:top}
+.setup-table tr:last-child td{border-bottom:0}
+.setup-table select{max-width:280px;width:100%;padding:8px 10px;border-radius:8px;border:1px solid var(--border);background:var(--bg-input);color:var(--text);font:inherit}
+.setup-cap{display:flex;align-items:center;gap:4px}
+.setup-cap span{color:var(--text-muted);font-size:12px}
+.setup-cap input{width:92px;padding:7px 8px;border-radius:8px;border:1px solid var(--border);background:var(--bg-input);color:var(--text);font:inherit}
+.setup-error{display:flex;gap:12px;padding:20px;border:1px solid color-mix(in srgb,var(--danger) 40%,var(--border));border-radius:12px;background:var(--bg-card)}
+.setup-error i{color:var(--danger);font-size:18px;margin-top:2px}
+.setup-error p{margin:6px 0 10px}
+
+@media(max-width:800px){
+ .setup-head{flex-direction:column;gap:14px}
+ .setup-progress{max-width:none;width:100%;min-width:0}
+ .setup-layout{grid-template-columns:1fr;gap:14px}
+ .setup-steps{position:static;flex-direction:row;overflow-x:auto;gap:6px;padding-bottom:4px;scrollbar-width:thin}
+ .setup-step{flex:0 0 auto;width:auto;padding:8px 10px;border:1px solid var(--border);background:var(--bg-secondary)}
+ .setup-step.active{border-color:var(--accent)}
+ .setup-step-text{max-width:110px}
+ .setup-step-name{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+ .setup-step.defaults{margin-top:0;border-top:1px solid var(--border);border-radius:10px;padding-top:8px}
+ .setup-card{padding:16px}
+ .setup-card-head{flex-direction:column;gap:10px}
+ .setup-rec{grid-template-columns:1fr}
+ .setup-rec-price{text-align:left;min-width:0;flex-direction:row;align-items:baseline;gap:8px;flex-wrap:wrap}
+ .setup-other{flex-direction:column;align-items:flex-start}
+ .setup-other-actions{justify-content:flex-start}
+ .setup-key-row{flex-direction:column;align-items:stretch}
+ .setup-key-row .btn{justify-content:center}
+ .setup-table{min-width:0}
+ .setup-table thead{display:none}
+ .setup-table tr{display:grid;grid-template-columns:1fr;gap:2px;padding:12px 10px;border-bottom:1px solid var(--border)}
+ .setup-table td{display:grid;grid-template-columns:108px minmax(0,1fr);gap:8px;align-items:center;padding:5px 0;border-bottom:0}
+ .setup-table td::before{content:attr(data-label);font-size:10px;text-transform:uppercase;letter-spacing:.05em;color:var(--text-muted);font-weight:700}
+ .setup-table td:first-child{grid-template-columns:1fr}
+ .setup-table td:first-child::before{display:none}
+ .setup-table select{max-width:none}
+}
+@media(max-width:420px){
+ .setup-card{padding:14px}
+ .setup-card-head h3{font-size:21px}
+ .setup-do-head{grid-template-columns:24px minmax(0,1fr);gap:10px}
+}
+
+/* The first card on /admin#ai */
+.setup-cta{border-color:color-mix(in srgb,var(--accent) 35%,var(--border));background:linear-gradient(135deg,color-mix(in srgb,var(--accent) 9%,transparent),transparent 65%),var(--bg-card)}
+.setup-cta-head{display:flex;align-items:flex-start;gap:14px}
+.setup-cta-icon{flex:none;display:grid;place-items:center;width:42px;height:42px;border-radius:12px;background:color-mix(in srgb,var(--accent) 16%,transparent);color:var(--accent-light);font-size:18px}
+.setup-cta-head h3{font-size:17px;margin:0 0 4px}
+.setup-cta-head p{margin:0;line-height:1.55;max-width:74ch}
+.setup-cta-count{margin-left:auto;text-align:right;flex:none}
+.setup-cta-count strong{display:block;font-size:22px;line-height:1}
+.setup-cta-count span{font-size:10.5px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--text-muted)}
+.setup-cta-foot{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:14px;padding-top:12px;border-top:1px solid var(--border);flex-wrap:wrap}
+@media(max-width:600px){
+ .setup-cta-head{flex-wrap:wrap}
+ .setup-cta-count{margin-left:0;text-align:left}
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,394 @@
@@ -0,0 +1,394 @@
+/* ═══════════════════════════════════════════════════════════════
+ OpenVibe.Network — admin panel (/admin/ai/setup): the guided AI setup.
+ One stepper (left on a computer, across the top on a phone), one card per job the estate
+ needs, and a last Defaults step for every task kind's model, estimated spend and monthly cap.
+ The API is /api/admin/ai/setup (server/admin/ai-setup.js) → OpenVibe.AI's own setup API.
+ Classic script, global scope: it relies on admin/core.js's api(), esc() and toast().
+ ═══════════════════════════════════════════════════════════════ */
+let aiSetupState = null;
+let aiSetupStep = 'chat';
+let aiSetupChoice = {}; // step id -> { provider, model }
+let aiSetupBusy = false;
+
+const AI_SETUP_DEFAULT = 'defaults';
+const AI_SETUP_STATE_WORD = { ready: 'Ready', not_set: 'Not set', failing: 'Failing' };
+const AI_SETUP_STATE_CLASS = { ready: 'ok', not_set: 'todo', failing: 'bad' };
+
+function aiSetupMoney(n) {
+ const v = Number(n) || 0;
+ if (!v) return 'free';
+ return v < 0.01 ? '$' + v.toFixed(4) : '$' + v.toFixed(2);
+}
+
+function aiSetupShortPrice(price) {
+ if (!price) return 'price not published';
+ return price.text || 'price not published';
+}
+
+function aiSetupStepById(id) {
+ return (aiSetupState && aiSetupState.steps || []).find((s) => s.id === id) || null;
+}
+
+function aiSetupSelected(step) {
+ const chosen = aiSetupChoice[step.id];
+ if (chosen) {
+ const all = [step.recommended, ...(step.alternatives || [])];
+ const hit = all.find((o) => o.provider === chosen.provider && o.model === chosen.model);
+ if (hit) return hit;
+ }
+ return step.recommended;
+}
+
+function aiSetupIsChoice(step, offer) {
+ const selected = aiSetupSelected(step);
+ return selected.provider === offer.provider && selected.model === offer.model;
+}
+
+async function loadAiSetup(force) {
+ const main = document.getElementById('setup-main');
+ if (!main) return;
+ if (!force && aiSetupState) { renderAiSetup(); return; }
+ main.innerHTML = '<div class="loading">Loading the setup…</div>';
+ try {
+ aiSetupState = await api('/api/admin/ai/setup');
+ if (!aiSetupState || !Array.isArray(aiSetupState.steps)) throw new Error('OpenVibe.AI did not answer with the setup');
+ if (!aiSetupState.steps.some((s) => s.id === aiSetupStep) && aiSetupStep !== AI_SETUP_DEFAULT) aiSetupStep = 'chat';
+ renderAiSetup();
+ } catch (err) {
+ main.innerHTML = `<div class="setup-error"><i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i>
+ <div><strong>The setup could not be read.</strong><p class="muted">${esc(err.message || 'OpenVibe.AI did not answer')}</p>
+ <button class="btn btn-outline btn-sm" onclick="loadAiSetup(true)">Try again</button></div></div>`;
+ }
+}
+
+function renderAiSetup() {
+ if (!aiSetupState) return;
+ renderAiSetupStepper();
+ renderAiSetupProgress();
+ if (aiSetupStep === AI_SETUP_DEFAULT) renderAiSetupDefaults();
+ else renderAiSetupStep();
+}
+
+function renderAiSetupStepper() {
+ const nav = document.getElementById('setup-steps');
+ if (!nav) return;
+ const rows = aiSetupState.steps.map((s, i) => {
+ const active = aiSetupStep === s.id;
+ const state = s.ready ? 'ready' : s.state;
+ return `<button class="setup-step ${esc(state)} ${active ? 'active' : ''}" onclick="selectAiSetupStep('${esc(s.id)}')" aria-current="${active ? 'step' : 'false'}">
+ <span class="setup-step-dot" aria-hidden="true">${s.ready ? '<i class="fa-solid fa-check"></i>' : (i + 1)}</span>
+ <span class="setup-step-text"><span class="setup-step-name">${esc(s.name)}</span><span class="setup-step-state">${esc(AI_SETUP_STATE_WORD[state] || state)}</span></span>
+ </button>`;
+ });
+ rows.push(`<button class="setup-step defaults ${aiSetupStep === AI_SETUP_DEFAULT ? 'active' : ''}" onclick="selectAiSetupStep('${AI_SETUP_DEFAULT}')" aria-current="${aiSetupStep === AI_SETUP_DEFAULT ? 'step' : 'false'}">
+ <span class="setup-step-dot" aria-hidden="true"><i class="fa-solid fa-sliders"></i></span>
+ <span class="setup-step-text"><span class="setup-step-name">Defaults</span><span class="setup-step-state">Models, spend and caps</span></span>
+ </button>`);
+ nav.innerHTML = rows.join('');
+}
+
+function renderAiSetupProgress() {
+ const el = document.getElementById('setup-progress');
+ if (!el) return;
+ const total = aiSetupState.summary.total;
+ const ready = aiSetupState.summary.ready;
+ const pct = total ? Math.round((ready / total) * 100) : 0;
+ el.innerHTML = `<div class="setup-progress-top"><span><strong>${ready} of ${total}</strong> ready</span>
+ <button class="btn btn-primary btn-sm" onclick="nextAiSetupNotReady()">${ready === total ? 'Review defaults' : 'Continue setup'} <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></button></div>
+ <div class="setup-bar" role="progressbar" aria-valuemin="0" aria-valuemax="${total}" aria-valuenow="${ready}"><span style="width:${pct}%"></span></div>`;
+}
+
+function selectAiSetupStep(id) {
+ aiSetupStep = id;
+ renderAiSetup();
+ const main = document.getElementById('setup-main');
+ if (main && window.matchMedia && window.matchMedia('(max-width: 800px)').matches) main.scrollIntoView({ behavior: 'smooth', block: 'start' });
+}
+
+function nextAiSetupNotReady() {
+ if (!aiSetupState) return;
+ const ids = aiSetupState.steps.map((s) => s.id);
+ const from = ids.indexOf(aiSetupStep);
+ const order = from >= 0 ? [...ids.slice(from + 1), ...ids.slice(0, from + 1)] : ids;
+ const next = order.find((id) => { const s = aiSetupStepById(id); return s && !s.ready; });
+ selectAiSetupStep(next || AI_SETUP_DEFAULT);
+}
+
+function skipAiSetupStep() {
+ const step = aiSetupStepById(aiSetupStep);
+ if (step) toast(`Skipped ${step.name}. ${step.uses} will not work until it is set.`, 'warning');
+ nextAiSetupNotReady();
+}
+
+async function aiSetupChoose(stepId, provider, model) {
+ aiSetupChoice[stepId] = { provider, model };
+ const step = aiSetupStepById(stepId);
+ const offer = step && [step.recommended, ...(step.alternatives || [])].find((o) => o.provider === provider && o.model === model);
+ renderAiSetup();
+ const card = document.getElementById('setup-main');
+ if (card) card.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ if (step && step.route && offer && offer.configured) {
+ try {
+ await api(`/api/admin/ai/setup/routes/${encodeURIComponent(step.id)}`, { method: 'PUT', body: { provider, model } });
+ toast('Route saved for ' + step.name);
+ aiSetupState = null;
+ await loadAiSetup(true);
+ } catch (err) { toast(err.message || 'The route was not saved', 'error'); }
+ }
+}
+
+function aiSetupKeyPage(offer) {
+ if (offer && offer.key_page && offer.key_page.url) return offer.key_page;
+ return null;
+}
+
+function renderAiSetupStep() {
+ const step = aiSetupStepById(aiSetupStep) || (aiSetupState.steps[0] || null);
+ const main = document.getElementById('setup-main');
+ if (!step || !main) return;
+ const selected = aiSetupSelected(step);
+ const index = aiSetupState.steps.findIndex((s) => s.id === step.id);
+ const state = step.ready ? 'ready' : step.state;
+ const page = aiSetupKeyPage(selected);
+ const keyless = selected.keyless || (selected.provider === 'stt' || selected.provider === 'local' || selected.provider === 'whisper');
+ const hint = (step.key_hint && step.key_hint.last4) || (selected.key_hint && selected.key_hint.last4);
+ const configured = selected.configured;
+ const alternatives = (step.alternatives || []).filter((o) => !aiSetupIsChoice(step, o));
+
+ main.innerHTML = `
+ <article class="setup-card" aria-labelledby="setup-step-title">
+ <header class="setup-card-head">
+ <div>
+ <span class="setup-kicker">Step ${index + 1} of ${aiSetupState.steps.length}</span>
+ <h3 id="setup-step-title">${esc(step.name)}</h3>
+ <p class="setup-what">${esc(step.what)}</p>
+ <p class="setup-uses"><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i> ${esc(step.uses)}</p>
+ </div>
+ <span class="setup-badge ${AI_SETUP_STATE_CLASS[state] || 'todo'}">${esc(AI_SETUP_STATE_WORD[state] || state)}</span>
+ </header>
+
+ <section class="setup-rec" aria-label="Recommended provider">
+ <div class="setup-rec-main">
+ <div class="setup-rec-top"><span class="setup-rec-flag"><i class="fa-solid fa-star" aria-hidden="true"></i> ${aiSetupIsChoice(step, step.recommended) ? 'Recommended' : 'Chosen'}</span>
+ ${selected.configured ? '<span class="setup-pill ok"><i class="fa-solid fa-check" aria-hidden="true"></i> Key saved</span>' : '<span class="setup-pill todo">Key needed</span>'}</div>
+ <h4>${esc(selected.provider_name)}<span class="setup-model"> · ${esc(selected.label || selected.model)}</span></h4>
+ <p class="setup-why">${esc(step.recommended.why || selected.why || '')}</p>
+ ${selected.note ? `<p class="setup-note">${esc(selected.note)}</p>` : ''}
+ </div>
+ <div class="setup-rec-price">
+ <span class="setup-price-label">Price</span>
+ <strong>${esc(aiSetupShortPrice(selected.price))}</strong>
+ ${selected.source ? `<a href="${esc(selected.source)}" target="_blank" rel="noopener noreferrer">seen ${esc(selected.checked || 'today')}</a>` : ''}
+ </div>
+ </section>
+
+ ${alternatives.length ? `<details class="setup-others">
+ <summary><i class="fa-solid fa-chevron-right" aria-hidden="true"></i> Other choices</summary>
+ <div class="setup-other-list">
+ ${alternatives.map((o) => `<div class="setup-other">
+ <div><strong>${esc(o.provider_name)}</strong> <span class="muted">· ${esc(o.label || o.model)}</span>
+ <div class="setup-other-price">${esc(aiSetupShortPrice(o.price))}</div></div>
+ <div class="setup-other-actions">
+ ${o.key_page && o.key_page.url ? `<a class="btn btn-outline btn-sm" href="${esc(o.key_page.url)}" target="_blank" rel="noopener noreferrer">Get a key</a>` : ''}
+ <button class="btn btn-outline btn-sm" onclick="aiSetupChoose('${esc(step.id)}','${esc(o.provider)}','${esc(o.model)}')">Choose</button>
+ </div>
+ </div>`).join('')}
+ </div>
+ </details>` : ''}
+
+ <section class="setup-do">
+ <div class="setup-do-head"><span class="setup-num">1</span><div><h4>Get a key</h4>
+ ${keyless ? '<p class="muted">No key needed: this runs on our own machine or the network\'s own index.</p>'
+ : page ? `<p class="muted">Open ${esc(selected.provider_name)}'s key page, sign in, and create a key.</p>
+ <p><a class="btn btn-primary" href="${esc(page.url)}" target="_blank" rel="noopener noreferrer"><i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i> Open ${esc(selected.provider_name)}'s key page</a></p>
+ <p class="setup-how">${esc(page.how || '')}</p>`
+ : '<p class="muted">This provider has no key page; paste the key its owner gave you.</p>'}
+ </div></div>
+
+ ${keyless ? '' : `<div class="setup-do-head"><span class="setup-num">2</span><div><h4>Paste it</h4>
+ ${hint && configured ? `<p class="setup-saved">Saved, ending in <strong>${esc(hint)}</strong>. It is never shown in full again.</p>` : ''}
+ <div class="setup-key-row">
+ <input id="setup-key-input" type="password" autocomplete="off" spellcheck="false" aria-label="${esc(selected.provider_name)} API key" placeholder="${configured ? 'Paste a new key to replace it' : 'Paste the key here'}" ${aiSetupBusy ? 'disabled' : ''}>
+ <button class="btn btn-primary" onclick="saveAiSetupKey('${esc(step.id)}')" ${aiSetupBusy ? 'disabled' : ''}><i class="fa-solid fa-lock" aria-hidden="true"></i> Save</button>
+ </div>
+ <p class="setup-help">The key goes straight to OpenVibe.AI, is sealed there, and is never logged or shown again. Only the last four characters stay visible.</p>
+ </div></div>`}
+
+ <div class="setup-do-head"><span class="setup-num">${keyless ? 2 : 3}</span><div><h4>Test</h4>
+ <p class="muted">${step.id === 'image' ? 'Makes one small picture, shows it at 256 px, and reports what it cost.' : step.id === 'stt' ? 'Transcribes a built-in two-second clip and reports what it cost.' : step.id === 'speech' ? 'Speaks one short sentence and reports what it cost.' : step.id === 'embedding' ? 'Embeds one short sentence and reports what it cost.' : step.id === 'search' ? 'Runs one small web search and reports what it cost.' : 'Asks one tiny real question through the route and reports what it cost.'}</p>
+ <div class="setup-test-row">
+ <button class="btn btn-outline" onclick="runAiSetupTest('${esc(step.id)}')" ${aiSetupBusy ? 'disabled' : ''}><i class="fa-solid fa-play" aria-hidden="true"></i> Run the test</button>
+ <span class="muted" id="setup-test-status"></span>
+ </div>
+ <div id="setup-test-out" class="setup-test-out" hidden></div>
+ </div></div>
+ </section>
+
+ <footer class="setup-card-foot">
+ <button class="btn btn-outline" onclick="selectAiSetupStep('${AI_SETUP_DEFAULT}')">Defaults</button>
+ <div class="setup-foot-next">
+ <button class="btn btn-outline" onclick="skipAiSetupStep()">Skip</button>
+ <button class="btn btn-primary" onclick="nextAiSetupNotReady()">Next <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></button>
+ </div>
+ </footer>
+ </article>`;
+}
+
+async function saveAiSetupKey(stepId) {
+ if (aiSetupBusy) return;
+ const step = aiSetupStepById(stepId);
+ const selected = step && aiSetupSelected(step);
+ const input = document.getElementById('setup-key-input');
+ if (!step || !selected || !input) return;
+ const key = String(input.value || '').trim();
+ if (key.length < 8) { toast('Paste the whole key first', 'error'); input.focus(); return; }
+ aiSetupBusy = true; input.disabled = true;
+ const status = document.getElementById('setup-test-status');
+ if (status) status.textContent = 'Saving…';
+ try {
+ await api(`/api/admin/ai/setup/providers/${encodeURIComponent(selected.provider)}/key`, { method: 'PUT', body: { key } });
+ input.value = '';
+ if (step.route) {
+ await api(`/api/admin/ai/setup/routes/${encodeURIComponent(step.id)}`, { method: 'PUT', body: { provider: selected.provider, model: selected.model } });
+ }
+ toast('Key saved. It will never be shown in full again.');
+ aiSetupBusy = false;
+ await loadAiSetup(true);
+ } catch (err) {
+ aiSetupBusy = false; input.disabled = false;
+ if (status) status.textContent = '';
+ toast(err.message || 'The key was not saved', 'error');
+ }
+}
+
+function aiSetupTestHtml(step, out) {
+ const a = out.answer || {};
+ let answer = '';
+ if (a.image) answer = `<img class="setup-picture" src="${esc(a.image)}" width="256" height="256" alt="The picture the test made">`;
+ else if (a.text) answer = `<blockquote>${esc(a.text)}</blockquote>`;
+ else if (a.title) answer = `<p><a href="${esc(a.url || '#')}" target="_blank" rel="noopener noreferrer">${esc(a.title)}</a> <span class="muted">· ${Number(a.count) || 0} results</span></p>`;
+ else if (step.id === 'embedding') answer = `<p class="setup-vector">${Number(a.dimensions) || 0} numbers: ${(a.sample || []).map((n) => Number(n).toFixed(3)).join(', ')} …</p>`;
+ else if (step.id === 'speech') answer = `<p>${Number(a.characters) || 0} characters spoken (${esc(a.mime_type || 'audio')}).</p>`;
+ return `<div class="setup-result">
+ <div class="setup-result-head"><strong>It works</strong>
+ <span>${esc(out.provider)}${out.model ? ' · ' + esc(out.model) : ''}</span>
+ <span class="setup-cost">${aiSetupMoney(out.cost_usd)}</span>
+ <span class="muted">${Number(out.latency_ms) || 0} ms</span>
+ ${out.synthetic ? '<span class="setup-pill todo">stub answer</span>' : ''}
+ </div>
+ <div class="setup-result-body">${answer || '<p class="muted">The test answered.</p>'}</div>
+ </div>`;
+}
+
+async function runAiSetupTest(stepId) {
+ if (aiSetupBusy) return;
+ const step = aiSetupStepById(stepId);
+ const selected = step && aiSetupSelected(step);
+ const out = document.getElementById('setup-test-out');
+ const status = document.getElementById('setup-test-status');
+ if (!step || !selected || !out) return;
+ aiSetupBusy = true;
+ out.hidden = true; out.innerHTML = '';
+ if (status) status.textContent = 'Running one tiny request…';
+ document.querySelectorAll('#sev-ai-setup button, #sec-ai-setup button').forEach((b) => { if (b.textContent.includes('Run the test') || b.textContent.includes('Save')) b.disabled = true; });
+ try {
+ const body = { provider: selected.provider };
+ if (step.route) body.model = selected.model;
+ const result = await api(`/api/admin/ai/setup/test/${encodeURIComponent(step.id)}`, { method: 'POST', body });
+ out.innerHTML = aiSetupTestHtml(step, result);
+ out.hidden = false;
+ if (status) status.textContent = '';
+ toast('The test answered');
+ aiSetupState = null;
+ await loadAiSetup(true);
+ const freshOut = document.getElementById('setup-test-out');
+ if (freshOut) { freshOut.innerHTML = aiSetupTestHtml(step, result); freshOut.hidden = false; }
+ } catch (err) {
+ if (status) status.textContent = '';
+ out.innerHTML = `<div class="setup-result bad"><div class="setup-result-head"><strong>It did not work</strong></div><div class="setup-result-body"><p>${esc(err.message || 'The test did not answer')}</p><p class="muted">Check the key, that the provider has the model, and that the route can reach it.</p></div></div>`;
+ out.hidden = false;
+ } finally {
+ aiSetupBusy = false;
+ document.querySelectorAll('#sec-ai-setup button').forEach((b) => { b.disabled = false; });
+ }
+}
+
+function renderAiSetupDefaults() {
+ const main = document.getElementById('setup-main');
+ if (!main || !aiSetupState) return;
+ const rows = (aiSetupState.defaults || []).map((d) => {
+ const options = (d.options || []).map((o) => {
+ const value = `${o.provider}|${o.model}`;
+ const selected = d.current && d.current.provider === o.provider && d.current.model === o.model;
+ return `<option value="${esc(value)}" ${selected ? 'selected' : ''}>${esc(o.provider_name)} · ${esc(o.label || o.model)}${o.recommended ? ' (recommended)' : ''}${o.configured ? '' : ' — key needed'}</option>`;
+ }).join('');
+ const auto = `<option value="" ${!d.current || !d.current.model ? 'selected' : ''}>OpenVibe picks the best offer</option>`;
+ return `<tr>
+ <td data-label="Task kind"><strong>${esc(d.name)}</strong>${d.route ? `<div class="muted setup-route">${esc(d.route)}</div>` : '<div class="muted setup-route">OpenVibe.Search</div>'}</td>
+ <td data-label="Model"><select aria-label="Model for ${esc(d.name)}" onchange="saveAiSetupRoute('${esc(d.id)}', this.value)">${auto}${options}</select></td>
+ <td data-label="Calls (30 days)">${Number(d.calls_30d) || 0}</td>
+ <td data-label="Est. monthly">${aiSetupMoney(d.estimated_month_usd)}</td>
+ <td data-label="This month">${aiSetupMoney(d.month_usd)}</td>
+ <td data-label="Monthly cap"><div class="setup-cap"><span>$</span><input type="number" min="0" max="100000" step="0.01" value="${Number(d.cap_usd) || 0}" aria-label="Monthly cap for ${esc(d.name)}" onchange="saveAiSetupCap('${esc(d.id)}', this.value)"></div><div class="setup-cap-note">${Number(d.cap_usd) ? 'Refuses above the cap' : 'No cap'}</div></td>
+ </tr>`;
+ }).join('');
+ main.innerHTML = `<article class="setup-card">
+ <header class="setup-card-head">
+ <div><span class="setup-kicker">Last step</span><h3>Defaults</h3>
+ <p class="setup-what">Every task kind, the model it uses, and what it has cost.</p>
+ <p class="setup-uses"><i class="fa-solid fa-coins" aria-hidden="true"></i> Estimated monthly is the last 30 days of real calls at their own prices; spend this month is what has been used so far.</p></div>
+ <button class="btn btn-primary" onclick="applyAiSetupRecommended()"><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i> Use the recommended set</button>
+ </header>
+ <div class="setup-table-wrap">
+ <table class="setup-table">
+ <thead><tr><th>Task kind</th><th>Model</th><th>Calls (30d)</th><th>Est. monthly</th><th>This month</th><th>Monthly cap</th></tr></thead>
+ <tbody>${rows}</tbody>
+ </table>
+ </div>
+ <p class="setup-help">A model listed with “key needed” still works after its step's key is saved. “OpenVibe picks” lets the route choose the best offer by price and health.</p>
+ <footer class="setup-card-foot">
+ <button class="btn btn-outline" onclick="selectAiSetupStep('chat')"><i class="fa-solid fa-arrow-left" aria-hidden="true"></i> Back to the steps</button>
+ <div class="setup-foot-next"><button class="btn btn-primary" onclick="nextAiSetupNotReady()">Next <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></button></div>
+ </footer>
+ </article>`;
+}
+
+async function saveAiSetupRoute(kind, value) {
+ const [provider, model] = String(value || '').split('|');
+ try {
+ await api(`/api/admin/ai/setup/routes/${encodeURIComponent(kind)}`, { method: 'PUT', body: provider ? { provider, model } : { provider: null, model: null } });
+ toast(provider ? 'Model saved for ' + kind : 'The route picks for itself again');
+ aiSetupState = null;
+ await loadAiSetup(true);
+ } catch (err) {
+ toast(err.message || 'The model was not saved', 'error');
+ }
+}
+
+async function saveAiSetupCap(kind, value) {
+ const cap = Number(value);
+ if (!Number.isFinite(cap) || cap < 0) { toast('The cap is 0 or more', 'error'); return; }
+ try {
+ await api(`/api/admin/ai/setup/caps/${encodeURIComponent(kind)}`, { method: 'PUT', body: { cap_usd: cap } });
+ toast(cap ? 'Cap saved for ' + kind : 'Cap removed for ' + kind);
+ aiSetupState = null;
+ await loadAiSetup(true);
+ } catch (err) {
+ toast(err.message || 'The cap was not saved', 'error');
+ }
+}
+
+async function applyAiSetupRecommended() {
+ if (aiSetupBusy) return;
+ aiSetupBusy = true;
+ try {
+ const out = await api('/api/admin/ai/setup/recommended', { method: 'POST', body: {} });
+ const ok = (out.applied || []).filter((x) => x.ok).length;
+ const bad = (out.applied || []).filter((x) => !x.ok);
+ toast(bad.length ? `Recommended set saved for ${ok}; ${bad.length} needs a key first` : `Recommended set saved for all ${ok} task kinds`);
+ aiSetupState = null;
+ await loadAiSetup(true);
+ } catch (err) {
+ toast(err.message || 'The recommended set was not saved', 'error');
+ } finally { aiSetupBusy = false; }
+}
@@ -65,7 +65,40 @@ async function loadStaffRoom() {
@@ -65,7 +65,40 @@ async function loadStaffRoom() {
staffRoomTimer = setTimeout(() => { if (document.getElementById('sec-ai') && document.getElementById('sec-ai').classList.contains('active') && !document.hidden) loadStaffRoom(); }, 15000);
staffRoomTimer = setTimeout(() => { if (document.getElementById('sec-ai') && document.getElementById('sec-ai').classList.contains('active') && !document.hidden) loadStaffRoom(); }, 15000);
}
}
+// ── The guided setup's first card on /admin#ai ──
+// One page for every key, route, test and cap: /admin/ai/setup (js/admin/ai-setup.js).
+async function loadAiSetupCard() {
+ const el = document.getElementById('ai-setup-card');
+ if (!el) return;
+ if (!currentUser || !currentUser.is_owner) { el.innerHTML = ''; return; }
+ try {
+ const s = await api('/api/admin/ai/setup');
+ const ready = (s.summary && s.summary.ready) || 0;
+ const total = (s.summary && s.summary.total) || 0;
+ const pending = (s.steps || []).filter((x) => !x.ready);
+ const next = pending[0] || null;
+ el.innerHTML = `<div class="card setup-cta">
+ <div class="setup-cta-head">
+ <div class="setup-cta-icon"><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i></div>
+ <div><h3>Set up AI</h3>
+ <p class="muted">Add a key for each job the network does — chat, pictures, speech, search — and test it with one real request. Everything routes through OpenVibe Exchange; no other service keeps a provider key.</p></div>
+ <div class="setup-cta-count"><strong>${ready} of ${total}</strong><span>ready</span></div>
+ </div>
+ <div class="setup-cta-foot">
+ <span class="muted">${next ? 'Next: ' + esc(next.name) : (total && ready === total ? 'Every job has a key. Review the defaults and caps.' : 'The setup could not be read.')}</span>
+ <a class="btn btn-primary" href="/admin/ai/setup">${total && ready === total ? 'Review setup' : 'Continue setup'} <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a>
+ </div>
+ </div>`;
+ } catch (err) {
+ el.innerHTML = `<div class="card setup-cta"><div class="setup-cta-head"><div class="setup-cta-icon"><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i></div>
+ <div><h3>Set up AI</h3><p class="muted">Every AI job on one page: add a key, test it, choose the default model and set a cap.</p></div></div>
+ <div class="setup-cta-foot"><span class="muted">The setup page could not be read: ${esc(err.message || 'OpenVibe.AI did not answer')}</span>
+ <a class="btn btn-primary" href="/admin/ai/setup">Open setup <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div></div>`;
+ }
+}
+
async function loadAi() {
async function loadAi() {
+ loadAiSetupCard();
loadPlatformAi();
loadPlatformAi();
loadStaffRoom();
loadStaffRoom();
const c = document.getElementById('ai-content');
const c = document.getElementById('ai-content');
@@ -21,6 +21,7 @@ const tabLoaders = {
@@ -21,6 +21,7 @@ const tabLoaders = {
cashouts: () => {}, // a pointer to OpenVibe.Billing's console
cashouts: () => {}, // a pointer to OpenVibe.Billing's console
payments: loadPayments,
payments: loadPayments,
ai: loadAi,
ai: loadAi,
+ 'ai-setup': loadAiSetup,
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', 'deploy', 'domains', 'payments', 'cashouts', 'storage', 'email', 'settings', 'grants', 'eventsops']);
+const OWNER_ONLY_TABS = new Set(['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';
@@ -134,6 +134,7 @@ function urlParamToPeriod(p) {
@@ -134,6 +134,7 @@ function urlParamToPeriod(p) {
}
}
function _adminPath() {
function _adminPath() {
+ if (currentTab === 'ai-setup') return '/admin/ai/setup';
if (currentTab === 'analytics') return '/admin/analytics/' + (analyticsCurrentSubTab || 'overview');
if (currentTab === 'analytics') return '/admin/analytics/' + (analyticsCurrentSubTab || 'overview');
if (currentTab === 'deploy') return '/admin/deploy/' + (deployCurrentSubTab || 'overview');
if (currentTab === 'deploy') return '/admin/deploy/' + (deployCurrentSubTab || 'overview');
if (currentTab && currentTab !== 'dashboard') return '/admin/' + currentTab;
if (currentTab && currentTab !== 'dashboard') return '/admin/' + currentTab;
@@ -172,6 +173,8 @@ function routeFromUrl() {
@@ -172,6 +173,8 @@ function routeFromUrl() {
}
}
let tab = parts[0];
let tab = parts[0];
const sub = parts[1] || null;
const sub = parts[1] || null;
+ // /admin/ai/setup is its own page, not the AI tab: the guided setup walks one job at a time.
+ if (tab === 'ai' && sub === 'setup') tab = 'ai-setup';
if (!tab || !tabLoaders[tab]) tab = 'dashboard';
if (!tab || !tabLoaders[tab]) tab = 'dashboard';
const per = urlParamToPeriod(new URLSearchParams(search).get('last'));
const per = urlParamToPeriod(new URLSearchParams(search).get('last'));
if (tab === 'analytics' && per) {
if (tab === 'analytics' && per) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
+'use strict';
+/**
+ * The owner's guided AI setup (Network's /admin/ai/setup), a thin proxy to OpenVibe.AI.
+ *
+ * GET /api/admin/ai/setup steps, prices, key pages, spend and caps
+ * PUT /api/admin/ai/setup/providers/:p/key write a provider key (AI seals it; it never comes back)
+ * POST /api/admin/ai/setup/providers/:p/test one tiny request with that provider
+ * POST /api/admin/ai/setup/test/:step one tiny request through the step's route
+ * PUT /api/admin/ai/setup/routes/:step pin the model a route uses
+ * PUT /api/admin/ai/setup/caps/:step the monthly cap per kind
+ * POST /api/admin/ai/setup/recommended fill every route with its recommended model
+ *
+ * Every route is owner only: API keys and money are the owner's, never any admin's. Network keeps
+ * no value: the request body goes to AI and is dropped. AI is called with Network's own service
+ * token (ai.provider.manage), and its answer is passed through as JSON. The page is
+ * public/js/admin/ai-setup.js; server/admin/routes.js mounts this behind requireOwner.
+ */
+const express = require('express');
+const { requireOwner } = require('../auth/owner-guard');
+
+const AI_CAP = 'ai.provider.manage';
+const JSON_TYPE = /^application\/([a-z.+-]+\+)?json\b/i;
+const TIMEOUT_MS = 120000; // a test makes one real provider call, and the speech test runs ffmpeg
+
+function createAiSetup({ fetchImpl = globalThis.fetch, log = console } = {}) {
+ const r = express.Router();
+ r.use(requireOwner);
+
+ function target(req) {
+ const config = req.app.locals.config || {};
+ const base = (config.services && config.services.ai && config.services.ai.internalUrl) || 'http://127.0.0.1:4700';
+ return String(base).replace(/\/+$/, '');
+ }
+
+ async function forward(req, res, path, { method = req.method, body = undefined } = {}) {
+ const app = req.app;
+ const token = app.locals.selfToken ? app.locals.selfToken('openvibe.ai', [AI_CAP]) : null;
+ if (!token) return res.status(503).json({ error: 'Network cannot sign a service token for OpenVibe.AI' });
+ let upstream;
+ try {
+ upstream = await fetchImpl(target(req) + path, {
+ method,
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
+ signal: AbortSignal.timeout(TIMEOUT_MS),
+ });
+ } catch (err) {
+ (log.warn || log.log).call(log, `[ai-setup] ${method} ${path}: ${err && err.code ? err.code : 'unreachable'}`);
+ return res.status(502).json({ error: 'OpenVibe.AI did not answer; try again in a moment' });
+ }
+ const text = await upstream.text().catch(() => '');
+ res.status(upstream.status);
+ if (JSON_TYPE.test(upstream.headers.get('content-type') || '')) return res.type('application/json').send(text || '{}');
+ return res.type('text/plain').send(text);
+ }
+
+ r.get('/', (req, res) => forward(req, res, '/api/v1/setup'));
+ r.get('/state', (req, res) => forward(req, res, '/api/v1/setup'));
+ r.put('/providers/:provider/key', (req, res) => forward(req, res, `/api/v1/setup/providers/${encodeURIComponent(req.params.provider)}/key`, { body: req.body || {} }));
+ r.post('/providers/:provider/test', (req, res) => forward(req, res, `/api/v1/setup/providers/${encodeURIComponent(req.params.provider)}/test`, { body: req.body || {} }));
+ r.post('/test/:step', (req, res) => forward(req, res, `/api/v1/setup/test/${encodeURIComponent(req.params.step)}`, { body: req.body || {} }));
+ r.put('/routes/:step', (req, res) => forward(req, res, `/api/v1/setup/routes/${encodeURIComponent(req.params.step)}`, { body: req.body || {} }));
+ r.put('/caps/:step', (req, res) => forward(req, res, `/api/v1/setup/caps/${encodeURIComponent(req.params.step)}`, { body: req.body || {} }));
+ r.post('/recommended', (req, res) => forward(req, res, '/api/v1/setup/recommended', { body: req.body || {} }));
+ return r;
+}
+
+module.exports = { createAiSetup };
@@ -1037,6 +1037,9 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -1037,6 +1037,9 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
// owner (server/admin/ai-config-proxy.js). Owner only on every route; the proxy re-checks, and a write carries the
// owner (server/admin/ai-config-proxy.js). Owner only on every route; the proxy re-checks, and a write carries the
// owner in X-OV-Subject, with a fresh sign-in for keys and x-danger money settings.
// owner in X-OV-Subject, with a fresh sign-in for keys and x-danger money settings.
router.use('/ai/config', requireOwner, require('./ai-config-proxy').createAiConfigProxy({ db, notificationService }));
router.use('/ai/config', requireOwner, require('./ai-config-proxy').createAiConfigProxy({ db, notificationService }));
+ // 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 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,103 @@
@@ -0,0 +1,103 @@
+'use strict';
+// The owner's guided AI setup (/admin/ai/setup): every route is owner only, Network signs its own
+// service token for OpenVibe.AI's setup API, and a provider key travels through once and is never
+// kept or echoed by Network.
+// node test/admin-ai-setup.test.js
+const assert = require('assert');
+const fs = require('fs');
+const http = require('http');
+const path = require('path');
+const express = require('express');
+const { createAiSetup } = require('../server/admin/ai-setup');
+
+function upstream() {
+ const seen = [];
+ const srv = http.createServer((req, res) => {
+ let raw = '';
+ req.on('data', (d) => { raw += d; });
+ req.on('end', () => {
+ const body = raw ? JSON.parse(raw) : null;
+ seen.push({ method: req.method, url: req.url, auth: req.headers.authorization || null, body });
+ res.writeHead(200, { 'content-type': 'application/json' });
+ if (/\/key$/.test(req.url)) return res.end(JSON.stringify({ ok: true, provider: 'deepseek', hint: { last4: '1234' }, configured: true }));
+ if (/\/test\//.test(req.url) || /\/test$/.test(req.url)) return res.end(JSON.stringify({ ok: true, step: 'chat', answer: { text: 'ready' }, cost_usd: 0.0001 }));
+ if (/\/recommended$/.test(req.url)) return res.end(JSON.stringify({ ok: true, applied: [{ step: 'chat', ok: true }] }));
+ return res.end(JSON.stringify({ ok: true, summary: { ready: 3, total: 8 }, steps: [{ id: 'chat', name: 'Chat and writing', ready: false }] }));
+ });
+ });
+ return new Promise((resolve) => srv.listen(0, '127.0.0.1', () => resolve({ srv, seen, url: `http://127.0.0.1:${srv.address().port}` })));
+}
+
+(async () => {
+ const ai = await upstream();
+ const app = express();
+ app.locals.config = { services: { ai: { internalUrl: ai.url } } };
+ app.locals.selfToken = (aud, caps) => { assert.strictEqual(aud, 'openvibe.ai'); assert.deepStrictEqual(caps, ['ai.provider.manage']); return 'network-signed-token'; };
+ app.use(express.json());
+ // The signed-in person: X-Test-User, or none for a 401. requireOwner reads req.user.
+ app.use((req, _res, next) => { const raw = req.headers['x-test-user']; if (raw) req.user = JSON.parse(raw); next(); });
+ app.use('/api/admin/ai/setup', createAiSetup({ log: { warn() {}, log() {} } }));
+ const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
+ const base = `http://127.0.0.1:${server.address().port}`;
+
+ const owner = { id: 1, role: 'admin', username: 'goosely', is_owner: true };
+ const admin = { id: 2, role: 'admin', username: 'someone', is_owner: false };
+ async function call(who, method, p, body) {
+ const headers = { 'content-type': 'application/json' };
+ if (who) headers['x-test-user'] = JSON.stringify(who);
+ const r = await fetch(base + p, { method, headers, body: body ? JSON.stringify(body) : undefined });
+ const text = await r.text();
+ let json = null; try { json = JSON.parse(text); } catch { /* not JSON */ }
+ return { status: r.status, text, json };
+ }
+ try {
+ // ── owner only ───────────────────────────────────────────────
+ let r = await call(admin, 'GET', '/api/admin/ai/setup');
+ assert.strictEqual(r.status, 403, 'a plain admin may not see the keys');
+ r = await call(null, 'GET', '/api/admin/ai/setup');
+ assert.strictEqual(r.status, 403, 'no person is not the owner');
+
+ // ── the state ────────────────────────────────────────────────
+ r = await call(owner, 'GET', '/api/admin/ai/setup');
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json.summary.total, 8);
+ assert.strictEqual(ai.seen.at(-1).url, '/api/v1/setup', 'the state comes from AI setup API');
+ assert.strictEqual(ai.seen.at(-1).auth, 'Bearer network-signed-token', 'Network signs its own service token');
+
+ // ── a key goes through once and is never kept ────────────────
+ r = await call(owner, 'PUT', '/api/admin/ai/setup/providers/deepseek/key', { key: 'forwarded-once-9999' });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual(ai.seen.at(-1).body, { key: 'forwarded-once-9999' }, 'the key is forwarded, not rewritten');
+ assert.strictEqual(r.json.hint.last4, '1234', 'only the last four come back');
+ assert.ok(!fs.existsSync(path.join(__dirname, '..', 'data', 'ai-setup-keys.json')), 'Network kept no key file');
+
+ // ── tests and writes ─────────────────────────────────────────
+ r = await call(owner, 'POST', '/api/admin/ai/setup/test/chat', { provider: 'deepseek', model: 'deepseek-flash' });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(ai.seen.at(-1).url, '/api/v1/setup/test/chat');
+ r = await call(owner, 'PUT', '/api/admin/ai/setup/routes/chat', { provider: 'deepseek', model: 'deepseek-flash' });
+ assert.strictEqual(ai.seen.at(-1).url, '/api/v1/setup/routes/chat');
+ r = await call(owner, 'PUT', '/api/admin/ai/setup/caps/chat', { cap_usd: 5 });
+ assert.strictEqual(ai.seen.at(-1).url, '/api/v1/setup/caps/chat');
+ r = await call(owner, 'POST', '/api/admin/ai/setup/recommended', {});
+ assert.strictEqual(ai.seen.at(-1).url, '/api/v1/setup/recommended');
+
+ // ── the page exists and is wired ─────────────────────────────
+ const html = fs.readFileSync(path.join(__dirname, '..', 'public', 'admin.html'), 'utf8');
+ assert.ok(html.includes('id="sec-ai-setup"'), 'the setup section is in the admin page');
+ assert.ok(html.includes('/js/admin/ai-setup.js'), 'the setup script is loaded');
+ const core = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'admin', 'core.js'), 'utf8');
+ assert.ok(core.includes("'/admin/ai/setup'"), 'the client route is /admin/ai/setup');
+ const aiJs = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'admin', 'ai.js'), 'utf8');
+ assert.ok(aiJs.includes('loadAiSetupCard()'), 'the AI tab shows the setup card first');
+
+ // ── no signer: a clear 503, not an unhandled failure ─────────
+ app.locals.selfToken = () => null;
+ r = await call(owner, 'GET', '/api/admin/ai/setup');
+ assert.strictEqual(r.status, 503, r.text);
+ } finally {
+ await new Promise((resolve) => server.close(resolve));
+ await new Promise((resolve) => ai.srv.close(resolve));
+ }
+ process.stdout.write('admin-ai-setup: owner proxy, key forwarding and page wiring passed\n');
+})().catch((err) => { process.stderr.write(`${err.stack || err}\n`); process.exitCode = 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', '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', '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,13 +7,15 @@ const fs = require('fs');
@@ -7,13 +7,15 @@ 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, css] of [['admin.html', 'admin.css'], ['my.html', 'my.css'], ['login.html', 'login.css']]) {
+for (const [page, sheets] of [['admin.html', ['admin.css', 'ai-setup.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, [css], `${page} links /css/${css} exactly once`);
+ assert.deepStrictEqual(links, sheets, `${page} links ${sheets.map((s) => '/css/' + s).join(', ')} exactly once`);
assert.ok(!/<style\b/.test(html), `${page} keeps no inline <style> block`);
assert.ok(!/<style\b/.test(html), `${page} keeps no inline <style> block`);
- const body = fs.readFileSync(path.join(pub, 'css', css), 'utf8');
- assert.ok(body.length > 1000, `public/css/${css} carries the page styles`);
+ for (const css of sheets) {
+ const body = fs.readFileSync(path.join(pub, 'css', css), 'utf8');
+ assert.ok(body.length > 1000, `public/css/${css} carries the page styles`);
+ }
}
}
for (const page of ['admin.html', 'my.html']) {
for (const page of ['admin.html', 'my.html']) {
const html = fs.readFileSync(path.join(pub, page), 'utf8');
const html = fs.readFileSync(path.join(pub, page), 'utf8');
@@ -142,6 +142,7 @@ const REVIEWED = {
@@ -142,6 +142,7 @@ const REVIEWED = {
'server/stats/open.js fetch': [1, 'open stats (/stats): fixed first-party loopback URLs only — Live (OV_LIVE_INTERNAL_URL, its public home numbers and service-token analytics totals) and Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; the request only picks 7, 30 or 90 days'],
'server/stats/open.js fetch': [1, 'open stats (/stats): fixed first-party loopback URLs only — Live (OV_LIVE_INTERNAL_URL, its public home numbers and service-token analytics totals) and Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; the request only picks 7, 30 or 90 days'],
'server/admin/service-proxies.js fetch': [1, 'the admin panel proxies: Live\'s and Chat\'s internal URLs from config, fixed path prefixes, staff only'],
'server/admin/service-proxies.js fetch': [1, 'the admin panel proxies: Live\'s and Chat\'s internal URLs from config, fixed path prefixes, staff only'],
'server/admin/review-queue.js fetch': [2, 'the reviewer queue: Wiki/Blog/News/Trade internal URLs from operator config (loopback defaults), fixed /api/v1/review-queue and review paths, ids percent-encoded, the signed-in staff member\'s own token, staff only'],
'server/admin/review-queue.js fetch': [2, 'the reviewer queue: Wiki/Blog/News/Trade internal URLs from operator config (loopback defaults), fixed /api/v1/review-queue and review paths, ids percent-encoded, the signed-in staff member\'s own token, staff only'],
+ 'server/admin/ai-setup.js fetch': [1, 'the guided AI setup proxy: OpenVibe.AI internal URL from config.services (operator-set, loopback default), fixed /api/v1/setup paths, owner only, no caller-selected host or URL, 120 s timeout'],
'server/admin/ai-config-proxy.js fetch': [1, 'the owner settings proxy and the Setup tab (server/admin/setup.js calls through it): the reachable services\' internal URLs from config.services (operator-set, loopback defaults), fixed /internal/config and AI /api/v1/providers paths, service, namespace and key checked against the known names, owner only'],
'server/admin/ai-config-proxy.js fetch': [1, 'the owner settings proxy and the Setup tab (server/admin/setup.js calls through it): the reachable services\' internal URLs from config.services (operator-set, loopback defaults), fixed /internal/config and AI /api/v1/providers paths, service, namespace and key checked against the known names, owner only'],
'server/integrations/github.js fetch': [1, 'fixed host api.github.com (asserted below)'],
'server/integrations/github.js fetch': [1, 'fixed host api.github.com (asserted below)'],
'server/link/send.js fetch': [8, 'browser-only Link Send requests to fixed same-origin routes (the session read, machine keys, the resumable transfer parts and the delivery list); page input supplies file bytes, never a URL or destination host'],
'server/link/send.js fetch': [8, 'browser-only Link Send requests to fixed same-origin routes (the session read, machine keys, the resumable transfer parts and the delivery list); page input supplies file bytes, never a URL or destination host'],