feat(update): 页面一键更新 + 守护启动脚本

系统配置页新增「版本与更新」卡片,顶栏版本号旁在有新版时亮出提示胶囊。
更新链路:下载发布包 → 比对 SHA256SUMS → -h 冒烟测试 → 暂存 artex.new
→ 以退出码 75 退出 → 守护脚本重新拉起并完成换装,页面轮询 /api/health
等版本号变化后自动刷新。

新增 start.sh / start.bat 作为正式启动入口,只按退出码决定是否重新拉起
(0 停止 / 75 应用新版 / 其余崩溃退避)。校验、冒烟、换装、回滚全部收在
selfupdate 包里而不写进脚本:这些逻辑在 sh 和 bat 上要写两套,而它们恰恰
最不能出错——换上跑不起来的二进制后守护进程会反复拉起它,只能上机器手工救。
Docker 的 ENTRYPOINT 一并改为该脚本,并由它转发 SIGTERM,否则 docker stop
只发给 PID 1,artex 做不了优雅关闭会被 SIGKILL 硬杀。

失败保护两道闸门互补:无法 exec 由换装前的冒烟挡住,能执行但初始化崩溃
由启动计数兜住(连续 3 次失败自动回滚 artex.old,坏的留作 artex.failed)。

GitHub 查询结果后端缓存 30 分钟(失败 2 分钟)。未认证 API 是每 IP 每小时
60 次,且这 60 次是服务器 IP 上所有用户共用的,顶栏每次整页加载都查一次,
不缓存会很快耗光配额导致真想更新时反而查不动。手动「检查更新」带 force=1
绕过缓存。

安全:只认 GitHub 域名且强制 HTTPS(逐跳校验重定向),发布源写死不可配置;
升级文件挂在可执行文件所在目录而非 CWD(服务化运行时 CWD 可能是 /);
开发构建版本号不可比较,禁用一键更新。

走完整 zip 而非裸二进制,因为现有 Release 的 SHA256SUMS 只覆盖 zip,
这样无需改 CI,也兼容已发布的历史版本。

已知限制:Docker 下只换程序不换镜像(工具链不同步、重建容器会退回镜像
版本);不同步发布包里的 skills/;更新即重启会中断运行中的任务。
This commit is contained in:
Autumn-27 committed 2026-09-11 12:39:59 -04:00
1 parent 557317491b
commit 44156a0a4c
22 files changed
+2619 -16

No files matched your search

@@ -14,6 +14,7 @@ import { AccountSwitcher } from "./sidebar/account-switcher";
import { LayoutControls } from "./sidebar/layout-controls";
import { SearchDialog } from "./sidebar/search-dialog";
import { ThemeSwitcher } from "./sidebar/theme-switcher";
import { UpdateBadge } from "./update-badge";
// 任务详情页保持原样:它自带头部/Tabs 与内边距,这里不再叠加全局头部和 padding。
function isFullBleed(pathname: string) {
@@ -64,6 +65,7 @@ export function MainContent({ children }: { children: ReactNode }) {
{version && (
<span className="font-medium text-muted-foreground text-xs tabular-nums">版本 · {version}</span>
)}
<UpdateBadge />
<LayoutControls />
<ThemeSwitcher />
<AccountSwitcher users={[currentUser]} />
@@ -0,0 +1,58 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { ArrowUpCircleIcon } from "lucide-react";
import { api } from "@/lib/api";
/**
* 顶栏的"有新版本"提示:整页加载时查一次,有更新就在版本号旁边亮出来,
* 点击直达系统配置页的「版本与更新」卡片。
*
* 后端对 GitHub 的查询结果有 30 分钟缓存,所以这里每次挂载都查一次是安全的
* ——未认证的 GitHub API 只有 60 次/小时/IP,没有那层缓存的话,多开几个标签页
* 就会把配额耗光,之后真想更新反而查不动。
*
* 查询失败一律静默:顶栏不是报错的地方,用户进设置页点「检查更新」会看到原因。
*/
export function UpdateBadge() {
const [latest, setLatest] = React.useState("");
React.useEffect(() => {
let alive = true;
api
.checkUpdate()
.then((r) => {
// has_update 已经包含了"版本号可比较"的判断,开发构建不会亮这个提示。
if (alive && r.has_update && r.latest) setLatest(r.latest.replace(/^v(?=\d)/, ""));
})
.catch(() => {
// 静默:没网 / GitHub 限流都不该在顶栏弹错误。
});
return () => {
alive = false;
};
}, []);
if (!latest) return null;
return (
<Link
href="/system/settings"
title={`发现新版本 ${latest},点击前往更新`}
className="inline-flex items-center gap-1.5 rounded-full bg-primary px-2.5 py-1 font-medium text-primary-foreground text-xs transition-opacity hover:opacity-90"
>
{/* 呼吸点:顶栏元素很多,纯文字容易被忽略,动效让它一眼可见。 */}
<span className="relative flex size-1.5">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-primary-foreground opacity-75" />
<span className="relative inline-flex size-1.5 rounded-full bg-primary-foreground" />
</span>
<ArrowUpCircleIcon className="size-3.5" />
<span className="hidden sm:inline">新版本 {latest}</span>
<span className="sm:hidden">新版本</span>
</Link>
);
}
@@ -0,0 +1,314 @@
"use client";
import * as React from "react";
import {
CheckCircle2Icon,
DownloadIcon,
ExternalLinkIcon,
RefreshCwIcon,
RotateCcwIcon,
TriangleAlertIcon,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { api, sseUrl } from "@/lib/api";
import type { UpdateCheck, UpdateProgress } from "@/lib/types";
/** 等待新版本上线的最长时间。一次升级要经过三次进程启动(暂存 → 换装 → 新版),
* 每次都是秒级,三分钟足够覆盖慢磁盘和 Docker 容器重建。 */
const RESTART_TIMEOUT_MS = 180_000;
function humanSize(n?: number): string {
if (!n || n <= 0) return "";
const units = ["B", "KB", "MB", "GB"];
let v = n;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export function UpdateCard() {
const [info, setInfo] = React.useState<UpdateCheck | null>(null);
const [checking, setChecking] = React.useState(true);
const [progress, setProgress] = React.useState<UpdateProgress | null>(null);
// 与 progress 分开:暂存完成后进程就没了,SSE 会断,此时要切到轮询 /api/health。
const [restarting, setRestarting] = React.useState(false);
const [busy, setBusy] = React.useState(false);
// quiet 同时决定要不要绕过后端缓存:进页面时的自动检查用缓存(顶栏刚查过),
// 用户手动点「检查更新」则强制回源,否则刚发布的版本要等缓存过期才看得到。
const check = React.useCallback((quiet = false) => {
setChecking(true);
api
.checkUpdate(!quiet)
.then((r) => {
setInfo(r);
if (!quiet) {
if (r.error) toast.error("检查更新失败:" + r.error);
else if (r.has_update) toast.success(`发现新版本 ${r.latest}`);
else if (r.comparable) toast.success("当前已是最新版本");
}
})
.catch((e) => {
if (!quiet) toast.error("检查更新失败:" + (e as Error).message);
})
.finally(() => setChecking(false));
}, []);
React.useEffect(() => {
check(true);
}, [check]);
// 轮询 /api/health 直到版本号变化。
//
// 判据必须是"版本变了"而不是"能连上了":换装过程中旧版本会短暂地重新起来一次
// (那一次只负责把 artex.new 换上去然后立刻退出),只看连通性会误判成功。
const waitForNewVersion = React.useCallback(async (fromVersion: string) => {
setRestarting(true);
const deadline = Date.now() + RESTART_TIMEOUT_MS;
while (Date.now() < deadline) {
await sleep(2000);
try {
const r = await fetch("/api/health", { cache: "no-store" });
if (r.ok) {
const j = (await r.json()) as { version?: string };
if (j.version && j.version !== fromVersion) {
toast.success(`已更新到 ${j.version},正在重新加载页面`);
await sleep(800);
window.location.reload();
return;
}
}
} catch {
// 重启窗口内连不上是预期的,继续轮询。
}
}
setRestarting(false);
toast.error("等待服务重启超时。请检查后端日志,或确认 artex 是通过 start.sh / start.bat 启动的。");
}, []);
// 订阅更新进度。SSE 不走 Next 的 /api 重写(那层会缓冲,事件推不出来)。
const openStream = React.useCallback(
(fromVersion: string) => {
const es = new EventSource(sseUrl("/api/update/stream"));
es.onmessage = (ev) => {
let p: UpdateProgress;
try {
p = JSON.parse(ev.data) as UpdateProgress;
} catch {
return;
}
setProgress(p);
if (p.phase === "failed") {
es.close();
setBusy(false);
toast.error("更新失败:" + (p.error || p.message));
return;
}
if (p.phase === "staged") {
es.close();
void waitForNewVersion(fromVersion);
}
};
es.onerror = () => {
// 进程退出时 SSE 必然断开。如果已经进入等待重启,这属于正常现象,
// 交给 /api/health 轮询继续判定即可。
es.close();
};
return es;
},
[waitForNewVersion],
);
const doUpdate = () => {
if (!info) return;
const from = info.current;
const ok = window.confirm(
`确定更新到 ${info.latest}?\n\n` +
"更新会重启程序,正在运行的任务会被中断。\n" +
(info.mode === "docker"
? "\n注意:容器内更新只替换程序本身,不会更新镜像里的 playwright / nmap 等工具链;" +
"若新版本依赖新工具,请改用 docker compose pull。"
: ""),
);
if (!ok) return;
setBusy(true);
setProgress({ phase: "downloading", percent: 0, message: "准备中…" });
const es = openStream(from);
api.applyUpdate().catch((e) => {
es.close();
setBusy(false);
setProgress(null);
toast.error("启动更新失败:" + (e as Error).message);
});
};
const doRollback = () => {
if (!info) return;
if (
!window.confirm(
"回滚到上一版本?\n\n程序会重启,正在运行的任务会被中断。\n注意:数据库结构不会回退,旧版本可能无法识别新版写入的数据。",
)
)
return;
const from = info.current;
setBusy(true);
api
.rollbackUpdate()
.then(() => {
toast.success("已切换到上一版本,正在重启…");
void waitForNewVersion(from);
})
.catch((e) => {
setBusy(false);
toast.error("回滚失败:" + (e as Error).message);
});
};
const phase = progress?.phase;
const showProgress = busy || restarting;
// 只有下载阶段拿得到真实百分比(按 Content-Length 算)。校验/解压/等待重启都是
// 时长不可知的阶段,进度条填满并加个脉冲动画表示"在忙但说不准还要多久"。
const downloading = !restarting && phase === "downloading";
const pct = downloading ? Math.max(progress?.percent ?? 0, 0) : 100;
return (
// 设置页是多列瀑布流布局,卡片自己负责行间距并禁止跨列断开(见 page.tsx 的注释)。
<Card className="mb-4 break-inside-avoid md:mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<DownloadIcon className="size-4" />
版本与更新
</CardTitle>
<CardDescription>从 GitHub 检查并安装新版本。更新会重启程序,正在运行的任务会被中断。</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-muted-foreground">当前版本</span>
<Badge variant="secondary" className="font-mono">
{info?.current ?? "…"}
</Badge>
{info && (
<>
<Badge variant="outline" className="font-mono">
{info.os}/{info.arch}
</Badge>
<Badge variant="outline">{info.mode === "docker" ? "Docker" : "独立程序"}</Badge>
</>
)}
{info?.latest && (
<>
<span className="text-muted-foreground">最新版本</span>
<Badge variant={info.has_update ? "default" : "secondary"} className="font-mono">
{info.latest}
</Badge>
</>
)}
{info?.html_url && (
<a
href={info.html_url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs text-muted-foreground underline-offset-4 hover:underline"
>
更新日志 <ExternalLinkIcon className="size-3" />
</a>
)}
</div>
{info?.boot_notice && (
<p className="flex items-start gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 p-2 text-xs text-amber-700 dark:text-amber-400">
<TriangleAlertIcon className="mt-0.5 size-3.5 shrink-0" />
{info.boot_notice}
</p>
)}
{info?.error && (
<p className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 p-2 text-xs text-destructive">
<TriangleAlertIcon className="mt-0.5 size-3.5 shrink-0" />
无法连接 GitHub:{info.error}
{" "}可在上方配置全局代理后重试。
</p>
)}
{info && !info.comparable && info.reason && <p className="text-xs text-muted-foreground">{info.reason}</p>}
{info?.has_update && info.asset_available === false && (
<p className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 p-2 text-xs text-destructive">
<TriangleAlertIcon className="mt-0.5 size-3.5 shrink-0" />
{info.latest} 没有提供 {info.os}/{info.arch} 的发布包(缺少 {info.asset}),无法自动更新。
</p>
)}
{info?.has_update && info.asset_available !== false && (
<p className="text-xs text-muted-foreground">
将下载 <span className="font-mono">{info.asset}</span>
{info.size ? `(${humanSize(info.size)})` : ""},校验 SHA256 并冒烟测试后才会替换,失败自动保留当前版本。
</p>
)}
{info && !info.has_update && info.comparable && !info.error && (
<p className="flex items-center gap-2 text-xs text-muted-foreground">
<CheckCircle2Icon className="size-3.5 text-emerald-600" />
当前已是最新版本。
</p>
)}
{info?.mode === "docker" && info.has_update && (
<p className="text-xs text-muted-foreground">
Docker 下的更新只替换程序本身,不更新镜像里的 playwright / nmap 等工具链,且
<span className="font-mono"> docker compose up -d </span>
重建容器后会退回镜像自带的版本。需要连镜像一起升级请执行
<span className="font-mono"> docker compose pull artex &amp;&amp; docker compose up -d artex</span>。
</p>
)}
{showProgress && (
<div className="space-y-1.5">
<Progress value={pct} className={downloading ? undefined : "animate-pulse"} />
<p className="text-xs text-muted-foreground">
{restarting ? "正在重启并应用新版本,请稍候(页面会自动刷新)…" : progress?.message}
</p>
</div>
)}
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => check(false)} disabled={checking || busy || restarting}>
<RefreshCwIcon className={checking ? "size-4 animate-spin" : "size-4"} />
检查更新
</Button>
<Button
size="sm"
onClick={doUpdate}
disabled={busy || restarting || !info?.has_update || info?.asset_available === false}
>
<DownloadIcon className="size-4" />
{info?.has_update ? `更新到 ${info.latest}` : "立即更新"}
</Button>
{info?.has_backup && (
<Button variant="ghost" size="sm" onClick={doRollback} disabled={busy || restarting}>
<RotateCcwIcon className="size-4" />
回滚到上一版本
</Button>
)}
</div>
<p className="text-xs text-muted-foreground">
一键更新依赖守护脚本重启程序。请通过 <span className="font-mono">start.sh</span>(Windows 为
<span className="font-mono"> start.bat</span>)启动 ARTEX;直接运行 artex 本体时,程序退出后不会被自动拉起。
</p>
</CardContent>
</Card>
);
}
@@ -15,6 +15,8 @@ import { api } from "@/lib/api";
import { CHAT_SEND_MODE_OPTIONS, type ChatSendMode, setChatSendMode, useChatSendMode } from "@/lib/chat-send-mode";
import type { Settings } from "@/lib/types";
import { UpdateCard } from "./_components/update-card";
export default function SystemSettingsPage() {
const [trafficCapture, setTrafficCapture] = React.useState(false);
const [agentTrafficBinding, setAgentTrafficBinding] = React.useState(false);
@@ -247,6 +249,8 @@ export default function SystemSettingsPage() {
多列则自动按内容高度平衡填充。卡片间距靠 mb 而非 gap——多列布局下
column-gap 只管列间距,行间距要由子元素自己给。 */}
<div className="columns-1 gap-4 md:gap-6 lg:columns-2">
<UpdateCard />
<Card className="mb-4 break-inside-avoid md:mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
+18 -2
View File
@@ -89,6 +89,7 @@ import type {
TrafficEvidenceRole,
TrafficHost,
TrafficResp,
UpdateCheck,
UsageStats,
WorkspaceFile,
WorkspaceListing,
@@ -619,11 +620,15 @@ export const api = {
// 删除漏洞:移除 findings 记录 + 来源探索节点(从发现列表/任务发现 Tab/探索图一并消失)。
deleteFinding: (id: string) => del<{ deleted: boolean; id: number }>(`/exploration/findings/${id}`),
findingRetests: (id: string) =>
get<{ retests: FindingRetest[] }>(`/exploration/findings/${encodeURIComponent(id)}/retests`).then((r) => arr(r.retests)),
get<{ retests: FindingRetest[] }>(`/exploration/findings/${encodeURIComponent(id)}/retests`).then((r) =>
arr(r.retests),
),
activeFindingRetests: () =>
get<{ retests: ActiveFindingRetest[] }>("/exploration/findings/retests/active").then((r) => arr(r.retests)),
startFindingRetest: (id: string, notes: string) =>
post<{ retest: FindingRetest; created: boolean }>(`/exploration/findings/${encodeURIComponent(id)}/retests`, { notes }),
post<{ retest: FindingRetest; created: boolean }>(`/exploration/findings/${encodeURIComponent(id)}/retests`, {
notes,
}),
deepenFinding: (id: string, description: string) =>
post<FindingDeepenResponse>(`/exploration/findings/${id}/deepen`, { description }),
intents: (task?: string) => get<TaskNode[]>(`/exploration/intents${tq(task)}`).then(arr),
@@ -1123,4 +1128,15 @@ export const api = {
// per-agent 绑定 / 轮询 / 中断消耗都覆盖)。
tokensByModel: (task: string) =>
get<{ models: ModelTokenStat[] }>(`/llm/records/by-model?task=${encodeURIComponent(task)}`),
// ---- 一键更新 ----
// 检查以后端为准:下载是后端做的,浏览器能连 GitHub 而服务器连不上的情况很常见
// (服务器在内网、代理只配在浏览器上),那时点更新必然失败。
// 后端对 GitHub 的查询结果有 30 分钟缓存(未认证的 GitHub API 是 60 次/小时/IP,
// 顶栏每次整页加载都会查一次,不缓存会很快耗光配额)。force=true 强制回源,
// 留给用户显式点「检查更新」时用。
checkUpdate: (force = false) => get<UpdateCheck>(`/update/check${force ? "?force=1" : ""}`),
// 202 即返回,实际下载在后台跑,进度走 /api/update/stream。
applyUpdate: () => post<{ ok: boolean; target: string }>(`/update/apply`),
rollbackUpdate: () => post<{ ok: boolean }>(`/update/rollback`),
};
+44 -1
View File
@@ -1288,7 +1288,9 @@ export interface InterceptAudit {
input_digest: string;
user_message: string;
user_truncated?: boolean;
context: { kind: string; tool?: string; tool_use_id?: string; text: string; is_error?: boolean; truncated?: boolean }[] | null;
context:
| { kind: string; tool?: string; tool_use_id?: string; text: string; is_error?: boolean; truncated?: boolean }[]
| null;
context_truncated?: boolean;
captured_at: string;
model_fallback?: boolean;
@@ -1358,3 +1360,44 @@ export interface FindingTrafficDetail {
request: EvidenceBodyPreview;
response: EvidenceBodyPreview;
}
/** GET /api/update/check —— 当前版本与 GitHub 最新正式版的比较结果。 */
export interface UpdateCheck {
/** 当前运行的版本;开发构建为 "dev" 或 git describe 的带后缀形式。 */
current: string;
/** 运行形态。docker 下换装只作用于容器可写层,重建容器会退回镜像版本。 */
mode: "docker" | "binary";
os: string;
arch: string;
repo: string;
/** 是否存在可回滚的上一版本(artex.old)。 */
has_backup: boolean;
/** 本次启动时自更新自举的结论(换装失败 / 已回滚等),无事发生时为空。 */
boot_notice?: string;
rolled_back?: boolean;
/** 查询 GitHub 失败时给出原因,此时下面的字段都不会有。 */
error?: string;
latest?: string;
notes?: string;
html_url?: string;
published_at?: string;
/** 当前平台对应的发布包名,以及该 Release 是否真的带了它。 */
asset?: string;
asset_available?: boolean;
size?: number;
has_update?: boolean;
/** 双方版本号是否可比较;开发构建为 false,此时禁用一键更新。 */
comparable?: boolean;
/** comparable 为 false 时的说明。 */
reason?: string;
}
/** /api/update/stream 推送的一条更新进度。 */
export interface UpdateProgress {
phase: "idle" | "downloading" | "verifying" | "extracting" | "staged" | "failed";
/** 仅下载阶段有意义(0-100);其余阶段为 -1。 */
percent: number;
message: string;
version?: string;
error?: string;
}