fix(web): show egress IPs in navbar middle via settings toggle

This commit is contained in:
RuoJi6 committed 2026-10-27 13:46:34 +08:00
1 parent 251dccb921
commit 7e5c828fc2
8 files changed
+158 -152

No files matched your search

+1
View File
@@ -266,6 +266,7 @@ const (
// (http/https/socks5). Empty = direct. Distinct from web_search_proxy (which
// only routes the search backend) and the per-profile LLM proxy.
settingGlobalProxy = "global_proxy"
settingShowEgressIP = "show_egress_ip"
settingWorkers = "workers"
settingWorkerPools = "worker_pools"
settingLLMRecord = "llm_record"
+8
View File
@@ -3454,6 +3454,7 @@ func (s *Server) settingsPayload() map[string]any {
"tavily_key_set": strings.TrimSpace(tavilyKey) != "",
"web_search_proxy": proxy, // 独立出口代理(http/https/socks5),空=直连
"global_proxy": s.m.GlobalProxy(), // 全局出口代理(http/https/socks5),所有目标流量走它,空=直连
"show_egress_ip": s.m.pg.GetBool(settingShowEgressIP, false),
"python_interpreter": strings.TrimSpace(pyStored), // 用户/自动设的值(空=用运行时检测)
"workers": s.m.Workers(), // 并发工作 agent 数(默认3);对之后启动的任务生效
"worker_pools": s.m.WorkerPools(), // 非空时按池顺序绑定 worker 会话
@@ -3530,6 +3531,7 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
TavilyKey *string `json:"tavily_search_api_key"`
WebSearchProxy *string `json:"web_search_proxy"` // 独立出口代理(http/https/socks5);null=不改,""=清空
GlobalProxy *string `json:"global_proxy"` // 全局出口代理(http/https/socks5);null=不改,""=清空(直连)
ShowEgressIP *bool `json:"show_egress_ip"` // 在顶栏显示直连/全局代理出口 IP;默认关
PythonInterp *string `json:"python_interpreter"` // 自定义脚本工具的 python 解释器路径
Workers *int `json:"workers"` // 并发工作 agent 数(>0);对之后启动的任务生效
WorkerPools *[]WorkerPool `json:"worker_pools"`
@@ -3712,6 +3714,12 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
}
changed = true // capture-off egress is baked into agents at build time → rebuild
}
if req.ShowEgressIP != nil {
if err := s.m.pg.SetBool(settingShowEgressIP, *req.ShowEgressIP); err != nil {
writeErr(w, 500, err.Error())
return
}
}
if req.WebSearchEnabled != nil || req.WebSearchBackend != nil || req.BraveKey != nil || req.TavilyKey != nil || req.WebSearchProxy != nil {
// Fill unspecified fields from current state so a partial PUT doesn't reset them.
on, backend, _, _, _ := s.m.WebSearch()
@@ -2,96 +2,40 @@
import * as React from "react";
import { Globe2Icon, Loader2Icon, XIcon } from "lucide-react";
import { Globe2Icon, Loader2Icon, RefreshCwIcon, XIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { api } from "@/lib/api";
import type { EgressCheck, EgressReport } from "@/lib/types";
import { cn } from "@/lib/utils";
const DISMISSED_STORAGE_KEY = "artex-egress-ip-banner-dismissed";
const REPORT_STORAGE_KEY = "artex-egress-ip-banner-report";
type ProbeOptions = {
globalProxy: string;
probeUrl?: string;
};
type EgressIpBannerContextValue = {
probeEgress: (options: ProbeOptions) => Promise<EgressReport>;
type EgressIpContextValue = {
enabled: boolean;
isLoading: boolean;
report: EgressReport | null;
error: string | null;
dismissed: boolean;
dismiss: () => void;
setEnabled: (enabled: boolean) => Promise<void>;
probeEgress: () => Promise<EgressReport>;
dismiss: () => Promise<void>;
};
const EgressIpBannerContext = React.createContext<EgressIpBannerContextValue | null>(null);
const EgressIpContext = React.createContext<EgressIpContextValue | null>(null);
function readDismissed() {
if (typeof window === "undefined") return false;
try {
return window.sessionStorage.getItem(DISMISSED_STORAGE_KEY) === "true";
} catch {
return false;
}
}
function setDismissedStorage(dismissed: boolean) {
try {
if (dismissed) window.sessionStorage.setItem(DISMISSED_STORAGE_KEY, "true");
else window.sessionStorage.removeItem(DISMISSED_STORAGE_KEY);
} catch {
// sessionStorage can be unavailable in privacy-restricted browsers.
}
}
function readStoredReport(): EgressReport | null {
if (typeof window === "undefined") return null;
try {
const raw = window.sessionStorage.getItem(REPORT_STORAGE_KEY);
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || !Array.isArray((parsed as { checks?: unknown }).checks)) {
return null;
}
return parsed as EgressReport;
} catch {
return null;
}
}
function setStoredReport(report: EgressReport | null) {
try {
if (report) window.sessionStorage.setItem(REPORT_STORAGE_KEY, JSON.stringify(report));
else window.sessionStorage.removeItem(REPORT_STORAGE_KEY);
} catch {
// sessionStorage can be unavailable in privacy-restricted browsers.
}
}
export function EgressIpBannerProvider({ children }: { children: React.ReactNode }) {
const [report, setReport] = React.useState<EgressReport | null>(readStoredReport);
export function EgressIpProvider({ children }: { children: React.ReactNode }) {
const [enabled, setEnabledState] = React.useState(false);
const [report, setReport] = React.useState<EgressReport | null>(null);
const [error, setError] = React.useState<string | null>(null);
const [isLoading, setIsLoading] = React.useState(false);
const [dismissed, setDismissed] = React.useState(readDismissed);
const probeEgress = React.useCallback(async ({ globalProxy, probeUrl }: ProbeOptions) => {
setDismissed(false);
setDismissedStorage(false);
const probeForProxy = React.useCallback(async (globalProxy: string) => {
setError(null);
setIsLoading(true);
try {
const nextReport = await api.testEgressProxy({
global_proxy: globalProxy,
...(probeUrl ? { probe_url: probeUrl } : {}),
});
const nextReport = await api.testEgressProxy({ global_proxy: globalProxy });
setReport(nextReport);
setStoredReport(nextReport);
return nextReport;
} catch (cause) {
setReport(null);
setStoredReport(null);
const message = cause instanceof Error ? cause.message : "未知错误";
setError(message);
throw cause;
@@ -100,80 +44,117 @@ export function EgressIpBannerProvider({ children }: { children: React.ReactNode
}
}, []);
const dismiss = React.useCallback(() => {
setDismissed(true);
setDismissedStorage(true);
}, []);
React.useEffect(() => {
let cancelled = false;
api
.settings()
.then((settings) => {
if (cancelled) return;
const nextEnabled = !!settings.show_egress_ip;
const nextProxy = settings.global_proxy ?? "";
setEnabledState(nextEnabled);
if (nextEnabled) void probeForProxy(nextProxy);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [probeForProxy]);
const value = React.useMemo(
() => ({ probeEgress, isLoading, report, error, dismissed, dismiss }),
[dismiss, dismissed, error, isLoading, probeEgress, report],
const setEnabled = React.useCallback(
async (nextEnabled: boolean) => {
const settings = await api.setSettings({ show_egress_ip: nextEnabled });
const nextProxy = settings.global_proxy ?? "";
setEnabledState(nextEnabled);
if (nextEnabled) {
await probeForProxy(nextProxy);
} else {
setReport(null);
setError(null);
}
},
[probeForProxy],
);
return <EgressIpBannerContext.Provider value={value}>{children}</EgressIpBannerContext.Provider>;
// Refresh from settings first so a proxy saved on the settings page is always
// the one used for the navbar probe.
const probeEgress = React.useCallback(async () => {
const settings = await api.settings();
const nextProxy = settings.global_proxy ?? "";
return probeForProxy(nextProxy);
}, [probeForProxy]);
const dismiss = React.useCallback(() => setEnabled(false), [setEnabled]);
const value = React.useMemo(
() => ({ enabled, isLoading, report, error, setEnabled, probeEgress, dismiss }),
[dismiss, enabled, error, isLoading, probeEgress, report, setEnabled],
);
return <EgressIpContext.Provider value={value}>{children}</EgressIpContext.Provider>;
}
export function useEgressIpBanner() {
const context = React.useContext(EgressIpBannerContext);
if (!context) throw new Error("useEgressIpBanner must be used within EgressIpBannerProvider");
export function useEgressIp() {
const context = React.useContext(EgressIpContext);
if (!context) throw new Error("useEgressIp must be used within EgressIpProvider");
return context;
}
function checkMessage(check: EgressCheck | undefined) {
if (!check) return "未返回结果";
if (check.skipped) return "未配置 / 已跳过";
if (check.result.ok) return check.result.exit_ip || "已连通,但未返回 IP";
return `探测失败:${check.result.probes.find((probe) => probe.error)?.error || check.result.error || "未知错误"}`;
if (check.skipped) return "未配置";
if (check.result.ok) return check.result.exit_ip || "已连通";
return "探测失败";
}
function IpRow({ label, check }: { label: string; check: EgressCheck | undefined }) {
function IpValue({ label, check }: { label: string; check: EgressCheck | undefined }) {
const failed = !!check && !check.skipped && !check.result.ok;
return (
<div className="flex min-w-0 items-baseline gap-2">
<span className="shrink-0 text-muted-foreground">{label}</span>
<span className={cn("min-w-0 truncate font-mono", failed && "text-destructive")}>{checkMessage(check)}</span>
</div>
<span className="min-w-0 truncate" title={`${label}:${checkMessage(check)}`}>
<span className="text-muted-foreground">{label}</span> {"· "}
<span className={cn("font-mono", failed && "text-destructive")}>{checkMessage(check)}</span>
</span>
);
}
export function EgressIpBanner() {
const { report, error, isLoading, dismissed, dismiss } = useEgressIpBanner();
if (dismissed) return null;
if (!isLoading && !report && !error) return null;
export function EgressIpNavbar() {
const { enabled, report, error, isLoading, probeEgress, dismiss } = useEgressIp();
if (!enabled) return null;
const direct = report?.checks.find((check) => check.key === "direct");
const global = report?.checks.find((check) => check.key === "global");
let statusContent: React.ReactNode;
if (isLoading) {
statusContent = (
<div className="mt-1 flex items-center gap-2 text-muted-foreground">
<Loader2Icon className="size-3.5 animate-spin" aria-hidden="true" />
正在探测直连和全局代理出口 IP…
</div>
);
} else if (error) {
statusContent = <div className="mt-1 text-destructive">探测失败:{error}</div>;
} else {
statusContent = (
<div className="mt-1 grid gap-x-6 gap-y-1 sm:grid-cols-2">
<IpRow label="直连 IP" check={direct} />
<IpRow label="全局代理 IP" check={global} />
</div>
);
}
return (
<div className="border-b bg-primary/5 px-4 py-2.5 md:px-6" role="status" aria-live="polite">
<div className="flex items-start gap-3">
<Globe2Icon className="mt-0.5 size-4 shrink-0 text-primary" aria-hidden="true" />
<div className="min-w-0 flex-1 text-sm">
<div className="font-medium">出口 IP</div>
{statusContent}
<div className="flex min-w-0 items-center justify-center gap-2 text-xs" role="status" aria-live="polite">
<Globe2Icon className="size-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
{isLoading ? (
<span className="flex items-center gap-1 text-muted-foreground">
<Loader2Icon className="size-3 animate-spin" aria-hidden="true" /> 正在探测出口 IP…
</span>
) : error ? (
<span className="truncate text-destructive" title={error}>
探测失败:{error}
</span>
) : report ? (
<div className="flex min-w-0 items-center gap-3">
<IpValue label="直连 IP" check={direct} />
<IpValue label="全局代理 IP" check={global} />
</div>
<Button type="button" variant="ghost" size="icon-sm" onClick={dismiss} aria-label="关闭出口 IP 提示">
<XIcon aria-hidden="true" />
) : (
<span className="text-muted-foreground">出口 IP 暂无结果</span>
)}
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => void probeEgress()}
disabled={isLoading}
aria-label="刷新出口 IP"
>
<RefreshCwIcon className={cn("size-3.5", isLoading && "animate-spin")} aria-hidden="true" />
</Button>
<Button type="button" variant="ghost" size="icon-sm" onClick={() => void dismiss()} aria-label="关闭出口 IP显示">
<XIcon className="size-3.5" aria-hidden="true" />
</Button>
</div>
</div>
);
}
@@ -15,6 +15,7 @@ import { LayoutControls } from "./sidebar/layout-controls";
import { SearchDialog } from "./sidebar/search-dialog";
import { ThemeSwitcher } from "./sidebar/theme-switcher";
import { UpdateBadge } from "./update-badge";
import { EgressIpNavbar, EgressIpProvider } from "./egress-ip-banner";
// 任务详情页保持原样:它自带头部/Tabs 与内边距,这里不再叠加全局头部和 padding。
function isFullBleed(pathname: string) {
@@ -32,6 +33,14 @@ function isFullBleed(pathname: string) {
}
export function MainContent({ children }: { children: ReactNode }) {
return (
<EgressIpProvider>
<MainContentInner>{children}</MainContentInner>
</EgressIpProvider>
);
}
function MainContentInner({ children }: { children: ReactNode }) {
const currentUser = useCurrentUser();
const pathname = usePathname();
const [version, setVersion] = useState("");
@@ -55,8 +64,8 @@ export function MainContent({ children }: { children: ReactNode }) {
"[html[data-navbar-style=sticky]_&]:sticky [html[data-navbar-style=sticky]_&]:top-0 [html[data-navbar-style=sticky]_&]:z-50 [html[data-navbar-style=sticky]_&]:overflow-hidden [html[data-navbar-style=sticky]_&]:rounded-t-[inherit] [html[data-navbar-style=sticky]_&]:bg-background/50 [html[data-navbar-style=sticky]_&]:backdrop-blur-md",
)}
>
<div className="flex w-full items-center justify-between px-4 lg:px-6">
<div className="flex items-center gap-1 lg:gap-2">
<div className="flex min-w-0 w-full items-center gap-3 px-4 lg:gap-4 lg:px-6">
<div className="flex shrink-0 items-center gap-1 lg:gap-2">
<SidebarTrigger className="-ml-1" />
<Separator
orientation="vertical"
@@ -64,7 +73,10 @@ export function MainContent({ children }: { children: ReactNode }) {
/>
<SearchDialog />
</div>
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<EgressIpNavbar />
</div>
<div className="flex shrink-0 items-center gap-2">
{version && (
<span className="font-medium text-muted-foreground text-xs tabular-nums">版本 · {version}</span>
)}
-4
View File
@@ -16,7 +16,6 @@ import {
import { PREFERENCE_DEFAULTS } from "@/lib/preferences/preferences-config";
import { cn } from "@/lib/utils";
import { EgressIpBanner, EgressIpBannerProvider } from "./_components/egress-ip-banner";
import { MainContent } from "./_components/main-content";
// Reads a layout-critical preference from the browser cookie (falls back to the
@@ -61,7 +60,6 @@ export default function Layout({ children }: Readonly<{ children: ReactNode }>)
if (!authed) return null;
return (
<EgressIpBannerProvider>
<SidebarProvider
defaultOpen={defaultOpen}
style={
@@ -81,10 +79,8 @@ export default function Layout({ children }: Readonly<{ children: ReactNode }>)
"min-w-0 overflow-x-hidden",
)}
>
<EgressIpBanner />
<MainContent>{children}</MainContent>
</SidebarInset>
</SidebarProvider>
</EgressIpBannerProvider>
);
}
+32 -26
View File
@@ -16,7 +16,7 @@ import { api } from "@/lib/api";
import { CHAT_SEND_MODE_OPTIONS, type ChatSendMode, setChatSendMode, useChatSendMode } from "@/lib/chat-send-mode";
import type { EgressReport, LLMProfile, Settings, WorkerPool } from "@/lib/types";
import { useEgressIpBanner } from "../../_components/egress-ip-banner";
import { useEgressIp } from "../../_components/egress-ip-banner";
import { BasicAuthCard } from "./_components/basic-auth-card";
import { UpdateCard } from "./_components/update-card";
@@ -39,7 +39,7 @@ export default function SystemSettingsPage() {
const [savingProxy, setSavingProxy] = React.useState(false);
const [globalProxyInput, setGlobalProxyInput] = React.useState("");
const [savingGlobalProxy, setSavingGlobalProxy] = React.useState(false);
const [savedGlobalProxy, setSavedGlobalProxy] = React.useState("");
const [savingEgressVisibility, setSavingEgressVisibility] = React.useState(false);
// 出口探测:测一次真实请求,拿到"直连 / 经全局代理"两条出口 IP。
const [egressTesting, setEgressTesting] = React.useState(false);
const [egressReport, setEgressReport] = React.useState<EgressReport | null>(null);
@@ -62,7 +62,12 @@ export default function SystemSettingsPage() {
const [noaCompaction, setNoaCompaction] = React.useState(false);
// 纯前端偏好:不走 /api/settings,直接读写 localStorage。
const sendMode = useChatSendMode();
const { probeEgress: probeEgressForBanner, isLoading: egressBannerLoading } = useEgressIpBanner();
const {
enabled: showEgressIp,
isLoading: egressProbeLoading,
setEnabled: setShowEgressIp,
probeEgress,
} = useEgressIp();
const apply = React.useCallback((s: Settings) => {
setCredentialAgent(!!s.credential_agent_enabled);
@@ -77,7 +82,6 @@ export default function SystemSettingsPage() {
setProxyInput(s.web_search_proxy ?? "");
const savedProxy = s.global_proxy ?? "";
setGlobalProxyInput(savedProxy);
setSavedGlobalProxy(savedProxy);
setPyInterp(s.python_interpreter ?? "");
setWorkers(String(s.workers ?? 3));
setWorkerPools(s.worker_pools ?? []);
@@ -308,6 +312,7 @@ export default function SystemSettingsPage() {
apply(s);
// 保存后链路变了,旧结果(尤其"尚未保存")立刻失效。
setEgressReport(null);
if (showEgressIp) void probeEgress().catch(() => undefined);
toast.success(globalProxyInput.trim() ? "已保存全局代理" : "已清除全局代理(改为直连)");
})
.catch((e) => toast.error("保存失败:" + (e as Error).message))
@@ -340,22 +345,15 @@ export default function SystemSettingsPage() {
.finally(() => setEgressTesting(false));
};
// 发布到站点顶栏时始终使用已保存的 global_proxy,避免把输入框里的草稿
// 当成 Agent 当前实际使用的代理。
const showEgressIp = async () => {
if (!settingsLoaded) {
toast.error("设置尚未加载完成,请稍后再试");
return;
}
const toggleEgressVisibility = async (nextEnabled: boolean) => {
setSavingEgressVisibility(true);
try {
const rep = await probeEgressForBanner({
globalProxy: savedGlobalProxy.trim(),
...(egressProbeUrl.trim() ? { probeUrl: egressProbeUrl.trim() } : {}),
});
if (rep.ok) toast.success("已在顶栏显示出口 IP");
else toast.error("出口探测完成,但部分结果不可用");
await setShowEgressIp(nextEnabled);
toast.success(nextEnabled ? "已在顶栏显示出口 IP" : "已关闭顶栏出口 IP显示");
} catch (e) {
toast.error("出口探测失败:" + (e as Error).message);
toast.error("保存顶栏出口 IP设置失败:" + (e as Error).message);
} finally {
setSavingEgressVisibility(false);
}
};
@@ -513,18 +511,26 @@ export default function SystemSettingsPage() {
>
{egressTesting ? "测试中…" : "测试"}
</Button>
<Button
type="button"
variant="secondary"
onClick={showEgressIp}
disabled={!loaded || egressBannerLoading}
>
{egressBannerLoading ? "探测中…" : "显示出口 IP"}
</Button>
</div>
<p className="text-muted-foreground text-xs">
{globalProxyInput.trim() ? "已配置 · 所有目标流量经此代理出网" : "未配置 · 目标流量直连出网"}
</p>
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2">
<div className="min-w-0">
<Label htmlFor="show-egress-ip" className="text-sm font-normal">
在顶栏显示出口 IP
</Label>
<p className="text-muted-foreground text-xs">
开启后显示直连 IP 和全局代理 IP,使用已保存的全局代理探测。
</p>
</div>
<Switch
id="show-egress-ip"
checked={showEgressIp}
disabled={!settingsLoaded || savingEgressVisibility || egressProbeLoading}
onCheckedChange={(checked) => void toggleEgressVisibility(checked)}
/>
</div>
{/* 紧贴结果区:用户看两行时正好需要它。细节放文档。 */}
<p className="text-muted-foreground text-xs leading-relaxed">
点<b>测试</b>会访问 IP 回显服务(默认 <code>api.ipify.org</code>,失败自动换{" "}
+1
View File
@@ -3336,6 +3336,7 @@ export const settings: Settings = {
tavily_key_set: true,
web_search_proxy: "",
global_proxy: "",
show_egress_ip: false,
python_interpreter: "/usr/bin/python3",
workers: 3,
worker_pools: [],
+1
View File
@@ -985,6 +985,7 @@ export interface Settings {
// 全局出口代理(http/https/socks5,可带 user:pass),所有目标流量走它。开启流量捕获时作为
// MITM 上游;关闭捕获时直接注入 agent 的 bash/WebFetch。空=直连。
global_proxy?: string;
show_egress_ip?: boolean; // 在顶栏显示直连 / 全局代理出口 IP;默认关闭
python_interpreter?: string; // 自定义脚本工具的 python 解释器路径(空=运行时检测)
workers?: number; // 并发工作 agent 数(默认3);对之后启动的任务生效
worker_pools: WorkerPool[];