diff --git a/web/src/app/(main)/_components/egress-ip-banner.tsx b/web/src/app/(main)/_components/egress-ip-banner.tsx new file mode 100644 index 0000000..485c657 --- /dev/null +++ b/web/src/app/(main)/_components/egress-ip-banner.tsx @@ -0,0 +1,179 @@ +"use client"; + +import * as React from "react"; + +import { Globe2Icon, Loader2Icon, 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; + isLoading: boolean; + report: EgressReport | null; + error: string | null; + dismissed: boolean; + dismiss: () => void; +}; + +const EgressIpBannerContext = 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); + 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); + setError(null); + setIsLoading(true); + try { + const nextReport = await api.testEgressProxy({ + global_proxy: globalProxy, + ...(probeUrl ? { probe_url: probeUrl } : {}), + }); + setReport(nextReport); + setStoredReport(nextReport); + return nextReport; + } catch (cause) { + setReport(null); + setStoredReport(null); + const message = cause instanceof Error ? cause.message : "未知错误"; + setError(message); + throw cause; + } finally { + setIsLoading(false); + } + }, []); + + const dismiss = React.useCallback(() => { + setDismissed(true); + setDismissedStorage(true); + }, []); + + const value = React.useMemo( + () => ({ probeEgress, isLoading, report, error, dismissed, dismiss }), + [dismiss, dismissed, error, isLoading, probeEgress, report], + ); + + return {children}; +} + +export function useEgressIpBanner() { + const context = React.useContext(EgressIpBannerContext); + if (!context) throw new Error("useEgressIpBanner must be used within EgressIpBannerProvider"); + 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 || "未知错误"}`; +} + +function IpRow({ label, check }: { label: string; check: EgressCheck | undefined }) { + const failed = !!check && !check.skipped && !check.result.ok; + return ( +
+ {label} + {checkMessage(check)} +
+ ); +} + +export function EgressIpBanner() { + const { report, error, isLoading, dismissed, dismiss } = useEgressIpBanner(); + if (dismissed) return null; + if (!isLoading && !report && !error) 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 ( +
+
+
+
+ ); +} diff --git a/web/src/app/(main)/layout.tsx b/web/src/app/(main)/layout.tsx index 1022dfc..6b803d1 100644 --- a/web/src/app/(main)/layout.tsx +++ b/web/src/app/(main)/layout.tsx @@ -16,6 +16,7 @@ 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 @@ -60,27 +61,30 @@ export default function Layout({ children }: Readonly<{ children: ReactNode }>) if (!authed) return null; return ( - - - *]:mx-auto", - "[html[data-content-layout=centered]_&>*]:w-full", - "[html[data-content-layout=centered]_&>*]:max-w-screen-2xl", - "peer-data-[variant=inset]:border", - "[--dashboard-header-height:--spacing(12)]", - "min-w-0 overflow-x-hidden", - )} + + - {children} - - + + *]:mx-auto", + "[html[data-content-layout=centered]_&>*]:w-full", + "[html[data-content-layout=centered]_&>*]:max-w-screen-2xl", + "peer-data-[variant=inset]:border", + "[--dashboard-header-height:--spacing(12)]", + "min-w-0 overflow-x-hidden", + )} + > + + {children} + + + ); } diff --git a/web/src/app/(main)/system/settings/page.tsx b/web/src/app/(main)/system/settings/page.tsx index e4646f3..a2e2ac0 100644 --- a/web/src/app/(main)/system/settings/page.tsx +++ b/web/src/app/(main)/system/settings/page.tsx @@ -16,6 +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 { BasicAuthCard } from "./_components/basic-auth-card"; import { UpdateCard } from "./_components/update-card"; @@ -38,6 +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(""); // 出口探测:测一次真实请求,拿到"直连 / 经全局代理"两条出口 IP。 const [egressTesting, setEgressTesting] = React.useState(false); const [egressReport, setEgressReport] = React.useState(null); @@ -60,6 +62,7 @@ export default function SystemSettingsPage() { const [noaCompaction, setNoaCompaction] = React.useState(false); // 纯前端偏好:不走 /api/settings,直接读写 localStorage。 const sendMode = useChatSendMode(); + const { probeEgress: probeEgressForBanner, isLoading: egressBannerLoading } = useEgressIpBanner(); const apply = React.useCallback((s: Settings) => { setCredentialAgent(!!s.credential_agent_enabled); @@ -72,7 +75,9 @@ export default function SystemSettingsPage() { setBraveKeySet(!!s.brave_key_set); setTavilyKeySet(!!s.tavily_key_set); setProxyInput(s.web_search_proxy ?? ""); - setGlobalProxyInput(s.global_proxy ?? ""); + const savedProxy = s.global_proxy ?? ""; + setGlobalProxyInput(savedProxy); + setSavedGlobalProxy(savedProxy); setPyInterp(s.python_interpreter ?? ""); setWorkers(String(s.workers ?? 3)); setWorkerPools(s.worker_pools ?? []); @@ -335,6 +340,25 @@ export default function SystemSettingsPage() { .finally(() => setEgressTesting(false)); }; + // 发布到站点顶栏时始终使用已保存的 global_proxy,避免把输入框里的草稿 + // 当成 Agent 当前实际使用的代理。 + const showEgressIp = async () => { + if (!settingsLoaded) { + toast.error("设置尚未加载完成,请稍后再试"); + return; + } + try { + const rep = await probeEgressForBanner({ + globalProxy: savedGlobalProxy.trim(), + ...(egressProbeUrl.trim() ? { probeUrl: egressProbeUrl.trim() } : {}), + }); + if (rep.ok) toast.success("已在顶栏显示出口 IP"); + else toast.error("出口探测完成,但部分结果不可用"); + } catch (e) { + toast.error("出口探测失败:" + (e as Error).message); + } + }; + // Run a real "test" search ("test") against the CURRENT form values (backend + // proxy + entered key), falling back to saved values server-side. Toasts result. const runTest = () => { @@ -463,9 +487,10 @@ export default function SystemSettingsPage() { -
+
{egressTesting ? "测试中…" : "测试"} +

{globalProxyInput.trim() ? "已配置 · 所有目标流量经此代理出网" : "未配置 · 目标流量直连出网"}