openvibers/openvibe.exchange Commits
Models and Search, designed: a row per model, engines from AI by kind with their prices (#152)
Changes
@@@ -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; }++}@@@ -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) };}/**@@@ -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;}@@@ -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))nnew file mode 100644@@@ -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 };@@@ -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; },
@@ -226,3 +226,61 @@
@@ -226,3 +226,61 @@
.ex-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.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; }
.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; }
+}
@@ -87,6 +87,21 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = (
@@ -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) {
async function usage(days = 30) {
const key = String(days);
const key = String(days);
const hit = usageHit.get(key);
const hit = usageHit.get(key);
@@ -313,7 +328,7 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = (
@@ -313,7 +328,7 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = (
return createCredentialsClient(client, { baseUrl: config.ai.url });
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) };
}
}
/**
/**
@@ -1,30 +1,22 @@
@@ -1,30 +1,22 @@
'use strict';
'use strict';
const express = require('express');
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 { send } = require('../render/layout');
const { sameOrigin } = require('./principal');
const { sameOrigin } = require('./principal');
function createSearchRoutes({ config, ai }) {
function createSearchRoutes({ config, ai }) {
const r = express.Router();
const r = express.Router();
r.use(express.urlencoded({ extended: false, limit: '8kb' }));
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, {
send(res, status, {
viewer: req.viewer,
viewer: req.viewer,
config,
config,
path: req.originalUrl,
path: req.originalUrl,
title: 'Search',
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) => {
r.post('/search', async (req, res) => {
if (req.viewer?.kind !== 'user')
if (req.viewer?.kind !== 'user')
return res.redirect(303, '/auth/login?next=%2Fsearch');
return res.redirect(303, '/auth/login?next=%2Fsearch');
@@ -54,7 +46,7 @@ function createSearchRoutes({ config, ai }) {
@@ -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;
return r;
}
}
@@ -37,15 +37,27 @@ const code = (m) =>
@@ -37,15 +37,27 @@ const code = (m) =>
id: 'platform-curl',
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' }] })}'`,
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) {
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() {
function pricing() {
return section(
return section(
@@ -95,6 +107,7 @@ function modelsPage({ catalog, query = {} }) {
@@ -95,6 +107,7 @@ function modelsPage({ catalog, query = {} }) {
);
);
if (q.get(key) === value) q.delete(key);
if (q.get(key) === value) q.delete(key);
else q.set(key, value);
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>`;
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))
for (const [key, test] of Object.entries(tests))
new file mode 100644
new file mode 100644
@@ -0,0 +1,66 @@
@@ -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 };
@@ -70,6 +70,16 @@ function createFakeAi({ chat = null, community = [] } = {}) {
@@ -70,6 +70,16 @@ function createFakeAi({ chat = null, community = [] } = {}) {
return {
return {
calls,
calls,
configured: true,
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 catalog() { return catalog; },
async usage() { return usage; },
async usage() { return usage; },
async communityOffers() { return communityData; },
async communityOffers() { return communityData; },