mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
feat(search): say why a SearXNG instance does not answer, and test the address from settings
This commit is contained in:
1 parent
ee92b3f4d7
commit
e858a46f70
22 files changed
+432
-15
No files matched your search
@@ -215,6 +215,12 @@ class BenchPort extends MockPort {
|
||||
};
|
||||
}
|
||||
|
||||
async probeSearxng(url: string) {
|
||||
const answer = query.get("probe");
|
||||
if (!answer || answer === "ok") return super.probeSearxng(url);
|
||||
return { ok: false, code: `search.searxng_${answer}`, status: answer === "status" ? 429 : answer === "json_disabled" ? 403 : undefined };
|
||||
}
|
||||
|
||||
async peersSettings() {
|
||||
if (!query.has("peerset")) return super.peersSettings();
|
||||
return {
|
||||
|
||||
@@ -554,6 +554,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "web-access.backend", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "web-access.move", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "web-access.consent", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "web-access.probe", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "web-access.searxng-url", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "appearance.reading-size", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "appearance.text-size", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
|
||||
@@ -235,6 +235,14 @@ export const EN: Record<string, string> = {
|
||||
"同意后,前面的后端都失败时才会调用它,费用记在你的 DeepSeek 账户上。不勾选就不会用它。": "Once you agree, it is called only when the backends before it have failed, and the cost goes to your DeepSeek account. Without your agreement it is never used.",
|
||||
"需要一个带密钥的 DeepSeek 提供方,才能使用并同意这一项。": "This needs a DeepSeek provider with a key before it can be used or agreed to.",
|
||||
"SearXNG 地址": "SearXNG address",
|
||||
"正在测试…": "Testing…",
|
||||
"没能测试这个地址:{detail}": "Could not test this address: {detail}",
|
||||
"连通:这个 SearXNG 能返回 JSON 搜索结果。": "Connected: this SearXNG returns JSON search results.",
|
||||
"这个 SearXNG 没有开启 JSON 格式:在它的 settings.yml 的 search.formats 里加上 json,然后重启它。": "This SearXNG does not allow the JSON format: add json to search.formats in its settings.yml, then restart it.",
|
||||
"连不上这个地址:检查它是否在运行,地址和端口是否写对。": "Cannot reach this address: check that it is running and that the address and port are right.",
|
||||
"这个地址有应答,但不是 SearXNG 的 JSON 搜索结果:确认地址指向 SearXNG 本身,而不是登录页或别的服务。": "This address answers, but not with SearXNG's JSON results: make sure it points at SearXNG itself, not a login page or another service.",
|
||||
"这个地址把请求转到了别处:请直接填 SearXNG 的最终地址。": "This address redirects elsewhere: enter SearXNG's final address directly.",
|
||||
"这个 SearXNG 拒绝了测试搜索(HTTP {status}):稍后重试,或查看它的日志。": "This SearXNG refused the test search (HTTP {status}): try again later, or check its log.",
|
||||
"保存后只对之后新开的窗格生效,已开的窗格沿用旧设置。这些设置写在用户配置文件里:": "A saved change reaches only panes opened afterwards; open panes keep the old settings. These settings are kept in the user config file: ",
|
||||
"这些设置现在是只读的:这个窗口没有被授予编辑权限。": "These settings are read-only now: this window has not been granted editing.",
|
||||
"启用会话间消息": "Turn on session messages",
|
||||
|
||||
@@ -471,6 +471,7 @@ const SAID: Record<string, string> = {
|
||||
"web_access.editing_disabled": "这台服务器没有开启联网搜索与抓取设置的编辑:只有这台电脑上的窗口能改。",
|
||||
"web_access.save_failed": "联网搜索与抓取的设置未能保存:{detail}",
|
||||
"web_access.unknown_backend": "“{backend}”不是可选的联网后端,未做任何修改。",
|
||||
"web_access.probe_failed": "没能测试这个地址:{detail}",
|
||||
"web_access.bad_searxng_url": "SearXNG 地址必须是带主机名的 http(s) 地址,不能含账号、查询串或片段,未做任何修改。",
|
||||
"ask_timeout.save_failed": "提问等待时长未能保存:{detail}",
|
||||
"ask_timeout.out_of_range": "等待时长超出允许范围,未做任何修改:{detail}",
|
||||
|
||||
@@ -243,6 +243,13 @@ export interface WebAccessSettings {
|
||||
path: string;
|
||||
}
|
||||
|
||||
// What one test search to a SearXNG address showed. code is empty when it answered.
|
||||
export interface SearxngProbe {
|
||||
ok: boolean;
|
||||
code?: string;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export type WebAccessPatch = Partial<Pick<WebAccessSettings, "search_backends" | "searxng_url" | "search_paid_allowed" | "fetch_backends" | "fetch_paid_allowed">>;
|
||||
|
||||
export interface ChangedFilesSettings {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Adjudications, BrowserToolsSettings, SubagentClaimsSettings, WriteLeaseSettings, PlanApprovalSettings, PeerReceipt, PeersState, PeersSettings, PeersSettingsEdit, PeerRemember, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, SandboxSettings, UpdateMode } from "./port";
|
||||
import type { AskTimeoutSettings, AutoArchiveSettings, ChangedFilesSettings, FoldWorkSettings, TurnNarrationSettings, WebAccessPatch, WebAccessSettings, DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
|
||||
import type { AskTimeoutSettings, AutoArchiveSettings, ChangedFilesSettings, FoldWorkSettings, TurnNarrationSettings, WebAccessPatch, WebAccessSettings, SearxngProbe, DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
|
||||
import { HttpError } from "./http_error";
|
||||
import { MockShell } from "./mock_shell";
|
||||
|
||||
@@ -270,6 +270,10 @@ export class MockBoundary extends MockShell {
|
||||
return { ...this.webAccess };
|
||||
}
|
||||
|
||||
async probeSearxng(url: string): Promise<SearxngProbe> {
|
||||
return url.trim() ? { ok: true } : { ok: false, code: "search.searxng_unreachable" };
|
||||
}
|
||||
|
||||
async saveFoldWorkSettings(fold: boolean): Promise<FoldWorkSettings> {
|
||||
this.foldWork = { ...this.foldWork, fold };
|
||||
return { ...this.foldWork };
|
||||
|
||||
@@ -46,7 +46,7 @@ import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
|
||||
import type { PluginExport, PluginInstallRequest, PluginPackage, PluginPlan } from "./plugin";
|
||||
import type { MarketDetail, MarketList, MarketOwnRequest, MarketPackage, MarketPlan, MarketPublished, MarketQuery, MarketRequest, MarketSubmission, MarketVote } from "./market";
|
||||
import type { Appearance, ThemeImport, ThemePack } from "./look";
|
||||
import type { AskTimeoutSettings, AutoArchiveSettings, ChangedFilesSettings, FoldWorkSettings, TurnNarrationSettings, WebAccessPatch, WebAccessSettings, BrowserToolsSettings, SubagentClaimsSettings, WriteLeaseSettings, PlanApprovalSettings, PeerReceipt, PeersState, PeersSettings, PeersSettingsEdit, PeerRemember, ConfigProblem, ConfigRepair, DisplayCurrencyMode, DisplayCurrencySettings, PermissionLists, PermissionRules, ProgressWatchSettings, SandboxSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
|
||||
import type { AskTimeoutSettings, AutoArchiveSettings, ChangedFilesSettings, FoldWorkSettings, TurnNarrationSettings, WebAccessPatch, WebAccessSettings, SearxngProbe, BrowserToolsSettings, SubagentClaimsSettings, WriteLeaseSettings, PlanApprovalSettings, PeerReceipt, PeersState, PeersSettings, PeersSettingsEdit, PeerRemember, ConfigProblem, ConfigRepair, DisplayCurrencyMode, DisplayCurrencySettings, PermissionLists, PermissionRules, ProgressWatchSettings, SandboxSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
|
||||
import type { Protocol, ProviderCheck, ProviderCheckDraft, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup, ProviderRemoval, ProviderPreset, ProviderPresetInstall } from "./provider";
|
||||
export type { ModelEffort, ModelLimit, ModelRate, Protocol, ProviderCheck, ProviderCheckDraft, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup, ProviderRemoval, ProviderPreset, ProviderPresetInstall } from "./provider";
|
||||
import type { StoragePlan, StorageQuery, StorageState } from "./storage";
|
||||
@@ -302,6 +302,7 @@ export interface AgentPort {
|
||||
saveTurnNarrationSettings(level: TurnNarrationSettings["level"]): Promise<TurnNarrationSettings>;
|
||||
webAccessSettings(): Promise<WebAccessSettings>;
|
||||
saveWebAccessSettings(patch: WebAccessPatch): Promise<WebAccessSettings>;
|
||||
probeSearxng(url: string): Promise<SearxngProbe>;
|
||||
progressWatch(): Promise<ProgressWatchSettings>;
|
||||
// Applies to the running turn at its next round; no rebuild.
|
||||
saveProgressWatch(s: Pick<ProgressWatchSettings, "pause" | "rounds" | "tokenMultiple">): Promise<ProgressWatchSettings>;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { SseShell } from "./sse_shell";
|
||||
import type { Adjudications, BrowserToolsSettings, SubagentClaimsSettings, WriteLeaseSettings, PlanApprovalSettings, PeerReceipt, PeersState, PeersSettings, PeersSettingsEdit, PeerRemember, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, SandboxSettings } from "./port";
|
||||
import type { AskTimeoutSettings, AutoArchiveSettings, ChangedFilesSettings, FoldWorkSettings, TurnNarrationSettings, WebAccessPatch, WebAccessSettings, DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
|
||||
import type { AskTimeoutSettings, AutoArchiveSettings, ChangedFilesSettings, FoldWorkSettings, TurnNarrationSettings, WebAccessPatch, WebAccessSettings, SearxngProbe, DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
|
||||
|
||||
// Where the agent may reach: the permission rules a call is matched against and
|
||||
// the sandbox the shell runs in.
|
||||
@@ -101,6 +101,9 @@ export class SseBoundary extends SseShell {
|
||||
webAccessSettings() {
|
||||
return this.get<WebAccessSettings>("/web-access-settings");
|
||||
}
|
||||
probeSearxng(url: string) {
|
||||
return this.post0<SearxngProbe>("/web-access-settings/probe", { url });
|
||||
}
|
||||
saveWebAccessSettings(patch: WebAccessPatch) {
|
||||
return this.post0<WebAccessSettings>("/web-access-settings", patch);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,10 @@ textarea.peerfield { min-height: 4.5em; resize: vertical; }
|
||||
.wa-consent { display: flex; flex: 1 1 100%; align-items: center; gap: 10px; padding: 6px 0 2px 14px; }
|
||||
.wa-consent > .tx { display: grid; flex: 1 1 12em; min-width: 0; }
|
||||
.wa-url { display: grid; gap: 4px; padding: 6px 12px 10px; }
|
||||
.wa-test { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; }
|
||||
.wa-test > .act { flex: none; }
|
||||
.wa-test > .peernote { flex: 1 1 14em; padding: 0; }
|
||||
.wa-test > .peernote.bad { color: var(--err-ink); }
|
||||
.peerreleased { display: grid; margin: 0; padding: 0; list-style: none; }
|
||||
.peerreleased li { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 6px 12px; border-top: 1px solid var(--hair); }
|
||||
.peerreleased .tx { display: grid; min-width: 0; }
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { reason } from "../i18n/kernel";
|
||||
import { HttpError } from "../port/http_error";
|
||||
import type { AgentPort, WebAccessPatch, WebAccessSettings } from "../port/port";
|
||||
import type { AgentPort, SearxngProbe, WebAccessPatch, WebAccessSettings } from "../port/port";
|
||||
import { Group } from "./Group";
|
||||
import { Switch } from "./Switch";
|
||||
|
||||
@@ -16,6 +16,16 @@ const NOTE: Record<Kind, Record<string, string>> = {
|
||||
search: { searxng: "你自己部署的搜索服务,免费,需要填它的地址。", deepseek_api: "DeepSeek 官方的搜索接口,按次计费。" },
|
||||
fetch: { local: "由本机直接下载网页,免费。", deepseek_api: "DeepSeek 官方的网页抓取接口,按次计费。" },
|
||||
};
|
||||
const PROBE: Record<string, string> = {
|
||||
"search.searxng_json_disabled": "这个 SearXNG 没有开启 JSON 格式:在它的 settings.yml 的 search.formats 里加上 json,然后重启它。",
|
||||
"search.searxng_unreachable": "连不上这个地址:检查它是否在运行,地址和端口是否写对。",
|
||||
"search.searxng_not_searxng": "这个地址有应答,但不是 SearXNG 的 JSON 搜索结果:确认地址指向 SearXNG 本身,而不是登录页或别的服务。",
|
||||
"search.searxng_redirected": "这个地址把请求转到了别处:请直接填 SearXNG 的最终地址。",
|
||||
};
|
||||
const probeSaid = (r: SearxngProbe) =>
|
||||
r.ok ? t("连通:这个 SearXNG 能返回 JSON 搜索结果。")
|
||||
: PROBE[r.code ?? ""] ? t(PROBE[r.code ?? ""])
|
||||
: t("这个 SearXNG 拒绝了测试搜索(HTTP {status}):稍后重试,或查看它的日志。", { status: String(r.status ?? "") });
|
||||
const said = (kind: Kind, id: string) => (NAME[kind][id] ? t(NAME[kind][id]) : id);
|
||||
|
||||
/** Where the agent looks things up and reads pages from: an ordered list per
|
||||
@@ -26,6 +36,8 @@ export function WebAccess({ port }: { port: AgentPort }) {
|
||||
const [state, setState] = useState<WebAccessSettings | null>(null);
|
||||
const [url, setUrl] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [probe, setProbe] = useState<SearxngProbe | null>(null);
|
||||
const [probing, setProbing] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
// A window the server has not granted editing to is read-only; the first refused
|
||||
// save is how that is learned, and nothing here asks again.
|
||||
@@ -58,6 +70,20 @@ export function WebAccess({ port }: { port: AgentPort }) {
|
||||
}
|
||||
}, [port]);
|
||||
|
||||
const test = async () => {
|
||||
setProbing(true);
|
||||
setProbe(null);
|
||||
setError("");
|
||||
try {
|
||||
setProbe(await port.probeSearxng(url.trim()));
|
||||
} catch (e) {
|
||||
if (e instanceof HttpError && e.reason?.code === "web_access.editing_disabled") setReadOnly(true);
|
||||
setError(reason(e));
|
||||
} finally {
|
||||
setProbing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!state) return null;
|
||||
const locked = busy || readOnly;
|
||||
|
||||
@@ -113,6 +139,10 @@ export function WebAccess({ port }: { port: AgentPort }) {
|
||||
<input id="searxng-url" className="peerfield" data-action-change="web-access.searxng-url" aria-label={t("SearXNG 地址")} placeholder="http://127.0.0.1:8080"
|
||||
value={url} disabled={locked} onChange={(e) => setUrl(e.target.value)}
|
||||
onBlur={() => { if (url.trim() !== state.searxng_url) void apply({ searxng_url: url.trim() }); }} />
|
||||
<div className="wa-test">
|
||||
<button className="act" data-action="web-access.probe" disabled={readOnly || probing || !url.trim()} onClick={() => void test()}>{probing ? t("正在测试…") : t("测试连接")}</button>
|
||||
{probe && <div className={probe.ok ? "peernote" : "peernote bad"} role="status">{probeSaid(probe)}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -181,4 +181,36 @@ describe("when the settings apply", () => {
|
||||
expect(screen.getByText(/之后新开的窗格/)).toBeTruthy();
|
||||
expect(screen.getByText("/home/me/.reasonix/config.toml")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("tests the address in the field and names why it fails", async () => {
|
||||
const { port, save } = portWith(settings({ search_backends: ["searxng"], searxng_url: "http://127.0.0.1:8080" }));
|
||||
const probe = vi.spyOn(port, "probeSearxng").mockResolvedValue({ ok: false, code: "search.searxng_json_disabled", status: 403 });
|
||||
render(<WebAccess port={port} />);
|
||||
const field = await screen.findByRole("textbox", { name: "SearXNG 地址" });
|
||||
await userEvent.clear(field);
|
||||
await userEvent.type(field, "http://127.0.0.1:9999");
|
||||
await userEvent.click(screen.getByRole("button", { name: "测试连接" }));
|
||||
expect(probe).toHaveBeenCalledExactlyOnceWith("http://127.0.0.1:9999");
|
||||
expect((await screen.findByRole("status")).textContent).toMatch(/search\.formats/);
|
||||
for (const [patch] of save.mock.calls) expect(Object.keys(patch)).toEqual(["searxng_url"]);
|
||||
});
|
||||
|
||||
it("says plainly when the instance answers", async () => {
|
||||
const { port } = portWith(settings({ search_backends: ["searxng"], searxng_url: "http://127.0.0.1:8080" }));
|
||||
vi.spyOn(port, "probeSearxng").mockResolvedValue({ ok: true });
|
||||
render(<WebAccess port={port} />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: "测试连接" }));
|
||||
expect((await screen.findByRole("status")).textContent).toMatch(/连通/);
|
||||
});
|
||||
|
||||
it("cannot test an empty address, and a refused test locks the section as read-only", async () => {
|
||||
const { port } = portWith(settings({ search_backends: ["searxng"] }));
|
||||
vi.spyOn(port, "probeSearxng").mockRejectedValue(new HttpError(403, "x", { code: "web_access.editing_disabled", error: "editing is not enabled" }));
|
||||
render(<WebAccess port={port} />);
|
||||
const button = await screen.findByRole("button", { name: "测试连接" });
|
||||
expect((button as HTMLButtonElement).disabled).toBe(true);
|
||||
await userEvent.type(screen.getByRole("textbox", { name: "SearXNG 地址" }), "http://x");
|
||||
await userEvent.click(button);
|
||||
expect(await screen.findByText(/只读/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -2004,6 +2004,16 @@ A backend counts as not answering when:
|
||||
Each time that happens Reasonix shows a notice naming the backend and the cause, then tries the next one. If none answers, the model receives `search.all_backends_failed`.
|
||||
|
||||
- Engines inside SearXNG that rate-limit you do not fail a search while one engine still answers.
|
||||
|
||||
In Settings, **Web access** has a **Test connection** button beside the address. It sends one search to the address in the field, follows no redirect, and says why the instance does not answer. The model is told the same code when a real search fails.
|
||||
|
||||
| Code | Meaning | What to do |
|
||||
|---|---|---|
|
||||
| `search.searxng_json_disabled` | The instance answered 403. SearXNG does that when `json` is not in `search.formats` of its `settings.yml`; a reverse proxy in front of it can also answer 403. | Add `json` to `search.formats` and restart it; if that is set, look at the proxy. |
|
||||
| `search.searxng_unreachable` | Nothing answered at the address. | Check that it runs and that host and port are right. |
|
||||
| `search.searxng_not_searxng` | It answered 200 but not with SearXNG's JSON results. | Point at SearXNG itself, not a login page or another service. |
|
||||
| `search.searxng_redirected` | The address redirects. | Enter the final address, for example the `https` one. |
|
||||
| `search.searxng_status` | Any other HTTP status, for example 429 or 500. | Retry later or read the instance's log. |
|
||||
- Results are third-party content and are labelled as such to the model.
|
||||
|
||||
### Paid fallback: the DeepSeek Search API
|
||||
|
||||
@@ -1571,6 +1571,16 @@ searxng_url = "http://localhost:8888"
|
||||
每次发生时 Reasonix 会弹出通知,写明后端和原因,再尝试下一个;全部没有回答时,模型收到 `search.all_backends_failed`。
|
||||
|
||||
- SearXNG 内部某些引擎被限流,只要还有一个引擎能回答,搜索就不算失败。
|
||||
|
||||
设置里的「联网:搜索与抓取」在地址旁有「测试连接」按钮:它向框里的地址发一次搜索,不跟随重定向,并说明这个实例为什么没有应答。真实搜索失败时,模型收到的是同一个码。
|
||||
|
||||
| 码 | 含义 | 怎么办 |
|
||||
|---|---|---|
|
||||
| `search.searxng_json_disabled` | 实例回了 403。SearXNG 在 `settings.yml` 的 `search.formats` 没有 `json` 时就这样回;它前面的反向代理也可能回 403。 | 在 `search.formats` 里加上 `json` 并重启;已有的话检查代理。 |
|
||||
| `search.searxng_unreachable` | 这个地址没有任何应答。 | 确认它在运行,主机和端口写对。 |
|
||||
| `search.searxng_not_searxng` | 回了 200,但不是 SearXNG 的 JSON 结果。 | 地址要指向 SearXNG 本身,不是登录页或别的服务。 |
|
||||
| `search.searxng_redirected` | 这个地址会重定向。 | 直接填最终地址,例如 `https` 的那个。 |
|
||||
| `search.searxng_status` | 其他 HTTP 状态,比如 429、500。 | 稍后重试,或查看实例的日志。 |
|
||||
- 搜索结果是第三方内容,会向模型标注为外部内容。
|
||||
|
||||
### 付费回退:DeepSeek 搜索接口
|
||||
|
||||
@@ -118,7 +118,7 @@ func TestEffectSearxngFailureReachesTheNoticeAndTheModelWithACode(t *testing.T)
|
||||
}), "[tools]\nweb_search_backends = [\"searxng\"]\nsearxng_url = \"SEARX_URL\"\n", sink)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(notices) != 1 || notices[0].Detail != "searxng|status|429||none" {
|
||||
if len(notices) != 1 || notices[0].Detail != "searxng|status|429|search.searxng_status|none" {
|
||||
t.Fatalf("notices = %+v", notices)
|
||||
}
|
||||
results := probe.toolResults()
|
||||
@@ -127,6 +127,18 @@ func TestEffectSearxngFailureReachesTheNoticeAndTheModelWithACode(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// An instance whose settings do not list the JSON format refuses with 403; the
|
||||
// model is told that cause by its code, not just that every backend failed.
|
||||
func TestEffectSearxngWithoutJSONFormatTellsTheModelWhy(t *testing.T) {
|
||||
probe, _ := searchChainSession(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
}), "[tools]\nweb_search_backends = [\"searxng\"]\nsearxng_url = \"SEARX_URL\"\n", nil)
|
||||
results := probe.toolResults()
|
||||
if len(results) != 1 || !strings.Contains(results[0], "search.searxng_json_disabled") {
|
||||
t.Fatalf("tool results = %v, want the cause by its code", results)
|
||||
}
|
||||
}
|
||||
|
||||
// With no backend chosen nothing is sent to any instance: the setting is the
|
||||
// only thing that turns a query loose.
|
||||
func TestEffectNoSearchBackendSendsNothing(t *testing.T) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/session/control"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
func (s *Server) registerWebAccessRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /web-access-settings", s.webAccessSettings)
|
||||
mux.HandleFunc("POST /web-access-settings", s.saveWebAccessSettings)
|
||||
mux.HandleFunc("POST /web-access-settings/probe", s.probeSearxng)
|
||||
}
|
||||
|
||||
func (s *Server) webAccessSettings(w http.ResponseWriter, _ *http.Request) {
|
||||
@@ -43,3 +45,30 @@ func (s *Server) saveWebAccessSettings(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, saved)
|
||||
}
|
||||
}
|
||||
|
||||
// probeSearxng sends one test search to the address in the request, so a window
|
||||
// can say why an instance fails before the address is saved. It reaches out from
|
||||
// this machine to a place the caller names, which is the window's to ask for.
|
||||
func (s *Server) probeSearxng(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.grants.at(r).providerEdit {
|
||||
refuse(w, http.StatusForbidden, "web_access.editing_disabled", "web search and fetch settings editing is not enabled on this server", nil)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&body); err != nil {
|
||||
badBody(w)
|
||||
return
|
||||
}
|
||||
probe, err := s.ctl().ProbeSearxng(r.Context(), strings.TrimSpace(body.URL))
|
||||
if errors.Is(err, config.ErrBadSearxngURL) {
|
||||
refuse(w, http.StatusBadRequest, "web_access.bad_searxng_url", err.Error(), nil)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
saveFailed(w, http.StatusInternalServerError, "web_access.probe_failed", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, probe)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/session/control"
|
||||
"reasonix/internal/tools/websearch"
|
||||
)
|
||||
|
||||
func webAccessFile(t *testing.T) config.WebAccessSettings {
|
||||
@@ -152,3 +153,82 @@ func TestWebAccessSettingsAreWrittenOnlyWithTheEditGrant(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func searxngAt(t *testing.T, h http.HandlerFunc) string {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(h)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv.URL
|
||||
}
|
||||
|
||||
func probeBody(t *testing.T, resp *http.Response) websearch.SearxngProbe {
|
||||
t.Helper()
|
||||
defer resp.Body.Close()
|
||||
var out websearch.SearxngProbe
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestProbeNamesWhyAnInstanceFailsWithoutSavingAnything(t *testing.T) {
|
||||
s := newProviderEditServer(t)
|
||||
s.AllowProviderEdit()
|
||||
srv := httptest.NewServer(operatorHandler(s))
|
||||
defer srv.Close()
|
||||
before, _ := os.ReadFile(config.UserConfigPath())
|
||||
|
||||
refusing := searxngAt(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusForbidden) })
|
||||
resp := postProvider(t, srv.URL, "/web-access-settings/probe", `{"url":"`+refusing+`"}`)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("probe = %d", resp.StatusCode)
|
||||
}
|
||||
if got := probeBody(t, resp); got.OK || got.Code != websearch.CodeSearxngJSONDisabled || got.Status != http.StatusForbidden {
|
||||
t.Fatalf("probe = %+v, want the JSON format reported as not enabled", got)
|
||||
}
|
||||
|
||||
working := searxngAt(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"results":[]}`)) })
|
||||
if got := probeBody(t, postProvider(t, srv.URL, "/web-access-settings/probe", `{"url":"`+working+`"}`)); !got.OK {
|
||||
t.Fatalf("an instance that answers must read as ok: %+v", got)
|
||||
}
|
||||
if after, _ := os.ReadFile(config.UserConfigPath()); string(after) != string(before) {
|
||||
t.Fatal("a probe must not write the user file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeUsesTheSavedAddressWhenTheRequestNamesNone(t *testing.T) {
|
||||
s := newProviderEditServer(t)
|
||||
s.AllowProviderEdit()
|
||||
srv := httptest.NewServer(operatorHandler(s))
|
||||
defer srv.Close()
|
||||
saved := searxngAt(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusTooManyRequests) })
|
||||
postProvider(t, srv.URL, "/web-access-settings", `{"searxng_url":"`+saved+`"}`).Body.Close()
|
||||
if got := probeBody(t, postProvider(t, srv.URL, "/web-access-settings/probe", `{}`)); got.Code != websearch.CodeSearxngStatus || got.Status != http.StatusTooManyRequests {
|
||||
t.Fatalf("probe = %+v, want the saved instance's refusal", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeRefusesWithoutTheWindowsGrantAndForABadAddress(t *testing.T) {
|
||||
denied := httptest.NewServer(operatorHandler(newProviderEditServer(t)))
|
||||
defer denied.Close()
|
||||
reached := make(chan struct{}, 1)
|
||||
target := searxngAt(t, func(http.ResponseWriter, *http.Request) { reached <- struct{}{} })
|
||||
resp := postProvider(t, denied.URL, "/web-access-settings/probe", `{"url":"`+target+`"}`)
|
||||
if resp.StatusCode != http.StatusForbidden || webAccessRefusal(t, resp) != "web_access.editing_disabled" {
|
||||
t.Fatalf("a server that did not open provider editing must refuse the probe: %d", resp.StatusCode)
|
||||
}
|
||||
select {
|
||||
case <-reached:
|
||||
t.Fatal("a refused probe must not send a request")
|
||||
default:
|
||||
}
|
||||
|
||||
s := newProviderEditServer(t)
|
||||
s.AllowProviderEdit()
|
||||
allowed := httptest.NewServer(operatorHandler(s))
|
||||
defer allowed.Close()
|
||||
resp = postProvider(t, allowed.URL, "/web-access-settings/probe", `{"url":"file:///etc/passwd"}`)
|
||||
if resp.StatusCode != http.StatusBadRequest || webAccessRefusal(t, resp) != "web_access.bad_searxng_url" {
|
||||
t.Fatalf("a non-http address: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"reasonix/internal/state/checkpoint"
|
||||
"reasonix/internal/state/memory"
|
||||
"reasonix/internal/tools/jobs"
|
||||
"reasonix/internal/tools/websearch"
|
||||
)
|
||||
|
||||
// This file defines the driving port: the typed, segregated interface surface
|
||||
@@ -302,6 +303,7 @@ type RuntimeSettings interface {
|
||||
SaveTurnNarrationSettings(level string) error
|
||||
WebAccessSettings() WebAccessSettings
|
||||
SaveWebAccessSettings(patch WebAccessPatch) (WebAccessSettings, error)
|
||||
ProbeSearxng(ctx context.Context, address string) (websearch.SearxngProbe, error)
|
||||
ProgressWatchSettings() ProgressWatchSettings
|
||||
SaveProgressWatchSettings(in ProgressWatchSettings) error
|
||||
AskTimeoutSettings() AskTimeoutSettings
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package control
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/tools/websearch"
|
||||
)
|
||||
|
||||
// WebAccessSettings is where web_search and web_fetch may send things, as the
|
||||
@@ -115,3 +117,13 @@ func orEmpty(list []string) []string {
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// ProbeSearxng sends one test search to the address a window has in its field, or
|
||||
// to the saved one when that is empty, and reports why the instance did not
|
||||
// answer. The address is validated first and nothing is saved.
|
||||
func (c *Controller) ProbeSearxng(ctx context.Context, address string) (websearch.SearxngProbe, error) {
|
||||
if address == "" {
|
||||
address = c.WebAccessSettings().SearxngURL
|
||||
}
|
||||
return websearch.ProbeSearxng(ctx, address)
|
||||
}
|
||||
@@ -21,6 +21,16 @@ const (
|
||||
searxngMaxSnippet = 500
|
||||
)
|
||||
|
||||
// Codes for why the user's own instance did not answer. SearXNG owns the first:
|
||||
// it refuses a format its settings do not list with 403.
|
||||
const (
|
||||
CodeSearxngJSONDisabled = "search.searxng_json_disabled"
|
||||
CodeSearxngUnreachable = "search.searxng_unreachable"
|
||||
CodeSearxngNotSearxng = "search.searxng_not_searxng"
|
||||
CodeSearxngRedirected = "search.searxng_redirected"
|
||||
CodeSearxngStatus = "search.searxng_status"
|
||||
)
|
||||
|
||||
// Searxng queries the user's own SearXNG instance through its JSON format. It is
|
||||
// free, so it is the first backend a chain offers.
|
||||
type Searxng struct {
|
||||
@@ -44,12 +54,16 @@ func NewSearxng(raw string, client *http.Client) (*Searxng, error) {
|
||||
func (*Searxng) Name() string { return "searxng" }
|
||||
func (*Searxng) Paid() bool { return false }
|
||||
|
||||
type searxResult struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// searxResponse keeps results raw so an answer without the key, which is not
|
||||
// SearXNG's, differs from an instance that found nothing.
|
||||
type searxResponse struct {
|
||||
Results []struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Content string `json:"content"`
|
||||
} `json:"results"`
|
||||
Results json.RawMessage `json:"results"`
|
||||
}
|
||||
|
||||
func (s *Searxng) Search(ctx context.Context, query string) ([]Hit, error) {
|
||||
@@ -63,18 +77,22 @@ func (s *Searxng) Search(ctx context.Context, query string) ([]Hit, error) {
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, &BackendError{Backend: s.Name(), Kind: FailTransport, Err: err}
|
||||
return nil, &BackendError{Backend: s.Name(), Kind: FailTransport, Code: CodeSearxngUnreachable, Err: err}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, &BackendError{Backend: s.Name(), Kind: FailStatus, Status: resp.StatusCode}
|
||||
return nil, &BackendError{Backend: s.Name(), Kind: FailStatus, Status: resp.StatusCode, Code: searxngStatusCode(resp.StatusCode)}
|
||||
}
|
||||
var body searxResponse
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, searxngMaxBody)).Decode(&body); err != nil {
|
||||
return nil, &BackendError{Backend: s.Name(), Kind: FailDecode, Err: err}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, searxngMaxBody)).Decode(&body); err != nil || body.Results == nil {
|
||||
return nil, &BackendError{Backend: s.Name(), Kind: FailDecode, Code: CodeSearxngNotSearxng, Err: err}
|
||||
}
|
||||
var found []searxResult
|
||||
if err := json.Unmarshal(body.Results, &found); err != nil {
|
||||
return nil, &BackendError{Backend: s.Name(), Kind: FailDecode, Code: CodeSearxngNotSearxng, Err: err}
|
||||
}
|
||||
hits := make([]Hit, 0, searxngMaxHits)
|
||||
for _, r := range body.Results {
|
||||
for _, r := range found {
|
||||
if r.URL == "" || len(hits) == searxngMaxHits {
|
||||
continue
|
||||
}
|
||||
@@ -85,3 +103,13 @@ func (s *Searxng) Search(ctx context.Context, query string) ([]Hit, error) {
|
||||
}
|
||||
return hits, nil
|
||||
}
|
||||
|
||||
func searxngStatusCode(status int) string {
|
||||
switch {
|
||||
case status == http.StatusForbidden:
|
||||
return CodeSearxngJSONDisabled
|
||||
case status >= 300 && status < 400:
|
||||
return CodeSearxngRedirected
|
||||
}
|
||||
return CodeSearxngStatus
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package websearch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const searxngProbeTimeout = 8 * time.Second
|
||||
|
||||
// SearxngProbe is what one test query to an instance showed. Code is empty when
|
||||
// the instance answered; Status is the HTTP status when that is what refused.
|
||||
type SearxngProbe struct {
|
||||
OK bool `json:"ok"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Status int `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// ProbeSearxng sends one search to the address and reports why it did not
|
||||
// answer. It follows no redirect, so the address the user typed is the only
|
||||
// host it ever contacts, and it is bounded in time and in body size.
|
||||
func ProbeSearxng(ctx context.Context, raw string) (SearxngProbe, error) {
|
||||
client := &http.Client{
|
||||
Timeout: searxngProbeTimeout,
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
||||
}
|
||||
backend, err := NewSearxng(raw, client)
|
||||
if err != nil {
|
||||
return SearxngProbe{}, err
|
||||
}
|
||||
_, err = backend.Search(ctx, "searxng")
|
||||
var failed *BackendError
|
||||
switch {
|
||||
case err == nil, errors.As(err, &failed) && failed.Kind == FailEmpty:
|
||||
return SearxngProbe{OK: true}, nil
|
||||
case errors.As(err, &failed):
|
||||
return SearxngProbe{Code: failed.Code, Status: failed.Status}, nil
|
||||
}
|
||||
return SearxngProbe{Code: CodeSearxngUnreachable}, nil
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package websearch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func probeOf(t *testing.T, h http.HandlerFunc) SearxngProbe {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(h)
|
||||
defer srv.Close()
|
||||
got, err := ProbeSearxng(context.Background(), srv.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func TestProbeNamesWhyAnInstanceDidNotAnswer(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
h http.HandlerFunc
|
||||
ok bool
|
||||
code string
|
||||
status int
|
||||
}{
|
||||
{"answers", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"results":[{"title":"t","url":"https://example.com","content":"c"}]}`))
|
||||
}, true, "", 0},
|
||||
{"found nothing is still an instance that answers", func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"results":[]}`))
|
||||
}, true, "", 0},
|
||||
{"json format not listed", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusForbidden) }, false, CodeSearxngJSONDisabled, 403},
|
||||
{"a page that is not json", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("<html>login</html>")) }, false, CodeSearxngNotSearxng, 0},
|
||||
{"json that is not searxng's", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"ok":true}`)) }, false, CodeSearxngNotSearxng, 0},
|
||||
{"rate limited", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusTooManyRequests) }, false, CodeSearxngStatus, 429},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := probeOf(t, c.h)
|
||||
if got.OK != c.ok || got.Code != c.code || got.Status != c.status {
|
||||
t.Fatalf("probe = %+v, want ok=%v code=%q status=%d", got, c.ok, c.code, c.status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeReportsAnAddressNothingListensOn(t *testing.T) {
|
||||
srv := httptest.NewServer(http.NotFoundHandler())
|
||||
url := srv.URL
|
||||
srv.Close()
|
||||
got, err := ProbeSearxng(context.Background(), url)
|
||||
if err != nil || got.OK || got.Code != CodeSearxngUnreachable {
|
||||
t.Fatalf("probe = %+v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeNeverContactsAHostTheRedirectNames(t *testing.T) {
|
||||
var elsewhere atomic.Int32
|
||||
other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
elsewhere.Add(1)
|
||||
_, _ = w.Write([]byte(`{"results":[]}`))
|
||||
}))
|
||||
defer other.Close()
|
||||
got := probeOf(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, other.URL+"/search", http.StatusFound)
|
||||
})
|
||||
if got.OK || got.Code != CodeSearxngRedirected || elsewhere.Load() != 0 {
|
||||
t.Fatalf("probe = %+v, other host contacted %d times", got, elsewhere.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeRefusesAnAddressThatIsNotAnInstanceOrigin(t *testing.T) {
|
||||
if _, err := ProbeSearxng(context.Background(), "file:///etc/passwd"); err == nil {
|
||||
t.Fatal("a non-http address must be refused before any request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchTellsTheModelWhyTheInstanceFailed(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusForbidden) }))
|
||||
defer srv.Close()
|
||||
backend, err := NewSearxng(srv.URL, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = backend.Search(context.Background(), "q")
|
||||
if got := failure(err).identity(); got != CodeSearxngJSONDisabled {
|
||||
t.Fatalf("identity = %q, want %q", got, CodeSearxngJSONDisabled)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
- 设置里的 SearXNG 地址旁新增「测试连接」,说明连不上的原因(未开 JSON 格式、无应答、不是 SearXNG、被重定向);搜索失败时模型也收到同一原因码。关联 #13541。
|
||||
- A Test connection button beside the SearXNG address says why an instance does not answer (JSON off, no answer, not SearXNG, redirected); the model gets the same code. Refs #13541.
|
||||
Reference in new issue
Block a user