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:
1 parent
40dd0e69e4
commit
d53df25fc8
32 files changed
+1854
-39
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user