feat(ai-proxy): Update 9router button — shut down, npm update, relaunch hidden
Settings → AI Proxy gains an admin-only Update 9router button beside Config 9router. One in-memory job (server/lib/nine-router-update.js) stops 9router (its user unit, else this account's own 9router processes), runs npm install -g --prefix <its prefix> 9router@latest (sudo -n only when sudoers already allows it, probed before anything stops), then relaunches it hidden only if it was running. A failed update still relaunches the old version. Nothing in the request names a command, path or package.
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* @file NineRouterUpdate.tsx
|
||||
* @description **Update 9router** on the AI Proxy page (an admin's; beside
|
||||
* Config 9router): a button — dotted when npm has a newer 9router — and a
|
||||
* dialog that runs the server's three-step job (`server/lib/nine-router-update.js`):
|
||||
* disconnect and shut 9router down, `npm install -g 9router@latest`, start it
|
||||
* again hidden (tray, no browser) — the last only when it was running before.
|
||||
* The dialog shows why an update cannot run right now (the server's `blocker`,
|
||||
* with the command to run by hand when sudo needs a password) and follows the
|
||||
* job by polling until it ends. Closing the dialog does not stop the job.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowUpCircle, Check, Circle, Copy, Loader2, MinusCircle, X } from "lucide-react";
|
||||
import { api, type NineRouterUpdateStatus, type NineRouterUpdateStep } from "../../lib/api";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
|
||||
const POLL_MS = 1000;
|
||||
|
||||
function StepIcon({ status }: { status: NineRouterUpdateStep["status"] }) {
|
||||
if (status === "running") return <Loader2 className="h-3.5 w-3.5 animate-spin text-accent" />;
|
||||
if (status === "done") return <Check className="h-3.5 w-3.5 text-status-success" />;
|
||||
if (status === "failed") return <X className="h-3.5 w-3.5 text-status-danger" />;
|
||||
if (status === "skipped") return <MinusCircle className="h-3.5 w-3.5 text-fg-muted" />;
|
||||
return <Circle className="h-3.5 w-3.5 text-fg-muted" />;
|
||||
}
|
||||
|
||||
export function NineRouterUpdate() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [status, setStatus] = useState<NineRouterUpdateStatus | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
useDialog(open, dialogRef, () => setOpen(false));
|
||||
|
||||
const load = useCallback(async (fresh = false) => {
|
||||
try {
|
||||
setStatus(await api.aiProxies.nineRouterUpdateStatus(fresh));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const running = status?.job?.status === "running";
|
||||
// Follow a running job — also one started from another tab.
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
const timer = window.setInterval(() => void load(), POLL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [running, load]);
|
||||
|
||||
const openDialog = () => {
|
||||
setError(null);
|
||||
setOpen(true);
|
||||
void load(true);
|
||||
};
|
||||
|
||||
const start = async () => {
|
||||
setStarting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const job = await api.aiProxies.nineRouterUpdate();
|
||||
setStatus((s) => (s ? { ...s, job } : s));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
void load(true);
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!status?.installed) return null;
|
||||
const job = status.job;
|
||||
const blocker = status.blocker;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openDialog}
|
||||
className="relative inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary transition-colors hover:border-accent/30 hover:bg-accent/10 hover:text-accent"
|
||||
>
|
||||
{running ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<ArrowUpCircle className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("aiProxy.update9router")}
|
||||
{status.updateAvailable && !running && (
|
||||
<span
|
||||
className="absolute -right-1 -top-1 h-2 w-2 rounded-full bg-accent-solid"
|
||||
aria-label={t("aiProxy.update9routerAvailable")}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-surface-0/70 p-4"
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="nine-router-update-title"
|
||||
className="max-h-[90vh] w-full max-w-md overflow-y-auto rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
|
||||
>
|
||||
<h3
|
||||
id="nine-router-update-title"
|
||||
className="mb-3 text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
{t("aiProxy.update9router")}
|
||||
</h3>
|
||||
|
||||
<dl className="mb-3 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
|
||||
<dt className="text-fg-muted">{t("aiProxy.update9routerCurrent")}</dt>
|
||||
<dd className="font-mono text-fg-primary">{status.version ?? "—"}</dd>
|
||||
<dt className="text-fg-muted">{t("aiProxy.update9routerLatest")}</dt>
|
||||
<dd className="font-mono text-fg-primary">{status.latest ?? "—"}</dd>
|
||||
<dt className="text-fg-muted">{t("aiProxy.update9routerState")}</dt>
|
||||
<dd className="text-fg-primary">
|
||||
{status.running
|
||||
? status.managedBy === "systemd"
|
||||
? t("aiProxy.update9routerRunningUnit")
|
||||
: t("aiProxy.update9routerRunning")
|
||||
: t("aiProxy.update9routerStopped")}
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<p className="mb-3 text-[11px] text-fg-muted">
|
||||
{status.running
|
||||
? t("aiProxy.update9routerExplain")
|
||||
: t("aiProxy.update9routerExplainStopped")}
|
||||
</p>
|
||||
|
||||
{job && (
|
||||
<ol className="mb-3 space-y-1.5 rounded-md border border-border bg-surface-0 p-3">
|
||||
{job.steps.map((s) => (
|
||||
<li key={s.id} className="flex items-start gap-2 text-xs">
|
||||
<span className="mt-0.5">
|
||||
<StepIcon status={s.status} />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="text-fg-primary">
|
||||
{t(`aiProxy.update9routerStep.${s.id}`)}
|
||||
</span>
|
||||
{s.message && (
|
||||
<span className="block break-words text-[11px] text-fg-muted">
|
||||
{s.message}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
{job && job.status !== "running" && (
|
||||
<p
|
||||
className={`mb-3 text-xs ${
|
||||
job.status === "done" ? "text-status-success" : "text-status-danger"
|
||||
}`}
|
||||
>
|
||||
{job.status !== "done"
|
||||
? t("aiProxy.update9routerFailed")
|
||||
: job.steps.find((s) => s.id === "relaunch")?.status === "skipped"
|
||||
? t("aiProxy.update9routerDoneStopped", { version: job.to ?? "—" })
|
||||
: t("aiProxy.update9routerDone", { version: job.to ?? "—" })}
|
||||
</p>
|
||||
)}
|
||||
{job?.log && (
|
||||
<details className="mb-3">
|
||||
<summary className="cursor-pointer text-[11px] text-fg-muted">
|
||||
{t("aiProxy.update9routerLog")}
|
||||
</summary>
|
||||
<pre className="mt-1 max-h-48 overflow-auto whitespace-pre-wrap rounded-md bg-surface-0 p-2 font-mono text-[10px] text-fg-secondary">
|
||||
{job.log}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{blocker && !running && blocker.code !== "EUPTODATE" && (
|
||||
<div className="mb-3 rounded-md border border-status-warning/40 bg-status-warning/10 px-3 py-2 text-xs text-fg-secondary">
|
||||
<p>{blocker.message}</p>
|
||||
{blocker.command && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<code className="min-w-0 flex-1 truncate rounded bg-surface-0 px-2 py-1 font-mono text-[11px]">
|
||||
{blocker.command}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyText(blocker.command!)}
|
||||
className="shrink-0 rounded p-1 text-fg-muted hover:text-accent"
|
||||
aria-label={t("aiProxy.update9routerCopy")}
|
||||
title={t("aiProxy.update9routerCopy")}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{blocker?.code === "EUPTODATE" && !running && (
|
||||
<p className="mb-3 text-xs text-fg-muted">{t("aiProxy.update9routerUpToDate")}</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="mb-3 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-lg border border-border px-3 py-1.5 text-xs text-fg-secondary hover:bg-surface-2"
|
||||
>
|
||||
{t("aiProxy.update9routerClose")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void start()}
|
||||
disabled={Boolean(blocker) || running || starting}
|
||||
className="btn-primary inline-flex items-center gap-1.5 px-3 py-1.5 text-xs disabled:opacity-50"
|
||||
>
|
||||
{(running || starting) && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
{t("aiProxy.update9routerStart")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* @file Tests for **Update 9router** on the AI Proxy page: hidden without a
|
||||
* 9router, a dialog that shows versions and starts the job, the server's
|
||||
* blocker (with the manual command) disabling the start, and the job's steps.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { NineRouterUpdate } from "../NineRouterUpdate";
|
||||
import { api, type NineRouterUpdateJob, type NineRouterUpdateStatus } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { aiProxies: { nineRouterUpdateStatus: vi.fn(), nineRouterUpdate: vi.fn() } },
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
const statusMock = vi.mocked(api.aiProxies.nineRouterUpdateStatus);
|
||||
const startMock = vi.mocked(api.aiProxies.nineRouterUpdate);
|
||||
|
||||
const base: NineRouterUpdateStatus = {
|
||||
installed: true,
|
||||
version: "0.5.104",
|
||||
latest: "0.5.109",
|
||||
updateAvailable: true,
|
||||
running: true,
|
||||
managedBy: "systemd",
|
||||
blocker: null,
|
||||
job: null,
|
||||
};
|
||||
|
||||
const job: NineRouterUpdateJob = {
|
||||
status: "running",
|
||||
startedAt: "2026-10-26T00:00:00Z",
|
||||
finishedAt: null,
|
||||
from: "0.5.104",
|
||||
to: null,
|
||||
managedBy: "systemd",
|
||||
install: "direct",
|
||||
steps: [
|
||||
{ id: "shutdown", status: "done", message: null },
|
||||
{ id: "update", status: "running", message: null },
|
||||
{ id: "relaunch", status: "pending", message: null },
|
||||
],
|
||||
log: "",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
statusMock.mockReset();
|
||||
startMock.mockReset();
|
||||
});
|
||||
|
||||
describe("NineRouterUpdate", () => {
|
||||
it("renders nothing when 9router is not installed", async () => {
|
||||
statusMock.mockResolvedValue({ ...base, installed: false });
|
||||
const { container } = render(<NineRouterUpdate />);
|
||||
await waitFor(() => expect(statusMock).toHaveBeenCalled());
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("opens with both versions and starts the three-step job", async () => {
|
||||
statusMock.mockResolvedValue(base);
|
||||
startMock.mockResolvedValue(job);
|
||||
render(<NineRouterUpdate />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: /aiProxy.update9router/ }));
|
||||
expect(statusMock).toHaveBeenLastCalledWith(true);
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(dialog).toHaveTextContent("0.5.104");
|
||||
expect(dialog).toHaveTextContent("0.5.109");
|
||||
await userEvent.click(screen.getByRole("button", { name: "aiProxy.update9routerStart" }));
|
||||
expect(startMock).toHaveBeenCalledTimes(1);
|
||||
expect(await screen.findByText("aiProxy.update9routerStep.shutdown")).toBeInTheDocument();
|
||||
expect(screen.getByText("aiProxy.update9routerStep.update")).toBeInTheDocument();
|
||||
expect(screen.getByText("aiProxy.update9routerStep.relaunch")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the blocker with its manual command and keeps the start disabled", async () => {
|
||||
statusMock.mockResolvedValue({
|
||||
...base,
|
||||
blocker: {
|
||||
code: "ENOPERM",
|
||||
message: "/usr/local is not writable by this account and sudo needs a password",
|
||||
command: "sudo npm install -g 9router@latest",
|
||||
},
|
||||
});
|
||||
render(<NineRouterUpdate />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: /aiProxy.update9router/ }));
|
||||
expect(await screen.findByText("sudo npm install -g 9router@latest")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "aiProxy.update9routerStart" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -759,6 +759,29 @@
|
||||
"description": "For example 9Router, OpenRouter or a self-hosted gateway. Save one here, then pick it for a session in the workspace prompt box.",
|
||||
"add": "Add profile",
|
||||
"config9router": "Config 9router",
|
||||
"update9router": "Update 9router",
|
||||
"update9routerAvailable": "A newer 9router is available",
|
||||
"update9routerCurrent": "Installed",
|
||||
"update9routerLatest": "Latest",
|
||||
"update9routerState": "State",
|
||||
"update9routerRunning": "Running",
|
||||
"update9routerRunningUnit": "Running (9router.service)",
|
||||
"update9routerStopped": "Not running",
|
||||
"update9routerExplain": "Every session using 9router is disconnected while it updates: 9router is shut down, updated with npm, then started again hidden (tray, no browser).",
|
||||
"update9routerExplainStopped": "9router is not running: it is updated with npm and left off.",
|
||||
"update9routerStep": {
|
||||
"shutdown": "Disconnect and shut down 9router",
|
||||
"update": "Run the update (npm install -g 9router@latest)",
|
||||
"relaunch": "Start 9router again, hidden"
|
||||
},
|
||||
"update9routerDone": "9router {{version}} is running.",
|
||||
"update9routerDoneStopped": "9router {{version}} is installed and left off, as it was.",
|
||||
"update9routerFailed": "The update did not finish — see the steps and the log.",
|
||||
"update9routerLog": "npm output",
|
||||
"update9routerUpToDate": "9router is already the latest version.",
|
||||
"update9routerCopy": "Copy command",
|
||||
"update9routerClose": "Close",
|
||||
"update9routerStart": "Update now",
|
||||
"edit": "Edit",
|
||||
"remove": "Remove",
|
||||
"empty": "No proxy profile saved yet. Native runs against the vendor's own API.",
|
||||
|
||||
@@ -747,6 +747,29 @@
|
||||
"description": "Ví dụ 9Router, OpenRouter hoặc gateway tự dựng. Lưu ở đây rồi chọn cho từng session ở ô nhập prompt của workspace.",
|
||||
"add": "Thêm profile",
|
||||
"config9router": "Config 9router",
|
||||
"update9router": "Cập nhật 9router",
|
||||
"update9routerAvailable": "Đã có bản 9router mới",
|
||||
"update9routerCurrent": "Đang cài",
|
||||
"update9routerLatest": "Mới nhất",
|
||||
"update9routerState": "Trạng thái",
|
||||
"update9routerRunning": "Đang chạy",
|
||||
"update9routerRunningUnit": "Đang chạy (9router.service)",
|
||||
"update9routerStopped": "Không chạy",
|
||||
"update9routerExplain": "Mọi session đang dùng 9router sẽ bị ngắt kết nối trong lúc cập nhật: 9router được tắt, cập nhật bằng npm rồi mở lại ở chế độ ẩn (khay hệ thống, không mở trình duyệt).",
|
||||
"update9routerExplainStopped": "9router đang không chạy: chỉ cập nhật bằng npm và giữ nguyên trạng thái tắt.",
|
||||
"update9routerStep": {
|
||||
"shutdown": "Ngắt kết nối và tắt 9router",
|
||||
"update": "Chạy cập nhật (npm install -g 9router@latest)",
|
||||
"relaunch": "Mở lại 9router ở chế độ ẩn"
|
||||
},
|
||||
"update9routerDone": "9router {{version}} đang chạy.",
|
||||
"update9routerDoneStopped": "Đã cài 9router {{version}}, giữ nguyên trạng thái tắt như trước.",
|
||||
"update9routerFailed": "Cập nhật chưa hoàn tất — xem các bước và log.",
|
||||
"update9routerLog": "Output của npm",
|
||||
"update9routerUpToDate": "9router đã là bản mới nhất.",
|
||||
"update9routerCopy": "Sao chép lệnh",
|
||||
"update9routerClose": "Đóng",
|
||||
"update9routerStart": "Cập nhật ngay",
|
||||
"edit": "Sửa",
|
||||
"remove": "Xoá",
|
||||
"empty": "Chưa lưu profile proxy nào. Native là chạy thẳng vào API của hãng.",
|
||||
|
||||
@@ -3281,6 +3281,12 @@ export const api = {
|
||||
list: () => request<{ items: AiProxy[] }>("/ai-proxies"),
|
||||
/** GET /api/ai-proxies/9router - whether a `9router` binary is installed on the server. */
|
||||
nineRouter: () => request<{ installed: boolean }>("/ai-proxies/9router"),
|
||||
/** GET /api/ai-proxies/9router/update - versions, whether it runs, why an update would be refused, the job. */
|
||||
nineRouterUpdateStatus: (fresh = false) =>
|
||||
request<NineRouterUpdateStatus>(`/ai-proxies/9router/update${fresh ? "?fresh=1" : ""}`),
|
||||
/** POST /api/ai-proxies/9router/update - shut 9router down, update it, start it again hidden (admin). */
|
||||
nineRouterUpdate: () =>
|
||||
request<NineRouterUpdateJob>("/ai-proxies/9router/update", { method: "POST" }),
|
||||
/** POST /api/ai-proxies - save a new profile. */
|
||||
create: (body: AiProxyInput) =>
|
||||
request<AiProxy>("/ai-proxies", { method: "POST", body: JSON.stringify(body) }),
|
||||
@@ -4836,6 +4842,38 @@ export interface AiProxy {
|
||||
* error is still a successful HTTP call: `ok` is the PROXY's verdict, `status`
|
||||
* is its HTTP code (null when nothing answered at all), and `message` is its
|
||||
* own words — truncated server-side, never the key. */
|
||||
/** One step of the 9router update job, in order: shutdown → update → relaunch. */
|
||||
export interface NineRouterUpdateStep {
|
||||
id: "shutdown" | "update" | "relaunch";
|
||||
status: "pending" | "running" | "done" | "failed" | "skipped";
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
/** The server's one 9router update job (in memory; null before the first). */
|
||||
export interface NineRouterUpdateJob {
|
||||
status: "running" | "done" | "failed";
|
||||
startedAt: string;
|
||||
finishedAt: string | null;
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
managedBy: "systemd" | "process" | null;
|
||||
install: "direct" | "sudo";
|
||||
steps: NineRouterUpdateStep[];
|
||||
log: string;
|
||||
}
|
||||
|
||||
/** GET /api/ai-proxies/9router/update. `blocker` is why a start would be refused now. */
|
||||
export interface NineRouterUpdateStatus {
|
||||
installed: boolean;
|
||||
version: string | null;
|
||||
latest: string | null;
|
||||
updateAvailable: boolean;
|
||||
running: boolean;
|
||||
managedBy: "systemd" | "process" | null;
|
||||
blocker: { code: string; message: string; command?: string } | null;
|
||||
job: NineRouterUpdateJob | null;
|
||||
}
|
||||
|
||||
export interface AiProxyProbe {
|
||||
ok: boolean;
|
||||
status: number | null;
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
* — on the DASHBOARD's host at `NINE_ROUTER_PORT` (`nineRouterUrl`), never the
|
||||
* viewer's own localhost: a teammate on the LAN has no 9router of their own.
|
||||
* It is shown only when the server finds a `9router` binary installed.
|
||||
* Beside it an admin gets **Update 9router** (`NineRouterUpdate`): shut it
|
||||
* down, `npm install -g 9router@latest`, start it again hidden.
|
||||
*
|
||||
* **Test** asks the endpoint itself, once as Claude Code would
|
||||
* (`/v1/messages`) and once as Codex would (`/v1/responses`), one line each.
|
||||
@@ -54,6 +56,7 @@ import { confirmDialog } from "../lib/confirm";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { useDialog } from "../hooks/useDialog";
|
||||
import { useAuth } from "../components/AuthGate";
|
||||
import { NineRouterUpdate } from "../components/settings/NineRouterUpdate";
|
||||
|
||||
const NINE_ROUTER_PORT = 20128;
|
||||
|
||||
@@ -271,6 +274,7 @@ export function AiProxyConfig() {
|
||||
{t("aiProxy.config9router")}
|
||||
</a>
|
||||
)}
|
||||
{nineRouter && isAdmin && <NineRouterUpdate />}
|
||||
<button
|
||||
type="button"
|
||||
onClick={startAdd}
|
||||
|
||||
Reference in New Issue
Block a user