mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-28 01:03:50 +08:00
fix(web): show egress IPs in navbar middle via settings toggle
This commit is contained in:
1 parent
251dccb921
commit
7e5c828fc2
8 files changed
+180
-174
No files matched your search
@@ -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" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,30 +60,27 @@ export default function Layout({ children }: Readonly<{ children: ReactNode }>)
|
||||
if (!authed) return null;
|
||||
|
||||
return (
|
||||
<EgressIpBannerProvider>
|
||||
<SidebarProvider
|
||||
defaultOpen={defaultOpen}
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": "calc(var(--spacing) * 68)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
<SidebarProvider
|
||||
defaultOpen={defaultOpen}
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": "calc(var(--spacing) * 68)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<AppSidebar variant={variant} collapsible={collapsible} />
|
||||
<SidebarInset
|
||||
className={cn(
|
||||
"[html[data-content-layout=centered]_&>*]: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",
|
||||
)}
|
||||
>
|
||||
<AppSidebar variant={variant} collapsible={collapsible} />
|
||||
<SidebarInset
|
||||
className={cn(
|
||||
"[html[data-content-layout=centered]_&>*]: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",
|
||||
)}
|
||||
>
|
||||
<EgressIpBanner />
|
||||
<MainContent>{children}</MainContent>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</EgressIpBannerProvider>
|
||||
<MainContent>{children}</MainContent>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
@@ -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>,失败自动换{" "}
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in new issue
Block a user