feat(agent): web.search / web.fetch via Brave Search (WEB-1)

- adapters/websearch: Brave client (X-Subscription-Token, error classes),
  result normalization with redaction/truncation, SSRF guard, HTML→text fetch
  (10s, 512KB, text/html|text/plain only)
- modules/websearch: 10-min PG cache (region websearch), cross-process
  per-minute limit, web.fetch allowlist = URLs from the caller's latest
  web.search; POST /websearch/test
- settings.agent.webSearch (key write-only → secret_ref); settings card
  under Agent; web.* offered to the model only when enabled, plus a system
  prompt rule; outputs marked untrusted
- capability count 27 → 29; e2e E13; runbook docs/runbook/websearch.md

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 00:14:08 +08:00
1 parent 40dd0e69e4
commit d53df25fc8
32 files changed
+1854 -39

No files matched your search

+14 -1
View File
@@ -1,5 +1,10 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { type GlobalSettings, type GlobalSettingsUpdate, mergeSettings } from "@umt/contracts";
import {
type GlobalSettings,
type GlobalSettingsUpdate,
mergeSettings,
type WebSearchTestResult,
} from "@umt/contracts";
import { apiData } from "~/lib/api.ts";
import { qk } from "~/lib/query-keys.ts";
@@ -30,3 +35,11 @@ export function useUpdateSettings() {
},
});
}
/** POST /websearch/test:用已保存的搜索服务 Key 实际查一次 */
export function useTestWebSearch() {
return useMutation({
mutationFn: (body: { query?: string } = {}) =>
apiData<WebSearchTestResult>("/websearch/test", { method: "POST", body }),
});
}
@@ -0,0 +1,164 @@
import type { GlobalSettings } from "@umt/contracts";
import { LoaderCircle } from "lucide-react";
import { toast } from "sonner";
import { useTestWebSearch, useUpdateSettings } from "~/api/settings.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { num, str } from "./resource-section.tsx";
import { type CardSpec, n } from "./settings-card.tsx";
// Agent 分区「网页搜索」:provider / Key(只写,存 secret)/ 地区 / 语言 / 安全搜索 / 每次条数 / 每分钟上限 + 测试
export const WEBSEARCH_CARD: CardSpec = {
anchor: "websearch",
title: "网页搜索",
description:
"开启后 Agent 可以用 web.search 搜索站外公开信息,用 web.fetch 读取搜索结果里的网页。结果和正文按不可信数据交给模型,web.fetch 不能读内网地址和结果以外的链接。",
fields: [
{
name: "agent.webSearch.provider",
label: "搜索服务",
kind: "select",
options: [
{ value: "none", label: "关闭" },
{ value: "brave", label: "Brave Search" },
],
},
{
name: "agent.webSearch.apiKey",
label: "API Key",
kind: "password",
hint: "Brave Search API 的 Subscription Token;留空不修改",
visible: (v) => v["agent.webSearch.provider"] !== "none",
},
{
name: "agent.webSearch.country",
label: "搜索地区",
kind: "text",
placeholder: "ALL",
hint: "两位国家码,如 CN、US;ALL 表示不限",
visible: (v) => v["agent.webSearch.provider"] !== "none",
},
{
name: "agent.webSearch.searchLang",
label: "结果语言",
kind: "text",
placeholder: "不限",
hint: "如 zh-hans、en;留空不限",
visible: (v) => v["agent.webSearch.provider"] !== "none",
},
{
name: "agent.webSearch.safesearch",
label: "安全搜索",
kind: "select",
options: [
{ value: "off", label: "关闭" },
{ value: "moderate", label: "适中" },
{ value: "strict", label: "严格" },
],
visible: (v) => v["agent.webSearch.provider"] !== "none",
},
{
name: "agent.webSearch.maxResults",
label: "每次条数",
kind: "number",
hint: "1–10,Agent 可以要更少",
visible: (v) => v["agent.webSearch.provider"] !== "none",
},
{
name: "agent.webSearch.requestsPerMinute",
label: "每分钟上限",
kind: "number",
hint: "三个进程共用;10 分钟内的相同搜索走缓存,不计次数",
visible: (v) => v["agent.webSearch.provider"] !== "none",
},
],
fromSettings: (s) => {
const w = s.agent.webSearch;
return {
"agent.webSearch.provider": w.provider,
"agent.webSearch.apiKey": "",
"agent.webSearch.country": w.country,
"agent.webSearch.searchLang": w.searchLang ?? "",
"agent.webSearch.safesearch": w.safesearch,
"agent.webSearch.maxResults": n(w.maxResults),
"agent.webSearch.requestsPerMinute": n(w.requestsPerMinute),
};
},
toPatch: (v) => {
const key = str(v["agent.webSearch.apiKey"]);
const safe = str(v["agent.webSearch.safesearch"]);
return {
agent: {
webSearch: {
provider: v["agent.webSearch.provider"] === "brave" ? "brave" : "none",
country: str(v["agent.webSearch.country"]).toUpperCase() || "ALL",
searchLang: str(v["agent.webSearch.searchLang"]).toLowerCase() || null,
safesearch: safe === "off" || safe === "strict" ? safe : "moderate",
maxResults: num(v["agent.webSearch.maxResults"]),
requestsPerMinute: num(v["agent.webSearch.requestsPerMinute"]),
...(key ? { apiKey: key } : {}),
},
},
};
},
extra: (s) => <WebSearchKeyStatus settings={s} />,
};
function WebSearchKeyStatus({ settings }: { settings: GlobalSettings }) {
const update = useUpdateSettings();
const test = useTestWebSearch();
const w = settings.agent.webSearch;
if (w.provider === "none") return null;
const saved = !!w.apiKeySecretRef;
return (
<div className="mb-2 flex flex-wrap items-center gap-2">
<StatusBadge tone={saved ? "success" : "neutral"}>{saved ? "API Key 已保存" : "未保存 API Key"}</StatusBadge>
{saved ? (
<>
<Button
type="button"
variant="outline"
size="xs"
disabled={test.isPending}
onClick={async () => {
try {
const r = await test.mutateAsync({});
if (r.ok) toast.success(`搜索可用:${r.count} 条结果,用时 ${r.durationMs} ms`);
else toast.error(r.error?.message ?? "搜索失败");
} catch (err) {
toast.error(errorMessage(err, "测试失败"));
}
}}
>
{test.isPending ? <LoaderCircle className="animate-spin" /> : null}
测试搜索
</Button>
<Button
type="button"
variant="ghost"
size="xs"
className="hover:text-destructive"
disabled={update.isPending}
onClick={async () => {
try {
await update.mutateAsync({ agent: { webSearch: { apiKey: null } } });
toast.success("搜索服务 API Key 已清除");
} catch (err) {
toast.error(errorMessage(err, "清除失败"));
}
}}
>
清除
</Button>
</>
) : null}
{test.data?.ok ? (
<span className="text-meta text-muted-foreground tabular-nums">
「{test.data.query}」{test.data.count} 条 · {test.data.durationMs} ms
</span>
) : null}
</div>
);
}
+3 -1
View File
@@ -1,10 +1,11 @@
import { AgentToolsBlock } from "./agent-tools.tsx";
import { WEBSEARCH_CARD } from "./agent-websearch.tsx";
import { PiUpstreamsBlock } from "./pi-upstreams.tsx";
import type { FieldValue } from "./resource-section.tsx";
import { num, str } from "./resource-section.tsx";
import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx";
// Agent 分区:上游(pi_upstream)+ 上下文与子代理(pi)+ 预算(budget.agent* 与单次上限)+ 自动调查 + 高级
// Agent 分区:上游(pi_upstream)+ 上下文与子代理(pi)+ 预算(budget.agent* 与单次上限)+ 自动调查 + 网页搜索 + 高级
/** 可清空的数字:空串 → null */
const nullNum = (v: FieldValue | undefined) => (str(v) === "" ? null : Number(str(v)));
@@ -218,6 +219,7 @@ export function AgentSection() {
<SettingsCard section="agent" spec={RUNTIME_CARD} settings={s} />
<SettingsCard section="agent" spec={BUDGET_CARD} settings={s} />
<SettingsCard section="agent" spec={INVESTIGATE_CARD} settings={s} />
<SettingsCard section="agent" spec={WEBSEARCH_CARD} settings={s} />
<SettingsCard section="agent" spec={ADVANCED_CARD} settings={s} />
</>
)}