Enable automatic web search by default; improve toggle behavior and search planning.
CI / check (22) (push) Successful in 9m35s
CI / check (24) (push) Successful in 9m34s

This commit is contained in:
2026-10-06 08:50:03 +02:00
parent 674abf78f6
commit 7be654af11
17 changed files with 300 additions and 54 deletions
+63 -10
View File
@@ -7,7 +7,7 @@ import { createServer } from "node:http";
import { createApp } from "../src/app.js";
import { createChatService } from "../src/services/chatService.js";
import { runMigrations } from "../src/db/schema.js";
import { SearXNGSearchProvider, WebSearchError, buildWebSearchContext, buildWebSearchQuery, decideWebSearch, selectSearchResults, retrieveWebSearch } from "../src/services/webSearchService.js";
import { SearXNGSearchProvider, WebSearchError, buildWebSearchContext, buildWebSearchQuery, decideWebSearch, planWebSearch, selectSearchResults, retrieveWebSearch } from "../src/services/webSearchService.js";
import { createModelService } from "../src/services/modelService.js";
import { formatChatMessage } from "../src/services/chatMessageHelpers.js";
import { appendSources, createWebSearchToggle } from "../public/js/webSearch.js";
@@ -81,6 +81,55 @@ test("search decisions and source selection use only relevant, renumbered eviden
]);
});
test("the model can choose research beyond keywords and skip keyword matches", async () => {
let planningRequest;
const history = [{role: "user", content: "I am choosing a Swiss rail pass."}];
const modelService = {async generateReply(model, messages, options) {
planningRequest = {model, messages, options};
return '{"search":true,"query":"Swiss rail pass visitor comparison"}';
}};
let query;
const outcome = await retrieveWebSearch("Which one suits me?", {async search(value) {
query = value;
return [{title: "Swiss rail pass comparison", url: "https://example.org/passes", snippet: "Compare Swiss rail pass options for visitors and their trips."}];
}}, {modelService, model: "selected:model", history, referenceText: ""});
assert.equal(outcome.state, "succeeded");
assert.equal(query, "Swiss rail pass visitor comparison");
assert.equal(planningRequest.model, "selected:model");
assert.deepEqual(JSON.parse(planningRequest.messages.at(-1).content).history, history);
assert.equal(planningRequest.options.reasoning, false);
assert.equal(planningRequest.options.format.properties.search.type, "boolean");
const skipped = await retrieveWebSearch("Write a fictional breaking news story", {search() { assert.fail("The model chose not to search"); }}, {
modelService: {async generateReply() { return '{"search":false,"query":""}'; }}, model: "selected:model"
});
assert.equal(skipped.state, "skipped");
assert.equal(skipped.sources, null);
const explicit = await planWebSearch("Search the web for Swiss rail passes", {
modelService: {generateReply() { assert.fail("Explicit searches do not need planning"); }}
});
assert.equal(explicit.shouldSearch, true);
});
test("search planning handles invalid output and failure without swallowing cancellation", async () => {
for (const response of ["not JSON", "null", '{"search":"yes","query":"wrong"}', '{"search":true,"query":""}']) {
const modelService = {async generateReply() { return response; }};
assert.equal((await planWebSearch("latest news", {modelService})).shouldSearch, true);
assert.equal((await planWebSearch("hello", {modelService})).shouldSearch, false);
}
assert.equal((await planWebSearch("hello", {modelService: {async generateReply() { throw new Error("Offline"); }}})).shouldSearch, false);
const controller = new AbortController();
await assert.rejects(planWebSearch("hello", {signal: controller.signal, modelService: {async generateReply(_model, _messages, {signal}) {
controller.abort();
assert.equal(signal.aborted, true);
return '{"search":true,"query":"unused"}';
}}}), {name: "AbortError"});
const unavailable = await retrieveWebSearch("hello", new SearXNGSearchProvider({searxngUrl: ""}), {
modelService: {generateReply() { assert.fail("Do not plan use of an unconfigured search provider"); }}
});
assert.equal(unavailable.state, "skipped");
});
test("every Ollama request includes dynamic server date context", async () => {
let requestBody;
const modelService = createModelService({
@@ -147,7 +196,13 @@ test("chat search integrates with streaming, persistence, personas, settings, re
});
const calls = [];
const modelService = {
async generateReply(model, messages, options) { calls.push({ model, messages, options }); return "A fact [1]."; },
async generateReply(model, messages, options) {
if (options.format?.properties?.search) {
const {message} = JSON.parse(messages.at(-1).content);
return JSON.stringify({search: decideWebSearch(message).shouldSearch, query: message});
}
calls.push({ model, messages, options }); return "A fact [1].";
},
async streamReply(model, messages, onChunk, options) {
calls.push({ model, messages, options });
onChunk("A fact ", "A fact ");
@@ -222,7 +277,7 @@ test("chat search integrates with streaming, persistence, personas, settings, re
assert.equal(messages[0].sources, undefined);
assert.deepEqual(messages.at(-1).sources, [source]);
const botId = messages.at(-1).id;
const retry = await api(`/chats/${chatId}/messages/${botId}/retry`, {});
const retry = await api(`/chats/${chatId}/messages/${botId}/retry`, {webSearch: false});
assert.equal(retry.status, 200);
assert.deepEqual(retry.body.message.sources, []);
await api(`/chats/${chatId}/messages/${botId}/versions/0`, {});
@@ -379,23 +434,21 @@ class Element {
get childElementCount() { return this.children.length; }
}
test("composer toggle resets and source renderer supports old messages and safe numbered links", (t) => {
test("composer search defaults on and source renderer supports old messages and safe numbered links", (t) => {
const original = globalThis.document;
globalThis.document = { createElement: (tag) => new Element(tag) };
t.after(() => { if (original === undefined) delete globalThis.document; else globalThis.document = original; });
const button = new Element("button");
const toggle = createWebSearchToggle(button);
assert.equal(toggle.enabled, false);
assert.equal(button.title, "Search the web");
button.listeners.click();
assert.equal(toggle.enabled, true);
assert.equal(button.attributes["aria-pressed"], "true");
assert.equal(button.title, "Web search enabled");
assert.match(button.title, /Automatic web search on/);
button.listeners.click();
assert.equal(toggle.enabled, false);
assert.equal(button.attributes["aria-pressed"], "false");
assert.match(button.title, /Web search off/);
button.listeners.click();
toggle.reset();
assert.equal(toggle.enabled, false);
assert.equal(toggle.enabled, true);
const container = new Element("div");
appendSources(container, undefined);
appendSources(container, []);