feat(ui): 系统设置接入会话发送键位与 Web 搜索代理

- 会话发送键位改用 chat-send-mode 统一配置(CHAT_SEND_MODE_OPTIONS/
  useChatSendMode),替换页面内自维护的模式常量。
- Web 搜索区新增代理输入框(http/socks5,留空为直连)。

Claude-Session: https://claude.ai/code/session_01RtPQYJ8W1mcaace3sHWGDm
This commit is contained in:
Autumn-27 committed 2026-08-22 11:46:12 -04:00
1 parent be90d520a3
commit a3fe9b767b
1 file changed
+273 -261
+273 -261
View File
@@ -12,12 +12,7 @@ import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { import { CHAT_SEND_MODE_OPTIONS, type ChatSendMode, setChatSendMode, useChatSendMode } from "@/lib/chat-send-mode";
CHAT_SEND_MODE_OPTIONS,
type ChatSendMode,
setChatSendMode,
useChatSendMode,
} from "@/lib/chat-send-mode";
import type { Settings } from "@/lib/types"; import type { Settings } from "@/lib/types";
export default function SystemSettingsPage() { export default function SystemSettingsPage() {
@@ -82,7 +77,11 @@ export default function SystemSettingsPage() {
}; };
const detectPython = () => { const detectPython = () => {
setSaving(true); setSaving(true);
api.detectPython().then((r) => setPyInterp(r.python_interpreter)).catch(() => undefined).finally(() => setSaving(false)); api
.detectPython()
.then((r) => setPyInterp(r.python_interpreter))
.catch(() => undefined)
.finally(() => setSaving(false));
}; };
React.useEffect(() => { React.useEffect(() => {
@@ -189,280 +188,293 @@ export default function SystemSettingsPage() {
<p className="text-muted-foreground text-sm">全局运行时开关</p> <p className="text-muted-foreground text-sm">全局运行时开关</p>
</div> </div>
<Card className="max-w-2xl"> {/* 多列而非 grid:网络搜索卡片比其余高数倍,且高度随所选后端变化(brave/tavily
<CardHeader> 的 key 输入是条件渲染)。grid 会按最高的一张撑满整行、在旁边留下大片空白,
<CardTitle className="flex items-center gap-2 text-base"> 多列则自动按内容高度平衡填充。卡片间距靠 mb 而非 gap——多列布局下
<RadioTowerIcon className="size-4" /> column-gap 只管列间距,行间距要由子元素自己给。 */}
流量捕获 <div className="columns-1 gap-4 md:gap-6 lg:columns-2">
</CardTitle> <Card className="mb-4 break-inside-avoid md:mb-6">
<CardDescription> <CardHeader>
开启后,所有 Agent 的 HTTP 流量经记录代理全量落库,并向 Agent 注入 traffic_search / traffic_get <CardTitle className="flex items-center gap-2 text-base">
工具与代理配置(提示词含代理说明)。 <RadioTowerIcon className="size-4" />
<br /> 流量捕获
关闭(默认)时不记录任何流量:Agent </CardTitle>
<b>不会</b>拿到代理配置与流量工具,提示词也<b>不含</b>代理相关内容。切换后会即时重建 Agent 生效。 <CardDescription>
</CardDescription> 开启后,所有 Agent 的 HTTP 流量经记录代理全量落库,并向 Agent 注入 traffic_search / traffic_get
</CardHeader> 工具与代理配置(提示词含代理说明)。
<CardContent className="flex items-center justify-between gap-4"> <br />
<Label htmlFor="traffic-capture" className="text-sm font-normal text-muted-foreground"> 关闭(默认)时不记录任何流量:Agent
{trafficCapture ? "已开启 · 正在记录流量并注入代理" : "已关闭 · 不记录、不注入代理"} <b>不会</b>拿到代理配置与流量工具,提示词也<b>不含</b>代理相关内容。切换后会即时重建 Agent 生效。
</Label> </CardDescription>
<Switch </CardHeader>
id="traffic-capture" <CardContent className="flex items-center justify-between gap-4">
checked={trafficCapture} <Label htmlFor="traffic-capture" className="text-sm font-normal text-muted-foreground">
disabled={!loaded || saving} {trafficCapture ? "已开启 · 正在记录流量并注入代理" : "已关闭 · 不记录、不注入代理"}
onCheckedChange={toggleTraffic}
/>
</CardContent>
</Card>
<Card className="max-w-2xl">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<SearchIcon className="size-4" />
网络搜索
</CardTitle>
<CardDescription>
这是网络搜索的<b>总开关 + 来源配置</b>。开启后,才能在<b>每个 Agent 的配置</b>里单独选择是否启用
<b>web_search</b>(仅返回标题/链接/摘要,不抓取正文;抓取由 WebFetch 负责)。网络搜索<b>不走</b>记录代理,独立于流量捕获。
<br />
来源可选 <b>DuckDuckGo(ddgs)</b>(无需 Key)、<b>Brave(免费版)</b>(需填写 Brave API Key)或 <b>Tavily</b>(需填写 Tavily API Key)。总开关关闭时,各 Agent 的网络搜索开关不可用。
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<Label htmlFor="web-search" className="text-sm font-normal text-muted-foreground">
{webSearch ? "总开关已开启 · 可在各 Agent 配置里单独启用" : "已关闭 · 各 Agent 无法启用网络搜索"}
</Label> </Label>
<Switch <Switch
id="web-search" id="traffic-capture"
checked={webSearch} checked={trafficCapture}
disabled={!loaded || saving} disabled={!loaded || saving}
onCheckedChange={(v) => { onCheckedChange={toggleTraffic}
setWebSearch(v); // optimistic
saveWebSearch({ web_search_enabled: v });
}}
/> />
</div> </CardContent>
</Card>
{webSearch && ( <Card className="mb-4 break-inside-avoid md:mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<SearchIcon className="size-4" />
网络搜索
</CardTitle>
<CardDescription>
这是网络搜索的<b>总开关 + 来源配置</b>。开启后,才能在<b>每个 Agent 的配置</b>里单独选择是否启用
<b>web_search</b>(仅返回标题/链接/摘要,不抓取正文;抓取由 WebFetch 负责)。网络搜索<b>不走</b>
记录代理,独立于流量捕获。
<br />
来源可选 <b>DuckDuckGo(ddgs)</b>(无需 Key)、<b>Brave(免费版)</b>(需填写 Brave API Key)或{" "}
<b>Tavily</b>(需填写 Tavily API Key)。总开关关闭时,各 Agent 的网络搜索开关不可用。
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<Label className="text-sm font-normal text-muted-foreground">搜索来源</Label> <Label htmlFor="web-search" className="text-sm font-normal text-muted-foreground">
<Select {webSearch ? "总开关已开启 · 可在各 Agent 配置里单独启用" : "已关闭 · 各 Agent 无法启用网络搜索"}
value={backend} </Label>
<Switch
id="web-search"
checked={webSearch}
disabled={!loaded || saving} disabled={!loaded || saving}
onValueChange={(v) => { onCheckedChange={(v) => {
setBackend(v); // optimistic setWebSearch(v); // optimistic
saveWebSearch({ web_search_backend: v }); saveWebSearch({ web_search_enabled: v });
}} }}
> />
<SelectTrigger className="w-48 shrink-0">
<SelectValue placeholder="选择来源" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ddgs">DuckDuckGo(ddgs · 免费无 Key)</SelectItem>
<SelectItem value="brave-free">Brave(免费版 · 需 Key)</SelectItem>
<SelectItem value="tavily">Tavily(需 Key)</SelectItem>
</SelectContent>
</Select>
</div> </div>
)}
{webSearch && backend === "brave-free" && ( {webSearch && (
<div className="flex flex-col gap-2"> <div className="flex items-center justify-between gap-4">
<Label htmlFor="brave-key" className="text-sm font-normal text-muted-foreground"> <Label className="text-sm font-normal text-muted-foreground">搜索来源</Label>
Brave Search API Key <Select
{braveKeySet && <span className="ml-2 text-xs text-emerald-500">已配置</span>} value={backend}
</Label> disabled={!loaded || saving}
<div className="flex items-center gap-2"> onValueChange={(v) => {
<Input setBackend(v); // optimistic
id="brave-key" saveWebSearch({ web_search_backend: v });
type="password" }}
autoComplete="off" >
placeholder={braveKeySet ? "已配置(留空则不变)" : "输入 Brave API Key"} <SelectTrigger className="w-48 shrink-0">
value={braveKeyInput} <SelectValue placeholder="选择来源" />
disabled={!loaded || savingKey} </SelectTrigger>
onChange={(e) => setBraveKeyInput(e.target.value)} <SelectContent>
/> <SelectItem value="ddgs">DuckDuckGo(ddgs · 免费无 Key)</SelectItem>
<SelectItem value="brave-free">Brave(免费版 · 需 Key)</SelectItem>
<SelectItem value="tavily">Tavily(需 Key)</SelectItem>
</SelectContent>
</Select>
</div>
)}
{webSearch && backend === "brave-free" && (
<div className="flex flex-col gap-2">
<Label htmlFor="brave-key" className="text-sm font-normal text-muted-foreground">
Brave Search API Key
{braveKeySet && <span className="ml-2 text-xs text-emerald-500">已配置</span>}
</Label>
<div className="flex items-center gap-2">
<Input
id="brave-key"
type="password"
autoComplete="off"
placeholder={braveKeySet ? "已配置(留空则不变)" : "输入 Brave API Key"}
value={braveKeyInput}
disabled={!loaded || savingKey}
onChange={(e) => setBraveKeyInput(e.target.value)}
/>
<Button
type="button"
onClick={saveBraveKey}
disabled={!loaded || savingKey || braveKeyInput.trim() === ""}
>
保存
</Button>
</div>
{braveNeedsKey && (
<p className="text-xs text-amber-500">
已选择 Brave 但尚未配置 Key —— 在保存 Key 之前,搜索工具不会启用。
</p>
)}
<p className="text-muted-foreground text-xs">
免费版额度约 2,000 次/月。前往 https://brave.com/search/api/ 获取 Key。
</p>
</div>
)}
{webSearch && backend === "tavily" && (
<div className="flex flex-col gap-2">
<Label htmlFor="tavily-key" className="text-sm font-normal text-muted-foreground">
Tavily Search API Key
{tavilyKeySet && <span className="ml-2 text-xs text-emerald-500">已配置</span>}
</Label>
<div className="flex items-center gap-2">
<Input
id="tavily-key"
type="password"
autoComplete="off"
placeholder={tavilyKeySet ? "已配置(留空则不变)" : "输入 Tavily API Key(tvly-…)"}
value={tavilyKeyInput}
disabled={!loaded || savingTavilyKey}
onChange={(e) => setTavilyKeyInput(e.target.value)}
/>
<Button
type="button"
onClick={saveTavilyKey}
disabled={!loaded || savingTavilyKey || tavilyKeyInput.trim() === ""}
>
保存
</Button>
</div>
{webSearch && backend === "tavily" && !tavilyKeySet && (
<p className="text-xs text-amber-500">
已选择 Tavily 但尚未配置 Key —— 在保存 Key 之前,搜索工具不会启用。
</p>
)}
<p className="text-muted-foreground text-xs">前往 https://tavily.com 注册并获取 API Key。</p>
</div>
)}
{webSearch && (
<div className="flex flex-col gap-2">
<Label htmlFor="ws-proxy" className="text-sm font-normal text-muted-foreground">
出口代理(可选)
</Label>
<div className="flex items-center gap-2">
<Input
id="ws-proxy"
autoComplete="off"
placeholder="http://host:port 或 socks5://host:port(留空=直连)"
value={proxyInput}
disabled={!loaded || savingProxy}
onChange={(e) => setProxyInput(e.target.value)}
/>
<Button type="button" onClick={saveProxy} disabled={!loaded || savingProxy}>
保存
</Button>
</div>
<p className="text-muted-foreground text-xs">
独立出口代理,仅用于访问搜索端点(VPN/SOCKS 等)。与记录流量的 MITM 代理无关;网络不通时经此代理访问。
</p>
</div>
)}
{webSearch && (
<div className="flex items-center justify-between gap-4 border-t pt-4">
<p className="text-muted-foreground text-xs">
用当前配置(来源 + 代理 + Key)实际搜索一次「test」,验证是否可用。
</p>
<Button <Button
type="button" type="button"
onClick={saveBraveKey} variant="outline"
disabled={!loaded || savingKey || braveKeyInput.trim() === ""} onClick={runTest}
disabled={!loaded || testing}
className="shrink-0"
> >
保存 {testing ? "测试中…" : "测试搜索"}
</Button> </Button>
</div> </div>
{braveNeedsKey && ( )}
<p className="text-xs text-amber-500"> </CardContent>
已选择 Brave 但尚未配置 Key —— 在保存 Key 之前,搜索工具不会启用。 </Card>
</p>
)}
<p className="text-muted-foreground text-xs">
免费版额度约 2,000 次/月。前往 https://brave.com/search/api/ 获取 Key。
</p>
</div>
)}
{webSearch && backend === "tavily" && ( <Card className="mb-4 break-inside-avoid md:mb-6">
<div className="flex flex-col gap-2"> <CardHeader>
<Label htmlFor="tavily-key" className="text-sm font-normal text-muted-foreground"> <CardTitle className="flex items-center gap-2 text-base">
Tavily Search API Key <RadioTowerIcon className="size-4" />
{tavilyKeySet && <span className="ml-2 text-xs text-emerald-500">已配置</span>} 自定义脚本 · Python 解释器
</Label> </CardTitle>
<div className="flex items-center gap-2"> <CardDescription>
<Input 自定义 <b>script</b> 类型工具用它跑 Python。开机会自动检测(python3 优先);此处可手填 venv /
id="tavily-key" 特定版本的绝对路径,留空则运行时自动检测。
type="password" </CardDescription>
autoComplete="off" </CardHeader>
placeholder={tavilyKeySet ? "已配置(留空则不变)" : "输入 Tavily API Key(tvly-…)"} <CardContent className="flex flex-col gap-3">
value={tavilyKeyInput} <div className="flex items-center gap-2">
disabled={!loaded || savingTavilyKey} <Input
onChange={(e) => setTavilyKeyInput(e.target.value)} className="font-mono text-sm"
/> placeholder="/usr/bin/python3(留空=自动检测)"
<Button value={pyInterp}
type="button" disabled={!loaded || saving}
onClick={saveTavilyKey} onChange={(e) => setPyInterp(e.target.value)}
disabled={!loaded || savingTavilyKey || tavilyKeyInput.trim() === ""} />
> <Button variant="outline" onClick={detectPython} disabled={!loaded || saving}>
保存 重新检测
</Button> </Button>
</div> <Button onClick={savePython} disabled={!loaded || saving}>
{webSearch && backend === "tavily" && !tavilyKeySet && ( 保存
<p className="text-xs text-amber-500">
已选择 Tavily 但尚未配置 Key —— 在保存 Key 之前,搜索工具不会启用。
</p>
)}
<p className="text-muted-foreground text-xs">
前往 https://tavily.com 注册并获取 API Key。
</p>
</div>
)}
{webSearch && (
<div className="flex flex-col gap-2">
<Label htmlFor="ws-proxy" className="text-sm font-normal text-muted-foreground">
出口代理(可选)
</Label>
<div className="flex items-center gap-2">
<Input
id="ws-proxy"
autoComplete="off"
placeholder="http://host:port 或 socks5://host:port(留空=直连)"
value={proxyInput}
disabled={!loaded || savingProxy}
onChange={(e) => setProxyInput(e.target.value)}
/>
<Button type="button" onClick={saveProxy} disabled={!loaded || savingProxy}>
保存
</Button>
</div>
<p className="text-muted-foreground text-xs">
独立出口代理,仅用于访问搜索端点(VPN/SOCKS 等)。与记录流量的 MITM 代理无关;网络不通时经此代理访问。
</p>
</div>
)}
{webSearch && (
<div className="flex items-center justify-between gap-4 border-t pt-4">
<p className="text-muted-foreground text-xs">
用当前配置(来源 + 代理 + Key)实际搜索一次「test」,验证是否可用。
</p>
<Button type="button" variant="outline" onClick={runTest} disabled={!loaded || testing} className="shrink-0">
{testing ? "测试中…" : "测试搜索"}
</Button> </Button>
</div> </div>
)} </CardContent>
</CardContent> </Card>
</Card>
<Card className="max-w-2xl"> <Card className="mb-4 break-inside-avoid md:mb-6">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
<RadioTowerIcon className="size-4" /> <CpuIcon className="size-4" />
自定义脚本 · Python 解释器 工作并发 · Work Agent 数
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
自定义 <b>script</b> 类型工具用它跑 Python。开机会自动检测(python3 优先);此处可手填 venv / 每个任务并发运行的工作 agent 数量(默认 3)。数值越大并发探测越多、消耗也越高。修改后
特定版本的绝对路径,留空则运行时自动检测。 <b>对之后启动的任务生效</b>,正在运行的任务不受影响。
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-3"> <CardContent className="flex flex-col gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Input <Input
className="font-mono text-sm" type="number"
placeholder="/usr/bin/python3(留空=自动检测)" min={1}
value={pyInterp} className="w-32 font-mono text-sm"
disabled={!loaded || saving} placeholder="3"
onChange={(e) => setPyInterp(e.target.value)} value={workers}
/> disabled={!loaded || savingWorkers}
<Button variant="outline" onClick={detectPython} disabled={!loaded || saving}> onChange={(e) => setWorkers(e.target.value)}
重新检测 />
</Button> <Button onClick={saveWorkers} disabled={!loaded || savingWorkers}>
<Button onClick={savePython} disabled={!loaded || saving}> 保存
保存 </Button>
</Button> </div>
</div> </CardContent>
</CardContent> </Card>
</Card>
<Card className="max-w-2xl"> <Card className="mb-4 break-inside-avoid md:mb-6">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
<CpuIcon className="size-4" /> <KeyboardIcon className="size-4" />
工作并发 · Work Agent 数 会话输入框发送键位
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
每个任务并发运行的工作 agent 数量(默认 3)。数值越大并发探测越多、消耗也越高。修改后<b>对之后启动的任务生效</b>,正在运行的任务不受影响。 对话页与任务详情的主 Agent 会话输入框共用此设置,选择后立即生效、无需保存。
</CardDescription> <br />
</CardHeader> 该偏好<b>只存在本浏览器</b>,不随账号同步,换浏览器或清理站点数据后需重新设置。
<CardContent className="flex flex-col gap-3"> </CardDescription>
<div className="flex items-center gap-2"> </CardHeader>
<Input <CardContent className="flex items-center justify-between gap-4">
type="number" <Label htmlFor="chat-send-mode" className="text-sm font-normal text-muted-foreground">
min={1} 发送方式
className="w-32 font-mono text-sm" </Label>
placeholder="3" <Select value={sendMode} onValueChange={(v) => setChatSendMode(v as ChatSendMode)}>
value={workers} <SelectTrigger id="chat-send-mode" className="w-72">
disabled={!loaded || savingWorkers} <SelectValue />
onChange={(e) => setWorkers(e.target.value)} </SelectTrigger>
/> <SelectContent>
<Button onClick={saveWorkers} disabled={!loaded || savingWorkers}> {CHAT_SEND_MODE_OPTIONS.map((option) => (
保存 <SelectItem key={option.value} value={option.value}>
</Button> {option.label}
</div> </SelectItem>
</CardContent> ))}
</Card> </SelectContent>
</Select>
<Card className="max-w-2xl"> </CardContent>
<CardHeader> </Card>
<CardTitle className="flex items-center gap-2 text-base"> </div>
<KeyboardIcon className="size-4" />
会话输入框发送键位
</CardTitle>
<CardDescription>
对话页与任务详情的主 Agent 会话输入框共用此设置,选择后立即生效、无需保存。
<br />
该偏好<b>只存在本浏览器</b>,不随账号同步,换浏览器或清理站点数据后需重新设置。
</CardDescription>
</CardHeader>
<CardContent className="flex items-center justify-between gap-4">
<Label htmlFor="chat-send-mode" className="text-sm font-normal text-muted-foreground">
发送方式
</Label>
<Select value={sendMode} onValueChange={(v) => setChatSendMode(v as ChatSendMode)}>
<SelectTrigger id="chat-send-mode" className="w-72">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CHAT_SEND_MODE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</CardContent>
</Card>
</div> </div>
); );
} }