openvibers/openvibe.exchange Commits

Models and Search, designed: a row per model, engines from AI by kind with their prices (#152)

OP OpenVibers committed 4 days ago · 459cf48991 · 6 files, +178 −24

Models (home's popular models and /models): one row per model, its name and maker with badges,
in / out per million, uptime and speed labelled (a dash where nothing is measured, never three
'Not measured' a row), Try in Chat and How it ranks as quiet buttons; a grid row on a computer.
A filter with nothing behind it is muted and not a link.
Search: the engines are AI's own (GET /api/v1/exchange/search/engines, cached), grouped Web,
Pictures, Icons, each with what it is, its price per 1,000 (Free when free) and Ready / Your key /
Not set up yet; Try two engines is a card with the results side by side. Copy speaks to the reader.
On a phone nothing widens the page and every target is 44 px.

Changes

ddiff --git a/public/css/platform.css b/public/css/platform.css
iindex a512e9e..c292b3a 100644
---- a/public/css/platform.css
++++ b/public/css/platform.css
@@@ -226,3 +226,61 @@
.ex-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.ex-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 0; }
++
++/* ── The model rows (home's popular models, /models): Opus, 11-09 ── */
++.xm-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
++.xm-row { display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; padding: 14px 16px; border: 1px solid var(--ovk-line); border-radius: var(--ov-radius-2, 16px); background: var(--ovk-panel); }
++.xm-name { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; min-width: 0; }
++.xm-name > a { font-weight: 700; font-size: 1.05rem; color: var(--ovk-text); text-decoration: none; min-height: 0; }
++.xm-name > a:hover { text-decoration: underline; }
++.xm-maker { color: var(--ovk-muted); font-size: .86rem; }
++.xm-badges { display: inline-flex; flex-wrap: wrap; gap: 6px; }
++.xm-facts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin: 0; }
++.xm-facts > div { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
++.xm-facts dt { color: var(--ovk-muted); font-size: .76rem; }
++.xm-facts dd { margin: 0; font-weight: 600; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
++.xm-acts { display: flex; flex-wrap: wrap; gap: 8px; margin: 0; }
++.xm-acts .ovk-btn { min-height: 40px; }
++.xm-empty { color: var(--ovk-muted); }
++.ex-chip.is-empty { opacity: .45; cursor: default; }
++@media (min-width: 900px) {
++ .xm-row { grid-template-columns: minmax(220px, 1.3fr) minmax(0, 2fr) auto; align-items: center; gap: 16px; }
++}
++
++/* ── /search: engines by kind, the try form as a card, results side by side ── */
++.xs { padding-block: 32px 48px; display: grid; gap: 22px; }
++.xs-group h2, .xs-try h2 { display: flex; align-items: center; gap: 8px; margin: 0 0 10px; font-size: 1.12rem; }
++.xs-engines { list-style: none; margin: 0; padding: 0; border: 1px solid var(--ovk-line); border-radius: var(--ov-radius-2, 16px); background: var(--ovk-panel); overflow: hidden; }
++.xs-engine { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 4px 12px; padding: 12px 16px; border-top: 1px solid var(--ovk-line); }
++.xs-engine:first-child { border-top: 0; }
++.xs-engine.is-off { color: var(--ovk-muted); }
++.xs-engine-main { display: grid; gap: 2px; min-width: 0; }
++.xs-engine-main span { color: var(--ovk-muted); font-size: .86rem; }
++.xs-engine-price { font-variant-numeric: tabular-nums; font-weight: 600; white-space: nowrap; }
++.xs-try { padding: 18px; border: 1px solid var(--ovk-line); border-radius: var(--ov-radius-2, 16px); background: var(--ovk-panel); }
++.xs-form { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px 14px; align-items: end; }
++.xs-field { display: grid; gap: 6px; min-width: 0; }
++.xs-field > span { font-weight: 600; font-size: .9rem; }
++.xs-field--wide { grid-column: 1 / -1; }
++.xs-field input, .xs-field select { width: 100%; min-height: 44px; box-sizing: border-box; padding: 10px 12px; border: 1px solid var(--ovk-line); border-radius: 10px; background: var(--ovk-bg, transparent); color: var(--ovk-text); font: inherit; }
++.xs-go { grid-column: 1 / -1; margin: 0; }
++.xs-results { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; margin-top: 16px; }
++.xs-result { min-width: 0; padding: 12px 14px; border-radius: 12px; background: var(--ovk-panel-2); }
++.xs-result h3 { margin: 0 0 2px; font-size: 1rem; }
++.xs-result ol { margin: 8px 0 0; padding-left: 18px; display: grid; gap: 8px; }
++.xs-result li p { margin: 2px 0 0; color: var(--ovk-muted); font-size: .86rem; overflow-wrap: anywhere; }
++.xs-receipt, .xs-note { margin: 0; color: var(--ovk-muted); font-size: .86rem; }
++@media (max-width: 760px) {
++ .xs { padding-block: 20px 36px; }
++ .xs-engine { grid-template-columns: minmax(0, 1fr) auto; }
++ .xs-engine > .ovk-badge { grid-column: 2; }
++ .xs-form, .xs-results { grid-template-columns: minmax(0, 1fr); }
++ .xs-try { padding: 14px; }
++}
++/* Grid children never stretch the page for a long line (the head's subtitle widened /search to 514 px on a phone). */
++.xs > * { min-width: 0; }
++@media (max-width: 760px) {
++ /* 44 px targets on a phone: a model's name and its two buttons. */
++ .xm-name > a { display: inline-flex; align-items: center; min-height: 44px; }
++ .xm-acts .ovk-btn { min-height: 44px; }
++}
ddiff --git a/server/ai.js b/server/ai.js
iindex 5d44f91..33f6ade 100644
---- a/server/ai.js
++++ b/server/ai.js
@@@ -87,6 +87,21 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = (
}
}
++ /** AI's search engines (GET /api/v1/exchange/search/engines, public): ids, kinds, prices, availability. */
++ let enginesHit = { at: 0, value: null };
++ async function searchEngines() {
++ if (enginesHit.value && now() - enginesHit.at < CATALOG_MS) return enginesHit.value;
++ try {
++ const body = await get('/api/v1/exchange/search/engines', 'search engines');
++ const value = Array.isArray(body.engines) ? body.engines : [];
++ enginesHit = { at: now(), value };
++ return value;
++ } catch (err) {
++ warned('search engines', err);
++ return enginesHit.value || [];
++ }
++ }
++
async function usage(days = 30) {
const key = String(days);
const hit = usageHit.get(key);
@@@ -313,7 +328,7 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = (
return createCredentialsClient(client, { baseUrl: config.ai.url });
}
-- return { catalog, usage, communityOffers, chat, presetsFor, setKeyPrivacy, ownedProjects, gpuQuote, ownStatus, ownList, ownCreate, ownStop, adminRequest, meRequest, credentialsFor, configured: Boolean(tokens) };
++ return { catalog, searchEngines, usage, communityOffers, chat, presetsFor, setKeyPrivacy, ownedProjects, gpuQuote, ownStatus, ownList, ownCreate, ownStop, adminRequest, meRequest, credentialsFor, configured: Boolean(tokens) };
}
/**
ddiff --git a/server/http/search.js b/server/http/search.js
iindex 405fc8e..6e2db9d 100644
---- a/server/http/search.js
++++ b/server/http/search.js
@@@ -1,30 +1,22 @@
'use strict';
const express = require('express');
--const kit = require('openvibe-shared/kit');
--const { esc, raw } = require('../render/html');
++const views = require('../views/search');
++const { raw } = require('../render/html');
const { send } = require('../render/layout');
const { sameOrigin } = require('./principal');
function createSearchRoutes({ config, ai }) {
const r = express.Router();
r.use(express.urlencoded({ extended: false, limit: '8kb' }));
-- const engines = [
-- ['openvibe', 'OpenVibe Search', 'web'],
-- ['searxng', 'SearXNG', 'web · requires an operator instance'],
-- ['openverse', 'Openverse', 'images · CC licences'],
-- ['commons', 'Wikimedia Commons', 'images · attribution'],
-- ['iconify', 'Iconify', 'icons · SVG'],
-- ];
-- const render = (req, res, results = [], status = 200) =>
++ const render = async (req, res, found = [], query = {}, status = 200) =>
send(res, status, {
viewer: req.viewer,
config,
path: req.originalUrl,
title: 'Search',
-- body: raw(
-- `<div class="ex-wrap xp">${kit.head({ title: 'One search API, many engines', sub: 'One key for models, web search, images and icons.' })}<div class="xp-reasons">${engines.map(([id, name, kind]) => kit.section({ title: name, body: `<p>${esc(kind)}</p><p>$0 per 1,000 · no account required</p><p>Freshness: index dependent · BYOK: not required</p>` })).join('')}</div><h2>Try two engines</h2><form method="post" action="/search"><label>Query<input name="q" required maxlength="500"></label><label>Kind<select name="kind"><option>web</option><option>images</option><option>icons</option></select></label>${['first', 'second'].map((k, i) => `<label>${i ? 'Second' : 'First'} engine<select name="${k}">${engines.map(([id, name]) => `<option value="${id}">${name}</option>`).join('')}</select></label>`).join('')}${kit.button({ label: 'Compare results', type: 'submit' })}</form><div class="xp-search-results">${results.map((x) => `<section><h3>${esc(x.engine)}</h3>${x.error ? `<p>${esc(x.error)}</p>` : `<p>$${Number(x.receipt.cost_usd)} · ${Number(x.receipt.time_ms)} ms</p><ol>${x.results.map((r) => `<li><a href="${esc(r.source_page || r.url)}" rel="noopener noreferrer">${esc(r.title)}</a><p>${esc(r.snippet || r.attribution || '')}</p></li>`).join('')}</ol>`}</section>`).join('')}</div><p>Image search powered by Openverse and Wikimedia Commons. Keep the returned attribution with each image.</p><p>Paid engines and BYOK providers will become available after their owner accounts and prices are configured.</p></div>`,
-- ),
++ description: 'One search API for the web, pictures and icons: many engines, one key, a receipt with every answer.',
++ body: raw(views.searchPage({ engines: await ai.searchEngines(), found, query })),
});
-- r.get('/search', (req, res) => render(req, res));
++ r.get('/search', (req, res) => render(req, res).catch((err) => res.status(500).end(String(err && err.message || err))));
r.post('/search', async (req, res) => {
if (req.viewer?.kind !== 'user')
return res.redirect(303, '/auth/login?next=%2Fsearch');
@@@ -54,7 +46,7 @@ function createSearchRoutes({ config, ai }) {
}
}),
);
-- render(req, res, results);
++ await render(req, res, results, { q: String(req.body.q || '').slice(0, 500), kind });
});
return r;
}
ddiff --git a/server/views/platform.js b/server/views/platform.js
iindex 308931d..f9ed531 100644
---- a/server/views/platform.js
++++ b/server/views/platform.js
@@@ -37,15 +37,27 @@ const code = (m) =>
id: 'platform-curl',
code: `curl https://api.openvibe.exchange/v1/chat/completions \\\n -H "Authorization: Bearer $OPENVIBE_KEY" \\\n -H "Content-Type: application/json" \\\n -d '${JSON.stringify({ model: m || 'openvibe/auto', messages: [{ role: 'user', content: 'Hello' }] })}'`,
});
++/** "$0.05" / "$0.40": prices per million tokens in a person's terms (free shows as Free). */
++const perM = (v) => (v == null ? '–' : Number(v) === 0 ? 'Free' : `$${Number(v).toLocaleString('en-US', { maximumFractionDigits: 4 })}`);
++const dash = (v, unit = '') => (v == null ? '–' : `${Number(v).toLocaleString('en-US', { maximumFractionDigits: 2 })}${unit}`);
++/**
++ * The models as rows (home's popular models and /models): the name and its maker with badges, in / out per million,
++ * uptime and speed with their labels, and Try in Chat / How it ranks as small quiet buttons. Opus, 11-09.
++ */
function table(models) {
-- return `<div class="xp-models"><table><thead><tr><th>Model</th><th>Input / output per million</th><th>Tokens/s</th><th>Uptime</th><th>Explore</th></tr></thead><tbody>${models
-- .map((m) => {
-- const o = best(m);
-- return `<tr><td><a href="/models/${encodeURIComponent(id(m))}"><strong>${esc(m.name)}</strong></a><small>${esc(legacy.familyLabel(m))}${(m.offers || []).some(free) ? ' · Free to try' : (m.offers || []).some(paused) ? ' · Free paused' : ''}${m.uncensored ? ' · 18+' : ''}</small></td><td data-label="Input / output per million">${price(o.price?.in)} / ${price(o.price?.out)}${o.fixed_price ? ' · Fixed' : ''}</td><td data-label="Tokens/s">${metric(o.speed?.tokens_per_second)}</td><td data-label="Uptime">${metric(o.uptime == null ? null : o.uptime * 100, '%')}</td><td><a href="${chat(m)}">Try in Chat</a><a href="${rank(m)}">How it ranks →</a></td></tr>`;
-- })
-- .join(
-- '',
-- )}</tbody></table>${!models.length ? '<p>No models match these filters.</p>' : ''}</div>`;
++ if (!models.length) return '<p class="xm-empty">No models match these filters.</p>';
++ return `<ul class="xm-list">${models.map((m) => {
++ const o = best(m);
++ const badges = [
++ (m.offers || []).some(free) ? kit.badge('Free to try') : (m.offers || []).some(paused) ? kit.badge('Free paused') : '',
++ m.uncensored ? kit.badge('18+', 'bad') : '',
++ o.fixed_price ? kit.badge('Fixed price') : '',
++ ].filter(Boolean).join(' ');
++ const fact = (label, value) => `<div><dt>${esc(label)}</dt><dd>${esc(value)}</dd></div>`;
++ return `<li class="xm-row"><div class="xm-name"><a href="/models/${encodeURIComponent(id(m))}">${esc(m.name)}</a><span class="xm-maker">${esc(legacy.familyLabel(m))}</span>${badges ? `<span class="xm-badges">${badges}</span>` : ''}</div>`
++ + `<dl class="xm-facts">${fact('In / out per M', `${perM(o.price?.in)} / ${perM(o.price?.out)}`)}${fact('Uptime', o.uptime == null ? '–' : `${dash(o.uptime * 100)}%`)}${fact('Speed', o.speed?.tokens_per_second == null ? '–' : `${dash(o.speed.tokens_per_second)} tok/s`)}</dl>`
++ + `<p class="xm-acts">${kit.button({ href: chat(m), label: 'Try in Chat', kind: 'quiet', small: true })} ${kit.button({ href: rank(m), label: 'How it ranks', kind: 'quiet', small: true })}</p></li>`;
++ }).join('')}</ul>`;
}
function pricing() {
return section(
@@@ -95,6 +107,7 @@ function modelsPage({ catalog, query = {} }) {
);
if (q.get(key) === value) q.delete(key);
else q.set(key, value);
++ if (!count && query[key] !== value) return `<span class="ex-chip is-empty" aria-disabled="true">${esc(label)} (0)</span>`;
return `<a class="ex-chip ${query[key] === value ? 'is-on' : ''}" href="/models?${esc(q.toString())}">${esc(label)} (${count})</a>`;
};
for (const [key, test] of Object.entries(tests))
ddiff --git a/server/views/search.js b/server/views/search.js
nnew file mode 100644
iindex 0000000..cde200a
---- /dev/null
++++ b/server/views/search.js
@@@ -0,0 +1,66 @@
++'use strict';
++/**
++ * /search (Opus, 11-09): the engines AI has, grouped by what they find (web, pictures, icons), each with what it is,
++ * its price per 1,000 searches and whether it works now; then "Try two engines" side by side. The engine list is
++ * AI's own (GET /api/v1/exchange/search/engines), so a new engine shows here the day AI adds it.
++ */
++const kit = require('openvibe-shared/kit');
++const { esc } = require('../render/html');
++
++// What each engine is, in a line; an engine AI adds before this list knows it shows its id and no line.
++const ENGINES = {
++ openvibe: ['OpenVibe Search', "OpenVibe's own index of the network's pages."],
++ searxng: ['SearXNG', 'Many search engines at once, through our own instance.'],
++ openverse: ['Openverse', 'Openly licensed pictures, with their licence and author.'],
++ commons: ['Wikimedia Commons', "Wikipedia's free media, with the attribution line."],
++ iconify: ['Iconify', 'Open icon sets, as SVG with their licence.'],
++ deepseek: ['DeepSeek Search', 'Web search with fetched page text.'],
++ brave: ['Brave Search', 'An independent web index.'],
++ exa: ['Exa', 'Search by meaning, built for agents.'],
++ tavily: ['Tavily', 'Web search shaped for AI answers.'],
++ serper: ['Serper', "Google's results, fast."],
++ serpapi: ['SerpAPI', "Google's results with every feature."],
++ jina: ['Jina', 'Search and a reader that cleans pages.'],
++ perplexity: ['Perplexity Sonar', 'An answer with its sources.'],
++ kagi: ['Kagi', 'A paid index without ads.'],
++ google: ['Google Programmable Search', 'Google, in a site-scoped engine.'],
++ unsplash: ['Unsplash', 'Free photographs.'],
++ pexels: ['Pexels', 'Free photographs and videos.'],
++ pixabay: ['Pixabay', 'Free pictures and illustrations.'],
++};
++const KINDS = [['web', 'Web'], ['images', 'Pictures'], ['icons', 'Icons']];
++const nameOf = (e) => (ENGINES[e.id] || [e.id])[0];
++const price = (e) => (!Number(e.price_per_1000) ? 'Free' : `$${Number(e.price_per_1000).toLocaleString('en-US', { maximumFractionDigits: 3 })} per 1,000`);
++const state = (e) => (e.available === false ? kit.badge(e.byok ? 'Your key' : 'Not set up yet') : e.healthy === false ? kit.badge('Resting', 'warn') : kit.badge('Ready', 'ok'));
++
++function engineRow(e) {
++ const line = (ENGINES[e.id] || [])[1];
++ return `<li class="xs-engine${e.available === false ? ' is-off' : ''}"><div class="xs-engine-main"><strong>${esc(nameOf(e))}</strong>${line ? `<span>${esc(line)}</span>` : ''}</div>`
++ + `<span class="xs-engine-price">${esc(price(e))}</span>${state(e)}</li>`;
++}
++
++function results(list) {
++ if (!list.length) return '';
++ return `<div class="xs-results">${list.map((x) => `<section class="xs-result"><h3>${esc(nameOf({ id: x.engine }))}</h3>${x.error
++ ? `<p class="xs-note">${esc(x.error)}</p>`
++ : `<p class="xs-receipt">${esc(Number(x.receipt.cost_usd) ? `$${Number(x.receipt.cost_usd)}` : 'Free')} · ${Number(x.receipt.time_ms)} ms</p><ol>${x.results.map((r) => `<li><a href="${esc(r.source_page || r.url)}" rel="noopener noreferrer">${esc(r.title)}</a>${r.snippet || r.attribution ? `<p>${esc(r.snippet || r.attribution)}</p>` : ''}</li>`).join('') || '<li class="xs-note">Nothing found.</li>'}</ol>`}</section>`).join('')}</div>`;
++}
++
++function searchPage({ engines = [], found = [], query = {} } = {}) {
++ const groups = KINDS.map(([kind, label]) => {
++ const rows = engines.filter((e) => Array.isArray(e.kinds) && e.kinds.includes(kind));
++ return rows.length ? `<section class="xs-group"><h2>${esc(label)}<span class="ovk-count">${rows.length}</span></h2><ul class="xs-engines">${rows.map(engineRow).join('')}</ul></section>` : '';
++ }).join('');
++ const options = engines.map((e) => `<option value="${esc(e.id)}">${esc(nameOf(e))}</option>`).join('');
++ const pick = (field, label, i) => `<label class="xs-field"><span>${label}</span><select name="${field}">${engines.map((e, j) => `<option value="${esc(e.id)}"${j === i ? ' selected' : ''}>${esc(nameOf(e))}</option>`).join('') || options}</select></label>`;
++ return `<div class="ex-wrap xs">${kit.head({ title: 'Search', sub: 'Web, pictures and icons from many engines, with the same key as your models.' })}`
++ + (groups || '<p class="xs-note">The engine list is not reachable right now.</p>')
++ + `<section class="xs-try"><h2>Try two engines</h2><form method="post" action="/search" class="xs-form">`
++ + `<label class="xs-field xs-field--wide"><span>Search for</span><input name="q" required maxlength="500" placeholder="open source live streaming" value="${esc(query.q || '')}"></label>`
++ + `<label class="xs-field"><span>Kind</span><select name="kind">${KINDS.map(([k, l]) => `<option value="${k}"${query.kind === k ? ' selected' : ''}>${l}</option>`).join('')}</select></label>`
++ + `${pick('first', 'First engine', 0)}${pick('second', 'Second engine', 1)}`
++ + `<p class="xs-go">${kit.button({ label: 'Compare', type: 'submit' })}</p></form>${results(found)}</section>`
++ + `<p class="xs-note">Pictures come with their licence and author: keep the attribution line with each one. With your own engine key a search costs the engine's price and nothing more.</p></div>`;
++}
++
++module.exports = { searchPage, ENGINES };
ddiff --git a/test/helpers/ai.js b/test/helpers/ai.js
iindex ddba160..c99702a 100644
---- a/test/helpers/ai.js
++++ b/test/helpers/ai.js
@@@ -70,6 +70,16 @@ function createFakeAi({ chat = null, community = [] } = {}) {
return {
calls,
configured: true,
++ // AI's search engines as GET /api/v1/exchange/search/engines answers them.
++ async searchEngines() {
++ return [
++ { id: 'openvibe', kinds: ['web'], price_per_1000: 0, byok: false, available: true, healthy: true },
++ { id: 'searxng', kinds: ['web'], price_per_1000: 0, byok: false, available: false, healthy: true },
++ { id: 'openverse', kinds: ['images'], price_per_1000: 0, byok: false, available: true, healthy: true },
++ { id: 'commons', kinds: ['images'], price_per_1000: 0, byok: false, available: true, healthy: true },
++ { id: 'iconify', kinds: ['icons'], price_per_1000: 0, byok: false, available: true, healthy: true },
++ ];
++ },
async catalog() { return catalog; },
async usage() { return usage; },
async communityOffers() { return communityData; },