mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-28 17:13: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
@@ -266,6 +266,7 @@ const (
|
|||||||
// (http/https/socks5). Empty = direct. Distinct from web_search_proxy (which
|
// (http/https/socks5). Empty = direct. Distinct from web_search_proxy (which
|
||||||
// only routes the search backend) and the per-profile LLM proxy.
|
// only routes the search backend) and the per-profile LLM proxy.
|
||||||
settingGlobalProxy = "global_proxy"
|
settingGlobalProxy = "global_proxy"
|
||||||
|
settingShowEgressIP = "show_egress_ip"
|
||||||
settingWorkers = "workers"
|
settingWorkers = "workers"
|
||||||
settingWorkerPools = "worker_pools"
|
settingWorkerPools = "worker_pools"
|
||||||
settingLLMRecord = "llm_record"
|
settingLLMRecord = "llm_record"
|
||||||
|
|||||||
@@ -3454,6 +3454,7 @@ func (s *Server) settingsPayload() map[string]any {
|
|||||||
"tavily_key_set": strings.TrimSpace(tavilyKey) != "",
|
"tavily_key_set": strings.TrimSpace(tavilyKey) != "",
|
||||||
"web_search_proxy": proxy, // 独立出口代理(http/https/socks5),空=直连
|
"web_search_proxy": proxy, // 独立出口代理(http/https/socks5),空=直连
|
||||||
"global_proxy": s.m.GlobalProxy(), // 全局出口代理(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), // 用户/自动设的值(空=用运行时检测)
|
"python_interpreter": strings.TrimSpace(pyStored), // 用户/自动设的值(空=用运行时检测)
|
||||||
"workers": s.m.Workers(), // 并发工作 agent 数(默认3);对之后启动的任务生效
|
"workers": s.m.Workers(), // 并发工作 agent 数(默认3);对之后启动的任务生效
|
||||||
"worker_pools": s.m.WorkerPools(), // 非空时按池顺序绑定 worker 会话
|
"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"`
|
TavilyKey *string `json:"tavily_search_api_key"`
|
||||||
WebSearchProxy *string `json:"web_search_proxy"` // 独立出口代理(http/https/socks5);null=不改,""=清空
|
WebSearchProxy *string `json:"web_search_proxy"` // 独立出口代理(http/https/socks5);null=不改,""=清空
|
||||||
GlobalProxy *string `json:"global_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 解释器路径
|
PythonInterp *string `json:"python_interpreter"` // 自定义脚本工具的 python 解释器路径
|
||||||
Workers *int `json:"workers"` // 并发工作 agent 数(>0);对之后启动的任务生效
|
Workers *int `json:"workers"` // 并发工作 agent 数(>0);对之后启动的任务生效
|
||||||
WorkerPools *[]WorkerPool `json:"worker_pools"`
|
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
|
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 {
|
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.
|
// Fill unspecified fields from current state so a partial PUT doesn't reset them.
|
||||||
on, backend, _, _, _ := s.m.WebSearch()
|
on, backend, _, _, _ := s.m.WebSearch()
|
||||||
|
|||||||
@@ -2,96 +2,40 @@
|
|||||||
|
|
||||||
import * as React from "react";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import type { EgressCheck, EgressReport } from "@/lib/types";
|
import type { EgressCheck, EgressReport } from "@/lib/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const DISMISSED_STORAGE_KEY = "artex-egress-ip-banner-dismissed";
|
type EgressIpContextValue = {
|
||||||
const REPORT_STORAGE_KEY = "artex-egress-ip-banner-report";
|
enabled: boolean;
|
||||||
|
|
||||||
type ProbeOptions = {
|
|
||||||
globalProxy: string;
|
|
||||||
probeUrl?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type EgressIpBannerContextValue = {
|
|
||||||
probeEgress: (options: ProbeOptions) => Promise<EgressReport>;
|
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
report: EgressReport | null;
|
report: EgressReport | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
dismissed: boolean;
|
setEnabled: (enabled: boolean) => Promise<void>;
|
||||||
dismiss: () => void;
|
probeEgress: () => Promise<EgressReport>;
|
||||||
|
dismiss: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const EgressIpBannerContext = React.createContext<EgressIpBannerContextValue | null>(null);
|
const EgressIpContext = React.createContext<EgressIpContextValue | null>(null);
|
||||||
|
|
||||||
function readDismissed() {
|
export function EgressIpProvider({ children }: { children: React.ReactNode }) {
|
||||||
if (typeof window === "undefined") return false;
|
const [enabled, setEnabledState] = React.useState(false);
|
||||||
try {
|
const [report, setReport] = React.useState<EgressReport | null>(null);
|
||||||
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);
|
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = React.useState(false);
|
const [isLoading, setIsLoading] = React.useState(false);
|
||||||
const [dismissed, setDismissed] = React.useState(readDismissed);
|
|
||||||
|
|
||||||
const probeEgress = React.useCallback(async ({ globalProxy, probeUrl }: ProbeOptions) => {
|
const probeForProxy = React.useCallback(async (globalProxy: string) => {
|
||||||
setDismissed(false);
|
|
||||||
setDismissedStorage(false);
|
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const nextReport = await api.testEgressProxy({
|
const nextReport = await api.testEgressProxy({ global_proxy: globalProxy });
|
||||||
global_proxy: globalProxy,
|
|
||||||
...(probeUrl ? { probe_url: probeUrl } : {}),
|
|
||||||
});
|
|
||||||
setReport(nextReport);
|
setReport(nextReport);
|
||||||
setStoredReport(nextReport);
|
|
||||||
return nextReport;
|
return nextReport;
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
setReport(null);
|
setReport(null);
|
||||||
setStoredReport(null);
|
|
||||||
const message = cause instanceof Error ? cause.message : "未知错误";
|
const message = cause instanceof Error ? cause.message : "未知错误";
|
||||||
setError(message);
|
setError(message);
|
||||||
throw cause;
|
throw cause;
|
||||||
@@ -100,80 +44,117 @@ export function EgressIpBannerProvider({ children }: { children: React.ReactNode
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const dismiss = React.useCallback(() => {
|
React.useEffect(() => {
|
||||||
setDismissed(true);
|
let cancelled = false;
|
||||||
setDismissedStorage(true);
|
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(
|
const setEnabled = React.useCallback(
|
||||||
() => ({ probeEgress, isLoading, report, error, dismissed, dismiss }),
|
async (nextEnabled: boolean) => {
|
||||||
[dismiss, dismissed, error, isLoading, probeEgress, report],
|
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() {
|
export function useEgressIp() {
|
||||||
const context = React.useContext(EgressIpBannerContext);
|
const context = React.useContext(EgressIpContext);
|
||||||
if (!context) throw new Error("useEgressIpBanner must be used within EgressIpBannerProvider");
|
if (!context) throw new Error("useEgressIp must be used within EgressIpProvider");
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkMessage(check: EgressCheck | undefined) {
|
function checkMessage(check: EgressCheck | undefined) {
|
||||||
if (!check) return "未返回结果";
|
if (!check) return "未返回结果";
|
||||||
if (check.skipped) return "未配置 / 已跳过";
|
if (check.skipped) return "未配置";
|
||||||
if (check.result.ok) return check.result.exit_ip || "已连通,但未返回 IP";
|
if (check.result.ok) return check.result.exit_ip || "已连通";
|
||||||
return `探测失败:${check.result.probes.find((probe) => probe.error)?.error || check.result.error || "未知错误"}`;
|
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;
|
const failed = !!check && !check.skipped && !check.result.ok;
|
||||||
return (
|
return (
|
||||||
<div className="flex min-w-0 items-baseline gap-2">
|
<span className="min-w-0 truncate" title={`${label}:${checkMessage(check)}`}>
|
||||||
<span className="shrink-0 text-muted-foreground">{label}</span>
|
<span className="text-muted-foreground">{label}</span> {"· "}
|
||||||
<span className={cn("min-w-0 truncate font-mono", failed && "text-destructive")}>{checkMessage(check)}</span>
|
<span className={cn("font-mono", failed && "text-destructive")}>{checkMessage(check)}</span>
|
||||||
</div>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EgressIpBanner() {
|
export function EgressIpNavbar() {
|
||||||
const { report, error, isLoading, dismissed, dismiss } = useEgressIpBanner();
|
const { enabled, report, error, isLoading, probeEgress, dismiss } = useEgressIp();
|
||||||
if (dismissed) return null;
|
if (!enabled) return null;
|
||||||
if (!isLoading && !report && !error) return null;
|
|
||||||
|
|
||||||
const direct = report?.checks.find((check) => check.key === "direct");
|
const direct = report?.checks.find((check) => check.key === "direct");
|
||||||
const global = report?.checks.find((check) => check.key === "global");
|
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 (
|
return (
|
||||||
<div className="border-b bg-primary/5 px-4 py-2.5 md:px-6" role="status" aria-live="polite">
|
<div className="flex min-w-0 items-center justify-center gap-2 text-xs" role="status" aria-live="polite">
|
||||||
<div className="flex items-start gap-3">
|
<Globe2Icon className="size-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||||
<Globe2Icon className="mt-0.5 size-4 shrink-0 text-primary" aria-hidden="true" />
|
{isLoading ? (
|
||||||
<div className="min-w-0 flex-1 text-sm">
|
<span className="flex items-center gap-1 text-muted-foreground">
|
||||||
<div className="font-medium">出口 IP</div>
|
<Loader2Icon className="size-3 animate-spin" aria-hidden="true" /> 正在探测出口 IP…
|
||||||
{statusContent}
|
</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>
|
</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>
|
)}
|
||||||
</div>
|
<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 { SearchDialog } from "./sidebar/search-dialog";
|
||||||
import { ThemeSwitcher } from "./sidebar/theme-switcher";
|
import { ThemeSwitcher } from "./sidebar/theme-switcher";
|
||||||
import { UpdateBadge } from "./update-badge";
|
import { UpdateBadge } from "./update-badge";
|
||||||
|
import { EgressIpNavbar, EgressIpProvider } from "./egress-ip-banner";
|
||||||
|
|
||||||
// 任务详情页保持原样:它自带头部/Tabs 与内边距,这里不再叠加全局头部和 padding。
|
// 任务详情页保持原样:它自带头部/Tabs 与内边距,这里不再叠加全局头部和 padding。
|
||||||
function isFullBleed(pathname: string) {
|
function isFullBleed(pathname: string) {
|
||||||
@@ -32,6 +33,14 @@ function isFullBleed(pathname: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MainContent({ children }: { children: ReactNode }) {
|
export function MainContent({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<EgressIpProvider>
|
||||||
|
<MainContentInner>{children}</MainContentInner>
|
||||||
|
</EgressIpProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MainContentInner({ children }: { children: ReactNode }) {
|
||||||
const currentUser = useCurrentUser();
|
const currentUser = useCurrentUser();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [version, setVersion] = useState("");
|
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",
|
"[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 min-w-0 w-full items-center gap-3 px-4 lg:gap-4 lg:px-6">
|
||||||
<div className="flex items-center gap-1 lg:gap-2">
|
<div className="flex shrink-0 items-center gap-1 lg:gap-2">
|
||||||
<SidebarTrigger className="-ml-1" />
|
<SidebarTrigger className="-ml-1" />
|
||||||
<Separator
|
<Separator
|
||||||
orientation="vertical"
|
orientation="vertical"
|
||||||
@@ -64,7 +73,10 @@ export function MainContent({ children }: { children: ReactNode }) {
|
|||||||
/>
|
/>
|
||||||
<SearchDialog />
|
<SearchDialog />
|
||||||
</div>
|
</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 && (
|
{version && (
|
||||||
<span className="font-medium text-muted-foreground text-xs tabular-nums">版本 · {version}</span>
|
<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 { PREFERENCE_DEFAULTS } from "@/lib/preferences/preferences-config";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import { EgressIpBanner, EgressIpBannerProvider } from "./_components/egress-ip-banner";
|
|
||||||
import { MainContent } from "./_components/main-content";
|
import { MainContent } from "./_components/main-content";
|
||||||
|
|
||||||
// Reads a layout-critical preference from the browser cookie (falls back to the
|
// 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;
|
if (!authed) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EgressIpBannerProvider>
|
<SidebarProvider
|
||||||
<SidebarProvider
|
defaultOpen={defaultOpen}
|
||||||
defaultOpen={defaultOpen}
|
style={
|
||||||
style={
|
{
|
||||||
{
|
"--sidebar-width": "calc(var(--spacing) * 68)",
|
||||||
"--sidebar-width": "calc(var(--spacing) * 68)",
|
} as React.CSSProperties
|
||||||
} 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} />
|
<MainContent>{children}</MainContent>
|
||||||
<SidebarInset
|
</SidebarInset>
|
||||||
className={cn(
|
</SidebarProvider>
|
||||||
"[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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -16,7 +16,7 @@ import { api } from "@/lib/api";
|
|||||||
import { CHAT_SEND_MODE_OPTIONS, type ChatSendMode, setChatSendMode, useChatSendMode } from "@/lib/chat-send-mode";
|
import { CHAT_SEND_MODE_OPTIONS, type ChatSendMode, setChatSendMode, useChatSendMode } from "@/lib/chat-send-mode";
|
||||||
import type { EgressReport, LLMProfile, Settings, WorkerPool } from "@/lib/types";
|
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 { BasicAuthCard } from "./_components/basic-auth-card";
|
||||||
import { UpdateCard } from "./_components/update-card";
|
import { UpdateCard } from "./_components/update-card";
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ export default function SystemSettingsPage() {
|
|||||||
const [savingProxy, setSavingProxy] = React.useState(false);
|
const [savingProxy, setSavingProxy] = React.useState(false);
|
||||||
const [globalProxyInput, setGlobalProxyInput] = React.useState("");
|
const [globalProxyInput, setGlobalProxyInput] = React.useState("");
|
||||||
const [savingGlobalProxy, setSavingGlobalProxy] = React.useState(false);
|
const [savingGlobalProxy, setSavingGlobalProxy] = React.useState(false);
|
||||||
const [savedGlobalProxy, setSavedGlobalProxy] = React.useState("");
|
const [savingEgressVisibility, setSavingEgressVisibility] = React.useState(false);
|
||||||
// 出口探测:测一次真实请求,拿到"直连 / 经全局代理"两条出口 IP。
|
// 出口探测:测一次真实请求,拿到"直连 / 经全局代理"两条出口 IP。
|
||||||
const [egressTesting, setEgressTesting] = React.useState(false);
|
const [egressTesting, setEgressTesting] = React.useState(false);
|
||||||
const [egressReport, setEgressReport] = React.useState<EgressReport | null>(null);
|
const [egressReport, setEgressReport] = React.useState<EgressReport | null>(null);
|
||||||
@@ -62,7 +62,12 @@ export default function SystemSettingsPage() {
|
|||||||
const [noaCompaction, setNoaCompaction] = React.useState(false);
|
const [noaCompaction, setNoaCompaction] = React.useState(false);
|
||||||
// 纯前端偏好:不走 /api/settings,直接读写 localStorage。
|
// 纯前端偏好:不走 /api/settings,直接读写 localStorage。
|
||||||
const sendMode = useChatSendMode();
|
const sendMode = useChatSendMode();
|
||||||
const { probeEgress: probeEgressForBanner, isLoading: egressBannerLoading } = useEgressIpBanner();
|
const {
|
||||||
|
enabled: showEgressIp,
|
||||||
|
isLoading: egressProbeLoading,
|
||||||
|
setEnabled: setShowEgressIp,
|
||||||
|
probeEgress,
|
||||||
|
} = useEgressIp();
|
||||||
|
|
||||||
const apply = React.useCallback((s: Settings) => {
|
const apply = React.useCallback((s: Settings) => {
|
||||||
setCredentialAgent(!!s.credential_agent_enabled);
|
setCredentialAgent(!!s.credential_agent_enabled);
|
||||||
@@ -77,7 +82,6 @@ export default function SystemSettingsPage() {
|
|||||||
setProxyInput(s.web_search_proxy ?? "");
|
setProxyInput(s.web_search_proxy ?? "");
|
||||||
const savedProxy = s.global_proxy ?? "";
|
const savedProxy = s.global_proxy ?? "";
|
||||||
setGlobalProxyInput(savedProxy);
|
setGlobalProxyInput(savedProxy);
|
||||||
setSavedGlobalProxy(savedProxy);
|
|
||||||
setPyInterp(s.python_interpreter ?? "");
|
setPyInterp(s.python_interpreter ?? "");
|
||||||
setWorkers(String(s.workers ?? 3));
|
setWorkers(String(s.workers ?? 3));
|
||||||
setWorkerPools(s.worker_pools ?? []);
|
setWorkerPools(s.worker_pools ?? []);
|
||||||
@@ -308,6 +312,7 @@ export default function SystemSettingsPage() {
|
|||||||
apply(s);
|
apply(s);
|
||||||
// 保存后链路变了,旧结果(尤其"尚未保存")立刻失效。
|
// 保存后链路变了,旧结果(尤其"尚未保存")立刻失效。
|
||||||
setEgressReport(null);
|
setEgressReport(null);
|
||||||
|
if (showEgressIp) void probeEgress().catch(() => undefined);
|
||||||
toast.success(globalProxyInput.trim() ? "已保存全局代理" : "已清除全局代理(改为直连)");
|
toast.success(globalProxyInput.trim() ? "已保存全局代理" : "已清除全局代理(改为直连)");
|
||||||
})
|
})
|
||||||
.catch((e) => toast.error("保存失败:" + (e as Error).message))
|
.catch((e) => toast.error("保存失败:" + (e as Error).message))
|
||||||
@@ -340,22 +345,15 @@ export default function SystemSettingsPage() {
|
|||||||
.finally(() => setEgressTesting(false));
|
.finally(() => setEgressTesting(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
// 发布到站点顶栏时始终使用已保存的 global_proxy,避免把输入框里的草稿
|
const toggleEgressVisibility = async (nextEnabled: boolean) => {
|
||||||
// 当成 Agent 当前实际使用的代理。
|
setSavingEgressVisibility(true);
|
||||||
const showEgressIp = async () => {
|
|
||||||
if (!settingsLoaded) {
|
|
||||||
toast.error("设置尚未加载完成,请稍后再试");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const rep = await probeEgressForBanner({
|
await setShowEgressIp(nextEnabled);
|
||||||
globalProxy: savedGlobalProxy.trim(),
|
toast.success(nextEnabled ? "已在顶栏显示出口 IP" : "已关闭顶栏出口 IP显示");
|
||||||
...(egressProbeUrl.trim() ? { probeUrl: egressProbeUrl.trim() } : {}),
|
|
||||||
});
|
|
||||||
if (rep.ok) toast.success("已在顶栏显示出口 IP");
|
|
||||||
else toast.error("出口探测完成,但部分结果不可用");
|
|
||||||
} catch (e) {
|
} 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 ? "测试中…" : "测试"}
|
{egressTesting ? "测试中…" : "测试"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={showEgressIp}
|
|
||||||
disabled={!loaded || egressBannerLoading}
|
|
||||||
>
|
|
||||||
{egressBannerLoading ? "探测中…" : "显示出口 IP"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
{globalProxyInput.trim() ? "已配置 · 所有目标流量经此代理出网" : "未配置 · 目标流量直连出网"}
|
{globalProxyInput.trim() ? "已配置 · 所有目标流量经此代理出网" : "未配置 · 目标流量直连出网"}
|
||||||
</p>
|
</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">
|
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||||
点<b>测试</b>会访问 IP 回显服务(默认 <code>api.ipify.org</code>,失败自动换{" "}
|
点<b>测试</b>会访问 IP 回显服务(默认 <code>api.ipify.org</code>,失败自动换{" "}
|
||||||
|
|||||||
@@ -3336,6 +3336,7 @@ export const settings: Settings = {
|
|||||||
tavily_key_set: true,
|
tavily_key_set: true,
|
||||||
web_search_proxy: "",
|
web_search_proxy: "",
|
||||||
global_proxy: "",
|
global_proxy: "",
|
||||||
|
show_egress_ip: false,
|
||||||
python_interpreter: "/usr/bin/python3",
|
python_interpreter: "/usr/bin/python3",
|
||||||
workers: 3,
|
workers: 3,
|
||||||
worker_pools: [],
|
worker_pools: [],
|
||||||
|
|||||||
@@ -985,6 +985,7 @@ export interface Settings {
|
|||||||
// 全局出口代理(http/https/socks5,可带 user:pass),所有目标流量走它。开启流量捕获时作为
|
// 全局出口代理(http/https/socks5,可带 user:pass),所有目标流量走它。开启流量捕获时作为
|
||||||
// MITM 上游;关闭捕获时直接注入 agent 的 bash/WebFetch。空=直连。
|
// MITM 上游;关闭捕获时直接注入 agent 的 bash/WebFetch。空=直连。
|
||||||
global_proxy?: string;
|
global_proxy?: string;
|
||||||
|
show_egress_ip?: boolean; // 在顶栏显示直连 / 全局代理出口 IP;默认关闭
|
||||||
python_interpreter?: string; // 自定义脚本工具的 python 解释器路径(空=运行时检测)
|
python_interpreter?: string; // 自定义脚本工具的 python 解释器路径(空=运行时检测)
|
||||||
workers?: number; // 并发工作 agent 数(默认3);对之后启动的任务生效
|
workers?: number; // 并发工作 agent 数(默认3);对之后启动的任务生效
|
||||||
worker_pools: WorkerPool[];
|
worker_pools: WorkerPool[];
|
||||||
|
|||||||
Reference in new issue
Block a user