feat: Exa as a search source; the speech buttons say what they do
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 1m0s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 6s

Two unrelated small things in the admin.

Exa searches by meaning rather than keywords, which suits a clinical
question asked as a question — the shape the assistant actually sends.
It is also the only provider that returns the page text in the same
call, and the snippet is the part the model reads, so asking for it
there saves a second fetch per result. type is 'auto' rather than
'neural': pinning neural makes it worse at the keyword-shaped queries
the other four handle well.

One adapter, one dropdown entry, no other change — the route already
validated against webSearch.PROVIDERS, so the backend accepted it the
moment the list grew. A test now walks both lists in both directions: a
provider the server takes but the admin never offers is unreachable, and
one the admin offers but the server rejects is a setting that cannot be
saved.

The speech and transcription buttons said "Set". Directly beneath them
is a Roster card headed "What has been added", and Set is the one thing
that does not add anything — there is a single voice and a single model,
and choosing one replaces the last. They now say "Make default", and the
note says plainly that nothing joins a list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
Daniel 2026-09-13 01:58:20 +02:00
parent d33d355627
commit 1242b01286
4 changed files with 98 additions and 12 deletions

View file

@ -225,7 +225,10 @@
<!-- One search for every kind of model. The kind decides which
gateway list is asked and what the row buttons do: chat and
image models are added to a roster; speech, transcription and
transcription models are single defaults, so their button is Set. -->
transcription models are single defaults, so their button says
Make default rather than Add: there is one of each, and it does
not join the Roster below. "Set" read like "add to the list",
which is the one thing it does not do. -->
<div class="admin-kind-switch" role="group" aria-label="Kind of model">
<button type="button" class="admin-discover-kind" id="admin-discover-kind-chat" data-kind="chat" aria-pressed="true"><i class="fas fa-comments"></i> Chat</button>
<button type="button" class="admin-discover-kind" id="admin-discover-kind-image" data-kind="image" aria-pressed="false"><i class="fas fa-image"></i> Image</button>
@ -245,12 +248,12 @@
<div class="admin-kind-panel admin-kind-status" data-kind="tts" hidden>
<span id="admin-tts-provider-badge" class="admin-badge">Loading...</span>
<span id="admin-tts-info" class="admin-note">Loading...</span>
<span class="admin-note"><strong>Set</strong> makes a voice or model the default for Read Aloud.</span>
<span class="admin-note">There is one voice and one model for Read Aloud. <strong>Make default</strong> chooses them; nothing is added to a list.</span>
</div>
<div class="admin-kind-panel admin-kind-status" data-kind="stt" hidden>
<span id="admin-stt-provider-badge" class="admin-badge">Loading...</span>
<span id="admin-stt-info" class="admin-note">Loading...</span>
<span class="admin-note"><strong>Set</strong> makes a model the default for dictation.</span>
<span class="admin-note">There is one model for dictation. <strong>Make default</strong> chooses it; nothing is added to a list.</span>
</div>
<div class="admin-search-row">
@ -335,7 +338,7 @@
<p class="admin-note">Loading...</p>
</div>
<p class="admin-note" style="border-top:1px solid var(--g100);padding-top:10px;">Speech and transcription models are single defaults rather than a roster: choose them with <strong>Set</strong> under Discover &amp; test.</p>
<p class="admin-note" style="border-top:1px solid var(--g100);padding-top:10px;">Speech and transcription are not on this roster. There is one voice and one model for each, chosen with <strong>Make default</strong> under Discover &amp; test.</p>
</div>
</details>
@ -533,6 +536,7 @@
<option value="tavily">Tavily &mdash; built for AI, clean extracts</option>
<option value="serper">Serper &mdash; Google results</option>
<option value="brave">Brave Search API</option>
<option value="exa">Exa &mdash; searches by meaning, not keywords</option>
<option value="searxng">SearXNG &mdash; self-hosted, no third party</option>
</select>
</div>

View file

@ -1354,7 +1354,7 @@ initImageSettings();
var setType = isModel ? 'model' : 'voice';
var badge = isModel ? '<span style="font-size:9px;padding:1px 5px;border-radius:4px;background:var(--blue);color:white;margin-left:4px;">MODEL</span>' : '<span style="font-size:9px;padding:1px 5px;border-radius:4px;background:var(--green);color:white;margin-left:4px;">VOICE</span>';
return '<div style="display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;background:var(--g50);font-size:13px;">' +
'<button class="btn-sm btn-primary admin-tts-set-btn" data-id="' + esc(v.id) + '" data-type="' + setType + '" style="padding:2px 8px;font-size:11px;">Set</button>' +
'<button class="btn-sm btn-primary admin-tts-set-btn" data-id="' + esc(v.id) + '" data-type="' + setType + '" style="padding:2px 8px;font-size:11px;">Make default</button>' +
'<span style="flex:1;">' + esc(v.name) + badge + '</span>' +
'<span style="font-size:10px;color:var(--g400);">' + esc(v.source || '') + '</span>' +
'</div>';
@ -1522,7 +1522,7 @@ initImageSettings();
container.innerHTML = '<p style="font-size:12px;color:var(--g500);margin:0 0 6px;">Found ' + data.count + ' models (provider: ' + esc(data.provider) + ')</p>' +
items.map(function(m) {
return '<div style="display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;background:var(--g50);font-size:13px;">' +
'<button class="btn-sm btn-primary admin-stt-set-btn" data-id="' + esc(m.id) + '" style="padding:2px 8px;font-size:11px;">Set</button>' +
'<button class="btn-sm btn-primary admin-stt-set-btn" data-id="' + esc(m.id) + '" style="padding:2px 8px;font-size:11px;">Make default</button>' +
'<span style="flex:1;">' + esc(m.name || m.id) + '</span>' +
'<span style="font-size:10px;color:var(--g400);">' + esc(m.source || '') + '</span>' +
'</div>';
@ -1951,7 +1951,7 @@ initImageSettings();
// ============================================================
// ADMIN IMAGE MODEL MANAGEMENT
// ============================================================
// Unlike TTS and STT there is no single default to Set: an image model is
// Unlike TTS and STT there is no single default to make: an image model is
// chosen per workflow, so discovery here ends in a Test, and the workflow
// pickers under Availability consume the same discovery call. Nothing loads
// on tab entry: the workflow pickers already make the one discovery call

View file

@ -9,15 +9,16 @@
// then, and only the search query leaves — never the corpus excerpts, never the
// generated content, never anything about the user.
//
// Four providers, one shape. Tavily and Brave are answer-oriented APIs, Serper
// fronts Google, and SearXNG is self-hosted — the only one where the query does
// not reach a commercial third party at all, which is why it is worth having
// even though it needs somewhere to run.
// Five providers, one shape. Tavily and Brave are answer-oriented APIs, Serper
// fronts Google, Exa searches by meaning rather than keywords — which suits a
// clinical question phrased as a question — and SearXNG is self-hosted, the
// only one where the query does not reach a commercial third party at all,
// which is why it is worth having even though it needs somewhere to run.
// ============================================================
var db = require('../db/database');
var PROVIDERS = ['tavily', 'serper', 'brave', 'searxng'];
var PROVIDERS = ['tavily', 'serper', 'brave', 'exa', 'searxng'];
var MAX_RESULTS = 8;
var TIMEOUT_MS = 15000;
@ -96,6 +97,32 @@ var ADAPTERS = {
}));
},
// Exa is an embeddings search: it matches on meaning, so a question asked as
// a question works better here than the keywords the others want. contents
// asks for a text extract in the same call — without it every result would
// need a second fetch, and the snippet is the part the model actually reads.
async exa(query, s) {
var r = await fetch('https://api.exa.ai/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': s.apiKey },
body: JSON.stringify({
query: query,
numResults: MAX_RESULTS,
// 'auto' lets Exa fall back to keyword search when a query reads like
// keywords; pinning 'neural' would make it worse at the queries the
// other providers handle well.
type: 'auto',
contents: { text: { maxCharacters: 1200 } }
}),
signal: AbortSignal.timeout(TIMEOUT_MS)
});
if (!r.ok) throw new Error('Exa returned ' + r.status);
var data = await r.json();
return normalize((data.results || []).map(function (x) {
return { title: x.title, url: x.url, snippet: x.text || x.summary || '' };
}));
},
async searxng(query, s) {
var base = s.baseUrl.replace(/\/+$/, '');
var r = await fetch(base + '/search?format=json&q=' + encodeURIComponent(query), {

View file

@ -50,6 +50,7 @@ test('every provider comes back in the same shape', async () => {
['tavily', { results: [{ title: 'T', url: 'https://a', content: 'snippet a' }] }],
['serper', { organic: [{ title: 'T', link: 'https://a', snippet: 'snippet a' }] }],
['brave', { web: { results: [{ title: 'T', url: 'https://a', description: 'snippet a' }] } }],
['exa', { results: [{ title: 'T', url: 'https://a', text: 'snippet a' }] }],
['searxng', { results: [{ title: 'T', url: 'https://a', content: 'snippet a' }] }]
];
for (const [provider, payload] of cases) {
@ -191,3 +192,57 @@ test('both screens say plainly that a query leaves the network', () => {
assert.match(js, /\['mr-modify-pubmed-row', 'pubmed'\]/);
assert.match(js, /row\.hidden = !available\[pair\[1\]\]/);
});
// ---- Exa -------------------------------------------------------------------
// Embeddings search rather than keywords, which suits a clinical question asked
// as a question. It is the only provider that can return the page text in the
// same call, and the snippet is the part the model reads.
test('Exa asks for the text extract in the search call', async () => {
// Without contents, every result would need a second fetch to be useful.
let sent = null;
const lib = load({ 'websearch.enabled': 'true', 'websearch.provider': 'exa', 'websearch.api_key': 'k' },
async (url, options) => { sent = { url, options }; return { ok: true, status: 200, json: async () => ({ results: [] }) }; });
await lib.search('does dexamethasone help croup');
assert.equal(sent.url, 'https://api.exa.ai/search');
const body = JSON.parse(sent.options.body);
assert.equal(body.query, 'does dexamethasone help croup');
assert.ok(body.contents && body.contents.text, 'no text extract requested');
// 'auto', not 'neural': pinning neural makes it worse at the keyword-shaped
// queries the other providers handle well.
assert.equal(body.type, 'auto');
assert.equal(sent.options.headers['x-api-key'], 'k', 'Exa authenticates with x-api-key, not a bearer token');
});
test('Exa falls back through its snippet fields rather than returning nothing', async () => {
const lib = load({ 'websearch.enabled': 'true', 'websearch.provider': 'exa', 'websearch.api_key': 'k' },
async () => ({ ok: true, status: 200, json: async () => ({ results: [
{ title: 'A', url: 'https://a', text: 'from text' },
{ title: 'B', url: 'https://b', summary: 'from summary' },
{ title: 'C', url: 'https://c' }
] }) }));
const out = await lib.search('x');
assert.deepEqual(out.results.map(r => r.snippet), ['from text', 'from summary', '']);
});
test('Exa needs a key, like every provider but SearXNG', async () => {
const lib = load({ 'websearch.enabled': 'true', 'websearch.provider': 'exa' },
async () => { throw new Error('must not be called'); });
assert.equal(await lib.isAvailable(), false);
});
test('the admin can choose it, and the server accepts what the admin can choose', async () => {
// The dropdown and the route validate against the same list; a provider in
// one and not the other is a setting that saves and then does nothing, or an
// option that cannot be saved at all.
const lib = load(ON, async () => ({ ok: true, status: 200, json: async () => ({ results: [] }) }));
const markup = read('public/components/admin.html');
for (const provider of lib.PROVIDERS) {
assert.match(markup, new RegExp('<option value="' + provider + '"'),
provider + ' is accepted by the server but not offered in the admin');
}
const offered = [...markup.matchAll(/<option value="(\w+)">[^<]*(?:Tavily|Serper|Brave|Exa|SearXNG)/g)].map(m => m[1]);
for (const provider of offered) {
assert.ok(lib.PROVIDERS.includes(provider), provider + ' is offered in the admin but rejected by the server');
}
});