diff --git a/server/manager.go b/server/manager.go index 9b61e13..5718eb3 100644 --- a/server/manager.go +++ b/server/manager.go @@ -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" diff --git a/server/server.go b/server/server.go index 3f20bb7..cffbd48 100644 --- a/server/server.go +++ b/server/server.go @@ -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() diff --git a/web/src/app/(main)/_components/egress-ip-banner.tsx b/web/src/app/(main)/_components/egress-ip-banner.tsx index 485c657..1197246 100644 --- a/web/src/app/(main)/_components/egress-ip-banner.tsx +++ b/web/src/app/(main)/_components/egress-ip-banner.tsx @@ -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; +type EgressIpContextValue = { + enabled: boolean; isLoading: boolean; report: EgressReport | null; error: string | null; - dismissed: boolean; - dismiss: () => void; + setEnabled: (enabled: boolean) => Promise; + probeEgress: () => Promise; + dismiss: () => Promise; }; -const EgressIpBannerContext = React.createContext(null); +const EgressIpContext = React.createContext(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(readStoredReport); +export function EgressIpProvider({ children }: { children: React.ReactNode }) { + const [enabled, setEnabledState] = React.useState(false); + const [report, setReport] = React.useState(null); const [error, setError] = React.useState(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 {children}; + // 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 {children}; } -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 ( -
- {label} - {checkMessage(check)} -
+ + {label} {"· "} + {checkMessage(check)} + ); } -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 = ( -
-
- ); - } else if (error) { - statusContent =
探测失败:{error}
; - } else { - statusContent = ( -
- - -
- ); - } - return ( -
-
-