openvibers/openvibe.exchange Commits
Search: one engine by default, honest price words, one combined cap when comparing (#259)
Changes
@@@ -4,6 +4,16 @@ What changed in OpenVibe.Exchange, newest first.## Unreleased++- **Search: one engine by default, honest prices, one combined cap when you compare (2026-11-13).** A search now++ runs one engine, the router's pick unless you name one; comparing two or three engines is an explicit choice and++ shares ONE spending cap that is shown before the run. Prices are words, never "Free": no provider fee, your++ instance, included allowance (with the allowance), or your key. Every filter says whether the chosen engine++ applies it, we filter the results, or it is not supported and will be refused rather than dropped. Image results++ keep the provider's rank beside our order, and the engine list names each supplier's upstream (Google, Bing,++ Yandex). New image engines: SerpAPI Google Images and reverse image, SearchAPI Images, TinEye. Pexels says new++ keys are paused. Unsplash results have a "Use this picture" action that records its required download event once.++ The picture and search views now allow provider thumbnails and the AI image proxy in the CSP.++- **Measured, and your own AI usage (2026-11-12).** A model page now has a **Measured** panel fromOpenVibe.Report's public performance rows: first token and whole-answer percentiles, tokens a second, success,cache hits, what a call and a million output tokens were charged, and calls in the last 30 days. A model below@@@ -353,6 +353,32 @@.xs-kinds input:focus-visible + span { outline: 2px solid var(--ovk-accent); outline-offset: 1px; }.xs-pair { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: end; gap: 10px; }.xs-vs { padding-bottom: 12px; color: var(--ovk-muted); font-size: .82rem; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; }++/* One engine by default; comparing is an explicit switch. CSS only, so the form works without JavaScript. */++.xs-modes { display: inline-flex; justify-self: start; gap: 4px; margin: 0; padding: 4px; border: 1px solid var(--ovk-line); border-radius: 999px; background: var(--ovk-panel-2); min-width: 0; }++.xs-modes label { position: relative; display: inline-flex; }++.xs-modes input { position: absolute; inset: 0; opacity: 0; margin: 0; cursor: pointer; }++.xs-modes span { display: inline-flex; align-items: center; min-height: 36px; padding: 0 14px; border-radius: 999px; font-size: .88rem; color: var(--ovk-muted); }++.xs-modes input:checked + span { background: color-mix(in srgb, var(--ovk-accent) 20%, transparent); color: var(--ovk-text); font-weight: 650; }++.xs-modes input:focus-visible + span { outline: 2px solid var(--ovk-accent); outline-offset: 1px; }++.xs-single { display: grid; }++.xs-compare { display: none; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }++.xs-form:has(input[name="mode"][value="compare"]:checked) .xs-single { display: none; }++.xs-form:has(input[name="mode"][value="compare"]:checked) .xs-compare { display: grid; }++.xs-cap small { color: var(--ovk-muted); font-size: .82rem; line-height: 1.4; }++.xs-check { display: flex; align-items: center; gap: 8px; min-height: 44px; font-size: .9rem; }++.xs-check input { width: 18px; height: 18px; accent-color: var(--ovk-accent); }++.xs-support { border: 1px solid var(--ovk-line); border-radius: 12px; }++.xs-support > summary { display: flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 14px; cursor: pointer; list-style: none; font-weight: 600; font-size: .92rem; }++.xs-support > summary::-webkit-details-marker { display: none; }++.xs-support-engine { display: grid; gap: 2px; padding: 9px 14px; border-top: 1px solid var(--ovk-line); }++.xs-support-engine span { color: var(--ovk-muted); font-size: .84rem; line-height: 1.45; overflow-wrap: anywhere; }++.xs-support > .xs-note { padding: 0 14px 12px; }++.xs-upstream { color: var(--ovk-accent) !important; }++.xs-price-note { color: var(--ovk-muted); font-size: .8rem !important; line-height: 1.4; }++.xs-use { margin: 6px 0 0; }++.xs-attempts { margin-top: 4px; }++.xs-result .xs-attempts ul { margin: 4px 0 0; padding-left: 16px; }++.xs-result .xs-attempts li { color: var(--ovk-muted); font-size: .8rem; }.xs-more { border: 1px solid var(--ovk-line); border-radius: 12px; }.xs-more > summary { display: flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 40px 0 14px; position: relative; cursor: pointer; list-style: none; font-weight: 600; font-size: .92rem; }.xs-more > summary::-webkit-details-marker { display: none; }@@@ -391,6 +417,7 @@.xs-try { padding: 14px; }.xs-more-body { grid-template-columns: repeat(2, minmax(0, 1fr)); }.xs-more-body > :last-child { grid-column: 1 / -1; }++ .xs-compare { grid-template-columns: minmax(0, 1fr); }}/* 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; }@@@ -158,7 +158,7 @@ async function createApp(opts = {}) {scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com'],styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],-- imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.media', 'https://avatars.githubusercontent.com'],++ imgSrc: ["'self'", 'data:', 'https:', 'https://openvibe.network', 'https://openvibe.media', 'https://avatars.githubusercontent.com'],connectSrc: ["'self'", 'https://openvibe.network', 'https://openvibe.chat', 'https://cloudflareinsights.com', 'https://openvibe.events', 'https://ai.openvibe.services'],frameSrc: ["'self'", 'https://openvibe.network'],frameAncestors: ["'none'"],@@@ -4,6 +4,85 @@ const views = require('../views/search');const { raw } = require('../render/html');const { send } = require('../render/layout');const { sameOrigin } = require('./principal');++++const FILTERS = {++ web: ['freshness', 'safe_search'],++ images: ['license', 'commercial', 'format', 'orientation', 'size', 'color', 'after', 'type', 'safe_search'],++ icons: ['set', 'style', 'license', 'format'],++};++const one = (value, max = 80) => String(value == null ? '' : value).slice(0, max);++const capOf = (value) => {++ if (value == null || value === '') return null;++ const n = Number(value);++ return Number.isFinite(n) && n >= 0 ? Math.round(n * 1e6) / 1e6 : null;++};++function filterBody(body, kind) {++ const out = {};++ for (const key of FILTERS[kind] || []) {++ const value = body[key];++ if (value == null || value === '' || value === 'false') continue;++ if (key === 'commercial') out.commercial = true;++ else if (key === 'safe_search') out.safe_search = 'strict';++ else out[key] = one(value);++ }++ return out;++}++/** One engine by default; compare is explicit and runs two or three distinct engines. */++function selectedEngines(body, engines, kind) {++ const mode = body.mode === 'compare' ? 'compare' : 'single';++ if (mode === 'single') return { mode, engines: [(body.engine || body.first) && (body.engine || body.first) !== 'auto' ? one(body.engine || body.first) : 'auto'] };++ const serving = engines.filter((e) => Array.isArray(e.kinds) && e.kinds.includes(kind));++ const picks = [body.first, body.second, body.third]++ .map((x) => one(x)).filter((x) => x && x !== 'auto');++ const unique = [...new Set(picks)].filter((id) => serving.some((e) => e.id === id)).slice(0, 3);++ if (!unique.length) unique.push(...serving.filter((e) => e.available !== false).slice(0, 3).map((e) => e.id));++ return { mode, engines: unique };++}++function why(err) {++ const status = err && err.status;++ if (status === 401 || status === 403) return 'Your sign-in could not search. Sign out and in again.';++ if (status === 429) return 'Too many searches this minute, or the key cap cannot cover this search. Try again shortly.';++ if (status === 422) return (err && err.message) || 'This engine does not take this kind of search or cannot honour a filter.';++ if (status === 503) return (err && err.message) || 'No configured engine serves this search within its price and freshness limits.';++ return 'This engine did not answer. Try again, or choose another engine.';++}++/**++ * Runs a single search once, or an explicit comparison sequentially against one combined cap. Sequential++ * calls are deliberate: the remaining cap is passed to each call, so the total can never exceed it.++ */++async function runSearch({ ai, token, body, kind, engines = [] }) {++ const chosen = selectedEngines(body, engines, kind);++ const cap = capOf(body.max_cost_usd);++ const filters = filterBody(body, kind);++ const results = [];++ let spent = 0;++ let note = '';++ if (chosen.mode === 'compare' && chosen.engines.length < 2) {++ note = 'Choose two or three engines to compare. One engine is the default, with one cap.';++ return { mode: chosen.mode, engines: chosen.engines, cap, spent, results, note };++ }++ for (const engine of chosen.engines) {++ const remaining = cap == null ? null : Math.max(0, Math.round((cap - spent) * 1e6) / 1e6);++ if (chosen.mode === 'compare' && remaining != null && spent > 0 && remaining <= 0) {++ results.push({ engine, meta: engines.find((e) => e.id === engine) || null, error: 'The one combined cap is spent; this engine was not called.' });++ continue;++ }++ const payload = { q: one(body.q, 500), engine, limit: 6, ...filters };++ if (remaining != null) payload.max_cost_usd = remaining;++ try {++ const out = await ai.meRequest(token, '/v1/search' + (kind === 'web' ? '' : '/' + kind), { method: 'POST', body: payload });++ const actual = out.receipt?.engine || engine;++ results.push({ engine, meta: engines.find((e) => e.id === actual) || engines.find((e) => e.id === engine) || null, ...out });++ spent = Math.round((spent + (Number(out.receipt?.cost_usd) || 0)) * 1e6) / 1e6;++ } catch (err) {++ console.warn('[exchange] search', engine, kind, err && err.status, err && err.code, (err && err.message) || '');++ results.push({ engine, meta: engines.find((e) => e.id === engine) || null, error: why(err) });++ }++ }++ if (cap != null && spent > cap + 1e-9) note = 'A provider may already have billed a timed-out attempt; the combined cap is a limit, not a promise about a failed call.';++ return { mode: chosen.mode, engines: chosen.engines, cap, spent, results, note };++}++function createSearchRoutes({ config, ai }) {const r = express.Router();r.use(express.urlencoded({ extended: false, limit: '8kb' }));@@@ -16,43 +95,44 @@ function createSearchRoutes({ config, ai }) {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).catch((err) => res.status(500).end(String(err && err.message || err))));++ r.get('/search', (req, res) => {++ const kind = ['web', 'images', 'icons'].includes(req.query.kind) ? req.query.kind : 'web';++ return render(req, res, [], {++ q: one(req.query.q, 500), kind, mode: req.query.mode === 'compare' ? 'compare' : 'single',++ engine: one(req.query.engine), first: one(req.query.first), second: one(req.query.second), third: one(req.query.third),++ max_cost_usd: req.query.max_cost_usd == null ? '' : one(req.query.max_cost_usd, 30),++ note: req.query.chosen === '1' ? 'The Unsplash download event was recorded once for that picture.' : one(req.query.note, 240),++ }).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');if (!sameOrigin(req, config.baseUrl)) return res.sendStatus(403);-- const kind = ['web', 'images', 'icons'].includes(req.body.kind)-- ? req.body.kind-- : 'web';-- const results = await Promise.all(-- [req.body.first, req.body.second].map(async (engine) => {-- try {-- return {-- engine,-- ...(await ai.meRequest(-- req.viewer.token,-- '/v1/search' + (kind === 'web' ? '' : '/' + kind),-- {-- method: 'POST',-- body: { q: req.body.q, engine, limit: 6, safe_search: 'strict',-- ...Object.fromEntries(['size', 'color', 'type', 'license', 'after'].filter((key) => req.body[key]).map((key) => [key, String(req.body[key]).slice(0, 80)])) },-- },-- )),-- };-- } 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 };-- }-- }),-- );-- await render(req, res, results, { q: String(req.body.q || '').slice(0, 500), kind,-- ...Object.fromEntries(['size', 'color', 'type', 'license', 'after'].map((key) => [key, String(req.body[key] || '').slice(0, 80)])) });++ const kind = ['web', 'images', 'icons'].includes(req.body.kind) ? req.body.kind : 'web';++ const body = req.body || {};++ const out = await runSearch({ ai, token: req.viewer.token, body, kind, engines: await ai.searchEngines() });++ await render(req, res, out.results, {++ q: one(body.q, 500), kind, mode: out.mode,++ engine: one(body.engine), first: one(body.first), second: one(body.second), third: one(body.third),++ max_cost_usd: capOf(body.max_cost_usd) == null ? '' : String(capOf(body.max_cost_usd)),++ spent: out.spent, note: out.note,++ ...Object.fromEntries(Object.entries(filterBody(body, kind)).map(([key, value]) => [key, String(value)])),++ });++ });++ // A person choosing an Unsplash picture is the moment for its required download event, not search.++ r.post('/search/unsplash', async (req, res) => {++ if (req.viewer?.kind !== 'user')++ return res.redirect(303, '/auth/login?next=%2Fsearch');++ if (!sameOrigin(req, config.baseUrl)) return res.sendStatus(403);++ try {++ await ai.meRequest(req.viewer.token, '/v1/search/images/unsplash/download', {++ method: 'POST', body: { download_location: one(req.body.download_location, 8192) },++ });++ return res.redirect(303, '/search?chosen=1');++ } catch (err) {++ return res.redirect(303, '/search?note=' + encodeURIComponent(why(err)));++ }});return r;}--module.exports = { createSearchRoutes };++module.exports = { createSearchRoutes, selectedEngines, runSearch, filterBody };@@@ -1,20 +1,19 @@'use strict';/**-- * /search (Opus, 11-09; the tool first 11-11): "Compare two engines" at the top (what to search for, the kind as a-- * switch, the two engines side by side, the picture filters in one fold), the results under it, then 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. The engine list is AI's own (GET /api/v1/exchange/search/engines), so a new engine shows here the day-- * AI adds it, named from its id until this list knows it.++ * /search (Opus, 11-09; one engine and honest words 11-13): one engine by default, the router's pick unless++ * the person names one. Comparing two or three engines is an explicit choice with ONE combined spending cap.++ * Prices are words, never "Free": no provider fee, your instance, included allowance or your key. The filters++ * say whether the chosen engine applies them, we filter locally, or they are not supported and therefore++ * refused rather than dropped. Results keep the provider's rank beside our order.*/const kit = require('openvibe-shared/kit');const { esc } = require('../render/html');--// What each engine is, in a 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."],++ commons: ['Wikimedia Commons', "Wikimedia's open 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.'],@@@ -24,70 +23,160 @@ const ENGINES = {serper: ['Serper', "Google's results, fast."],'serper-images': ['Serper Images', 'Google Images results, fast.'],serpapi: ['SerpAPI', "Google's results with every feature."],++ 'serpapi-images': ['SerpAPI Google Images', "Google Images through SerpAPI."],++ 'serpapi-reverse': ['SerpAPI Reverse Image', 'Pages that carry a picture, through Google.'],++ 'searchapi-images': ['SearchAPI Images', 'One supplier for Google, Bing and Yandex images.'],++ tineye: ['TinEye', 'Reverse image matching against its own index.'],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.'],meta: ['Meta AI search', 'A Muse Spark answer with the web pages it read.'],'openvibe-egress': ['OpenVibe Fetch', 'Reads one page you name, through OpenVibe.'],-- unsplash: ['Unsplash', 'Free photographs.'],-- pexels: ['Pexels', 'Free photographs and videos.'],-- pixabay: ['Pixabay', 'Free pictures and illustrations.'],++ unsplash: ['Unsplash', 'Stock photographs.'],++ pexels: ['Pexels', 'Stock photographs and videos.'],++ pixabay: ['Pixabay', 'Stock pictures and illustrations.'],};const KINDS = [['web', 'Web'], ['images', 'Pictures'], ['icons', 'Icons']];++const GROUPS = [...KINDS, ['reverse_image', 'Reverse image']];++const FILTERS = {++ web: [['freshness', 'Freshness'], ['safe_search', 'Strict safe search']],++ images: [++ ['license', 'Licence'], ['commercial', 'Commercial use'], ['format', 'Format'],++ ['orientation', 'Orientation'], ['size', 'Size'], ['color', 'Colour'],++ ['after', 'After'], ['type', 'Type'], ['safe_search', 'Strict safe search'],++ ],++ icons: [['set', 'Icon set'], ['style', 'Style'], ['license', 'Licence'], ['format', 'Format']],++};++const OPEN_DATA = new Set(['openverse', 'commons', 'iconify', 'openvibe']);const titled = (id) => String(id || '').split(/[-_]/).filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join(' ');const nameOf = (e) => (ENGINES[e.id] || [titled(e.id)])[0];--// A missing price is not free: an engine billed per token says so instead of claiming "Free".--const price = (e) => (e.price_per_1000 == null || !Number.isFinite(Number(e.price_per_1000)) ? 'Priced per call'-- : !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'));++const lineOf = (e) => (ENGINES[e.id] || [])[1] || '';++const money = (n) => `$${Number(n).toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 6 })}`;++const priceWords = (e) => {++ if (e.id === 'searxng') return 'Your instance';++ if (e.byok) return 'Your key';++ if (e.price_per_1000 == null) return 'Priced per call';++ if (OPEN_DATA.has(e.id) || Number(e.price_per_1000) === 0) return 'No provider fee';++ return `${money(e.price_per_1000)} per 1,000`;++};++const priceNote = (e) => {++ const bits = [];++ if (e.byok && Number(e.price_per_1000) > 0) bits.push(`The provider charges ${money(e.price_per_1000)} per 1,000`);++ if (e.included_allowance) bits.push(`Included allowance: ${e.included_allowance}`);++ if (e.onboarding === 'paused') bits.push('Not offered to new keys while issuance is paused');++ if (!bits.length) bits.push(priceWords(e) === 'No provider fee' ? 'Open data; no provider query fee' : '');++ return bits.filter(Boolean).join(' · ');++};++const state = (e) => {++ if (e.onboarding === 'paused' && e.available === false) return kit.badge('New keys paused', 'warn');++ if (e.available === false) return kit.badge(e.byok ? 'Add your key' : 'Not set up yet');++ if (e.healthy === false) return kit.badge('Resting', 'warn');++ return kit.badge('Ready', 'ok');++};++const supportWord = (v) => (v === 'native' ? 'Provider applies it' : v === 'local' ? 'We filter' : 'Not supported');++const supportShort = (v) => (v === 'native' ? 'Provider' : v === 'local' ? 'We filter' : 'Not supported');++const filterSupport = (e, kind) => (FILTERS[kind] || []).map(([key, label]) => [label, e && e.filters ? e.filters[key] || 'unsupported' : 'unsupported']);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"${e.pricing_note ? ` title="${esc(e.pricing_note)}"` : ''}>${esc(price(e))}</span>${state(e)}</li>`;++ const note = priceNote(e);++ return `<li class="xs-engine${e.available === false ? ' is-off' : ''}"><div class="xs-engine-main"><strong>${esc(nameOf(e))}</strong>`++ + (lineOf(e) ? `<span>${esc(lineOf(e))}</span>` : '')++ + `${e.upstreams && e.upstreams.length && !['openverse', 'commons', 'iconify', 'unsplash', 'pexels', 'pixabay', 'tineye', 'searxng', 'openvibe'].includes(e.id) ? `<span class="xs-upstream">Upstream: ${esc(e.upstreams.join(', '))}</span>` : ''}`++ + `${note ? `<span class="xs-price-note">${esc(note)}</span>` : ''}</div>`++ + `<span class="xs-engine-price">${esc(priceWords(e))}</span>${state(e)}</li>`;++}++++function enginePick(field, label, engines, chosen, includeAuto = false, optional = false) {++ const rows = engines.map((e) => `<option value="${esc(e.id)}"${e.id === chosen ? ' selected' : ''}>${esc(nameOf(e))}${e.available === false ? ' (needs a key)' : ''}</option>`).join('');++ const empty = optional ? `<option value=""${chosen ? '' : ' selected'}>No third engine</option>` : '';++ return `<label class="xs-field"><span>${label}</span><select name="${field}">${empty}${includeAuto ? `<option value="auto"${chosen === 'auto' ? ' selected' : ''}>Choose for me (the router's pick)</option>` : ''}${rows}</select></label>`;}--function results(list) {++function resultRows(list, engines, query) {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>` : ''}${r.thumbnail ? `<img src="${esc(r.thumbnail)}" alt="" loading="lazy" width="160">` : ''}${r.source_page ? `<details><summary>Source and usage</summary><p><a href="${esc(r.source_page)}" rel="noopener noreferrer">Source page</a>${r.author ? ` · ${esc(r.author)}` : ''}${r.license ? ` · ${r.license_url ? `<a href="${esc(r.license_url)}" rel="noopener noreferrer">${esc(r.license)}</a>` : esc(r.license)}` : ' · Reuse rights unknown'}${r.width && r.height ? ` · ${Number(r.width)} × ${Number(r.height)} px` : ''}${r.published ? ` · ${esc(r.published)}` : ''}</p></details>` : ''}</li>`).join('') || '<li class="xs-note">Nothing found.</li>'}</ol>`}</section>`).join('')}</div>`;++ const byId = new Map(engines.map((e) => [e.id, e]));++ return `<div class="xs-results">${list.map((x) => {++ const engineId = x.receipt?.engine || x.engine;++ const meta = byId.get(engineId) || byId.get(x.engine) || { id: engineId };++ const cost = Number(x.receipt?.cost_usd) || 0;++ const receipt = x.error ? '' : `<p class="xs-receipt">${cost > 0 ? `${money(cost)} provider cost` : 'No provider fee'} · ${Number(x.receipt?.time_ms) || 0} ms</p>`;++ const attempts = x.receipt?.attempts?.length++ ? `<details class="xs-attempts"><summary>Every attempt</summary><ul>${x.receipt.attempts.map((a) => `<li>${esc(a.engine)}: ${esc(a.status)}${a.reason ? ` · ${esc(a.reason)}` : ''}${Number(a.cost_usd) ? ` · ${money(a.cost_usd)}` : ''}</li>`).join('')}</ul></details>`++ : '';++ const rows = x.error ? `<p class="xs-note">${esc(x.error)}</p>` : `<ol>${x.results.map((r, i) => {++ const upstream = r.upstream || (meta.upstreams && meta.upstreams[0]) || null;++ const use = r.download_location && engineId === 'unsplash'++ ? `<form method="post" action="/search/unsplash" class="xs-use"><input type="hidden" name="download_location" value="${esc(r.download_location)}"><input type="hidden" name="url" value="${esc(r.url)}">${kit.button({ label: 'Use this picture', type: 'submit', kind: 'quiet' })}</form>`++ : '';++ return `<li><a href="${esc(r.source_page || r.url)}" rel="noopener noreferrer">${esc(r.title || r.url)}</a>${r.snippet || r.attribution ? `<p>${esc(r.snippet || r.attribution)}</p>` : ''}${r.thumbnail ? `<img src="${esc(r.thumbnail)}" alt="" loading="lazy" width="160">` : ''}`++ + `<details><summary>Source, rights and rank</summary><p><a href="${esc(r.source_page || r.url)}" rel="noopener noreferrer">Source page</a>${r.author ? ` · ${esc(r.author)}` : ''}${r.license ? ` · ${r.license_url ? `<a href="${esc(r.license_url)}" rel="noopener noreferrer">${esc(r.license)}</a>` : esc(r.license)}` : ' · Reuse rights unknown'}${r.width && r.height ? ` · ${Number(r.width)} × ${Number(r.height)} px` : ''}`++ + ` · Our order #${Number(r.rank) || i + 1}${r.upstream_rank ? ` · Provider's rank #${Number(r.upstream_rank)}` : ''}${upstream ? ` · Upstream: ${esc(upstream)}` : ''}${r.reverse_image ? ' · Reverse image match' : ''}${r.published ? ` · ${esc(r.published)}` : ''}</p></details>${use}</li>`;++ }).join('') || '<li class="xs-note">Nothing found.</li>'}</ol>`;++ return `<section class="xs-result"><h3>${esc(nameOf({ id: x.engine }))}</h3>${receipt}${attempts}${rows}</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));++ const kind = KINDS.some(([k]) => k === query.kind) ? query.kind : 'web';++ const mode = query.mode === 'compare' ? 'compare' : 'single';++ const forKind = engines.filter((e) => Array.isArray(e.kinds) && e.kinds.includes(kind));++ const ready = forKind.filter((e) => e.available !== false);++ const singleDefault = query.engine || 'auto';++ const compareDefaults = [++ query.first && query.first !== 'auto' ? query.first : (ready[0] || forKind[0] || {}).id,++ query.second || (ready[1] || forKind[1] || {}).id,++ query.third || '',++ ];++ const selectedIds = mode === 'compare'++ ? compareDefaults.filter(Boolean)++ : singleDefault === 'auto'++ ? (ready.length ? ready : forKind).slice(0, 3).map((e) => e.id)++ : [singleDefault].filter(Boolean);++ const selected = selectedIds.map((id) => engines.find((e) => e.id === id)).filter(Boolean);++ const support = selected.map((e) => `<div class="xs-support-engine"><strong>${esc(nameOf(e))}</strong><span>${filterSupport(e, kind).map(([label, v]) => `${esc(label)}: ${esc(supportShort(v))}`).join(' · ')}</span></div>`).join('');++++ const field = (name, label, type = 'text', extra = '') => `<label class="xs-field"><span>${label}</span><input name="${name}" type="${type}" ${extra} value="${esc(query[name] || '')}"></label>`;++ const select = (name, label, values) => `<label class="xs-field"><span>${label}</span><select name="${name}"><option value="">Any</option>${values.map((v) => `<option value="${esc(v[0])}"${query[name] === v[0] ? ' selected' : ''}>${esc(v[1])}</option>`).join('')}</select></label>`;++ const filters = kind === 'images'++ ? select('size', 'Size', [['small', 'Small (under 800 px)'], ['medium', 'Medium (800 to 1999 px)'], ['large', 'Large (2000 px and up)']])++ + select('orientation', 'Orientation', [['landscape', 'Landscape'], ['portrait', 'Portrait'], ['square', 'Square']])++ + select('color', 'Colour', ['red', 'orange', 'yellow', 'green', 'blue', 'purple', 'black_and_white'].map((v) => [v, v.replace(/_/g, ' ')]))++ + select('type', 'Type', [['photo', 'Photo'], ['illustration', 'Illustration'], ['vector', 'Vector']])++ + field('license', 'Licence', 'text', 'placeholder="cc0"')++ + `<label class="xs-check"><input type="checkbox" name="commercial" value="true"${query.commercial ? ' checked' : ''}><span>Commercial use only</span></label>`++ + field('format', 'Format', 'text', 'placeholder="jpeg or svg"')++ + field('after', 'After', 'date')++ + `<label class="xs-check"><input type="checkbox" name="safe_search" value="strict"${query.safe_search ? ' checked' : ''}><span>Strict safe search only</span></label>`++ : kind === 'icons'++ ? field('set', 'Icon set', 'text', 'placeholder="lucide"') + select('style', 'Style', [['any', 'Any'], ['line', 'Line'], ['solid', 'Solid']]) + field('license', 'Licence')++ : select('freshness', 'Freshness', [['day', 'Past day'], ['month', 'Past month'], ['year', 'Past year']])++ + `<label class="xs-check"><input type="checkbox" name="safe_search" value="strict"${query.safe_search ? ' checked' : ''}><span>Strict safe search only</span></label>`;++ const filterCount = Object.keys(FILTERS[kind].reduce((a, [key]) => ({ ...a, [key]: query[key] }), {})).filter((key) => query[key]).length;++++ const groups = GROUPS.map(([groupKind, label]) => {++ const rows = engines.filter((e) => Array.isArray(e.kinds) && e.kinds.includes(groupKind));return rows.length ? `<section class="xs-group"><h3>${esc(label)}<span class="ovk-count">${rows.length}</span></h3><ul class="xs-engines">${rows.map(engineRow).join('')}</ul></section>` : '';}).join('');-- const kind = KINDS.some(([k]) => k === query.kind) ? query.kind : 'web';-- // The engines in each list grouped by what they find; the first two that work are picked unless the search named some.-- // The first two that work for the chosen kind (Web picked two picture engines when those came first in AI's list).-- const forKind = (e) => !Array.isArray(e.kinds) || e.kinds.includes(kind);-- const ready = [...engines.filter((e) => e.available !== false && forKind(e)), ...engines.filter((e) => e.available !== false && !forKind(e))];-- const defaults = [query.first || (ready[0] || engines[0] || {}).id, query.second || (ready[1] || engines[1] || {}).id];-- const pick = (field, label, chosen) => `<label class="xs-field"><span>${label}</span><select name="${field}">${KINDS.map(([k, l]) => {-- const rows = engines.filter((e) => (Array.isArray(e.kinds) ? e.kinds[0] === k : k === 'web'));-- return rows.length ? `<optgroup label="${esc(l)}">${rows.map((e) => `<option value="${esc(e.id)}"${e.id === chosen ? ' selected' : ''}>${esc(nameOf(e))}${e.available === false ? ' (needs a key)' : ''}</option>`).join('')}</optgroup>` : '';-- }).join('')}</select></label>`;-- const sel = (name, label, values) => `<label class="xs-field"><span>${label}</span><select name="${name}"><option value="">Any</option>${values.map((v) => `<option value="${v}"${query[name] === v ? ' selected' : ''}>${v.replace(/_/g, ' ')}</option>`).join('')}</select></label>`;-- const set = ['size', 'color', 'type', 'license', 'after'].filter((k) => query[k]).length;-- const filters = `<details class="xs-more"${set ? ' open' : ''}><summary>Picture filters${set ? `<span class="ovk-count">${set}</span>` : ''}<small>for Pictures</small></summary><div class="xs-more-body">`-- + sel('size', 'Size', ['small', 'medium', 'large'])-- + sel('color', 'Colour', ['red', 'orange', 'yellow', 'green', 'blue', 'purple', 'black_and_white'])-- + sel('type', 'Type', ['photo', 'illustration', 'vector'])-- + `<label class="xs-field"><span>Licence</span><input name="license" value="${esc(query.license || '')}" placeholder="cc0"></label>`-- + `<label class="xs-field"><span>After</span><input name="after" type="date" value="${esc(query.after || '')}"></label></div></details>`;-- const kinds = `<fieldset class="xs-kinds"><legend class="ex-sr">What to find</legend>${KINDS.map(([k, l]) => `<label><input type="radio" name="kind" value="${k}"${k === kind ? ' checked' : ''}><span>${l}</span></label>`).join('')}</fieldset>`;-- 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.' })}`-- + `<section class="xs-try" aria-labelledby="xs-try-h"><h2 id="xs-try-h">Compare two engines</h2><form method="post" action="/search" class="xs-form">`++ const single = `<div class="xs-single">${enginePick('engine', 'Engine', forKind, singleDefault, true)}</div>`;++ const compare = `<div class="xs-compare">${enginePick('first', 'First engine', forKind, compareDefaults[0])}${enginePick('second', 'Second engine', forKind, compareDefaults[1])}${enginePick('third', 'Third engine (optional)', forKind, compareDefaults[2], false, true)}</div>`;++ const resultHead = mode === 'compare'++ ? `<p class="xs-receipt">One combined cap ${query.max_cost_usd != null && query.max_cost_usd !== '' ? money(query.max_cost_usd) : 'not set'} · spent ${money(query.spent || 0)}</p>`++ : `<p class="xs-receipt">Cap ${query.max_cost_usd != null && query.max_cost_usd !== '' ? money(query.max_cost_usd) : 'not set'} · spent ${money(query.spent || 0)}</p>`;++ 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. One engine by default; comparing is your choice.' })}`++ + `<section class="xs-try" aria-labelledby="xs-try-h"><h2 id="xs-try-h">Search</h2><form method="post" action="/search" class="xs-form">`+ `<div class="xs-q"><input name="q" required maxlength="500" placeholder="What are you looking for?" value="${esc(query.q || '')}" aria-label="Search for">`-- + `${kit.button({ label: 'Compare', type: 'submit', icon: 'search' })}</div>`-- + `${kinds}<div class="xs-pair">${pick('first', 'First engine', defaults[0])}<span class="xs-vs" aria-hidden="true">vs</span>${pick('second', 'Second engine', defaults[1])}</div>`-- + `${filters}</form>${results(found)}</section>`++ + `${kit.button({ label: 'Search', type: 'submit', icon: 'search' })}</div>`++ + `<fieldset class="xs-kinds"><legend class="ex-sr">What to find</legend>${KINDS.map(([k, l]) => `<label><input type="radio" name="kind" value="${k}"${k === kind ? ' checked' : ''}><span>${l}</span></label>`).join('')}</fieldset>`++ + `<fieldset class="xs-modes"><legend class="ex-sr">How many engines</legend><label><input type="radio" name="mode" value="single"${mode === 'single' ? ' checked' : ''}><span>One engine</span></label><label><input type="radio" name="mode" value="compare"${mode === 'compare' ? ' checked' : ''}><span>Compare 2 or 3 engines</span></label></fieldset>`++ + `${single}${compare}`++ + `<label class="xs-field xs-cap"><span>Spending cap, USD</span><input name="max_cost_usd" type="number" min="0" step="0.001" placeholder="Set a cap" value="${query.max_cost_usd != null ? esc(query.max_cost_usd) : ''}"><small>${mode === 'compare' ? 'One combined cap for every engine in this comparison. The search stops before it is exceeded.' : 'The most this search may spend with the provider.'}</small></label>`++ + `<details class="xs-more"${filterCount ? ' open' : ''}><summary>Filters${filterCount ? `<span class="ovk-count">${filterCount}</span>` : ''}<small>for ${esc((KINDS.find(([k]) => k === kind) || [])[1] || 'Web')}</small></summary><div class="xs-more-body">${filters}</div></details>`++ + `<details class="xs-support"${support ? ' open' : ''}><summary>What the chosen ${mode === 'compare' ? 'engines' : 'engine'} can filter</summary>${support || '<p class="xs-note">Choose an engine to see what it can honour.</p>'}<p class="xs-note">A filter the chosen engine cannot honour is refused before the search, never dropped.</p></details>`++ + `</form>${query.note ? `<p class="xs-note">${esc(query.note)}</p>` : ''}${resultHead}${resultRows(found, engines, query)}</section>`+ `<section class="xs-list" aria-labelledby="xs-list-h"><h2 id="xs-list-h">The engines<span class="ovk-count">${engines.length}</span></h2>`+ (groups || '<p class="xs-note">The engine list is not reachable right now.</p>') + '</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>`;++ + `<p class="xs-note">The words matter: “No provider fee” is open data; “Your instance” is a SearXNG you run; “Included allowance” is a provider free tier; “Your key” is your own key and the provider may charge. Nothing here is labelled free. Pictures come with their licence and author: keep the attribution line with each one.</p></div>`;}module.exports = { searchPage, ENGINES };@@@ -2,17 +2,54 @@const assert = require('node:assert/strict');const { searchPage } = require('../server/views/search');--const html = searchPage({ query: { kind: 'images', color: 'blue', size: 'large' }, found: [{ engine: 'openverse', receipt: { cost_usd: 0, time_ms: 12 }, results: [{-- title: 'Blue mountain', url: 'https://example.org/image.jpg', thumbnail: 'https://example.org/thumb.jpg', source_page: 'https://example.org/page',-- license: 'cc-by', license_url: 'https://example.org/license', author: 'Alice', width: 2400, height: 1600, published: '2026-10-01',--}] }] });--assert.ok(html.includes('Source and usage'));--assert.ok(html.includes('Reuse rights unknown') === false);--assert.ok(html.includes('2400 × 1600 px'));--assert.ok(html.includes('name="color"') && html.includes('name="after"'));--// The default engines follow the kind: Web picks the first two working web engines even when picture engines come first.--const E = (id, kinds, available = true) => ({ id, kinds, available, price_per_1000: 0 });--const web = searchPage({ engines: [E('searxng', ['web'], false), E('openverse', ['images']), E('commons', ['images']), E('openvibe', ['web']), E('deepseek', ['web'])] });--assert.match(web, /name="first">[\s\S]*?value="openvibe" selected/);--assert.match(web, /name="second">[\s\S]*?value="deepseek" selected/);++const E = (id, kinds, extra = {}) => ({++ id, kinds, available: true, price_per_1000: 0, byok: false, healthy: true,++ filters: { license: 'local', color: 'unsupported' }, upstreams: [], ...extra,++});++const engines = [++ E('openverse', ['images'], { filters: { license: 'native', color: 'unsupported' } }),++ E('unsplash', ['images'], { byok: true, filters: { license: 'local', color: 'native', safe_search: 'native' } }),++ E('pexels', ['images'], { byok: true, onboarding: 'paused', available: false }),++ E('serpapi-images', ['images'], { byok: true, price_per_1000: 25, included_allowance: '250 searches a month', upstreams: ['google'], filters: { license: 'unsupported', color: 'unsupported' } }),++ E('tineye', ['reverse_image'], { byok: true, price_per_1000: 40, filters: {} }),++ E('searxng', ['web'], { available: true }),++ E('openvibe', ['web']),++];++++const result = searchPage({++ engines,++ query: { kind: 'images', color: 'blue', size: 'large', first: 'openverse', max_cost_usd: '0.05' },++ found: [{ engine: 'openverse', receipt: { cost_usd: 0, time_ms: 12 }, results: [{++ title: 'Blue mountain', url: 'https://example.org/image.jpg', thumbnail: 'https://example.org/thumb.jpg',++ source_page: 'https://example.org/page', license: 'cc-by', license_url: 'https://example.org/license',++ author: 'Alice', width: 2400, height: 1600, rank: 1, upstream_rank: 2, upstream: 'openverse',++ }] }],++});++assert.ok(result.includes('Source, rights and rank'));++assert.ok(result.includes('2400 × 1600 px'));++assert.ok(result.includes('name="color"') && result.includes('name="after"'));++assert.ok(result.includes('name="max_cost_usd"'));++assert.ok(result.includes('Our order #1') && result.includes("Provider's rank #2"));++assert.ok(result.includes('No provider fee') && !result.includes('>Free<'));++assert.match(result, /name="mode" value="single" checked/);++assert.match(result, /name="engine">[\s\S]*?value="openverse" selected/);++const defaulted = searchPage({ engines, query: { kind: 'images' } });++assert.match(defaulted, /name="engine">[\s\S]*?value="auto" selected/);++assert.ok(result.includes('Compare 2 or 3 engines'));++assert.ok(result.includes('One combined cap') === false, 'single mode has one cap, not a combined one');++assert.ok(result.includes('Licence: Provider') && result.includes('Colour: Not supported'));++assert.ok(result.includes('Your key') && result.includes('Your instance') && result.includes('No provider fee'));++assert.ok(result.includes('Included allowance: 250 searches a month'));++assert.ok(result.includes('New keys paused'));++assert.ok(result.includes('Reverse image'));++++const compare = searchPage({++ engines, query: { kind: 'images', mode: 'compare', first: 'openverse', second: 'unsplash', max_cost_usd: '0.02', spent: 0.01 },++});++assert.match(compare, /name="mode" value="compare" checked/);++assert.ok(compare.includes('One combined cap $0.02') && compare.includes('spent $0.01'));++assert.match(compare, /name="second">[\s\S]*?value="unsplash" selected/);++++// The engine list never says a provider is free; only the words the brief allows.++assert.ok(!result.includes('Free'));process.stdout.write('image search view passed\n');nnew file mode 100644@@@ -0,0 +1,70 @@++'use strict';++const assert = require('node:assert/strict');++const { runSearch, selectedEngines } = require('../server/http/search');++++const card = (id, kinds = ['web'], extra = {}) => ({ id, kinds, available: true, price_per_1000: 0, byok: false, ...extra });++const engines = [card('openvibe'), card('deepseek'), card('brave'), card('openverse', ['images'])];++++(async () => {++ const calls = [];++ const ai = {++ async meRequest(_token, path, { body }) {++ calls.push({ path, body });++ const engine = body.engine === 'auto' ? 'openvibe' : body.engine;++ return { results: [], receipt: { engine, cost_usd: body.engine === 'brave' ? 0.002 : 0.003, time_ms: 1 } };++ },++ };++++ // One engine by default: the router's pick, not two engines paid twice.++ let out = await runSearch({ ai, token: 't', body: { q: 'x' }, kind: 'web', engines });++ assert.equal(out.mode, 'single');++ assert.equal(calls.length, 1);++ assert.equal(calls[0].body.engine, 'auto');++ assert.equal(out.spent, 0.003);++++ // An explicit engine is still one call.++ calls.length = 0;++ out = await runSearch({ ai, token: 't', body: { q: 'x', first: 'deepseek' }, kind: 'web', engines });++ assert.deepEqual(calls.map((c) => c.body.engine), ['deepseek']);++++ // Compare is explicit, sequential, and every call gets what is left of ONE combined cap.++ calls.length = 0;++ out = await runSearch({++ ai, token: 't', kind: 'web', engines,++ body: { q: 'x', mode: 'compare', first: 'openvibe', second: 'deepseek', third: 'brave', max_cost_usd: '0.01' },++ });++ assert.equal(out.mode, 'compare');++ assert.deepEqual(calls.map((c) => c.body.engine), ['openvibe', 'deepseek', 'brave']);++ assert.deepEqual(calls.map((c) => c.body.max_cost_usd), [0.01, 0.007, 0.004]);++ assert.ok(out.spent <= 0.01 + 1e-9);++++ // A duplicate choice never runs the same engine twice; fewer than two means a note, not a paid call.++ calls.length = 0;++ out = await runSearch({ ai, token: 't', kind: 'web', engines, body: { q: 'x', mode: 'compare', first: 'openvibe', second: 'openvibe' } });++ assert.equal(out.results.length, 0);++ assert.match(out.note, /two or three/);++ assert.equal(calls.length, 0);++++ // Four picks are capped at three.++ calls.length = 0;++ out = await runSearch({ ai, token: 't', kind: 'web', engines, body: { q: 'x', mode: 'compare', first: 'openvibe', second: 'deepseek', third: 'brave', fourth: 'openverse' } });++ assert.equal(calls.length, 3);++++ // Image filters are passed through, and strict safe search is a filter rather than a label.++ calls.length = 0;++ await runSearch({ ai, token: 't', kind: 'images', engines, body: { q: 'x', first: 'openverse', license: 'cc0', commercial: 'true', safe_search: 'strict', size: 'large' } });++ assert.equal(calls[0].path, '/v1/search/images');++ assert.equal(calls[0].body.license, 'cc0');++ assert.equal(calls[0].body.commercial, true);++ assert.equal(calls[0].body.safe_search, 'strict');++ assert.equal(calls[0].body.size, 'large');++++ // A filter the chosen engine cannot honour is shown to the person, not turned into an empty result.++ const refuses = { async meRequest() { throw Object.assign(new Error('openverse cannot honour color; choose an engine that supports it'), { status: 422 }); } };++ out = await runSearch({ ai: refuses, token: 't', kind: 'images', engines, body: { q: 'x', first: 'openverse', color: 'blue' } });++ assert.match(out.results[0].error, /cannot honour color/);++++ // The view and the handler agree on the default.++ assert.deepEqual(selectedEngines({}, engines, 'web'), { mode: 'single', engines: ['auto'] });++ console.log('search runs: one engine by default, explicit compare and one combined cap passed');++})().catch(e => { console.error(e); process.exitCode = 1; });
@@ -4,6 +4,16 @@ What changed in OpenVibe.Exchange, newest first.
@@ -4,6 +4,16 @@ What changed in OpenVibe.Exchange, newest first.
## Unreleased
## Unreleased
+- **Search: one engine by default, honest prices, one combined cap when you compare (2026-11-13).** A search now
+ runs one engine, the router's pick unless you name one; comparing two or three engines is an explicit choice and
+ shares ONE spending cap that is shown before the run. Prices are words, never "Free": no provider fee, your
+ instance, included allowance (with the allowance), or your key. Every filter says whether the chosen engine
+ applies it, we filter the results, or it is not supported and will be refused rather than dropped. Image results
+ keep the provider's rank beside our order, and the engine list names each supplier's upstream (Google, Bing,
+ Yandex). New image engines: SerpAPI Google Images and reverse image, SearchAPI Images, TinEye. Pexels says new
+ keys are paused. Unsplash results have a "Use this picture" action that records its required download event once.
+ The picture and search views now allow provider thumbnails and the AI image proxy in the CSP.
+
- **Measured, and your own AI usage (2026-11-12).** A model page now has a **Measured** panel from
- **Measured, and your own AI usage (2026-11-12).** A model page now has a **Measured** panel from
OpenVibe.Report's public performance rows: first token and whole-answer percentiles, tokens a second, success,
OpenVibe.Report's public performance rows: first token and whole-answer percentiles, tokens a second, success,
cache hits, what a call and a million output tokens were charged, and calls in the last 30 days. A model below
cache hits, what a call and a million output tokens were charged, and calls in the last 30 days. A model below
@@ -353,6 +353,32 @@
@@ -353,6 +353,32 @@
.xs-kinds input:focus-visible + span { outline: 2px solid var(--ovk-accent); outline-offset: 1px; }
.xs-kinds input:focus-visible + span { outline: 2px solid var(--ovk-accent); outline-offset: 1px; }
.xs-pair { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: end; gap: 10px; }
.xs-pair { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: end; gap: 10px; }
.xs-vs { padding-bottom: 12px; color: var(--ovk-muted); font-size: .82rem; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; }
.xs-vs { padding-bottom: 12px; color: var(--ovk-muted); font-size: .82rem; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; }
+/* One engine by default; comparing is an explicit switch. CSS only, so the form works without JavaScript. */
+.xs-modes { display: inline-flex; justify-self: start; gap: 4px; margin: 0; padding: 4px; border: 1px solid var(--ovk-line); border-radius: 999px; background: var(--ovk-panel-2); min-width: 0; }
+.xs-modes label { position: relative; display: inline-flex; }
+.xs-modes input { position: absolute; inset: 0; opacity: 0; margin: 0; cursor: pointer; }
+.xs-modes span { display: inline-flex; align-items: center; min-height: 36px; padding: 0 14px; border-radius: 999px; font-size: .88rem; color: var(--ovk-muted); }
+.xs-modes input:checked + span { background: color-mix(in srgb, var(--ovk-accent) 20%, transparent); color: var(--ovk-text); font-weight: 650; }
+.xs-modes input:focus-visible + span { outline: 2px solid var(--ovk-accent); outline-offset: 1px; }
+.xs-single { display: grid; }
+.xs-compare { display: none; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
+.xs-form:has(input[name="mode"][value="compare"]:checked) .xs-single { display: none; }
+.xs-form:has(input[name="mode"][value="compare"]:checked) .xs-compare { display: grid; }
+.xs-cap small { color: var(--ovk-muted); font-size: .82rem; line-height: 1.4; }
+.xs-check { display: flex; align-items: center; gap: 8px; min-height: 44px; font-size: .9rem; }
+.xs-check input { width: 18px; height: 18px; accent-color: var(--ovk-accent); }
+.xs-support { border: 1px solid var(--ovk-line); border-radius: 12px; }
+.xs-support > summary { display: flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 14px; cursor: pointer; list-style: none; font-weight: 600; font-size: .92rem; }
+.xs-support > summary::-webkit-details-marker { display: none; }
+.xs-support-engine { display: grid; gap: 2px; padding: 9px 14px; border-top: 1px solid var(--ovk-line); }
+.xs-support-engine span { color: var(--ovk-muted); font-size: .84rem; line-height: 1.45; overflow-wrap: anywhere; }
+.xs-support > .xs-note { padding: 0 14px 12px; }
+.xs-upstream { color: var(--ovk-accent) !important; }
+.xs-price-note { color: var(--ovk-muted); font-size: .8rem !important; line-height: 1.4; }
+.xs-use { margin: 6px 0 0; }
+.xs-attempts { margin-top: 4px; }
+.xs-result .xs-attempts ul { margin: 4px 0 0; padding-left: 16px; }
+.xs-result .xs-attempts li { color: var(--ovk-muted); font-size: .8rem; }
.xs-more { border: 1px solid var(--ovk-line); border-radius: 12px; }
.xs-more { border: 1px solid var(--ovk-line); border-radius: 12px; }
.xs-more > summary { display: flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 40px 0 14px; position: relative; cursor: pointer; list-style: none; font-weight: 600; font-size: .92rem; }
.xs-more > summary { display: flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 40px 0 14px; position: relative; cursor: pointer; list-style: none; font-weight: 600; font-size: .92rem; }
.xs-more > summary::-webkit-details-marker { display: none; }
.xs-more > summary::-webkit-details-marker { display: none; }
@@ -391,6 +417,7 @@
@@ -391,6 +417,7 @@
.xs-try { padding: 14px; }
.xs-try { padding: 14px; }
.xs-more-body { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.xs-more-body { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.xs-more-body > :last-child { grid-column: 1 / -1; }
.xs-more-body > :last-child { grid-column: 1 / -1; }
+ .xs-compare { grid-template-columns: minmax(0, 1fr); }
}
}
/* Grid children never stretch the page for a long line (the head's subtitle widened /search to 514 px on a phone). */
/* 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; }
.xs > * { min-width: 0; }
@@ -158,7 +158,7 @@ async function createApp(opts = {}) {
@@ -158,7 +158,7 @@ async function createApp(opts = {}) {
scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com'],
scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com'],
styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],
styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],
fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],
fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],
- imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.media', 'https://avatars.githubusercontent.com'],
+ imgSrc: ["'self'", 'data:', 'https:', 'https://openvibe.network', 'https://openvibe.media', 'https://avatars.githubusercontent.com'],
connectSrc: ["'self'", 'https://openvibe.network', 'https://openvibe.chat', 'https://cloudflareinsights.com', 'https://openvibe.events', 'https://ai.openvibe.services'],
connectSrc: ["'self'", 'https://openvibe.network', 'https://openvibe.chat', 'https://cloudflareinsights.com', 'https://openvibe.events', 'https://ai.openvibe.services'],
frameSrc: ["'self'", 'https://openvibe.network'],
frameSrc: ["'self'", 'https://openvibe.network'],
frameAncestors: ["'none'"],
frameAncestors: ["'none'"],
@@ -4,6 +4,85 @@ const views = require('../views/search');
@@ -4,6 +4,85 @@ const views = require('../views/search');
const { raw } = require('../render/html');
const { raw } = require('../render/html');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
const { sameOrigin } = require('./principal');
const { sameOrigin } = require('./principal');
+
+const FILTERS = {
+ web: ['freshness', 'safe_search'],
+ images: ['license', 'commercial', 'format', 'orientation', 'size', 'color', 'after', 'type', 'safe_search'],
+ icons: ['set', 'style', 'license', 'format'],
+};
+const one = (value, max = 80) => String(value == null ? '' : value).slice(0, max);
+const capOf = (value) => {
+ if (value == null || value === '') return null;
+ const n = Number(value);
+ return Number.isFinite(n) && n >= 0 ? Math.round(n * 1e6) / 1e6 : null;
+};
+function filterBody(body, kind) {
+ const out = {};
+ for (const key of FILTERS[kind] || []) {
+ const value = body[key];
+ if (value == null || value === '' || value === 'false') continue;
+ if (key === 'commercial') out.commercial = true;
+ else if (key === 'safe_search') out.safe_search = 'strict';
+ else out[key] = one(value);
+ }
+ return out;
+}
+/** One engine by default; compare is explicit and runs two or three distinct engines. */
+function selectedEngines(body, engines, kind) {
+ const mode = body.mode === 'compare' ? 'compare' : 'single';
+ if (mode === 'single') return { mode, engines: [(body.engine || body.first) && (body.engine || body.first) !== 'auto' ? one(body.engine || body.first) : 'auto'] };
+ const serving = engines.filter((e) => Array.isArray(e.kinds) && e.kinds.includes(kind));
+ const picks = [body.first, body.second, body.third]
+ .map((x) => one(x)).filter((x) => x && x !== 'auto');
+ const unique = [...new Set(picks)].filter((id) => serving.some((e) => e.id === id)).slice(0, 3);
+ if (!unique.length) unique.push(...serving.filter((e) => e.available !== false).slice(0, 3).map((e) => e.id));
+ return { mode, engines: unique };
+}
+function why(err) {
+ const status = err && err.status;
+ if (status === 401 || status === 403) return 'Your sign-in could not search. Sign out and in again.';
+ if (status === 429) return 'Too many searches this minute, or the key cap cannot cover this search. Try again shortly.';
+ if (status === 422) return (err && err.message) || 'This engine does not take this kind of search or cannot honour a filter.';
+ if (status === 503) return (err && err.message) || 'No configured engine serves this search within its price and freshness limits.';
+ return 'This engine did not answer. Try again, or choose another engine.';
+}
+/**
+ * Runs a single search once, or an explicit comparison sequentially against one combined cap. Sequential
+ * calls are deliberate: the remaining cap is passed to each call, so the total can never exceed it.
+ */
+async function runSearch({ ai, token, body, kind, engines = [] }) {
+ const chosen = selectedEngines(body, engines, kind);
+ const cap = capOf(body.max_cost_usd);
+ const filters = filterBody(body, kind);
+ const results = [];
+ let spent = 0;
+ let note = '';
+ if (chosen.mode === 'compare' && chosen.engines.length < 2) {
+ note = 'Choose two or three engines to compare. One engine is the default, with one cap.';
+ return { mode: chosen.mode, engines: chosen.engines, cap, spent, results, note };
+ }
+ for (const engine of chosen.engines) {
+ const remaining = cap == null ? null : Math.max(0, Math.round((cap - spent) * 1e6) / 1e6);
+ if (chosen.mode === 'compare' && remaining != null && spent > 0 && remaining <= 0) {
+ results.push({ engine, meta: engines.find((e) => e.id === engine) || null, error: 'The one combined cap is spent; this engine was not called.' });
+ continue;
+ }
+ const payload = { q: one(body.q, 500), engine, limit: 6, ...filters };
+ if (remaining != null) payload.max_cost_usd = remaining;
+ try {
+ const out = await ai.meRequest(token, '/v1/search' + (kind === 'web' ? '' : '/' + kind), { method: 'POST', body: payload });
+ const actual = out.receipt?.engine || engine;
+ results.push({ engine, meta: engines.find((e) => e.id === actual) || engines.find((e) => e.id === engine) || null, ...out });
+ spent = Math.round((spent + (Number(out.receipt?.cost_usd) || 0)) * 1e6) / 1e6;
+ } catch (err) {
+ console.warn('[exchange] search', engine, kind, err && err.status, err && err.code, (err && err.message) || '');
+ results.push({ engine, meta: engines.find((e) => e.id === engine) || null, error: why(err) });
+ }
+ }
+ if (cap != null && spent > cap + 1e-9) note = 'A provider may already have billed a timed-out attempt; the combined cap is a limit, not a promise about a failed call.';
+ return { mode: chosen.mode, engines: chosen.engines, cap, spent, results, note };
+}
+
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' }));
@@ -16,43 +95,44 @@ function createSearchRoutes({ config, ai }) {
@@ -16,43 +95,44 @@ function createSearchRoutes({ config, ai }) {
description: 'One search API for the web, pictures and icons: many engines, one key, a receipt with every answer.',
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 })),
body: raw(views.searchPage({ engines: await ai.searchEngines(), found, query })),
});
});
- r.get('/search', (req, res) => render(req, res).catch((err) => res.status(500).end(String(err && err.message || err))));
+ r.get('/search', (req, res) => {
+ const kind = ['web', 'images', 'icons'].includes(req.query.kind) ? req.query.kind : 'web';
+ return render(req, res, [], {
+ q: one(req.query.q, 500), kind, mode: req.query.mode === 'compare' ? 'compare' : 'single',
+ engine: one(req.query.engine), first: one(req.query.first), second: one(req.query.second), third: one(req.query.third),
+ max_cost_usd: req.query.max_cost_usd == null ? '' : one(req.query.max_cost_usd, 30),
+ note: req.query.chosen === '1' ? 'The Unsplash download event was recorded once for that picture.' : one(req.query.note, 240),
+ }).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');
if (!sameOrigin(req, config.baseUrl)) return res.sendStatus(403);
if (!sameOrigin(req, config.baseUrl)) return res.sendStatus(403);
- const kind = ['web', 'images', 'icons'].includes(req.body.kind)
- ? req.body.kind
- : 'web';
- const results = await Promise.all(
- [req.body.first, req.body.second].map(async (engine) => {
- try {
- return {
- engine,
- ...(await ai.meRequest(
- req.viewer.token,
- '/v1/search' + (kind === 'web' ? '' : '/' + kind),
- {
- method: 'POST',
- body: { q: req.body.q, engine, limit: 6, safe_search: 'strict',
- ...Object.fromEntries(['size', 'color', 'type', 'license', 'after'].filter((key) => req.body[key]).map((key) => [key, String(req.body[key]).slice(0, 80)])) },
- },
- )),
- };
- } 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 };
- }
- }),
- );
- await render(req, res, results, { q: String(req.body.q || '').slice(0, 500), kind,
- ...Object.fromEntries(['size', 'color', 'type', 'license', 'after'].map((key) => [key, String(req.body[key] || '').slice(0, 80)])) });
+ const kind = ['web', 'images', 'icons'].includes(req.body.kind) ? req.body.kind : 'web';
+ const body = req.body || {};
+ const out = await runSearch({ ai, token: req.viewer.token, body, kind, engines: await ai.searchEngines() });
+ await render(req, res, out.results, {
+ q: one(body.q, 500), kind, mode: out.mode,
+ engine: one(body.engine), first: one(body.first), second: one(body.second), third: one(body.third),
+ max_cost_usd: capOf(body.max_cost_usd) == null ? '' : String(capOf(body.max_cost_usd)),
+ spent: out.spent, note: out.note,
+ ...Object.fromEntries(Object.entries(filterBody(body, kind)).map(([key, value]) => [key, String(value)])),
+ });
+ });
+ // A person choosing an Unsplash picture is the moment for its required download event, not search.
+ r.post('/search/unsplash', async (req, res) => {
+ if (req.viewer?.kind !== 'user')
+ return res.redirect(303, '/auth/login?next=%2Fsearch');
+ if (!sameOrigin(req, config.baseUrl)) return res.sendStatus(403);
+ try {
+ await ai.meRequest(req.viewer.token, '/v1/search/images/unsplash/download', {
+ method: 'POST', body: { download_location: one(req.body.download_location, 8192) },
+ });
+ return res.redirect(303, '/search?chosen=1');
+ } catch (err) {
+ return res.redirect(303, '/search?note=' + encodeURIComponent(why(err)));
+ }
});
});
return r;
return r;
}
}
-module.exports = { createSearchRoutes };
+module.exports = { createSearchRoutes, selectedEngines, runSearch, filterBody };
@@ -1,20 +1,19 @@
@@ -1,20 +1,19 @@
'use strict';
'use strict';
/**
/**
- * /search (Opus, 11-09; the tool first 11-11): "Compare two engines" at the top (what to search for, the kind as a
- * switch, the two engines side by side, the picture filters in one fold), the results under it, then 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. The engine list is AI's own (GET /api/v1/exchange/search/engines), so a new engine shows here the day
- * AI adds it, named from its id until this list knows it.
+ * /search (Opus, 11-09; one engine and honest words 11-13): one engine by default, the router's pick unless
+ * the person names one. Comparing two or three engines is an explicit choice with ONE combined spending cap.
+ * Prices are words, never "Free": no provider fee, your instance, included allowance or your key. The filters
+ * say whether the chosen engine applies them, we filter locally, or they are not supported and therefore
+ * refused rather than dropped. Results keep the provider's rank beside our order.
*/
*/
const kit = require('openvibe-shared/kit');
const kit = require('openvibe-shared/kit');
const { esc } = require('../render/html');
const { esc } = require('../render/html');
-// What each engine is, in a line.
const ENGINES = {
const ENGINES = {
openvibe: ['OpenVibe Search', "OpenVibe's own index of the network's pages."],
openvibe: ['OpenVibe Search', "OpenVibe's own index of the network's pages."],
searxng: ['SearXNG', 'Many search engines at once, through our own instance.'],
searxng: ['SearXNG', 'Many search engines at once, through our own instance.'],
openverse: ['Openverse', 'Openly licensed pictures, with their licence and author.'],
openverse: ['Openverse', 'Openly licensed pictures, with their licence and author.'],
- commons: ['Wikimedia Commons', "Wikipedia's free media, with the attribution line."],
+ commons: ['Wikimedia Commons', "Wikimedia's open media, with the attribution line."],
iconify: ['Iconify', 'Open icon sets, as SVG with their licence.'],
iconify: ['Iconify', 'Open icon sets, as SVG with their licence.'],
deepseek: ['DeepSeek Search', 'Web search with fetched page text.'],
deepseek: ['DeepSeek Search', 'Web search with fetched page text.'],
brave: ['Brave Search', 'An independent web index.'],
brave: ['Brave Search', 'An independent web index.'],
@@ -24,70 +23,160 @@ const ENGINES = {
@@ -24,70 +23,160 @@ const ENGINES = {
serper: ['Serper', "Google's results, fast."],
serper: ['Serper', "Google's results, fast."],
'serper-images': ['Serper Images', 'Google Images results, fast.'],
'serper-images': ['Serper Images', 'Google Images results, fast.'],
serpapi: ['SerpAPI', "Google's results with every feature."],
serpapi: ['SerpAPI', "Google's results with every feature."],
+ 'serpapi-images': ['SerpAPI Google Images', "Google Images through SerpAPI."],
+ 'serpapi-reverse': ['SerpAPI Reverse Image', 'Pages that carry a picture, through Google.'],
+ 'searchapi-images': ['SearchAPI Images', 'One supplier for Google, Bing and Yandex images.'],
+ tineye: ['TinEye', 'Reverse image matching against its own index.'],
jina: ['Jina', 'Search and a reader that cleans pages.'],
jina: ['Jina', 'Search and a reader that cleans pages.'],
perplexity: ['Perplexity Sonar', 'An answer with its sources.'],
perplexity: ['Perplexity Sonar', 'An answer with its sources.'],
kagi: ['Kagi', 'A paid index without ads.'],
kagi: ['Kagi', 'A paid index without ads.'],
google: ['Google Programmable Search', 'Google, in a site-scoped engine.'],
google: ['Google Programmable Search', 'Google, in a site-scoped engine.'],
meta: ['Meta AI search', 'A Muse Spark answer with the web pages it read.'],
meta: ['Meta AI search', 'A Muse Spark answer with the web pages it read.'],
'openvibe-egress': ['OpenVibe Fetch', 'Reads one page you name, through OpenVibe.'],
'openvibe-egress': ['OpenVibe Fetch', 'Reads one page you name, through OpenVibe.'],
- unsplash: ['Unsplash', 'Free photographs.'],
- pexels: ['Pexels', 'Free photographs and videos.'],
- pixabay: ['Pixabay', 'Free pictures and illustrations.'],
+ unsplash: ['Unsplash', 'Stock photographs.'],
+ pexels: ['Pexels', 'Stock photographs and videos.'],
+ pixabay: ['Pixabay', 'Stock pictures and illustrations.'],
};
};
const KINDS = [['web', 'Web'], ['images', 'Pictures'], ['icons', 'Icons']];
const KINDS = [['web', 'Web'], ['images', 'Pictures'], ['icons', 'Icons']];
+const GROUPS = [...KINDS, ['reverse_image', 'Reverse image']];
+const FILTERS = {
+ web: [['freshness', 'Freshness'], ['safe_search', 'Strict safe search']],
+ images: [
+ ['license', 'Licence'], ['commercial', 'Commercial use'], ['format', 'Format'],
+ ['orientation', 'Orientation'], ['size', 'Size'], ['color', 'Colour'],
+ ['after', 'After'], ['type', 'Type'], ['safe_search', 'Strict safe search'],
+ ],
+ icons: [['set', 'Icon set'], ['style', 'Style'], ['license', 'Licence'], ['format', 'Format']],
+};
+const OPEN_DATA = new Set(['openverse', 'commons', 'iconify', 'openvibe']);
const titled = (id) => String(id || '').split(/[-_]/).filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join(' ');
const titled = (id) => String(id || '').split(/[-_]/).filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join(' ');
const nameOf = (e) => (ENGINES[e.id] || [titled(e.id)])[0];
const nameOf = (e) => (ENGINES[e.id] || [titled(e.id)])[0];
-// A missing price is not free: an engine billed per token says so instead of claiming "Free".
-const price = (e) => (e.price_per_1000 == null || !Number.isFinite(Number(e.price_per_1000)) ? 'Priced per call'
- : !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'));
+const lineOf = (e) => (ENGINES[e.id] || [])[1] || '';
+const money = (n) => `$${Number(n).toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 6 })}`;
+const priceWords = (e) => {
+ if (e.id === 'searxng') return 'Your instance';
+ if (e.byok) return 'Your key';
+ if (e.price_per_1000 == null) return 'Priced per call';
+ if (OPEN_DATA.has(e.id) || Number(e.price_per_1000) === 0) return 'No provider fee';
+ return `${money(e.price_per_1000)} per 1,000`;
+};
+const priceNote = (e) => {
+ const bits = [];
+ if (e.byok && Number(e.price_per_1000) > 0) bits.push(`The provider charges ${money(e.price_per_1000)} per 1,000`);
+ if (e.included_allowance) bits.push(`Included allowance: ${e.included_allowance}`);
+ if (e.onboarding === 'paused') bits.push('Not offered to new keys while issuance is paused');
+ if (!bits.length) bits.push(priceWords(e) === 'No provider fee' ? 'Open data; no provider query fee' : '');
+ return bits.filter(Boolean).join(' · ');
+};
+const state = (e) => {
+ if (e.onboarding === 'paused' && e.available === false) return kit.badge('New keys paused', 'warn');
+ if (e.available === false) return kit.badge(e.byok ? 'Add your key' : 'Not set up yet');
+ if (e.healthy === false) return kit.badge('Resting', 'warn');
+ return kit.badge('Ready', 'ok');
+};
+const supportWord = (v) => (v === 'native' ? 'Provider applies it' : v === 'local' ? 'We filter' : 'Not supported');
+const supportShort = (v) => (v === 'native' ? 'Provider' : v === 'local' ? 'We filter' : 'Not supported');
+const filterSupport = (e, kind) => (FILTERS[kind] || []).map(([key, label]) => [label, e && e.filters ? e.filters[key] || 'unsupported' : 'unsupported']);
function engineRow(e) {
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"${e.pricing_note ? ` title="${esc(e.pricing_note)}"` : ''}>${esc(price(e))}</span>${state(e)}</li>`;
+ const note = priceNote(e);
+ return `<li class="xs-engine${e.available === false ? ' is-off' : ''}"><div class="xs-engine-main"><strong>${esc(nameOf(e))}</strong>`
+ + (lineOf(e) ? `<span>${esc(lineOf(e))}</span>` : '')
+ + `${e.upstreams && e.upstreams.length && !['openverse', 'commons', 'iconify', 'unsplash', 'pexels', 'pixabay', 'tineye', 'searxng', 'openvibe'].includes(e.id) ? `<span class="xs-upstream">Upstream: ${esc(e.upstreams.join(', '))}</span>` : ''}`
+ + `${note ? `<span class="xs-price-note">${esc(note)}</span>` : ''}</div>`
+ + `<span class="xs-engine-price">${esc(priceWords(e))}</span>${state(e)}</li>`;
+}
+
+function enginePick(field, label, engines, chosen, includeAuto = false, optional = false) {
+ const rows = engines.map((e) => `<option value="${esc(e.id)}"${e.id === chosen ? ' selected' : ''}>${esc(nameOf(e))}${e.available === false ? ' (needs a key)' : ''}</option>`).join('');
+ const empty = optional ? `<option value=""${chosen ? '' : ' selected'}>No third engine</option>` : '';
+ return `<label class="xs-field"><span>${label}</span><select name="${field}">${empty}${includeAuto ? `<option value="auto"${chosen === 'auto' ? ' selected' : ''}>Choose for me (the router's pick)</option>` : ''}${rows}</select></label>`;
}
}
-function results(list) {
+function resultRows(list, engines, query) {
if (!list.length) return '';
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>` : ''}${r.thumbnail ? `<img src="${esc(r.thumbnail)}" alt="" loading="lazy" width="160">` : ''}${r.source_page ? `<details><summary>Source and usage</summary><p><a href="${esc(r.source_page)}" rel="noopener noreferrer">Source page</a>${r.author ? ` · ${esc(r.author)}` : ''}${r.license ? ` · ${r.license_url ? `<a href="${esc(r.license_url)}" rel="noopener noreferrer">${esc(r.license)}</a>` : esc(r.license)}` : ' · Reuse rights unknown'}${r.width && r.height ? ` · ${Number(r.width)} × ${Number(r.height)} px` : ''}${r.published ? ` · ${esc(r.published)}` : ''}</p></details>` : ''}</li>`).join('') || '<li class="xs-note">Nothing found.</li>'}</ol>`}</section>`).join('')}</div>`;
+ const byId = new Map(engines.map((e) => [e.id, e]));
+ return `<div class="xs-results">${list.map((x) => {
+ const engineId = x.receipt?.engine || x.engine;
+ const meta = byId.get(engineId) || byId.get(x.engine) || { id: engineId };
+ const cost = Number(x.receipt?.cost_usd) || 0;
+ const receipt = x.error ? '' : `<p class="xs-receipt">${cost > 0 ? `${money(cost)} provider cost` : 'No provider fee'} · ${Number(x.receipt?.time_ms) || 0} ms</p>`;
+ const attempts = x.receipt?.attempts?.length
+ ? `<details class="xs-attempts"><summary>Every attempt</summary><ul>${x.receipt.attempts.map((a) => `<li>${esc(a.engine)}: ${esc(a.status)}${a.reason ? ` · ${esc(a.reason)}` : ''}${Number(a.cost_usd) ? ` · ${money(a.cost_usd)}` : ''}</li>`).join('')}</ul></details>`
+ : '';
+ const rows = x.error ? `<p class="xs-note">${esc(x.error)}</p>` : `<ol>${x.results.map((r, i) => {
+ const upstream = r.upstream || (meta.upstreams && meta.upstreams[0]) || null;
+ const use = r.download_location && engineId === 'unsplash'
+ ? `<form method="post" action="/search/unsplash" class="xs-use"><input type="hidden" name="download_location" value="${esc(r.download_location)}"><input type="hidden" name="url" value="${esc(r.url)}">${kit.button({ label: 'Use this picture', type: 'submit', kind: 'quiet' })}</form>`
+ : '';
+ return `<li><a href="${esc(r.source_page || r.url)}" rel="noopener noreferrer">${esc(r.title || r.url)}</a>${r.snippet || r.attribution ? `<p>${esc(r.snippet || r.attribution)}</p>` : ''}${r.thumbnail ? `<img src="${esc(r.thumbnail)}" alt="" loading="lazy" width="160">` : ''}`
+ + `<details><summary>Source, rights and rank</summary><p><a href="${esc(r.source_page || r.url)}" rel="noopener noreferrer">Source page</a>${r.author ? ` · ${esc(r.author)}` : ''}${r.license ? ` · ${r.license_url ? `<a href="${esc(r.license_url)}" rel="noopener noreferrer">${esc(r.license)}</a>` : esc(r.license)}` : ' · Reuse rights unknown'}${r.width && r.height ? ` · ${Number(r.width)} × ${Number(r.height)} px` : ''}`
+ + ` · Our order #${Number(r.rank) || i + 1}${r.upstream_rank ? ` · Provider's rank #${Number(r.upstream_rank)}` : ''}${upstream ? ` · Upstream: ${esc(upstream)}` : ''}${r.reverse_image ? ' · Reverse image match' : ''}${r.published ? ` · ${esc(r.published)}` : ''}</p></details>${use}</li>`;
+ }).join('') || '<li class="xs-note">Nothing found.</li>'}</ol>`;
+ return `<section class="xs-result"><h3>${esc(nameOf({ id: x.engine }))}</h3>${receipt}${attempts}${rows}</section>`;
+ }).join('')}</div>`;
}
}
function searchPage({ engines = [], found = [], query = {} } = {}) {
function searchPage({ engines = [], found = [], query = {} } = {}) {
- const groups = KINDS.map(([kind, label]) => {
- const rows = engines.filter((e) => Array.isArray(e.kinds) && e.kinds.includes(kind));
+ const kind = KINDS.some(([k]) => k === query.kind) ? query.kind : 'web';
+ const mode = query.mode === 'compare' ? 'compare' : 'single';
+ const forKind = engines.filter((e) => Array.isArray(e.kinds) && e.kinds.includes(kind));
+ const ready = forKind.filter((e) => e.available !== false);
+ const singleDefault = query.engine || 'auto';
+ const compareDefaults = [
+ query.first && query.first !== 'auto' ? query.first : (ready[0] || forKind[0] || {}).id,
+ query.second || (ready[1] || forKind[1] || {}).id,
+ query.third || '',
+ ];
+ const selectedIds = mode === 'compare'
+ ? compareDefaults.filter(Boolean)
+ : singleDefault === 'auto'
+ ? (ready.length ? ready : forKind).slice(0, 3).map((e) => e.id)
+ : [singleDefault].filter(Boolean);
+ const selected = selectedIds.map((id) => engines.find((e) => e.id === id)).filter(Boolean);
+ const support = selected.map((e) => `<div class="xs-support-engine"><strong>${esc(nameOf(e))}</strong><span>${filterSupport(e, kind).map(([label, v]) => `${esc(label)}: ${esc(supportShort(v))}`).join(' · ')}</span></div>`).join('');
+
+ const field = (name, label, type = 'text', extra = '') => `<label class="xs-field"><span>${label}</span><input name="${name}" type="${type}" ${extra} value="${esc(query[name] || '')}"></label>`;
+ const select = (name, label, values) => `<label class="xs-field"><span>${label}</span><select name="${name}"><option value="">Any</option>${values.map((v) => `<option value="${esc(v[0])}"${query[name] === v[0] ? ' selected' : ''}>${esc(v[1])}</option>`).join('')}</select></label>`;
+ const filters = kind === 'images'
+ ? select('size', 'Size', [['small', 'Small (under 800 px)'], ['medium', 'Medium (800 to 1999 px)'], ['large', 'Large (2000 px and up)']])
+ + select('orientation', 'Orientation', [['landscape', 'Landscape'], ['portrait', 'Portrait'], ['square', 'Square']])
+ + select('color', 'Colour', ['red', 'orange', 'yellow', 'green', 'blue', 'purple', 'black_and_white'].map((v) => [v, v.replace(/_/g, ' ')]))
+ + select('type', 'Type', [['photo', 'Photo'], ['illustration', 'Illustration'], ['vector', 'Vector']])
+ + field('license', 'Licence', 'text', 'placeholder="cc0"')
+ + `<label class="xs-check"><input type="checkbox" name="commercial" value="true"${query.commercial ? ' checked' : ''}><span>Commercial use only</span></label>`
+ + field('format', 'Format', 'text', 'placeholder="jpeg or svg"')
+ + field('after', 'After', 'date')
+ + `<label class="xs-check"><input type="checkbox" name="safe_search" value="strict"${query.safe_search ? ' checked' : ''}><span>Strict safe search only</span></label>`
+ : kind === 'icons'
+ ? field('set', 'Icon set', 'text', 'placeholder="lucide"') + select('style', 'Style', [['any', 'Any'], ['line', 'Line'], ['solid', 'Solid']]) + field('license', 'Licence')
+ : select('freshness', 'Freshness', [['day', 'Past day'], ['month', 'Past month'], ['year', 'Past year']])
+ + `<label class="xs-check"><input type="checkbox" name="safe_search" value="strict"${query.safe_search ? ' checked' : ''}><span>Strict safe search only</span></label>`;
+ const filterCount = Object.keys(FILTERS[kind].reduce((a, [key]) => ({ ...a, [key]: query[key] }), {})).filter((key) => query[key]).length;
+
+ const groups = GROUPS.map(([groupKind, label]) => {
+ const rows = engines.filter((e) => Array.isArray(e.kinds) && e.kinds.includes(groupKind));
return rows.length ? `<section class="xs-group"><h3>${esc(label)}<span class="ovk-count">${rows.length}</span></h3><ul class="xs-engines">${rows.map(engineRow).join('')}</ul></section>` : '';
return rows.length ? `<section class="xs-group"><h3>${esc(label)}<span class="ovk-count">${rows.length}</span></h3><ul class="xs-engines">${rows.map(engineRow).join('')}</ul></section>` : '';
}).join('');
}).join('');
- const kind = KINDS.some(([k]) => k === query.kind) ? query.kind : 'web';
- // The engines in each list grouped by what they find; the first two that work are picked unless the search named some.
- // The first two that work for the chosen kind (Web picked two picture engines when those came first in AI's list).
- const forKind = (e) => !Array.isArray(e.kinds) || e.kinds.includes(kind);
- const ready = [...engines.filter((e) => e.available !== false && forKind(e)), ...engines.filter((e) => e.available !== false && !forKind(e))];
- const defaults = [query.first || (ready[0] || engines[0] || {}).id, query.second || (ready[1] || engines[1] || {}).id];
- const pick = (field, label, chosen) => `<label class="xs-field"><span>${label}</span><select name="${field}">${KINDS.map(([k, l]) => {
- const rows = engines.filter((e) => (Array.isArray(e.kinds) ? e.kinds[0] === k : k === 'web'));
- return rows.length ? `<optgroup label="${esc(l)}">${rows.map((e) => `<option value="${esc(e.id)}"${e.id === chosen ? ' selected' : ''}>${esc(nameOf(e))}${e.available === false ? ' (needs a key)' : ''}</option>`).join('')}</optgroup>` : '';
- }).join('')}</select></label>`;
- const sel = (name, label, values) => `<label class="xs-field"><span>${label}</span><select name="${name}"><option value="">Any</option>${values.map((v) => `<option value="${v}"${query[name] === v ? ' selected' : ''}>${v.replace(/_/g, ' ')}</option>`).join('')}</select></label>`;
- const set = ['size', 'color', 'type', 'license', 'after'].filter((k) => query[k]).length;
- const filters = `<details class="xs-more"${set ? ' open' : ''}><summary>Picture filters${set ? `<span class="ovk-count">${set}</span>` : ''}<small>for Pictures</small></summary><div class="xs-more-body">`
- + sel('size', 'Size', ['small', 'medium', 'large'])
- + sel('color', 'Colour', ['red', 'orange', 'yellow', 'green', 'blue', 'purple', 'black_and_white'])
- + sel('type', 'Type', ['photo', 'illustration', 'vector'])
- + `<label class="xs-field"><span>Licence</span><input name="license" value="${esc(query.license || '')}" placeholder="cc0"></label>`
- + `<label class="xs-field"><span>After</span><input name="after" type="date" value="${esc(query.after || '')}"></label></div></details>`;
- const kinds = `<fieldset class="xs-kinds"><legend class="ex-sr">What to find</legend>${KINDS.map(([k, l]) => `<label><input type="radio" name="kind" value="${k}"${k === kind ? ' checked' : ''}><span>${l}</span></label>`).join('')}</fieldset>`;
- 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.' })}`
- + `<section class="xs-try" aria-labelledby="xs-try-h"><h2 id="xs-try-h">Compare two engines</h2><form method="post" action="/search" class="xs-form">`
+ const single = `<div class="xs-single">${enginePick('engine', 'Engine', forKind, singleDefault, true)}</div>`;
+ const compare = `<div class="xs-compare">${enginePick('first', 'First engine', forKind, compareDefaults[0])}${enginePick('second', 'Second engine', forKind, compareDefaults[1])}${enginePick('third', 'Third engine (optional)', forKind, compareDefaults[2], false, true)}</div>`;
+ const resultHead = mode === 'compare'
+ ? `<p class="xs-receipt">One combined cap ${query.max_cost_usd != null && query.max_cost_usd !== '' ? money(query.max_cost_usd) : 'not set'} · spent ${money(query.spent || 0)}</p>`
+ : `<p class="xs-receipt">Cap ${query.max_cost_usd != null && query.max_cost_usd !== '' ? money(query.max_cost_usd) : 'not set'} · spent ${money(query.spent || 0)}</p>`;
+ 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. One engine by default; comparing is your choice.' })}`
+ + `<section class="xs-try" aria-labelledby="xs-try-h"><h2 id="xs-try-h">Search</h2><form method="post" action="/search" class="xs-form">`
+ `<div class="xs-q"><input name="q" required maxlength="500" placeholder="What are you looking for?" value="${esc(query.q || '')}" aria-label="Search for">`
+ `<div class="xs-q"><input name="q" required maxlength="500" placeholder="What are you looking for?" value="${esc(query.q || '')}" aria-label="Search for">`
- + `${kit.button({ label: 'Compare', type: 'submit', icon: 'search' })}</div>`
- + `${kinds}<div class="xs-pair">${pick('first', 'First engine', defaults[0])}<span class="xs-vs" aria-hidden="true">vs</span>${pick('second', 'Second engine', defaults[1])}</div>`
- + `${filters}</form>${results(found)}</section>`
+ + `${kit.button({ label: 'Search', type: 'submit', icon: 'search' })}</div>`
+ + `<fieldset class="xs-kinds"><legend class="ex-sr">What to find</legend>${KINDS.map(([k, l]) => `<label><input type="radio" name="kind" value="${k}"${k === kind ? ' checked' : ''}><span>${l}</span></label>`).join('')}</fieldset>`
+ + `<fieldset class="xs-modes"><legend class="ex-sr">How many engines</legend><label><input type="radio" name="mode" value="single"${mode === 'single' ? ' checked' : ''}><span>One engine</span></label><label><input type="radio" name="mode" value="compare"${mode === 'compare' ? ' checked' : ''}><span>Compare 2 or 3 engines</span></label></fieldset>`
+ + `${single}${compare}`
+ + `<label class="xs-field xs-cap"><span>Spending cap, USD</span><input name="max_cost_usd" type="number" min="0" step="0.001" placeholder="Set a cap" value="${query.max_cost_usd != null ? esc(query.max_cost_usd) : ''}"><small>${mode === 'compare' ? 'One combined cap for every engine in this comparison. The search stops before it is exceeded.' : 'The most this search may spend with the provider.'}</small></label>`
+ + `<details class="xs-more"${filterCount ? ' open' : ''}><summary>Filters${filterCount ? `<span class="ovk-count">${filterCount}</span>` : ''}<small>for ${esc((KINDS.find(([k]) => k === kind) || [])[1] || 'Web')}</small></summary><div class="xs-more-body">${filters}</div></details>`
+ + `<details class="xs-support"${support ? ' open' : ''}><summary>What the chosen ${mode === 'compare' ? 'engines' : 'engine'} can filter</summary>${support || '<p class="xs-note">Choose an engine to see what it can honour.</p>'}<p class="xs-note">A filter the chosen engine cannot honour is refused before the search, never dropped.</p></details>`
+ + `</form>${query.note ? `<p class="xs-note">${esc(query.note)}</p>` : ''}${resultHead}${resultRows(found, engines, query)}</section>`
+ `<section class="xs-list" aria-labelledby="xs-list-h"><h2 id="xs-list-h">The engines<span class="ovk-count">${engines.length}</span></h2>`
+ `<section class="xs-list" aria-labelledby="xs-list-h"><h2 id="xs-list-h">The engines<span class="ovk-count">${engines.length}</span></h2>`
+ (groups || '<p class="xs-note">The engine list is not reachable right now.</p>') + '</section>'
+ (groups || '<p class="xs-note">The engine list is not reachable right now.</p>') + '</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>`;
+ + `<p class="xs-note">The words matter: “No provider fee” is open data; “Your instance” is a SearXNG you run; “Included allowance” is a provider free tier; “Your key” is your own key and the provider may charge. Nothing here is labelled free. Pictures come with their licence and author: keep the attribution line with each one.</p></div>`;
}
}
module.exports = { searchPage, ENGINES };
module.exports = { searchPage, ENGINES };
@@ -2,17 +2,54 @@
@@ -2,17 +2,54 @@
const assert = require('node:assert/strict');
const assert = require('node:assert/strict');
const { searchPage } = require('../server/views/search');
const { searchPage } = require('../server/views/search');
-const html = searchPage({ query: { kind: 'images', color: 'blue', size: 'large' }, found: [{ engine: 'openverse', receipt: { cost_usd: 0, time_ms: 12 }, results: [{
- title: 'Blue mountain', url: 'https://example.org/image.jpg', thumbnail: 'https://example.org/thumb.jpg', source_page: 'https://example.org/page',
- license: 'cc-by', license_url: 'https://example.org/license', author: 'Alice', width: 2400, height: 1600, published: '2026-10-01',
-}] }] });
-assert.ok(html.includes('Source and usage'));
-assert.ok(html.includes('Reuse rights unknown') === false);
-assert.ok(html.includes('2400 × 1600 px'));
-assert.ok(html.includes('name="color"') && html.includes('name="after"'));
-// The default engines follow the kind: Web picks the first two working web engines even when picture engines come first.
-const E = (id, kinds, available = true) => ({ id, kinds, available, price_per_1000: 0 });
-const web = searchPage({ engines: [E('searxng', ['web'], false), E('openverse', ['images']), E('commons', ['images']), E('openvibe', ['web']), E('deepseek', ['web'])] });
-assert.match(web, /name="first">[\s\S]*?value="openvibe" selected/);
-assert.match(web, /name="second">[\s\S]*?value="deepseek" selected/);
+const E = (id, kinds, extra = {}) => ({
+ id, kinds, available: true, price_per_1000: 0, byok: false, healthy: true,
+ filters: { license: 'local', color: 'unsupported' }, upstreams: [], ...extra,
+});
+const engines = [
+ E('openverse', ['images'], { filters: { license: 'native', color: 'unsupported' } }),
+ E('unsplash', ['images'], { byok: true, filters: { license: 'local', color: 'native', safe_search: 'native' } }),
+ E('pexels', ['images'], { byok: true, onboarding: 'paused', available: false }),
+ E('serpapi-images', ['images'], { byok: true, price_per_1000: 25, included_allowance: '250 searches a month', upstreams: ['google'], filters: { license: 'unsupported', color: 'unsupported' } }),
+ E('tineye', ['reverse_image'], { byok: true, price_per_1000: 40, filters: {} }),
+ E('searxng', ['web'], { available: true }),
+ E('openvibe', ['web']),
+];
+
+const result = searchPage({
+ engines,
+ query: { kind: 'images', color: 'blue', size: 'large', first: 'openverse', max_cost_usd: '0.05' },
+ found: [{ engine: 'openverse', receipt: { cost_usd: 0, time_ms: 12 }, results: [{
+ title: 'Blue mountain', url: 'https://example.org/image.jpg', thumbnail: 'https://example.org/thumb.jpg',
+ source_page: 'https://example.org/page', license: 'cc-by', license_url: 'https://example.org/license',
+ author: 'Alice', width: 2400, height: 1600, rank: 1, upstream_rank: 2, upstream: 'openverse',
+ }] }],
+});
+assert.ok(result.includes('Source, rights and rank'));
+assert.ok(result.includes('2400 × 1600 px'));
+assert.ok(result.includes('name="color"') && result.includes('name="after"'));
+assert.ok(result.includes('name="max_cost_usd"'));
+assert.ok(result.includes('Our order #1') && result.includes("Provider's rank #2"));
+assert.ok(result.includes('No provider fee') && !result.includes('>Free<'));
+assert.match(result, /name="mode" value="single" checked/);
+assert.match(result, /name="engine">[\s\S]*?value="openverse" selected/);
+const defaulted = searchPage({ engines, query: { kind: 'images' } });
+assert.match(defaulted, /name="engine">[\s\S]*?value="auto" selected/);
+assert.ok(result.includes('Compare 2 or 3 engines'));
+assert.ok(result.includes('One combined cap') === false, 'single mode has one cap, not a combined one');
+assert.ok(result.includes('Licence: Provider') && result.includes('Colour: Not supported'));
+assert.ok(result.includes('Your key') && result.includes('Your instance') && result.includes('No provider fee'));
+assert.ok(result.includes('Included allowance: 250 searches a month'));
+assert.ok(result.includes('New keys paused'));
+assert.ok(result.includes('Reverse image'));
+
+const compare = searchPage({
+ engines, query: { kind: 'images', mode: 'compare', first: 'openverse', second: 'unsplash', max_cost_usd: '0.02', spent: 0.01 },
+});
+assert.match(compare, /name="mode" value="compare" checked/);
+assert.ok(compare.includes('One combined cap $0.02') && compare.includes('spent $0.01'));
+assert.match(compare, /name="second">[\s\S]*?value="unsplash" selected/);
+
+// The engine list never says a provider is free; only the words the brief allows.
+assert.ok(!result.includes('Free'));
process.stdout.write('image search view passed\n');
process.stdout.write('image search view passed\n');
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
+'use strict';
+const assert = require('node:assert/strict');
+const { runSearch, selectedEngines } = require('../server/http/search');
+
+const card = (id, kinds = ['web'], extra = {}) => ({ id, kinds, available: true, price_per_1000: 0, byok: false, ...extra });
+const engines = [card('openvibe'), card('deepseek'), card('brave'), card('openverse', ['images'])];
+
+(async () => {
+ const calls = [];
+ const ai = {
+ async meRequest(_token, path, { body }) {
+ calls.push({ path, body });
+ const engine = body.engine === 'auto' ? 'openvibe' : body.engine;
+ return { results: [], receipt: { engine, cost_usd: body.engine === 'brave' ? 0.002 : 0.003, time_ms: 1 } };
+ },
+ };
+
+ // One engine by default: the router's pick, not two engines paid twice.
+ let out = await runSearch({ ai, token: 't', body: { q: 'x' }, kind: 'web', engines });
+ assert.equal(out.mode, 'single');
+ assert.equal(calls.length, 1);
+ assert.equal(calls[0].body.engine, 'auto');
+ assert.equal(out.spent, 0.003);
+
+ // An explicit engine is still one call.
+ calls.length = 0;
+ out = await runSearch({ ai, token: 't', body: { q: 'x', first: 'deepseek' }, kind: 'web', engines });
+ assert.deepEqual(calls.map((c) => c.body.engine), ['deepseek']);
+
+ // Compare is explicit, sequential, and every call gets what is left of ONE combined cap.
+ calls.length = 0;
+ out = await runSearch({
+ ai, token: 't', kind: 'web', engines,
+ body: { q: 'x', mode: 'compare', first: 'openvibe', second: 'deepseek', third: 'brave', max_cost_usd: '0.01' },
+ });
+ assert.equal(out.mode, 'compare');
+ assert.deepEqual(calls.map((c) => c.body.engine), ['openvibe', 'deepseek', 'brave']);
+ assert.deepEqual(calls.map((c) => c.body.max_cost_usd), [0.01, 0.007, 0.004]);
+ assert.ok(out.spent <= 0.01 + 1e-9);
+
+ // A duplicate choice never runs the same engine twice; fewer than two means a note, not a paid call.
+ calls.length = 0;
+ out = await runSearch({ ai, token: 't', kind: 'web', engines, body: { q: 'x', mode: 'compare', first: 'openvibe', second: 'openvibe' } });
+ assert.equal(out.results.length, 0);
+ assert.match(out.note, /two or three/);
+ assert.equal(calls.length, 0);
+
+ // Four picks are capped at three.
+ calls.length = 0;
+ out = await runSearch({ ai, token: 't', kind: 'web', engines, body: { q: 'x', mode: 'compare', first: 'openvibe', second: 'deepseek', third: 'brave', fourth: 'openverse' } });
+ assert.equal(calls.length, 3);
+
+ // Image filters are passed through, and strict safe search is a filter rather than a label.
+ calls.length = 0;
+ await runSearch({ ai, token: 't', kind: 'images', engines, body: { q: 'x', first: 'openverse', license: 'cc0', commercial: 'true', safe_search: 'strict', size: 'large' } });
+ assert.equal(calls[0].path, '/v1/search/images');
+ assert.equal(calls[0].body.license, 'cc0');
+ assert.equal(calls[0].body.commercial, true);
+ assert.equal(calls[0].body.safe_search, 'strict');
+ assert.equal(calls[0].body.size, 'large');
+
+ // A filter the chosen engine cannot honour is shown to the person, not turned into an empty result.
+ const refuses = { async meRequest() { throw Object.assign(new Error('openverse cannot honour color; choose an engine that supports it'), { status: 422 }); } };
+ out = await runSearch({ ai: refuses, token: 't', kind: 'images', engines, body: { q: 'x', first: 'openverse', color: 'blue' } });
+ assert.match(out.results[0].error, /cannot honour color/);
+
+ // The view and the handler agree on the default.
+ assert.deepEqual(selectedEngines({}, engines, 'web'), { mode: 'single', engines: ['auto'] });
+ console.log('search runs: one engine by default, explicit compare and one combined cap passed');
+})().catch(e => { console.error(e); process.exitCode = 1; });