openvibers/openvibe.network Commits

Setup: the owner's guided AI setup page at /admin/ai/setup (#871)

OP OpenVibers committed 23 h ago · 9685a04b4a · 12 files, +779 −6

* Setup: the owner's guided AI setup page at /admin/ai/setup

Adds the Set up AI page and its owner-only proxy to OpenVibe.AI's setup API:
a stepper over every job the estate needs, one card per job with the recommended
provider, price and key page, a one-field key paste, a tiny real test with its
answer and cost, Skip/Next, and a Defaults table of model, spend and monthly cap
per task kind. The AI tab now opens with a 3-of-8/Continue setup card. Network
keeps no key: the request goes to AI and is dropped.

* Setup card is owner only on the AI tab

A plain admin still sees the AI tab's usage and explorer tools; the key/setup card is
the owner's, like the API it calls.

* Setup page: record the new admin module, stylesheet and reviewed proxy

admin-modules knows ai-setup.js, page-styles allows the setup stylesheet, and the SSRF
ratchet records the owner-only AI internal URL call with its review.

Changes

ddiff --git a/public/admin.html b/public/admin.html
iindex 286b2bd3..c57d9349 100644
---- a/public/admin.html
++++ b/public/admin.html
@@@ -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>
ddiff --git a/public/css/ai-setup.css b/public/css/ai-setup.css
nnew file mode 100644
iindex 00000000..9588bbe0
---- /dev/null
++++ b/public/css/ai-setup.css
@@@ -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}
++}
ddiff --git a/public/js/admin/ai-setup.js b/public/js/admin/ai-setup.js
nnew file mode 100644
iindex 00000000..c522a974
---- /dev/null
++++ b/public/js/admin/ai-setup.js
@@@ -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; }
++}
ddiff --git a/public/js/admin/ai.js b/public/js/admin/ai.js
iindex 3941cc70..400a6efb 100644
---- a/public/js/admin/ai.js
++++ b/public/js/admin/ai.js
@@@ -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');
ddiff --git a/public/js/admin/boot.js b/public/js/admin/boot.js
iindex f9af24b7..da332659 100644
---- a/public/js/admin/boot.js
++++ b/public/js/admin/boot.js
@@@ -21,6 +21,7 @@ const tabLoaders = {
cashouts: () => {}, // a pointer to OpenVibe.Billing's console
payments: loadPayments,
ai: loadAi,
++ 'ai-setup': loadAiSetup,
pastes: loadPastesAdmin,
storage: loadStorage,
notifications: () => {},
ddiff --git a/public/js/admin/core.js b/public/js/admin/core.js
iindex 347015ea..7cfb3592 100644
---- a/public/js/admin/core.js
++++ b/public/js/admin/core.js
@@@ -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) {
ddiff --git a/server/admin/ai-setup.js b/server/admin/ai-setup.js
nnew file mode 100644
iindex 00000000..b3570b1e
---- /dev/null
++++ b/server/admin/ai-setup.js
@@@ -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 };
ddiff --git a/server/admin/routes.js b/server/admin/routes.js
iindex 6775e874..c3ca2c8e 100644
---- a/server/admin/routes.js
++++ b/server/admin/routes.js
@@@ -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 }));
ddiff --git a/test/admin-ai-setup.test.js b/test/admin-ai-setup.test.js
nnew file mode 100644
iindex 00000000..bc71a3bc
---- /dev/null
++++ b/test/admin-ai-setup.test.js
@@@ -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; });
ddiff --git a/test/admin-modules.test.js b/test/admin-modules.test.js
iindex 7ff2d1b0..be667635 100644
---- a/test/admin-modules.test.js
++++ b/test/admin-modules.test.js
@@@ -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();
// 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]);
ddiff --git a/test/page-styles.test.js b/test/page-styles.test.js
iindex 7499af39..fafc92df 100644
---- a/test/page-styles.test.js
++++ b/test/page-styles.test.js
@@@ -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');
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex acd04bcf..b92f01fb 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -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'],