openvibers/openvibe.exchange Commits

Use shared composer for model directory search (#208)

OP OpenVibers committed 2 days ago · cfec7271f6 · 4 files, +50 −3

Changes

ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 1cd4486..c92704a 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -31,6 +31,7 @@ const platform = require('../views/platform');
const credentialViews = require('../views/credentials');
const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 60 });
++const COMPOSER_HEAD = raw(`<link rel="stylesheet" href="${ovServe.url('thread.css')}"><script src="${ovServe.url('ov-composer.js')}" defer></script>`);
const RETIRED_PATHS = ['/listings', '/listings/*', '/submit', '/sandbox', '/sandbox/*', '/installs', '/offers', '/safety', '/developers'];
const textOf = (s) => esc(String(s || '')).replace(/\n/g, '<br>');
@@@ -113,6 +114,7 @@ function createPageRoutes(ctx) {
title: null,
description: 'Every AI model, one account: live prices, speed and uptime of every offer, from the families on sale today and more as their providers join. Chat, or call one OpenAI-compatible endpoint.',
jsonLd: homeJsonLd(config),
++ head: COMPOSER_HEAD,
body: raw(platform.home({ catalog, usage, viewer: req.viewer, q, config })),
});
});
@@@ -139,6 +141,7 @@ function createPageRoutes(ctx) {
index: true, cache: PUBLIC_CACHE, agentLayer: true,
title: q ? `Models: ${q}` : 'Models',
description: 'Every model on OpenVibe.Exchange with its live offers: price in and out per million tokens, measured speed and uptime, context length and data policy.',
++ head: COMPOSER_HEAD,
body: raw(platform.modelsPage({ catalog: req.query.status && req.query.status !== 'available' ? { ...catalog, models: [] } : catalog, query: req.query, searchResults })
+ require('../model-directory').directoryList(directory.models || [], req.query, (catalog.models || []).map(m => m.slug))),
});
ddiff --git a/server/views/platform.js b/server/views/platform.js
iindex efd7650..db0d8dd 100644
---- a/server/views/platform.js
++++ b/server/views/platform.js
@@@ -1,5 +1,6 @@
'use strict';
const kit = require('openvibe-shared/kit');
++const thread = require('openvibe-shared/thread');
const legacy = require('./exchange');
const listed = (catalog) => legacy.chatModels(catalog);
const unavailable = (catalog) =>
@@@ -32,6 +33,24 @@ const chat = (m) =>
const rank = (m) =>
`https://openvibe.compare/things/${encodeURIComponent(id(m))}`;
const section = (title, body) => kit.section({ title, body });
++const modelSearch = (query = {}, filters = false) => {
++ const field = (name, label, placeholder = '') => `<label>${esc(label)} <input name="${name}" value="${esc(query[name] || '')}" placeholder="${esc(placeholder)}"></label>`;
++ const options = filters ? { label: 'Search filters', html: [
++ field('provider', 'Provider'), field('capability', 'Capability', 'vision'),
++ field('min_price', 'Min $/M out'), field('max_price', 'Max $/M out'),
++ field('max_speed_ms', 'Max median ms'), field('rating_kind', 'Benchmark', 'chat.arena'),
++ '<button class="ovk-btn ovk-btn--quiet" type="submit">Apply filters</button>',
++ ].join('') } : null;
++ const form = thread.composer({ action: '/models', method: 'get', name: 'q', value: query.q || '',
++ id: filters ? 'model-search' : 'home-model-search', label: 'Search models',
++ placeholder: 'Find a model or a provider', sendLabel: 'Search models', maxlength: 120,
++ options, attrs: { 'data-ovc-no-draft': '', style: '--ovt-line:var(--border,#1f2d47);--ovt-accent:var(--accent,#3b82f6)' },
++ commands: [{ name: 'gemini', hint: 'Find Gemini models', insert: 'gemini' },
++ { name: 'gpt', hint: 'Find GPT models', insert: 'gpt' }],
++ });
++ // The shared prompt is required by default. Filters can be submitted on their own.
++ return filters ? form.replace(' data-ovt-text required', ' data-ovt-text') : form;
++};
const code = (m) =>
kit.snippet({
id: 'platform-curl',
@@@ -79,7 +98,7 @@ function pricingPage() {
}
function home({ catalog, usage, viewer }) {
const models = listed(catalog);
-- return `<div class="ex-wrap xp">${unavailable(catalog)}<section class="xp-hero"><h1>One API for every model.<br>Pay what the model costs.</h1>${kit.find({ action: '/models', name: 'q', placeholder: 'Find a model', icon: 'search' })}<p>${kit.button({ href: viewer?.kind === 'user' ? '/dashboard/keys#new' : '/auth/login?next=%2Fdashboard%2Fkeys%23new', label: 'Get a key' })} ${kit.button({ href: '/docs', label: 'Read the docs', kind: 'quiet' })} ${kit.button({ href: '/compute', label: 'Compute prices', kind: 'quiet' })}</p>${code()}</section>${kit.stats(
++ return `<div class="ex-wrap xp">${unavailable(catalog)}<section class="xp-hero"><h1>One API for every model.<br>Pay what the model costs.</h1>${modelSearch()}<p>${kit.button({ href: viewer?.kind === 'user' ? '/dashboard/keys#new' : '/auth/login?next=%2Fdashboard%2Fkeys%23new', label: 'Get a key' })} ${kit.button({ href: '/docs', label: 'Read the docs', kind: 'quiet' })} ${kit.button({ href: '/compute', label: 'Compute prices', kind: 'quiet' })}</p>${code()}</section>${kit.stats(
[
[models.length, 'models'],
[
@@@ -159,8 +178,7 @@ function modelsPage({ catalog, query = {}, searchResults = null }) {
chips.push(`<a class="ex-chip ${query[key] === value ? 'is-on' : ''}" href="/models?${esc(params.toString())}">${label}</a>`);
}
const searchMeta = searchResults && new Map(searchResults.map((r) => [r.model, r]));
-- const field = (name, label, value = '') => `<label>${esc(label)} <input name="${name}" value="${esc(query[name] || '')}" placeholder="${esc(value)}"></label>`;
-- const filters = `<form action="/models" method="get">${field('q', 'Search', 'cheap vision model')} ${field('provider', 'Provider')} ${field('capability', 'Capability', 'vision')} ${field('min_price', 'Min $/M out')} ${field('max_price', 'Max $/M out')} ${field('max_speed_ms', 'Max median ms')} ${field('rating_kind', 'Benchmark', 'chat.arena')} ${kit.button({ label: 'Find', type: 'submit' })}</form>`;
++ const filters = modelSearch(query, true);
return `<div class="ex-wrap xp">${kit.head({ title: 'Models', sub: `${rows.length} models · live prices and measured performance` })}${unavailable(catalog)}${filters}<nav class="ex-chips" aria-label="Model filters">${chips.join('')}</nav>${table(rows, searchMeta)}<p class="ex-more"><a href="https://openvibe.compare/">Compare models side by side on OpenVibe.Compare →</a></p></div>`;
}
module.exports = { home, modelsPage, pricing, pricingPage, table };
ddiff --git a/test/model-composer.test.js b/test/model-composer.test.js
nnew file mode 100644
iindex 0000000..3c46684
---- /dev/null
++++ b/test/model-composer.test.js
@@@ -0,0 +1,22 @@
++'use strict';
++const assert = require('node:assert/strict');
++const { createFakeAi } = require('./helpers/ai');
++const platform = require('../server/views/platform');
++
++(async () => {
++ const catalog = await createFakeAi().catalog();
++ const home = platform.home({ catalog, usage: {}, viewer: { kind: 'anonymous' } });
++ assert.match(home, /<form[^>]+data-ovt-composer/);
++ assert.match(home, /action="\/models"/);
++ assert.match(home, /name="q"/);
++ const list = platform.modelsPage({ catalog, query: { q: 'Gemini', provider: 'google', capability: 'vision' } });
++ assert.equal((list.match(/name="q"/g) || []).length, 1, 'one main search box');
++ assert.match(list, /class="ovt-c-row"/);
++ assert.match(list, /class="ovt-c-tools"/);
++ assert.match(list, /name="provider" value="google"/);
++ assert.match(list, /name="capability" value="vision"/);
++ assert.match(list, /data-ovt-text>Gemini<\/textarea>/);
++ assert.match(list, /<button[^>]+type="submit">Apply filters<\/button>/);
++ assert.doesNotMatch(list, /data-ovt-text required/, 'filter-only search must be submittable');
++ process.stdout.write('model composer passed\n');
++})().catch((err) => { process.stderr.write(String(err.stack || err)); process.exitCode = 1; });
ddiff --git a/test/pages.test.js b/test/pages.test.js
iindex ca6d189..9e86fcd 100644
---- a/test/pages.test.js
++++ b/test/pages.test.js
@@@ -18,6 +18,7 @@ const { createFakeAi, downAi } = require('./helpers/ai');
assert.ok(r.text.includes('Get a key') && r.text.includes('api.openvibe.exchange/v1/chat/completions'));
assert.ok(r.text.includes('tokens routed this week') && r.text.includes('median time to first token'));
assert.ok(r.text.includes('DeepSeek Chat') && r.text.includes('Try in Chat') && r.text.includes('How it ranks'));
++ assert.ok(r.text.includes('data-ovt-composer') && r.text.includes('name="q"') && r.text.includes('ov-composer.'));
assert.strictEqual((r.text.match(/<h1[\s>]/g)||[]).length,1);
});
@@@ -26,6 +27,9 @@ const { createFakeAi, downAi } = require('./helpers/ai');
assert.strictEqual(r.status, 200);
assert.ok(r.text.includes('DeepSeek Chat'));
assert.ok(r.text.includes('$0.2 / $0.3'), 'the cheapest offer, not the dearest');
++ assert.ok(r.text.includes('class="ovt-c-row"') && r.text.includes('class="ovt-c-tools"'));
++ assert.ok(r.text.includes('Search filters') && r.text.includes('name="provider"') && r.text.includes('Apply filters'));
++ assert.ok(!r.text.includes('<input name="q"'), 'the main search box is the shared composer');
const filtered = await t.get('/models?q=llama');
assert.ok(filtered.text.includes('Llama 3 8B abliterated'));
assert.ok(!filtered.text.includes('DeepSeek Chat'), 'the search filters');