openvibers/openvibe.exchange Commits
Exchange: search, chat and deep dive work again; every page's plain elements styled; /compute, the home and the deep dive designed (#229)
Changes
@@@ -732,3 +732,21 @@ label.dd-depth:has(input:checked) { border-color: var(--accent); background: col.dd-chips { list-style: none; display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 0; padding: 0; }.dd-chips li { padding: 3px 10px; border-radius: 999px; border: 1px solid var(--border); background: var(--bg-secondary); font-size: .8rem; color: var(--text-secondary); }@media (max-width: 640px) { .dd-depth-row { grid-template-columns: minmax(0, 1fr); } .dd-depth-price { font-size: 1.35rem; } }++++/* ── Deep dive, 11-12 (the owner: unstyled boxes, "update the little dots", spacing, the label by the price) ── */++.dd-form { display: grid; gap: 18px; }++.ov-base .dd-field { margin: 0; }++.dd-field > span { display: block; margin-bottom: 8px; font-weight: 700; }++.dd-field textarea { line-height: 1.45; }++/* The depth's dot: drawn, the site's accent when chosen. */++label.dd-depth input { appearance: none; -webkit-appearance: none; display: grid; place-items: center; width: 22px; height: 22px; margin: 0; border: 2px solid color-mix(in srgb, var(--text-primary, #fff) 30%, transparent); border-radius: 50%; background: transparent; cursor: pointer; transition: border-color var(--ov-dur-2, .2s) ease; }++label.dd-depth input::before { content: ""; width: 10px; height: 10px; border-radius: 50%; background: var(--accent); transform: scale(0); transition: transform var(--ov-dur-2, .2s) var(--ov-ease-out, ease); }++label.dd-depth input:checked { border-color: var(--accent); }++label.dd-depth input:checked::before { transform: scale(1); }++label.dd-depth input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }++.dd-go { display: grid; justify-items: start; gap: 8px; }++.dd-go-note { display: flex; align-items: center; gap: 7px; margin: 0; color: var(--text-muted); font-size: .88rem; }++.dd-go-note svg { flex: none; width: 15px; height: 15px; }++.dd-go-note { align-items: flex-start; }++.dd-go-note svg { margin-top: 3px; }++.dd > .dd-form + *, .dd > .dd-error + * { margin-top: 26px; }@@@ -409,3 +409,74 @@.xs-q .ovk-btn { width: 48px; padding: 0; justify-content: center; }}@media (prefers-reduced-motion: reduce) { .xs-more > summary::after { transition: none; } }++++/* ── 11-12 design pass (the owner: "bad layout … add icons … improve"). ── */++/* Why Exchange: three cards, each with its mark, its words and its way on. */++.xp-features { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; margin: 24px 0; }++.xp-feature { display: grid; align-content: start; gap: 8px; padding: 18px; border: 1px solid var(--ovk-line); border-radius: var(--ov-radius-2, 16px); background: var(--ovk-panel); }++.xp-feature h3 { margin: 4px 0 0; font-size: 1.05rem; letter-spacing: -.01em; }++.xp-feature p { margin: 0; color: var(--ovk-muted); line-height: 1.5; }++.xp-feature-ic, .xpr-ic, .xp-q-ic, .xc-ic { display: inline-grid; place-items: center; flex: none; width: 38px; height: 38px; border-radius: 12px; color: var(--ovk-accent); background: color-mix(in srgb, var(--ovk-accent) 14%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ovk-accent) 22%, transparent); }++.xp-feature-ic svg, .xpr-ic svg, .xp-q-ic svg, .xc-ic svg { width: 19px; height: 19px; }++.xp-feature-a { display: inline-flex; align-items: center; gap: 6px; justify-self: start; margin-top: 4px; color: var(--ovk-accent); font-weight: 700; text-decoration: none; }++.xp-feature-a svg { width: 15px; height: 15px; transition: transform var(--ov-dur-2, .2s) var(--ov-ease-out, ease); }++.xp-feature-a:hover svg { transform: translateX(3px); }++/* Pricing: each line has its mark. */++.xpr-list li { grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 14px; }++.xpr-t { display: grid; gap: 2px; min-width: 0; }++.xpr-t > span { color: var(--ovk-muted); font-size: .92rem; }++.xpr-list .xpr-ic { width: 34px; height: 34px; border-radius: 10px; }++@media (min-width: 760px) { .xpr-list li { grid-template-columns: auto minmax(0, 1fr); } .xpr-t { grid-template-columns: minmax(200px, 1fr) 2fr; align-items: baseline; gap: 16px; } }++/* Questions: an accordion, each with its mark and a chevron that turns. */++.xp-faq { display: grid; gap: 8px; padding: 4px 14px 14px; }++.ov-base .xp-q { margin: 0; padding: 0; border: 1px solid var(--ovk-line); border-radius: 14px; background: var(--ovk-panel-2, var(--ovk-panel)); overflow: hidden; }++.xp-q > summary { display: flex; align-items: center; gap: 12px; min-height: 52px; padding: 8px 12px; list-style: none; cursor: pointer; font-weight: 700; }++.xp-q > summary::-webkit-details-marker { display: none; }++.xp-q .xp-q-ic { width: 32px; height: 32px; border-radius: 10px; }++.xp-q-t { flex: 1; min-width: 0; }++.xp-q-chev { display: grid; color: var(--ovk-muted); transition: transform var(--ov-dur-2, .2s) var(--ov-ease-out, ease); }++.xp-q-chev svg { width: 18px; height: 18px; }++.xp-q[open] .xp-q-chev { transform: rotate(180deg); }++.xp-q-a { padding: 0 14px 14px 56px; color: var(--ovk-muted); }++.xp-q-a p { margin: 0 0 6px; }++.xp-q-a a { font-weight: 700; }++++/* /compute: cards by kind, the cheapest marked. */++.xc-jump { display: flex; flex-wrap: wrap; gap: 8px; margin: 4px 0 18px; }++.xc-jump a { display: inline-flex; align-items: center; gap: 7px; min-height: 40px; padding: 0 14px; border: 1px solid var(--ovk-line); border-radius: 999px; background: var(--ovk-panel); color: var(--ovk-text); font-weight: 650; font-size: .92rem; text-decoration: none; }++.xc-jump a svg { width: 16px; height: 16px; color: var(--ovk-accent); }++.xc-jump a:hover { border-color: color-mix(in srgb, var(--ovk-accent) 50%, var(--ovk-line)); }++.xc-stale { padding: 10px 14px; border-radius: 12px; background: color-mix(in srgb, var(--ovk-warn, #f59e0b) 14%, transparent); color: var(--ovk-text); }++.xc-group { margin: 0 0 26px; scroll-margin-top: calc(var(--ovnav-h, 56px) + 12px); }++.ov-base .xc-group h2 { display: flex; align-items: center; gap: 10px; margin: 0 0 4px; font-size: 1.2rem; }++.xc-group .xc-ic { width: 32px; height: 32px; border-radius: 10px; }++.xc-n { display: inline-flex; align-items: center; height: 22px; padding: 0 8px; border-radius: 999px; background: color-mix(in srgb, var(--ovk-text) 8%, transparent); color: var(--ovk-muted); font-size: .76rem; font-weight: 700; }++.xc-note { margin: 0 0 10px; color: var(--ovk-muted); font-size: .92rem; }++.xc-list { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }++.ov-base .xc-list li + li { margin: 0; }++.xc-row { display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; align-items: center; gap: 12px; padding: 12px 10px 12px 14px; border: 1px solid var(--ovk-line); border-radius: 14px; background: var(--ovk-panel); }++.xc-mark { display: grid; place-items: center; width: 38px; height: 38px; border-radius: 11px; background: color-mix(in srgb, var(--ovk-accent) 16%, var(--ovk-panel-2, transparent)); color: var(--ovk-text); font-weight: 800; }++.xc-t { display: grid; gap: 2px; min-width: 0; }++.xc-t b { font-size: 1rem; }++.xc-t small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ovk-muted); font-size: .85rem; }++.xc-tags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }++.xc-tag { padding: 2px 8px; border-radius: 999px; background: color-mix(in srgb, var(--ovk-text) 8%, transparent); color: var(--ovk-muted); font-size: .72rem; font-weight: 700; }++.xc-tag.is-best { background: color-mix(in srgb, #22c55e 18%, transparent); color: #86efac; }++.xc-tag.is-promo { background: color-mix(in srgb, var(--ovk-accent) 16%, transparent); color: var(--ovk-accent); }++.xc-p { display: grid; justify-items: end; gap: 1px; text-align: right; font-variant-numeric: tabular-nums; }++.xc-p b { font-size: 1.15rem; letter-spacing: -.01em; white-space: nowrap; }++.xc-p b small { margin-left: 2px; color: var(--ovk-muted); font-size: .72rem; font-weight: 650; }++.xc-p > small { color: var(--ovk-muted); font-size: .72rem; white-space: nowrap; }++.xc-src { display: grid; place-items: center; width: 40px; height: 40px; border-radius: 10px; color: var(--ovk-muted); }++.xc-src:hover { color: var(--ovk-text); background: color-mix(in srgb, var(--ovk-text) 6%, transparent); }++.xc-src svg { width: 17px; height: 17px; }++.xc-foot { margin-top: 18px; color: var(--ovk-muted); font-size: .9rem; }++@media (max-width: 420px) { .xc-row { grid-template-columns: auto minmax(0, 1fr) auto; } .xc-src { display: none; } .xc-p > small { display: none; } }++/* The older `.xpr-list span` (muted) must not reach the title or the mark. */++.xpr-list .xpr-t { color: var(--ovk-text); }++.xpr-list .xpr-t > span { color: var(--ovk-muted); }++.xpr-list .xpr-ic { color: var(--ovk-accent); }++.ovk-section > .xpr-note, .xpr > .xpr-note { margin: 12px 16px 14px; }++/* The chevron points down, and up when open. */++.xp-q-chev { transform: rotate(90deg); }++.xp-q[open] .xp-q-chev { transform: rotate(-90deg); }@@@ -67,7 +67,10 @@ function load(env = process.env) {ai: {url: trim(env.OV_AI_URL || 'https://ai.openvibe.services'),audience: env.OV_AI_AUDIENCE || 'openvibe.ai',-- scope: env.OV_AI_SCOPE || 'ai.run.create ai.run.read',++ // Only what Network grants Exchange. Asking for ai.run.read too (#222) made Network refuse the whole token++ // (invalid_scope), so chat, Try it, presets and the deep dive's service calls failed; AI's deep-dive reads++ // accept ai.run.create.++ scope: env.OV_AI_SCOPE || 'ai.run.create',consoleScope: env.OV_AI_CONSOLE_SCOPE || 'ai.usage.read ai.workflow.manage ai.provider.manage',timeoutMs: Math.max(2000, int(env.EXCHANGE_AI_TIMEOUT_MS, 30_000)),waitMs: Math.max(1000, int(env.EXCHANGE_AI_WAIT_MS, 25_000)),@@@ -76,8 +76,7 @@ function createPageRoutes(ctx) {r.get('/compute', (req, res) => {const { prices, checked_at } = require('../compute-prices');-- const rows = prices.map(p => `<tr><td>${esc(p.provider)}</td><td>${esc(p.id)}</td><td>${esc(p.gpu || `${p.cpu} vCPU / ${p.ram_mb} MiB`)}</td><td>$${p.hourly.toFixed(6)}/hour</td><td>$${p.per_second.toFixed(9)}/second</td><td><a href="${esc(p.source)}">Provider price</a></td></tr>`).join('');-- return page(req,res,{title:'Compute prices',body:raw(`<main><h1>Compute prices</h1><p>Checked ${esc(checked_at)}. Base compute only; storage, network and plan fees may apply. Promotional balances are account-specific.</p><p><a href="/">Model prices</a> · <a href="https://openvibe.network/admin#ai">Owner provider settings</a></p><div style="overflow-x:auto"><table><thead><tr><th>Provider</th><th>Price ID</th><th>Resources</th><th>Hourly</th><th>Per second</th><th>Source</th></tr></thead><tbody>${rows}</tbody></table></div></main>`)});++ return page(req, res, { title: 'Compute prices', description: 'What an hour of a computer or a GPU costs at Baseten, Daytona, E2B, Fly.io and RunPod: base compute, checked weekly.', body: raw(platform.computePage({ prices, checked_at })) });});// ── Exchange v1, retired ──────────────────────────────────@@@ -39,11 +39,14 @@ function createSearchRoutes({ config, ai }) {},)),};-- } catch {-- return {-- engine,-- error: 'This engine is unavailable or does not support this search kind.',-- };++ } catch (err) {++ const status = err && err.status;++ console.warn('[exchange] search', engine, kind, status || '', (err && err.code) || '', (err && err.message) || '');++ const why = status === 401 || status === 403 ? 'Your sign-in could not search. Sign out and in again.'++ : status === 429 ? 'Too many searches this minute. Try again shortly.'++ : status === 400 || status === 422 ? 'This engine does not take this kind of search.'++ : 'This engine did not answer. Try the other one, or again in a minute.';++ return { engine, error: why };}}),);@@@ -23,7 +23,7 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');const NAV = [{ label: 'Models', href: '/models', icon: 'fa-microchip' },{ label: 'Search', href: '/search', icon: 'fa-magnifying-glass' },-- { label: 'Deep dive', href: '/deep-dive', icon: 'fa-binoculars' },++ { label: 'Deep dive', href: '/deep-dive', icon: 'fa-compass' }, // fa-binoculars has no glyph: it drew nothing{ label: 'Pricing', href: '/pricing', icon: 'fa-tag' },{ label: 'Docs', href: '/docs', icon: 'fa-code' },{ label: 'Status', href: '/status', icon: 'fa-gauge' },@@@ -103,6 +103,7 @@ function renderPage(o) {${appIcon.headTags({ site: site.icon || 'exchange' })}<link rel="stylesheet" href="${ovServe.url('kit.css')}"><link rel="stylesheet" href="${ovServe.url('kit-data.css')}">++<link rel="stylesheet" href="${ovServe.url('kit-layout.css')}"><link rel="stylesheet" href="${ovServe.url('room.css')}"><link rel="stylesheet" href="${ovServe.url('showcase.css')}"><link rel="stylesheet" href="${asset('css/exchange.css')}">@@@ -117,7 +118,7 @@ ${o.head || ''}body: `<a class="skip" href="#main">Skip to content</a><div id="navbar-mount"></div><noscript><div class="account-bar" role="navigation" aria-label="Account">${account}</div></noscript>--<main id="main" class="ex ov-room ${o.class || ''}" style="${rooms.style('exchange')}">++<main id="main" class="ex ov-room ov-base ${o.class || ''}" style="${rooms.style('exchange')}">${render(o.body)}</main><script>@@@ -82,7 +82,7 @@ function homePage({ viewer, depths = [], capture = null, error = null }) {<label class="dd-field"><span>What are you designing?</span><textarea name="question" rows="3" maxlength="500" required placeholder="a phone-first robot control panel"></textarea></label>${depthCards(cards)}<label class="dd-field"><span>Sites you already know (optional, one per line)</span><textarea name="sites" rows="2" maxlength="2048" placeholder="https://example.com/panel"></textarea></label>--<p class="dd-go">${kit.button({ label: 'Start the deep dive', type: 'submit' })}<span class="ovk-note">The price is held before it starts; you can cancel at any step.</span></p>++<div class="dd-go">${kit.button({ label: 'Start the deep dive', type: 'submit' })}<p class="dd-go-note">${require('openvibe-shared/glyphs').svg('lock')}The price is held before it starts; you can cancel at any step.</p></div></form>${error ? `<p class="dd-error" role="alert">${esc(error)}</p>` : ''}`: signIn();return `<div class="ex-wrap dd">${kit.head({ title: 'Deep dive', sub: 'Expert design research: we search everywhere, capture the best sites at phone and computer sizes, and tell you what works and why.' })}${form}${signedIn ? '' : `<section class="ovk-section"><h2>The depths and their prices</h2>${depthList(cards)}</section>`}`@@@ -82,18 +82,75 @@ function table(models, searchMeta = null) {}).join('')}</ul>`;}// What each way of calling costs, in the reader's words (home's Pricing section and /pricing). Opus, 11-09.++const glyph = (name) => require('openvibe-shared/glyphs').svg(name);const PRICING = [-- ['Your own provider key', "The provider's price. OpenVibe adds 0%, with no cap and no end date."],-- ['Managed models', 'The input and output price per million tokens shown on each model, before you call.'],-- ["Open models on OpenVibe's GPUs", 'A fixed price per million tokens, the same on every call. Coming next.'],-- ['Your own endpoint', 'An hourly price, quoted before it starts; it stops when idle.'],-- ['Search', "Each engine's price per 1,000 searches; the free engines first."],++ ['key', 'Your own provider key', "The provider's price. OpenVibe adds 0%, with no cap and no end date."],++ ['coin', 'Managed models', 'The input and output price per million tokens shown on each model, before you call.'],++ ['chip', "Open models on OpenVibe's GPUs", 'A fixed price per million tokens, the same on every call. Coming next.'],++ ['server', 'Your own endpoint', 'An hourly price, quoted before it starts; it stops when idle.'],++ ['search', 'Search', "Each engine's price per 1,000 searches; the free engines first."],];--const pricingRows = () => `<ul class="xpr-list">${PRICING.map(([name, what]) => `<li><strong>${esc(name)}</strong><span>${esc(what)}</span></li>`).join('')}</ul>`++const pricingRows = () => `<ul class="xpr-list">${PRICING.map(([icon, name, what]) => `<li><span class="xpr-ic" aria-hidden="true">${glyph(icon)}</span><span class="xpr-t"><strong>${esc(name)}</strong><span>${esc(what)}</span></span></li>`).join('')}</ul>`+ '<p class="xpr-note">Paid models open soon. Every price shows before you call, and nothing is charged until then.</p>';function pricing() {return section('Pricing', pricingRows());}++// Why Exchange: three cards, each with its mark, its words and the page that says more (the owner, 11-12: the++// reasons read as bare text flush against their boxes).++const REASONS = [++ ['key', '0% fee on your own key', "Bring your provider key and pay the provider's price, nothing more. No cap, no end date.", '/dashboard/keys', 'Add a key'],++ ['route', 'The cheapest route', "Every call goes to the cheapest provider that is up. Open models on OpenVibe's own GPUs, at a fixed price per million tokens, are coming next.", '/compute', 'Compute prices'],++ ['shield', 'Uncensored, 18+', 'Uncensored models for adults who opt in. The content floor applies on every request.', '/uncensored', 'What the floor covers'],++];++function reasons() {++ return `<section class="xp-features" aria-label="Why OpenVibe.Exchange">${REASONS.map(([icon, title, text, href, label]) =>++ `<article class="xp-feature"><span class="xp-feature-ic" aria-hidden="true">${glyph(icon)}</span><h3>${esc(title)}</h3><p>${esc(text)}</p><a class="xp-feature-a" href="${href}">${esc(label)}<span aria-hidden="true">${glyph('arrow-right')}</span></a></article>`).join('')}</section>`;++}++const QUESTIONS = [++ ['code', 'Can I use my existing client?', 'Yes. Point any OpenAI-compatible client at the Exchange endpoint with your Exchange key; Anthropic-style clients work through /v1/messages.', '/docs', 'Read the docs'],++ ['chat', 'Where can I talk to a model?', 'In Vibe on OpenVibe.Chat: talk to any model here, or two side by side.', 'https://openvibe.chat/vibe', 'Open Vibe'],++ ['chart', 'Where do the numbers come from?', 'Prices come from the live catalogue. Speed and reliability come from measured requests. A number nobody has measured stays unknown, never guessed.', '/status', 'See the status'],++];++function questions() {++ return section('Questions', `<div class="xp-faq">${QUESTIONS.map(([icon, q, a, href, label]) =>++ `<details class="xp-q"><summary><span class="xp-q-ic" aria-hidden="true">${glyph(icon)}</span><span class="xp-q-t">${esc(q)}</span><span class="xp-q-chev" aria-hidden="true">${glyph('chevron')}</span></summary><div class="xp-q-a"><p>${esc(a)}</p><a href="${href}">${esc(label)}</a></div></details>`).join('')}</div>`);++}++++// /compute: what an hour of a computer or a GPU costs at each provider (the owner, 11-12: the raw table ran off a++// phone; "this compute list should be on openvibe.compare … or at least interconnected"). Cards by kind, the cheapest++// marked, the provider's own price page one tap away; the same list is GET /api/compute/prices, and OpenVibe.Compare++// compares it.++const PROVIDER = { baseten: 'Baseten', daytona: 'Daytona', e2b: 'E2B', fly: 'Fly.io', runpod: 'RunPod', modal: 'Modal', vast: 'Vast.ai', lambda: 'Lambda' };++const REGION = { 'us-pdx-1': 'US West', 'eu-lon-1': 'London', us: 'US', eu: 'EU', iad: 'Virginia', ewr: 'New Jersey', 'provider-default': "The provider's region" };++const gb = (mb) => `${Math.round((Number(mb) || 0) / 1024)} GB`;++const perHour = (n) => `$${Number(n).toFixed(n < 0.1 ? 3 : 2)}`;++function ago(iso, now) {++ const days = Math.floor((now - Date.parse(iso)) / 86400000);++ return !Number.isFinite(days) ? 'recently' : days <= 0 ? 'today' : days === 1 ? 'yesterday' : `${days} days ago`;++}++function computePage({ prices = [], checked_at, now = Date.now() }) {++ const kinds = [['gpu', 'GPUs', 'chip', 'For models and anything that needs a graphics card.'], ['cpu', 'Computers', 'server', 'Sandboxes and desks: two cores and a few GB of memory.']];++ const stale = prices.some((p) => p.expires_at && now > p.expires_at);++ const row = (p, cheapest) => {++ const name = PROVIDER[p.provider] || p.provider;++ const what = p.gpu ? `${p.gpu} · ${gb(p.gpu_mb)}` : `${p.cpu} vCPU · ${gb(p.ram_mb)} memory`;++ const where = REGION[p.region] || p.region || '';++ const tags = [cheapest ? '<span class="xc-tag is-best">Cheapest</span>' : '', p.interruptible ? '<span class="xc-tag">Can be interrupted</span>' : '', p.promotion ? `<span class="xc-tag is-promo" title="${esc(p.promotion)}">Offer</span>` : ''].join('');++ return `<li class="xc-row"><span class="xc-mark" aria-hidden="true">${esc(name.charAt(0))}</span><span class="xc-t"><b>${esc(name)}</b><small>${esc([what, where].filter(Boolean).join(' · '))}</small>${tags ? `<span class="xc-tags">${tags}</span>` : ''}</span>`++ + `<span class="xc-p"><b>${perHour(p.hourly)}<small>/hour</small></b><small>$${Number(p.per_second).toFixed(6)}/second</small></span>`++ + `<a class="xc-src" href="${esc(p.source)}" rel="noopener nofollow" target="_blank" aria-label="${esc(name)}'s price page">${glyph('open')}</a></li>`;++ };++ const groups = kinds.map(([kind, title, icon, note]) => {++ const list = prices.filter((p) => (p.kind || (p.gpu ? 'gpu' : 'cpu')) === kind).sort((a, b) => a.hourly - b.hourly);++ if (!list.length) return '';++ return `<section class="xc-group" id="${kind}"><h2><span class="xc-ic" aria-hidden="true">${glyph(icon)}</span>${esc(title)} <span class="xc-n">${list.length}</span></h2><p class="xc-note">${esc(note)}</p><ul class="xc-list">${list.map((p, i) => row(p, i === 0)).join('')}</ul></section>`;++ }).join('');++ return `<div class="ex-wrap xc">${kit.head({ title: 'Compute prices', sub: `What an hour of a computer or a GPU costs at each provider, checked ${ago(checked_at, now)}.` })}`++ + `<nav class="xc-jump" aria-label="Kinds"><a href="#gpu">${glyph('chip')}GPUs</a><a href="#cpu">${glyph('server')}Computers</a><a href="https://openvibe.compare/compute">${glyph('scale')}Compare them on OpenVibe.Compare</a></nav>`++ + (stale ? '<p class="xc-stale" role="status">Some of these prices are past their review date: check the provider before you rely on one.</p>' : '')++ + groups++ + `<p class="xc-foot">Base compute only: storage, network and plan fees may apply, and offers are per account. The same list is <a href="/api/compute/prices">GET /api/compute/prices</a> (JSON, open to anyone). <a href="/">Model prices</a></p></div>`;++}/** /pricing: the page, with its head (it had no h1). */function pricingPage() {return `<div class="ex-wrap xp">${kit.head({ title: 'Pricing', sub: 'What a call costs, before you make it.' })}<section class="xpr">${pricingRows()}</section></div>`;@@@ -117,7 +174,7 @@ function home({ catalog, usage, viewer }) {'median time to first token',],],-- )}${section('Popular models', table([...models].sort((a, b) => (b.requests_7d || 0) - (a.requests_7d || 0)).slice(0, 10)))}<div class="xp-reasons">${section('0% fee on your own key', '<p>Bring your provider key and pay the provider\'s price, nothing more. No cap, no end date.</p>')}${section('The cheapest route', '<p>Every call goes to the cheapest provider that is up. Open models on OpenVibe\'s own GPUs, at a fixed price per million tokens, are coming next.</p>')}${section('Uncensored, 18+', '<p>Uncensored models for adults who opt in. The content floor applies on every request.</p><a href="/uncensored">What the floor covers</a>')}</div>${pricing()}${section('Questions', '<details><summary>Can I use my existing client?</summary><p>Yes. Use the OpenAI-compatible endpoint with your Exchange key.</p></details><details><summary>Where can I talk to a model?</summary><p><a href="https://openvibe.chat/vibe">Open Chat</a> to talk or compare models.</p></details><details><summary>Where do the numbers come from?</summary><p>Prices come from the live catalogue. Speed and reliability come from measured requests. Missing measurements stay unknown.</p></details>')}</div>`;++ )}${section('Popular models', table([...models].sort((a, b) => (b.requests_7d || 0) - (a.requests_7d || 0)).slice(0, 10)))}${reasons()}${pricing()}${questions()}</div>`;}function modelsPage({ catalog, query = {}, searchResults = null }) {const all = listed(catalog);@@@ -186,4 +243,4 @@ function modelsPage({ catalog, query = {}, searchResults = null }) {const sub = rows.length ? `${rows.length} to call now · live prices, measured speed` : 'None to call now match · the registry is below';return `<div class="ex-wrap xp xm-page">${kit.head({ title: 'Models', sub })}${unavailable(catalog)}<div class="xm-find">${filters}<nav class="ex-chips" aria-label="Model filters">${ordered.join('')}</nav></div>${table(rows, searchMeta)}<p class="ex-more"><a href="https://openvibe.compare/">Compare models side by side on OpenVibe.Compare →</a></p></div>`;}--module.exports = { home, modelsPage, pricing, pricingPage, table };++module.exports = { computePage, home, modelsPage, pricing, pricingPage, table };nnew file mode 100644@@@ -0,0 +1,54 @@++'use strict';++/**++ * The 11-12 design pass and fixes (the owner, phone screenshots of openvibe.exchange: search "broken", the compute list++ * a raw table off the screen, the home's reasons "bad layout … add icons … improve", the deep dive's boxes unstyled, a++ * broken icon in the menu).++ * node test/exchange-polish-1112.test.js++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const glyphs = require('openvibe-shared/glyphs');++++const read = (f) => fs.readFileSync(path.join(__dirname, '..', f), 'utf8');++++// The service token asks only for what Network grants Exchange (ai.run.read made Network refuse the whole token).++const config = require('../server/config').load({});++assert.strictEqual(config.ai.scope, 'ai.run.create');++++// Every menu icon is a glyph that exists (fa-binoculars drew nothing for Deep dive).++const { NAV } = require('../server/render/layout');++for (const item of NAV) if (item.icon) assert.ok(glyphs.fromFa(item.icon), `${item.label}: ${item.icon} has a glyph`);++// Plain elements get the themed base.++const layout = read('server/render/layout.js');++assert.ok(/class="ex ov-room ov-base /.test(layout) && /kit-layout\.css/.test(layout), 'main carries .ov-base and its stylesheet');++++// Search says why an engine did not answer.++const search = read('server/http/search.js');++assert.ok(!/This engine is unavailable or does not support this search kind/.test(search), 'no catch-all message');++assert.ok(/status === 401 \|\| status === 403/.test(search) && /status === 429/.test(search), 'a reason by status');++++// /compute: cards by kind, cheapest first and marked, the provider's page linked, no table; the JSON stays.++const platform = require('../server/views/platform');++const { prices, checked_at } = require('../server/compute-prices');++const html = platform.computePage({ prices, checked_at, now: Date.parse(checked_at) + 86400000 });++assert.ok(!/<table/.test(html), 'no table');++assert.match(html, /checked yesterday/);++const gpu = html.slice(html.indexOf('id="gpu"'), html.indexOf('id="cpu"'));++const hourly = [...gpu.matchAll(/\$([0-9.]+)<small>\/hour/g)].map((m) => Number(m[1]));++assert.deepStrictEqual(hourly, [...hourly].sort((a, b) => a - b), 'cheapest first');++assert.ok(gpu.indexOf('is-best') > 0 && gpu.indexOf('is-best') < gpu.indexOf('xc-row', gpu.indexOf('xc-row') + 1), 'the first is marked Cheapest');++assert.match(html, /openvibe\.compare\/compute/, 'linked to OpenVibe.Compare');++assert.match(html, /\/api\/compute\/prices/, 'the same list as JSON');++assert.match(read('server/app.js'), /app\.get\('\/api\/compute\/prices'/);++const stale = platform.computePage({ prices, checked_at, now: Date.parse('2027-01-01') });++assert.match(stale, /past their review date/, 'old prices say so');++++// The home: three reason cards with marks and links, pricing lines with marks, questions as an accordion.++const home = read('server/views/platform.js');++assert.ok(/class="xp-features"/.test(home) && /xp-feature-ic/.test(home) && /class="xp-faq"/.test(home) && /xpr-ic/.test(home));++++// The deep dive's note sits under its button.++assert.match(read('server/views/deep-dive.js'), /<p class="dd-go-note">/);++assert.match(read('public/css/exchange.css'), /label\.dd-depth input \{ appearance: none;/, 'the dot is drawn');++console.log('exchange-polish-1112: scope, menu glyphs, the base, search reasons, /compute cards, the home, the deep dive');
@@ -732,3 +732,21 @@ label.dd-depth:has(input:checked) { border-color: var(--accent); background: col
@@ -732,3 +732,21 @@ label.dd-depth:has(input:checked) { border-color: var(--accent); background: col
.dd-chips { list-style: none; display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 0; padding: 0; }
.dd-chips { list-style: none; display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 0; padding: 0; }
.dd-chips li { padding: 3px 10px; border-radius: 999px; border: 1px solid var(--border); background: var(--bg-secondary); font-size: .8rem; color: var(--text-secondary); }
.dd-chips li { padding: 3px 10px; border-radius: 999px; border: 1px solid var(--border); background: var(--bg-secondary); font-size: .8rem; color: var(--text-secondary); }
@media (max-width: 640px) { .dd-depth-row { grid-template-columns: minmax(0, 1fr); } .dd-depth-price { font-size: 1.35rem; } }
@media (max-width: 640px) { .dd-depth-row { grid-template-columns: minmax(0, 1fr); } .dd-depth-price { font-size: 1.35rem; } }
+
+/* ── Deep dive, 11-12 (the owner: unstyled boxes, "update the little dots", spacing, the label by the price) ── */
+.dd-form { display: grid; gap: 18px; }
+.ov-base .dd-field { margin: 0; }
+.dd-field > span { display: block; margin-bottom: 8px; font-weight: 700; }
+.dd-field textarea { line-height: 1.45; }
+/* The depth's dot: drawn, the site's accent when chosen. */
+label.dd-depth input { appearance: none; -webkit-appearance: none; display: grid; place-items: center; width: 22px; height: 22px; margin: 0; border: 2px solid color-mix(in srgb, var(--text-primary, #fff) 30%, transparent); border-radius: 50%; background: transparent; cursor: pointer; transition: border-color var(--ov-dur-2, .2s) ease; }
+label.dd-depth input::before { content: ""; width: 10px; height: 10px; border-radius: 50%; background: var(--accent); transform: scale(0); transition: transform var(--ov-dur-2, .2s) var(--ov-ease-out, ease); }
+label.dd-depth input:checked { border-color: var(--accent); }
+label.dd-depth input:checked::before { transform: scale(1); }
+label.dd-depth input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+.dd-go { display: grid; justify-items: start; gap: 8px; }
+.dd-go-note { display: flex; align-items: center; gap: 7px; margin: 0; color: var(--text-muted); font-size: .88rem; }
+.dd-go-note svg { flex: none; width: 15px; height: 15px; }
+.dd-go-note { align-items: flex-start; }
+.dd-go-note svg { margin-top: 3px; }
+.dd > .dd-form + *, .dd > .dd-error + * { margin-top: 26px; }
@@ -409,3 +409,74 @@
@@ -409,3 +409,74 @@
.xs-q .ovk-btn { width: 48px; padding: 0; justify-content: center; }
.xs-q .ovk-btn { width: 48px; padding: 0; justify-content: center; }
}
}
@media (prefers-reduced-motion: reduce) { .xs-more > summary::after { transition: none; } }
@media (prefers-reduced-motion: reduce) { .xs-more > summary::after { transition: none; } }
+
+/* ── 11-12 design pass (the owner: "bad layout … add icons … improve"). ── */
+/* Why Exchange: three cards, each with its mark, its words and its way on. */
+.xp-features { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; margin: 24px 0; }
+.xp-feature { display: grid; align-content: start; gap: 8px; padding: 18px; border: 1px solid var(--ovk-line); border-radius: var(--ov-radius-2, 16px); background: var(--ovk-panel); }
+.xp-feature h3 { margin: 4px 0 0; font-size: 1.05rem; letter-spacing: -.01em; }
+.xp-feature p { margin: 0; color: var(--ovk-muted); line-height: 1.5; }
+.xp-feature-ic, .xpr-ic, .xp-q-ic, .xc-ic { display: inline-grid; place-items: center; flex: none; width: 38px; height: 38px; border-radius: 12px; color: var(--ovk-accent); background: color-mix(in srgb, var(--ovk-accent) 14%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ovk-accent) 22%, transparent); }
+.xp-feature-ic svg, .xpr-ic svg, .xp-q-ic svg, .xc-ic svg { width: 19px; height: 19px; }
+.xp-feature-a { display: inline-flex; align-items: center; gap: 6px; justify-self: start; margin-top: 4px; color: var(--ovk-accent); font-weight: 700; text-decoration: none; }
+.xp-feature-a svg { width: 15px; height: 15px; transition: transform var(--ov-dur-2, .2s) var(--ov-ease-out, ease); }
+.xp-feature-a:hover svg { transform: translateX(3px); }
+/* Pricing: each line has its mark. */
+.xpr-list li { grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 14px; }
+.xpr-t { display: grid; gap: 2px; min-width: 0; }
+.xpr-t > span { color: var(--ovk-muted); font-size: .92rem; }
+.xpr-list .xpr-ic { width: 34px; height: 34px; border-radius: 10px; }
+@media (min-width: 760px) { .xpr-list li { grid-template-columns: auto minmax(0, 1fr); } .xpr-t { grid-template-columns: minmax(200px, 1fr) 2fr; align-items: baseline; gap: 16px; } }
+/* Questions: an accordion, each with its mark and a chevron that turns. */
+.xp-faq { display: grid; gap: 8px; padding: 4px 14px 14px; }
+.ov-base .xp-q { margin: 0; padding: 0; border: 1px solid var(--ovk-line); border-radius: 14px; background: var(--ovk-panel-2, var(--ovk-panel)); overflow: hidden; }
+.xp-q > summary { display: flex; align-items: center; gap: 12px; min-height: 52px; padding: 8px 12px; list-style: none; cursor: pointer; font-weight: 700; }
+.xp-q > summary::-webkit-details-marker { display: none; }
+.xp-q .xp-q-ic { width: 32px; height: 32px; border-radius: 10px; }
+.xp-q-t { flex: 1; min-width: 0; }
+.xp-q-chev { display: grid; color: var(--ovk-muted); transition: transform var(--ov-dur-2, .2s) var(--ov-ease-out, ease); }
+.xp-q-chev svg { width: 18px; height: 18px; }
+.xp-q[open] .xp-q-chev { transform: rotate(180deg); }
+.xp-q-a { padding: 0 14px 14px 56px; color: var(--ovk-muted); }
+.xp-q-a p { margin: 0 0 6px; }
+.xp-q-a a { font-weight: 700; }
+
+/* /compute: cards by kind, the cheapest marked. */
+.xc-jump { display: flex; flex-wrap: wrap; gap: 8px; margin: 4px 0 18px; }
+.xc-jump a { display: inline-flex; align-items: center; gap: 7px; min-height: 40px; padding: 0 14px; border: 1px solid var(--ovk-line); border-radius: 999px; background: var(--ovk-panel); color: var(--ovk-text); font-weight: 650; font-size: .92rem; text-decoration: none; }
+.xc-jump a svg { width: 16px; height: 16px; color: var(--ovk-accent); }
+.xc-jump a:hover { border-color: color-mix(in srgb, var(--ovk-accent) 50%, var(--ovk-line)); }
+.xc-stale { padding: 10px 14px; border-radius: 12px; background: color-mix(in srgb, var(--ovk-warn, #f59e0b) 14%, transparent); color: var(--ovk-text); }
+.xc-group { margin: 0 0 26px; scroll-margin-top: calc(var(--ovnav-h, 56px) + 12px); }
+.ov-base .xc-group h2 { display: flex; align-items: center; gap: 10px; margin: 0 0 4px; font-size: 1.2rem; }
+.xc-group .xc-ic { width: 32px; height: 32px; border-radius: 10px; }
+.xc-n { display: inline-flex; align-items: center; height: 22px; padding: 0 8px; border-radius: 999px; background: color-mix(in srgb, var(--ovk-text) 8%, transparent); color: var(--ovk-muted); font-size: .76rem; font-weight: 700; }
+.xc-note { margin: 0 0 10px; color: var(--ovk-muted); font-size: .92rem; }
+.xc-list { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
+.ov-base .xc-list li + li { margin: 0; }
+.xc-row { display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; align-items: center; gap: 12px; padding: 12px 10px 12px 14px; border: 1px solid var(--ovk-line); border-radius: 14px; background: var(--ovk-panel); }
+.xc-mark { display: grid; place-items: center; width: 38px; height: 38px; border-radius: 11px; background: color-mix(in srgb, var(--ovk-accent) 16%, var(--ovk-panel-2, transparent)); color: var(--ovk-text); font-weight: 800; }
+.xc-t { display: grid; gap: 2px; min-width: 0; }
+.xc-t b { font-size: 1rem; }
+.xc-t small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ovk-muted); font-size: .85rem; }
+.xc-tags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }
+.xc-tag { padding: 2px 8px; border-radius: 999px; background: color-mix(in srgb, var(--ovk-text) 8%, transparent); color: var(--ovk-muted); font-size: .72rem; font-weight: 700; }
+.xc-tag.is-best { background: color-mix(in srgb, #22c55e 18%, transparent); color: #86efac; }
+.xc-tag.is-promo { background: color-mix(in srgb, var(--ovk-accent) 16%, transparent); color: var(--ovk-accent); }
+.xc-p { display: grid; justify-items: end; gap: 1px; text-align: right; font-variant-numeric: tabular-nums; }
+.xc-p b { font-size: 1.15rem; letter-spacing: -.01em; white-space: nowrap; }
+.xc-p b small { margin-left: 2px; color: var(--ovk-muted); font-size: .72rem; font-weight: 650; }
+.xc-p > small { color: var(--ovk-muted); font-size: .72rem; white-space: nowrap; }
+.xc-src { display: grid; place-items: center; width: 40px; height: 40px; border-radius: 10px; color: var(--ovk-muted); }
+.xc-src:hover { color: var(--ovk-text); background: color-mix(in srgb, var(--ovk-text) 6%, transparent); }
+.xc-src svg { width: 17px; height: 17px; }
+.xc-foot { margin-top: 18px; color: var(--ovk-muted); font-size: .9rem; }
+@media (max-width: 420px) { .xc-row { grid-template-columns: auto minmax(0, 1fr) auto; } .xc-src { display: none; } .xc-p > small { display: none; } }
+/* The older `.xpr-list span` (muted) must not reach the title or the mark. */
+.xpr-list .xpr-t { color: var(--ovk-text); }
+.xpr-list .xpr-t > span { color: var(--ovk-muted); }
+.xpr-list .xpr-ic { color: var(--ovk-accent); }
+.ovk-section > .xpr-note, .xpr > .xpr-note { margin: 12px 16px 14px; }
+/* The chevron points down, and up when open. */
+.xp-q-chev { transform: rotate(90deg); }
+.xp-q[open] .xp-q-chev { transform: rotate(-90deg); }
@@ -67,7 +67,10 @@ function load(env = process.env) {
@@ -67,7 +67,10 @@ function load(env = process.env) {
ai: {
ai: {
url: trim(env.OV_AI_URL || 'https://ai.openvibe.services'),
url: trim(env.OV_AI_URL || 'https://ai.openvibe.services'),
audience: env.OV_AI_AUDIENCE || 'openvibe.ai',
audience: env.OV_AI_AUDIENCE || 'openvibe.ai',
- scope: env.OV_AI_SCOPE || 'ai.run.create ai.run.read',
+ // Only what Network grants Exchange. Asking for ai.run.read too (#222) made Network refuse the whole token
+ // (invalid_scope), so chat, Try it, presets and the deep dive's service calls failed; AI's deep-dive reads
+ // accept ai.run.create.
+ scope: env.OV_AI_SCOPE || 'ai.run.create',
consoleScope: env.OV_AI_CONSOLE_SCOPE || 'ai.usage.read ai.workflow.manage ai.provider.manage',
consoleScope: env.OV_AI_CONSOLE_SCOPE || 'ai.usage.read ai.workflow.manage ai.provider.manage',
timeoutMs: Math.max(2000, int(env.EXCHANGE_AI_TIMEOUT_MS, 30_000)),
timeoutMs: Math.max(2000, int(env.EXCHANGE_AI_TIMEOUT_MS, 30_000)),
waitMs: Math.max(1000, int(env.EXCHANGE_AI_WAIT_MS, 25_000)),
waitMs: Math.max(1000, int(env.EXCHANGE_AI_WAIT_MS, 25_000)),
@@ -76,8 +76,7 @@ function createPageRoutes(ctx) {
@@ -76,8 +76,7 @@ function createPageRoutes(ctx) {
r.get('/compute', (req, res) => {
r.get('/compute', (req, res) => {
const { prices, checked_at } = require('../compute-prices');
const { prices, checked_at } = require('../compute-prices');
- const rows = prices.map(p => `<tr><td>${esc(p.provider)}</td><td>${esc(p.id)}</td><td>${esc(p.gpu || `${p.cpu} vCPU / ${p.ram_mb} MiB`)}</td><td>$${p.hourly.toFixed(6)}/hour</td><td>$${p.per_second.toFixed(9)}/second</td><td><a href="${esc(p.source)}">Provider price</a></td></tr>`).join('');
- return page(req,res,{title:'Compute prices',body:raw(`<main><h1>Compute prices</h1><p>Checked ${esc(checked_at)}. Base compute only; storage, network and plan fees may apply. Promotional balances are account-specific.</p><p><a href="/">Model prices</a> · <a href="https://openvibe.network/admin#ai">Owner provider settings</a></p><div style="overflow-x:auto"><table><thead><tr><th>Provider</th><th>Price ID</th><th>Resources</th><th>Hourly</th><th>Per second</th><th>Source</th></tr></thead><tbody>${rows}</tbody></table></div></main>`)});
+ return page(req, res, { title: 'Compute prices', description: 'What an hour of a computer or a GPU costs at Baseten, Daytona, E2B, Fly.io and RunPod: base compute, checked weekly.', body: raw(platform.computePage({ prices, checked_at })) });
});
});
// ── Exchange v1, retired ──────────────────────────────────
// ── Exchange v1, retired ──────────────────────────────────
@@ -39,11 +39,14 @@ function createSearchRoutes({ config, ai }) {
@@ -39,11 +39,14 @@ function createSearchRoutes({ config, ai }) {
},
},
)),
)),
};
};
- } catch {
- return {
- engine,
- error: 'This engine is unavailable or does not support this search kind.',
- };
+ } catch (err) {
+ const status = err && err.status;
+ console.warn('[exchange] search', engine, kind, status || '', (err && err.code) || '', (err && err.message) || '');
+ const why = status === 401 || status === 403 ? 'Your sign-in could not search. Sign out and in again.'
+ : status === 429 ? 'Too many searches this minute. Try again shortly.'
+ : status === 400 || status === 422 ? 'This engine does not take this kind of search.'
+ : 'This engine did not answer. Try the other one, or again in a minute.';
+ return { engine, error: why };
}
}
}),
}),
);
);
@@ -23,7 +23,7 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
@@ -23,7 +23,7 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const NAV = [
const NAV = [
{ label: 'Models', href: '/models', icon: 'fa-microchip' },
{ label: 'Models', href: '/models', icon: 'fa-microchip' },
{ label: 'Search', href: '/search', icon: 'fa-magnifying-glass' },
{ label: 'Search', href: '/search', icon: 'fa-magnifying-glass' },
- { label: 'Deep dive', href: '/deep-dive', icon: 'fa-binoculars' },
+ { label: 'Deep dive', href: '/deep-dive', icon: 'fa-compass' }, // fa-binoculars has no glyph: it drew nothing
{ label: 'Pricing', href: '/pricing', icon: 'fa-tag' },
{ label: 'Pricing', href: '/pricing', icon: 'fa-tag' },
{ label: 'Docs', href: '/docs', icon: 'fa-code' },
{ label: 'Docs', href: '/docs', icon: 'fa-code' },
{ label: 'Status', href: '/status', icon: 'fa-gauge' },
{ label: 'Status', href: '/status', icon: 'fa-gauge' },
@@ -103,6 +103,7 @@ function renderPage(o) {
@@ -103,6 +103,7 @@ function renderPage(o) {
${appIcon.headTags({ site: site.icon || 'exchange' })}
${appIcon.headTags({ site: site.icon || 'exchange' })}
<link rel="stylesheet" href="${ovServe.url('kit.css')}">
<link rel="stylesheet" href="${ovServe.url('kit.css')}">
<link rel="stylesheet" href="${ovServe.url('kit-data.css')}">
<link rel="stylesheet" href="${ovServe.url('kit-data.css')}">
+<link rel="stylesheet" href="${ovServe.url('kit-layout.css')}">
<link rel="stylesheet" href="${ovServe.url('room.css')}">
<link rel="stylesheet" href="${ovServe.url('room.css')}">
<link rel="stylesheet" href="${ovServe.url('showcase.css')}">
<link rel="stylesheet" href="${ovServe.url('showcase.css')}">
<link rel="stylesheet" href="${asset('css/exchange.css')}">
<link rel="stylesheet" href="${asset('css/exchange.css')}">
@@ -117,7 +118,7 @@ ${o.head || ''}
@@ -117,7 +118,7 @@ ${o.head || ''}
body: `<a class="skip" href="#main">Skip to content</a>
body: `<a class="skip" href="#main">Skip to content</a>
<div id="navbar-mount"></div>
<div id="navbar-mount"></div>
<noscript><div class="account-bar" role="navigation" aria-label="Account">${account}</div></noscript>
<noscript><div class="account-bar" role="navigation" aria-label="Account">${account}</div></noscript>
-<main id="main" class="ex ov-room ${o.class || ''}" style="${rooms.style('exchange')}">
+<main id="main" class="ex ov-room ov-base ${o.class || ''}" style="${rooms.style('exchange')}">
${render(o.body)}
${render(o.body)}
</main>
</main>
<script>
<script>
@@ -82,7 +82,7 @@ function homePage({ viewer, depths = [], capture = null, error = null }) {
@@ -82,7 +82,7 @@ function homePage({ viewer, depths = [], capture = null, error = null }) {
<label class="dd-field"><span>What are you designing?</span><textarea name="question" rows="3" maxlength="500" required placeholder="a phone-first robot control panel"></textarea></label>
<label class="dd-field"><span>What are you designing?</span><textarea name="question" rows="3" maxlength="500" required placeholder="a phone-first robot control panel"></textarea></label>
${depthCards(cards)}
${depthCards(cards)}
<label class="dd-field"><span>Sites you already know (optional, one per line)</span><textarea name="sites" rows="2" maxlength="2048" placeholder="https://example.com/panel"></textarea></label>
<label class="dd-field"><span>Sites you already know (optional, one per line)</span><textarea name="sites" rows="2" maxlength="2048" placeholder="https://example.com/panel"></textarea></label>
-<p class="dd-go">${kit.button({ label: 'Start the deep dive', type: 'submit' })}<span class="ovk-note">The price is held before it starts; you can cancel at any step.</span></p>
+<div class="dd-go">${kit.button({ label: 'Start the deep dive', type: 'submit' })}<p class="dd-go-note">${require('openvibe-shared/glyphs').svg('lock')}The price is held before it starts; you can cancel at any step.</p></div>
</form>${error ? `<p class="dd-error" role="alert">${esc(error)}</p>` : ''}`
</form>${error ? `<p class="dd-error" role="alert">${esc(error)}</p>` : ''}`
: signIn();
: signIn();
return `<div class="ex-wrap dd">${kit.head({ title: 'Deep dive', sub: 'Expert design research: we search everywhere, capture the best sites at phone and computer sizes, and tell you what works and why.' })}${form}${signedIn ? '' : `<section class="ovk-section"><h2>The depths and their prices</h2>${depthList(cards)}</section>`}`
return `<div class="ex-wrap dd">${kit.head({ title: 'Deep dive', sub: 'Expert design research: we search everywhere, capture the best sites at phone and computer sizes, and tell you what works and why.' })}${form}${signedIn ? '' : `<section class="ovk-section"><h2>The depths and their prices</h2>${depthList(cards)}</section>`}`
@@ -82,18 +82,75 @@ function table(models, searchMeta = null) {
@@ -82,18 +82,75 @@ function table(models, searchMeta = null) {
}).join('')}</ul>`;
}).join('')}</ul>`;
}
}
// What each way of calling costs, in the reader's words (home's Pricing section and /pricing). Opus, 11-09.
// What each way of calling costs, in the reader's words (home's Pricing section and /pricing). Opus, 11-09.
+const glyph = (name) => require('openvibe-shared/glyphs').svg(name);
const PRICING = [
const PRICING = [
- ['Your own provider key', "The provider's price. OpenVibe adds 0%, with no cap and no end date."],
- ['Managed models', 'The input and output price per million tokens shown on each model, before you call.'],
- ["Open models on OpenVibe's GPUs", 'A fixed price per million tokens, the same on every call. Coming next.'],
- ['Your own endpoint', 'An hourly price, quoted before it starts; it stops when idle.'],
- ['Search', "Each engine's price per 1,000 searches; the free engines first."],
+ ['key', 'Your own provider key', "The provider's price. OpenVibe adds 0%, with no cap and no end date."],
+ ['coin', 'Managed models', 'The input and output price per million tokens shown on each model, before you call.'],
+ ['chip', "Open models on OpenVibe's GPUs", 'A fixed price per million tokens, the same on every call. Coming next.'],
+ ['server', 'Your own endpoint', 'An hourly price, quoted before it starts; it stops when idle.'],
+ ['search', 'Search', "Each engine's price per 1,000 searches; the free engines first."],
];
];
-const pricingRows = () => `<ul class="xpr-list">${PRICING.map(([name, what]) => `<li><strong>${esc(name)}</strong><span>${esc(what)}</span></li>`).join('')}</ul>`
+const pricingRows = () => `<ul class="xpr-list">${PRICING.map(([icon, name, what]) => `<li><span class="xpr-ic" aria-hidden="true">${glyph(icon)}</span><span class="xpr-t"><strong>${esc(name)}</strong><span>${esc(what)}</span></span></li>`).join('')}</ul>`
+ '<p class="xpr-note">Paid models open soon. Every price shows before you call, and nothing is charged until then.</p>';
+ '<p class="xpr-note">Paid models open soon. Every price shows before you call, and nothing is charged until then.</p>';
function pricing() {
function pricing() {
return section('Pricing', pricingRows());
return section('Pricing', pricingRows());
}
}
+// Why Exchange: three cards, each with its mark, its words and the page that says more (the owner, 11-12: the
+// reasons read as bare text flush against their boxes).
+const REASONS = [
+ ['key', '0% fee on your own key', "Bring your provider key and pay the provider's price, nothing more. No cap, no end date.", '/dashboard/keys', 'Add a key'],
+ ['route', 'The cheapest route', "Every call goes to the cheapest provider that is up. Open models on OpenVibe's own GPUs, at a fixed price per million tokens, are coming next.", '/compute', 'Compute prices'],
+ ['shield', 'Uncensored, 18+', 'Uncensored models for adults who opt in. The content floor applies on every request.', '/uncensored', 'What the floor covers'],
+];
+function reasons() {
+ return `<section class="xp-features" aria-label="Why OpenVibe.Exchange">${REASONS.map(([icon, title, text, href, label]) =>
+ `<article class="xp-feature"><span class="xp-feature-ic" aria-hidden="true">${glyph(icon)}</span><h3>${esc(title)}</h3><p>${esc(text)}</p><a class="xp-feature-a" href="${href}">${esc(label)}<span aria-hidden="true">${glyph('arrow-right')}</span></a></article>`).join('')}</section>`;
+}
+const QUESTIONS = [
+ ['code', 'Can I use my existing client?', 'Yes. Point any OpenAI-compatible client at the Exchange endpoint with your Exchange key; Anthropic-style clients work through /v1/messages.', '/docs', 'Read the docs'],
+ ['chat', 'Where can I talk to a model?', 'In Vibe on OpenVibe.Chat: talk to any model here, or two side by side.', 'https://openvibe.chat/vibe', 'Open Vibe'],
+ ['chart', 'Where do the numbers come from?', 'Prices come from the live catalogue. Speed and reliability come from measured requests. A number nobody has measured stays unknown, never guessed.', '/status', 'See the status'],
+];
+function questions() {
+ return section('Questions', `<div class="xp-faq">${QUESTIONS.map(([icon, q, a, href, label]) =>
+ `<details class="xp-q"><summary><span class="xp-q-ic" aria-hidden="true">${glyph(icon)}</span><span class="xp-q-t">${esc(q)}</span><span class="xp-q-chev" aria-hidden="true">${glyph('chevron')}</span></summary><div class="xp-q-a"><p>${esc(a)}</p><a href="${href}">${esc(label)}</a></div></details>`).join('')}</div>`);
+}
+
+// /compute: what an hour of a computer or a GPU costs at each provider (the owner, 11-12: the raw table ran off a
+// phone; "this compute list should be on openvibe.compare … or at least interconnected"). Cards by kind, the cheapest
+// marked, the provider's own price page one tap away; the same list is GET /api/compute/prices, and OpenVibe.Compare
+// compares it.
+const PROVIDER = { baseten: 'Baseten', daytona: 'Daytona', e2b: 'E2B', fly: 'Fly.io', runpod: 'RunPod', modal: 'Modal', vast: 'Vast.ai', lambda: 'Lambda' };
+const REGION = { 'us-pdx-1': 'US West', 'eu-lon-1': 'London', us: 'US', eu: 'EU', iad: 'Virginia', ewr: 'New Jersey', 'provider-default': "The provider's region" };
+const gb = (mb) => `${Math.round((Number(mb) || 0) / 1024)} GB`;
+const perHour = (n) => `$${Number(n).toFixed(n < 0.1 ? 3 : 2)}`;
+function ago(iso, now) {
+ const days = Math.floor((now - Date.parse(iso)) / 86400000);
+ return !Number.isFinite(days) ? 'recently' : days <= 0 ? 'today' : days === 1 ? 'yesterday' : `${days} days ago`;
+}
+function computePage({ prices = [], checked_at, now = Date.now() }) {
+ const kinds = [['gpu', 'GPUs', 'chip', 'For models and anything that needs a graphics card.'], ['cpu', 'Computers', 'server', 'Sandboxes and desks: two cores and a few GB of memory.']];
+ const stale = prices.some((p) => p.expires_at && now > p.expires_at);
+ const row = (p, cheapest) => {
+ const name = PROVIDER[p.provider] || p.provider;
+ const what = p.gpu ? `${p.gpu} · ${gb(p.gpu_mb)}` : `${p.cpu} vCPU · ${gb(p.ram_mb)} memory`;
+ const where = REGION[p.region] || p.region || '';
+ const tags = [cheapest ? '<span class="xc-tag is-best">Cheapest</span>' : '', p.interruptible ? '<span class="xc-tag">Can be interrupted</span>' : '', p.promotion ? `<span class="xc-tag is-promo" title="${esc(p.promotion)}">Offer</span>` : ''].join('');
+ return `<li class="xc-row"><span class="xc-mark" aria-hidden="true">${esc(name.charAt(0))}</span><span class="xc-t"><b>${esc(name)}</b><small>${esc([what, where].filter(Boolean).join(' · '))}</small>${tags ? `<span class="xc-tags">${tags}</span>` : ''}</span>`
+ + `<span class="xc-p"><b>${perHour(p.hourly)}<small>/hour</small></b><small>$${Number(p.per_second).toFixed(6)}/second</small></span>`
+ + `<a class="xc-src" href="${esc(p.source)}" rel="noopener nofollow" target="_blank" aria-label="${esc(name)}'s price page">${glyph('open')}</a></li>`;
+ };
+ const groups = kinds.map(([kind, title, icon, note]) => {
+ const list = prices.filter((p) => (p.kind || (p.gpu ? 'gpu' : 'cpu')) === kind).sort((a, b) => a.hourly - b.hourly);
+ if (!list.length) return '';
+ return `<section class="xc-group" id="${kind}"><h2><span class="xc-ic" aria-hidden="true">${glyph(icon)}</span>${esc(title)} <span class="xc-n">${list.length}</span></h2><p class="xc-note">${esc(note)}</p><ul class="xc-list">${list.map((p, i) => row(p, i === 0)).join('')}</ul></section>`;
+ }).join('');
+ return `<div class="ex-wrap xc">${kit.head({ title: 'Compute prices', sub: `What an hour of a computer or a GPU costs at each provider, checked ${ago(checked_at, now)}.` })}`
+ + `<nav class="xc-jump" aria-label="Kinds"><a href="#gpu">${glyph('chip')}GPUs</a><a href="#cpu">${glyph('server')}Computers</a><a href="https://openvibe.compare/compute">${glyph('scale')}Compare them on OpenVibe.Compare</a></nav>`
+ + (stale ? '<p class="xc-stale" role="status">Some of these prices are past their review date: check the provider before you rely on one.</p>' : '')
+ + groups
+ + `<p class="xc-foot">Base compute only: storage, network and plan fees may apply, and offers are per account. The same list is <a href="/api/compute/prices">GET /api/compute/prices</a> (JSON, open to anyone). <a href="/">Model prices</a></p></div>`;
+}
/** /pricing: the page, with its head (it had no h1). */
/** /pricing: the page, with its head (it had no h1). */
function pricingPage() {
function pricingPage() {
return `<div class="ex-wrap xp">${kit.head({ title: 'Pricing', sub: 'What a call costs, before you make it.' })}<section class="xpr">${pricingRows()}</section></div>`;
return `<div class="ex-wrap xp">${kit.head({ title: 'Pricing', sub: 'What a call costs, before you make it.' })}<section class="xpr">${pricingRows()}</section></div>`;
@@ -117,7 +174,7 @@ function home({ catalog, usage, viewer }) {
@@ -117,7 +174,7 @@ function home({ catalog, usage, viewer }) {
'median time to first token',
'median time to first token',
],
],
],
],
- )}${section('Popular models', table([...models].sort((a, b) => (b.requests_7d || 0) - (a.requests_7d || 0)).slice(0, 10)))}<div class="xp-reasons">${section('0% fee on your own key', '<p>Bring your provider key and pay the provider\'s price, nothing more. No cap, no end date.</p>')}${section('The cheapest route', '<p>Every call goes to the cheapest provider that is up. Open models on OpenVibe\'s own GPUs, at a fixed price per million tokens, are coming next.</p>')}${section('Uncensored, 18+', '<p>Uncensored models for adults who opt in. The content floor applies on every request.</p><a href="/uncensored">What the floor covers</a>')}</div>${pricing()}${section('Questions', '<details><summary>Can I use my existing client?</summary><p>Yes. Use the OpenAI-compatible endpoint with your Exchange key.</p></details><details><summary>Where can I talk to a model?</summary><p><a href="https://openvibe.chat/vibe">Open Chat</a> to talk or compare models.</p></details><details><summary>Where do the numbers come from?</summary><p>Prices come from the live catalogue. Speed and reliability come from measured requests. Missing measurements stay unknown.</p></details>')}</div>`;
+ )}${section('Popular models', table([...models].sort((a, b) => (b.requests_7d || 0) - (a.requests_7d || 0)).slice(0, 10)))}${reasons()}${pricing()}${questions()}</div>`;
}
}
function modelsPage({ catalog, query = {}, searchResults = null }) {
function modelsPage({ catalog, query = {}, searchResults = null }) {
const all = listed(catalog);
const all = listed(catalog);
@@ -186,4 +243,4 @@ function modelsPage({ catalog, query = {}, searchResults = null }) {
@@ -186,4 +243,4 @@ function modelsPage({ catalog, query = {}, searchResults = null }) {
const sub = rows.length ? `${rows.length} to call now · live prices, measured speed` : 'None to call now match · the registry is below';
const sub = rows.length ? `${rows.length} to call now · live prices, measured speed` : 'None to call now match · the registry is below';
return `<div class="ex-wrap xp xm-page">${kit.head({ title: 'Models', sub })}${unavailable(catalog)}<div class="xm-find">${filters}<nav class="ex-chips" aria-label="Model filters">${ordered.join('')}</nav></div>${table(rows, searchMeta)}<p class="ex-more"><a href="https://openvibe.compare/">Compare models side by side on OpenVibe.Compare →</a></p></div>`;
return `<div class="ex-wrap xp xm-page">${kit.head({ title: 'Models', sub })}${unavailable(catalog)}<div class="xm-find">${filters}<nav class="ex-chips" aria-label="Model filters">${ordered.join('')}</nav></div>${table(rows, searchMeta)}<p class="ex-more"><a href="https://openvibe.compare/">Compare models side by side on OpenVibe.Compare →</a></p></div>`;
}
}
-module.exports = { home, modelsPage, pricing, pricingPage, table };
+module.exports = { computePage, home, modelsPage, pricing, pricingPage, table };
new file mode 100644
new file mode 100644
@@ -0,0 +1,54 @@
@@ -0,0 +1,54 @@
+'use strict';
+/**
+ * The 11-12 design pass and fixes (the owner, phone screenshots of openvibe.exchange: search "broken", the compute list
+ * a raw table off the screen, the home's reasons "bad layout … add icons … improve", the deep dive's boxes unstyled, a
+ * broken icon in the menu).
+ * node test/exchange-polish-1112.test.js
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const glyphs = require('openvibe-shared/glyphs');
+
+const read = (f) => fs.readFileSync(path.join(__dirname, '..', f), 'utf8');
+
+// The service token asks only for what Network grants Exchange (ai.run.read made Network refuse the whole token).
+const config = require('../server/config').load({});
+assert.strictEqual(config.ai.scope, 'ai.run.create');
+
+// Every menu icon is a glyph that exists (fa-binoculars drew nothing for Deep dive).
+const { NAV } = require('../server/render/layout');
+for (const item of NAV) if (item.icon) assert.ok(glyphs.fromFa(item.icon), `${item.label}: ${item.icon} has a glyph`);
+// Plain elements get the themed base.
+const layout = read('server/render/layout.js');
+assert.ok(/class="ex ov-room ov-base /.test(layout) && /kit-layout\.css/.test(layout), 'main carries .ov-base and its stylesheet');
+
+// Search says why an engine did not answer.
+const search = read('server/http/search.js');
+assert.ok(!/This engine is unavailable or does not support this search kind/.test(search), 'no catch-all message');
+assert.ok(/status === 401 \|\| status === 403/.test(search) && /status === 429/.test(search), 'a reason by status');
+
+// /compute: cards by kind, cheapest first and marked, the provider's page linked, no table; the JSON stays.
+const platform = require('../server/views/platform');
+const { prices, checked_at } = require('../server/compute-prices');
+const html = platform.computePage({ prices, checked_at, now: Date.parse(checked_at) + 86400000 });
+assert.ok(!/<table/.test(html), 'no table');
+assert.match(html, /checked yesterday/);
+const gpu = html.slice(html.indexOf('id="gpu"'), html.indexOf('id="cpu"'));
+const hourly = [...gpu.matchAll(/\$([0-9.]+)<small>\/hour/g)].map((m) => Number(m[1]));
+assert.deepStrictEqual(hourly, [...hourly].sort((a, b) => a - b), 'cheapest first');
+assert.ok(gpu.indexOf('is-best') > 0 && gpu.indexOf('is-best') < gpu.indexOf('xc-row', gpu.indexOf('xc-row') + 1), 'the first is marked Cheapest');
+assert.match(html, /openvibe\.compare\/compute/, 'linked to OpenVibe.Compare');
+assert.match(html, /\/api\/compute\/prices/, 'the same list as JSON');
+assert.match(read('server/app.js'), /app\.get\('\/api\/compute\/prices'/);
+const stale = platform.computePage({ prices, checked_at, now: Date.parse('2027-01-01') });
+assert.match(stale, /past their review date/, 'old prices say so');
+
+// The home: three reason cards with marks and links, pricing lines with marks, questions as an accordion.
+const home = read('server/views/platform.js');
+assert.ok(/class="xp-features"/.test(home) && /xp-feature-ic/.test(home) && /class="xp-faq"/.test(home) && /xpr-ic/.test(home));
+
+// The deep dive's note sits under its button.
+assert.match(read('server/views/deep-dive.js'), /<p class="dd-go-note">/);
+assert.match(read('public/css/exchange.css'), /label\.dd-depth input \{ appearance: none;/, 'the dot is drawn');
+console.log('exchange-polish-1112: scope, menu glyphs, the base, search reasons, /compute cards, the home, the deep dive');