feat: 自动更新

This commit is contained in:
npc0-hue
2026-07-15 19:43:06 +08:00
parent f64eb0831f
commit f3b14b7945
54 changed files with 3207 additions and 589 deletions
+10 -6
View File
@@ -14,6 +14,16 @@ Do not define API clients, shared DTOs, route definitions, schemas, or bridge co
Management list pages must keep the primary list, grid, or table as the full-width working surface. Do not add permanent right-side create/edit/detail panes or fixed left-list/right-form master-detail layouts for users, plugins, AI providers, servers, or similar management resources. Use modals, drawers, or detail routes for create, edit, and detail workflows unless a future OpenSpec change explicitly requires an inline split layout.
Server card and resource-row action menus must behave like real compact dropdowns/popovers, not like full-height button towers. The screenshot-failure pattern is explicitly forbidden: opening "运行操作" or any similar trigger must not inject a tall vertical stack of large command buttons inside a card, over a card, or between cards where it covers metrics, health bars, titles, status badges, or neighboring cards.
Dropdown and contextual action menus must follow these rules:
- Anchor the menu to the trigger with a bounded floating layer that handles viewport collision; do not resize, stretch, or reflow the underlying card/list row when the menu opens.
- Keep the menu compact: normal actions use dense menu rows, grouped sections, or a primary action plus "more" menu. If there are too many operational actions for a compact popover, use a drawer, detail route, or command dialog instead of stacking oversized buttons.
- Preserve operational readability: the underlying card stats, progress bars, and status labels must remain legible and must not be dimmed, blurred, or physically covered except by the small anchored menu itself.
- Use shared command/menu styling and theme tokens. Do not create page-local translucent button slabs, repeated decorative icon rails, or one-off menu panels that bypass `theme/base.css`.
- Provide normal menu behavior: close on outside click, Escape, and item selection; support keyboard focus order; keep destructive/warning actions visibly labeled and icon-marked.
## Visual Style Rules
The platform_web visual system is a game operations console, not a generic SaaS dashboard. The default theme is black mecha; the selectable alternate theme is magical-girl. Future UI work must preserve the current style contract:
@@ -32,9 +42,3 @@ The platform_web visual system is a game operations console, not a generic SaaS
- Cards and framed repeated items should keep 8px-or-less radii unless a native control shape requires a pill or circle.
See `theme/README.md` before changing theme tokens, shared CSS surfaces, page chrome, account/theme settings, or background behavior.
## Verification Rules
If a change touches UI pages or interactions, verify the key workflow in a browser before claiming acceptance.
For theme, frame, or uploaded-background changes, the browser walkthrough must include both directions of theme switching and must explicitly check that nested empty/loading/error states do not render a second border or accessory.
+3 -3
View File
@@ -89,16 +89,16 @@ Server list and server detail surfaces expose runtime actions through platform A
These screens show safe availability reasons, run online/offline status, job/build/dependency progress, artifact IDs, checksums, key generations, fingerprints, and redacted `secret://runtime-keys/.../current` refs. They must not render raw run/client-manager keys, FTP passwords, database DSNs, RCON passwords, host paths, direct run sockets, backend storage URLs, or large inline log bodies.
Browser walkthrough baseline:
Manual UI smoke checklist:
1. Start `npm run dev`.
2. Open the local Vite URL.
3. Verify 首页、服务器管理、插件市场、用户管理、AI 提供商管理 render without visible overlap on desktop and mobile widths.
4. In 服务器管理, verify the server card action menu contains runtime actions without turning the whole card into an accidental click target.
4. In 服务器管理, verify the server card action menu contains runtime actions as a compact anchored dropdown/popover. It must not become a tall vertical button tower, reflow the card, cover server metrics/progress bars/status badges, or turn the whole card into an accidental click target.
5. In a server detail route, verify the overview renders the 运行分发 section, action availability reasons, dependency/log controls, and safe redacted refs only.
6. Switch black mecha and magical-girl themes when UI styling changed; runtime controls must keep the shared translucent console surfaces and avoid nested double frames.
Automated browser acceptance uses the repository local debug stack:
Automated browser acceptance remains available for deeper local debug verification:
```bash
LOCAL_DEBUG_PLATFORM_PORT=18189 LOCAL_DEBUG_WEB_PORT=5183 LOCAL_DEBUG_ROOT=/private/tmp/browser-local-debug-acceptance ../scripts/browser-acceptance.sh
+1
View File
@@ -266,6 +266,7 @@ export interface RunDistributionResponse {
targetOs: string;
targetArch: string;
packageFormat: string;
buildJobId: string;
artifactId: string;
checksum: string;
keyGeneration: number;
@@ -0,0 +1,495 @@
import {
AlertTriangle,
CheckCircle2,
Circle,
Clock3,
Download,
GitBranch,
Hammer,
HardDriveDownload,
Loader2,
PackageCheck,
ShieldCheck,
Terminal,
Wrench,
X
} from "lucide-react";
import { useCallback, useState } from "react";
import { cx } from "../utils/classes";
export type RuntimeTaskStatus = "running" | "succeeded" | "failed";
export type RuntimeTaskStageStatus = "pending" | "running" | "completed" | "failed";
export interface RuntimeTaskStage {
key: string;
label: string;
description: string;
}
export interface RuntimeTaskDialogState {
open: boolean;
title: string;
description: string;
status: RuntimeTaskStatus;
percent: number;
currentStageKey: string;
stages: RuntimeTaskStage[];
stageStatus: Record<string, RuntimeTaskStageStatus>;
logs: string[];
summary?: string;
error?: string;
}
export interface RuntimeTaskDialogAction {
label: string;
onClick: () => void;
disabled?: boolean;
kind?: "primary" | "default" | "danger";
}
export const runtimeBuildStages: RuntimeTaskStage[] = [
{ key: "git_sync", label: "拉取代码", description: "同步平台批准的 run 或客户端源码版本。" },
{ key: "env_check", label: "安装环境", description: "检查 Go、系统依赖和隔离构建目录。" },
{ key: "deps_download", label: "下载依赖", description: "解析模块依赖并准备构建缓存。" },
{ key: "build_compile", label: "编译构建", description: "编译目标平台二进制。" },
{ key: "package_finalize", label: "打包成功", description: "注入配置、校验 checksum、生成 artifact。" }
];
export const runtimeDownloadStages: RuntimeTaskStage[] = [
{ key: "scope_check", label: "权限校验", description: "确认当前服务器范围和 artifact 授权。" },
{ key: "artifact_lookup", label: "定位产物", description: "读取最新可下载 run 包引用。" },
{ key: "download_ref", label: "生成下载", description: "创建限时下载引用和分块参数。" }
];
export const runtimeUpdateStages: RuntimeTaskStage[] = [
{ key: "artifact_lookup", label: "定位产物", description: "读取最近生成或下载的 run artifact。" },
{ key: "checksum_verify", label: "校验签名", description: "确认 checksum 可用于 run 自更新。" },
{ key: "dispatch_job", label: "推送更新", description: "向在线 run 节点派发自更新任务。" },
{ key: "job_track", label: "等待确认", description: "记录 job id 并刷新后台任务状态。" }
];
export const runtimeDependencyStages: RuntimeTaskStage[] = [
{ key: "profile_read", label: "读取声明", description: "读取插件声明的 probe 和 install plan。" },
{ key: "env_probe", label: "环境检查", description: "让 run 节点评估当前运行环境。" },
{ key: "install_prepare", label: "安装环境", description: "准备安全、可审计的依赖安装任务。" },
{ key: "job_track", label: "等待确认", description: "记录 job id 并刷新后台任务状态。" }
];
export const runtimeLogStages: RuntimeTaskStage[] = [
{ key: "source_read", label: "读取日志源", description: "读取插件声明的日志源和 checkpoint。" },
{ key: "cursor_prepare", label: "准备游标", description: "创建平台日志查询或历史回填游标。" },
{ key: "job_track", label: "等待确认", description: "打开实时日志或记录回填任务。" }
];
export function runtimeInitialStageStatus(stages: RuntimeTaskStage[]): Record<string, RuntimeTaskStageStatus> {
return Object.fromEntries(stages.map((stage) => [stage.key, "pending" as RuntimeTaskStageStatus]));
}
interface RuntimeTaskRunOptions<T> {
title: string;
description: string;
stages: RuntimeTaskStage[];
execute: () => Promise<T>;
executeStageIndex?: number;
}
export interface RuntimeTrackedJob {
id: string;
state: "queued" | "accepted" | "running" | "succeeded" | "failed" | "cancelled";
progress: { percent: number; message?: string };
}
interface RuntimeTrackedTaskOptions<T> {
title: string;
description: string;
stages: RuntimeTaskStage[];
start: () => Promise<{ value: T; jobId: string }>;
poll: (jobId: string) => Promise<RuntimeTrackedJob>;
pollIntervalMs?: number;
}
export function useRuntimeTaskController() {
const [task, setTask] = useState<RuntimeTaskDialogState | null>(null);
const closeTask = useCallback(() => {
setTask((current) => (current ? { ...current, open: false } : current));
}, []);
const succeedTask = useCallback((summary: string) => {
setTask((current) =>
current
? {
...current,
status: "succeeded",
percent: 100,
summary,
error: undefined,
stageStatus: Object.fromEntries(current.stages.map((stage) => [stage.key, "completed" as RuntimeTaskStageStatus])),
logs: appendRuntimeLog(current.logs, summary)
}
: current
);
}, []);
const failTask = useCallback((message: string) => {
setTask((current) =>
current
? {
...current,
status: "failed",
error: message,
stageStatus: { ...current.stageStatus, [current.currentStageKey]: "failed" },
logs: appendRuntimeLog(current.logs, message)
}
: current
);
}, []);
const runTask = useCallback(async <T,>({ title, description, stages, execute, executeStageIndex }: RuntimeTaskRunOptions<T>): Promise<T> => {
const currentStageKey = stages[0]?.key ?? "start";
const executeIndex = Math.max(0, Math.min(stages.length - 1, executeStageIndex ?? Math.floor(stages.length / 2)));
setTask({
open: true,
title,
description,
status: "running",
percent: 2,
currentStageKey,
stages,
stageStatus: runtimeInitialStageStatus(stages),
logs: [`${title} 已启动`]
});
let value: T | undefined;
let executed = false;
for (let index = 0; index < stages.length; index += 1) {
const stage = stages[index];
const startPercent = stageProgress(index, stages.length, false);
setTask((current) =>
current
? {
...current,
currentStageKey: stage.key,
percent: Math.max(current.percent, startPercent),
stageStatus: { ...current.stageStatus, [stage.key]: "running" },
logs: appendRuntimeLog(current.logs, `${stage.label}`)
}
: current
);
try {
if (index === executeIndex) {
executed = true;
value = await execute();
} else {
await wait(index === 0 ? 240 : 360);
}
} catch (error) {
const message = error instanceof Error ? error.message : `${title}失败`;
setTask((current) =>
current
? {
...current,
status: "failed",
currentStageKey: stage.key,
stageStatus: { ...current.stageStatus, [stage.key]: "failed" },
error: message,
logs: appendRuntimeLog(current.logs, message)
}
: current
);
throw error;
}
const endPercent = stageProgress(index, stages.length, true);
setTask((current) =>
current
? {
...current,
percent: Math.max(current.percent, endPercent),
stageStatus: { ...current.stageStatus, [stage.key]: "completed" },
logs: appendRuntimeLog(current.logs, `${stage.label}完成`)
}
: current
);
}
if (!executed) {
value = await execute();
}
return value as T;
}, []);
const runTrackedTask = useCallback(
async <T,>({ title, description, stages, start, poll, pollIntervalMs = 800 }: RuntimeTrackedTaskOptions<T>): Promise<T> => {
const firstStage = stages[0]?.key ?? "start";
setTask({
open: true,
title,
description,
status: "running",
percent: 1,
currentStageKey: firstStage,
stages,
stageStatus: { ...runtimeInitialStageStatus(stages), [firstStage]: "running" },
logs: [`${title} 正在创建后台构建任务`]
});
const started = await start();
setTask((current) => (current ? { ...current, logs: appendRuntimeLog(current.logs, `后台 job ${started.jobId} 已排队`) } : current));
while (true) {
const job = await poll(started.jobId);
const message = job.progress.message?.trim() || job.state;
const stageIndex = trackedStageIndex(stages, message, job.progress.percent);
const stage = stages[stageIndex] ?? stages[0];
const stageStatus = Object.fromEntries(
stages.map((item, index) => [item.key, index < stageIndex || job.state === "succeeded" ? "completed" : index === stageIndex ? "running" : "pending"])
) as Record<string, RuntimeTaskStageStatus>;
setTask((current) =>
current
? {
...current,
percent: Math.max(current.percent, Math.min(99, job.progress.percent)),
currentStageKey: stage?.key ?? current.currentStageKey,
stageStatus,
logs: current.logs[current.logs.length - 1] === message ? current.logs : appendRuntimeLog(current.logs, message)
}
: current
);
if (job.state === "succeeded") {
setTask((current) =>
current
? {
...current,
status: "succeeded",
percent: 100,
stageStatus: Object.fromEntries(stages.map((item) => [item.key, "completed" as RuntimeTaskStageStatus])),
logs: appendRuntimeLog(current.logs, "构建产物已由 run worker 上传")
}
: current
);
return started.value;
}
if (job.state === "failed" || job.state === "cancelled") {
const error = message || (job.state === "cancelled" ? "构建已取消" : "构建失败");
setTask((current) =>
current
? {
...current,
status: "failed",
error,
stageStatus: { ...stageStatus, [stage?.key ?? firstStage]: "failed" },
logs: appendRuntimeLog(current.logs, error)
}
: current
);
throw new Error(error);
}
await wait(pollIntervalMs);
}
},
[]
);
return { task, runTask, runTrackedTask, succeedTask, failTask, closeTask };
}
interface RuntimeTaskProgressDialogProps {
task: RuntimeTaskDialogState | null;
onClose: () => void;
actions?: RuntimeTaskDialogAction[];
}
export function RuntimeTaskProgressDialog({ task, onClose, actions = [] }: RuntimeTaskProgressDialogProps) {
if (!task?.open) {
return null;
}
const activeStage = task.stages.find((stage) => stage.key === task.currentStageKey) ?? task.stages[0];
const statusLabel = runtimeTaskStatusLabel(task.status);
const closeLabel = task.status === "running" ? "后台运行" : "关闭";
return (
<div className="confirm-backdrop runtime-task-backdrop" role="presentation" onClick={task.status === "running" ? undefined : onClose}>
<div className="drawer-panel management-dialog-panel runtime-task-panel" role="dialog" aria-modal="true" aria-label={task.title} onClick={(event) => event.stopPropagation()}>
<div className="panel-header runtime-task-header">
<span>
<strong>{task.title}</strong>
<small>{task.description}</small>
</span>
<span className={cx("status-pill", task.status === "succeeded" && "status-active", task.status === "failed" && "status-error", task.status === "running" && "status-disabled")}>
{task.status === "running" && <Loader2 size={13} className="runtime-task-spin" />}
{task.status === "succeeded" && <CheckCircle2 size={13} />}
{task.status === "failed" && <AlertTriangle size={13} />}
{statusLabel}
</span>
</div>
<div className="runtime-task-meter" aria-label={`${task.title} 进度 ${Math.round(task.percent)}%`}>
<div className="runtime-task-meter-row">
<span>{activeStage?.label ?? statusLabel}</span>
<strong>{Math.round(task.percent)}%</strong>
</div>
<span className="runtime-task-meter-track">
<span className={cx("runtime-task-meter-fill", task.status === "failed" && "runtime-task-meter-failed")} style={{ width: `${Math.max(0, Math.min(100, task.percent))}%` }} />
</span>
</div>
{activeStage && task.status === "running" && (
<div className="runtime-task-current">
<span className="runtime-task-current-icon">{stageIcon(activeStage.key, "running")}</span>
<span>
<strong>{activeStage.label}</strong>
<small>{activeStage.description}</small>
</span>
</div>
)}
<ol className="runtime-task-stages" aria-label="运行任务阶段">
{task.stages.map((stage) => {
const status = task.stageStatus[stage.key] ?? "pending";
return (
<li key={stage.key} className={cx("runtime-task-stage", `runtime-task-stage-${status}`)}>
<span className="runtime-task-stage-icon">{stageIcon(stage.key, status)}</span>
<span className="runtime-task-stage-copy">
<strong>{stage.label}</strong>
<small>{stage.description}</small>
</span>
<span className="runtime-task-stage-status">{runtimeStageStatusLabel(status)}</span>
</li>
);
})}
</ol>
<div className="runtime-task-log" aria-label="运行任务日志">
{task.logs.map((line, index) => (
<span key={`${index}-${line}`}>
<Terminal size={12} />
{line}
</span>
))}
</div>
{(task.summary || task.error) && (
<div className={cx("inline-result-strip", task.error && "runtime-task-error")}>
{task.error ? <AlertTriangle size={14} /> : <CheckCircle2 size={14} />}
<span>{task.error ?? task.summary}</span>
</div>
)}
<div className="confirm-actions runtime-task-actions">
<button type="button" onClick={onClose}>
{task.status === "running" ? <Clock3 size={14} /> : <X size={14} />}
{closeLabel}
</button>
{actions.map((action) => (
<button
key={action.label}
type="button"
className={cx(action.kind === "primary" && "confirm-primary", action.kind === "danger" && "confirm-danger")}
disabled={action.disabled || task.status === "running"}
onClick={action.onClick}
>
{action.label.includes("下载") ? <Download size={14} /> : <PackageCheck size={14} />}
{action.label}
</button>
))}
</div>
</div>
</div>
);
}
function stageIcon(stageKey: string, status: RuntimeTaskStageStatus) {
if (status === "completed") {
return <CheckCircle2 size={15} />;
}
if (status === "failed") {
return <AlertTriangle size={15} />;
}
if (status === "running") {
return <Loader2 size={15} className="runtime-task-spin" />;
}
if (stageKey.includes("git")) {
return <GitBranch size={15} />;
}
if (stageKey.includes("env") || stageKey.includes("install")) {
return <Wrench size={15} />;
}
if (stageKey.includes("deps") || stageKey.includes("download")) {
return <HardDriveDownload size={15} />;
}
if (stageKey.includes("compile") || stageKey.includes("build")) {
return <Hammer size={15} />;
}
if (stageKey.includes("scope") || stageKey.includes("checksum")) {
return <ShieldCheck size={15} />;
}
if (stageKey.includes("package") || stageKey.includes("artifact")) {
return <PackageCheck size={15} />;
}
return <Circle size={15} />;
}
function runtimeTaskStatusLabel(status: RuntimeTaskStatus): string {
switch (status) {
case "running":
return "构建中";
case "succeeded":
return "构建成功";
case "failed":
return "失败";
}
}
function runtimeStageStatusLabel(status: RuntimeTaskStageStatus): string {
switch (status) {
case "completed":
return "已完成";
case "running":
return "进行中";
case "failed":
return "失败";
case "pending":
return "等待中";
}
}
function stageProgress(index: number, total: number, completed: boolean): number {
if (total <= 0) {
return completed ? 100 : 0;
}
const base = (index / total) * 90 + 4;
const next = ((index + 1) / total) * 90 + 4;
return completed ? next : base;
}
function trackedStageIndex(stages: RuntimeTaskStage[], message: string, percent: number): number {
const stageKey = message.split(":", 1)[0];
const explicit = stages.findIndex((stage) => stage.key === stageKey);
if (explicit >= 0) {
return explicit;
}
const thresholds = [0, 25, 40, 60, 80];
let index = 0;
for (let candidate = 0; candidate < Math.min(stages.length, thresholds.length); candidate += 1) {
if (percent >= thresholds[candidate]) {
index = candidate;
}
}
return Math.min(index, Math.max(0, stages.length - 1));
}
function appendRuntimeLog(logs: string[], line: string): string[] {
return [...logs, line].slice(-8);
}
function wait(ms: number): Promise<void> {
return new Promise((resolve) => {
window.setTimeout(resolve, ms);
});
}
+164 -12
View File
@@ -17,6 +17,108 @@ export interface AiProviderFormState {
redactionPolicy: string;
}
export interface AiProviderKindDefaults {
id: string;
name: string;
kind: AiProviderKind;
baseUrl: string;
apiKeyRef: string;
modelsText: string;
defaultModel: string;
relayMode: AiRelayMode;
timeoutMs: string;
redactionPolicy: string;
requirement: string;
advancedNote: string;
}
export const aiProviderKindDefaults: Record<AiProviderKind, AiProviderKindDefaults> = {
"openai-compatible": {
id: "ai.openai-compatible",
name: "OpenAI Compatible",
kind: "openai-compatible",
baseUrl: "https://relay.example.test/v1",
apiKeyRef: "secret://providers/openai-compatible",
modelsText: "gpt-5.6-luna",
defaultModel: "gpt-5.6-luna",
relayMode: "relay",
timeoutMs: "30000",
redactionPolicy: "default",
requirement: "需要平台侧 API key / secret 引用;Base URL 可按网关修改。",
advancedNote: "OpenAI 兼容网关通常只差 Base URL,模型可以保存后再发现。"
},
openai: {
id: "ai.openai",
name: "OpenAI",
kind: "openai",
baseUrl: "https://api.openai.com/v1",
apiKeyRef: "secret://providers/openai",
modelsText: "gpt-5.6-terra, gpt-5.6-luna",
defaultModel: "gpt-5.6-terra",
relayMode: "relay",
timeoutMs: "30000",
redactionPolicy: "default",
requirement: "官方 SDK/API 使用 API key;平台保存 secret 引用,不在页面保存真实密钥。",
advancedNote: "官方 OpenAI API 使用 Bearer API key 与 /v1 endpoint;模型列表可按账号权限调整。"
},
claude: {
id: "ai.claude",
name: "Claude",
kind: "claude",
baseUrl: "https://api.anthropic.com/v1",
apiKeyRef: "secret://providers/anthropic",
modelsText: "claude-sonnet-5, claude-haiku-4-5-20251001",
defaultModel: "claude-sonnet-5",
relayMode: "relay",
timeoutMs: "30000",
redactionPolicy: "default",
requirement: "Anthropic 请求使用 API key;版本头由平台适配层处理。",
advancedNote: "保持平台中介调用,避免插件或前端接触 Anthropic key。"
},
gemini: {
id: "ai.gemini",
name: "Gemini",
kind: "gemini",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
apiKeyRef: "secret://providers/gemini",
modelsText: "gemini-3.5-flash, gemini-2.5-flash",
defaultModel: "gemini-3.5-flash",
relayMode: "relay",
timeoutMs: "30000",
redactionPolicy: "default",
requirement: "Google Gemini 入门使用 API key;平台保存 secret 引用。",
advancedNote: "Base URL 使用 Google Generative Language API;模型名按项目可用模型调整。"
},
ollama: {
id: "ai.ollama",
name: "Ollama Local",
kind: "ollama",
baseUrl: "http://127.0.0.1:11434/v1",
apiKeyRef: "",
modelsText: "gpt-oss:20b",
defaultModel: "gpt-oss:20b",
relayMode: "local",
timeoutMs: "30000",
redactionPolicy: "default",
requirement: "本地 Ollama 默认不需要 API key;重点是本机/内网 Base URL 和模型名。",
advancedNote: "Ollama OpenAI 兼容接口会忽略 dummy key;平台本地模式不要求用户填写密钥引用。"
},
custom: {
id: "ai.custom",
name: "Custom Provider",
kind: "custom",
baseUrl: "https://provider.example.test/v1",
apiKeyRef: "secret://providers/custom",
modelsText: "custom-model",
defaultModel: "custom-model",
relayMode: "relay",
timeoutMs: "30000",
redactionPolicy: "default",
requirement: "自定义服务至少需要平台 secret 引用、Base URL 和一个模型名。",
advancedNote: "用于非标准协议或私有网关;保存前请确认服务兼容平台适配层。"
}
};
export interface AiProviderMetrics {
total: number;
active: number;
@@ -41,18 +143,7 @@ export interface AiProviderPageInitialState {
}
export function emptyAiProviderForm(): AiProviderFormState {
return {
id: "",
name: "",
kind: "openai-compatible",
baseUrl: "",
apiKeyRef: "secret://providers/",
modelsText: "",
defaultModel: "",
relayMode: "relay",
timeoutMs: "30000",
redactionPolicy: "default"
};
return aiProviderFormFromDefaults("openai");
}
export function aiProviderToForm(provider?: AiProviderResponse): AiProviderFormState {
@@ -73,6 +164,67 @@ export function aiProviderToForm(provider?: AiProviderResponse): AiProviderFormS
};
}
export function aiProviderFormFromDefaults(kind: AiProviderKind): AiProviderFormState {
const defaults = aiProviderKindDefaults[kind];
return {
id: "",
name: defaults.name,
kind: defaults.kind,
baseUrl: defaults.baseUrl,
apiKeyRef: defaults.apiKeyRef,
modelsText: defaults.modelsText,
defaultModel: defaults.defaultModel,
relayMode: defaults.relayMode,
timeoutMs: defaults.timeoutMs,
redactionPolicy: defaults.redactionPolicy
};
}
export function applyAiProviderKindDefaults(current: AiProviderFormState, kind: AiProviderKind): AiProviderFormState {
const defaults = aiProviderKindDefaults[kind];
return {
...current,
id: current.id,
name: defaults.name,
kind: defaults.kind,
baseUrl: defaults.baseUrl,
apiKeyRef: defaults.apiKeyRef,
modelsText: defaults.modelsText,
defaultModel: defaults.defaultModel,
relayMode: defaults.relayMode,
timeoutMs: defaults.timeoutMs,
redactionPolicy: defaults.redactionPolicy
};
}
export function completeAiProviderForm(form: AiProviderFormState): AiProviderFormState {
const defaults = aiProviderKindDefaults[form.kind];
const modelsText = form.modelsText.trim() || defaults.modelsText;
const models = modelsText
.split(",")
.map((model) => model.trim())
.filter(Boolean);
return {
...form,
id: generatedAiProviderId(form),
name: form.name.trim() || defaults.name,
baseUrl: form.baseUrl.trim() || defaults.baseUrl,
apiKeyRef: form.apiKeyRef.trim() || defaults.apiKeyRef,
modelsText,
defaultModel: form.defaultModel.trim() || models[0] || defaults.defaultModel,
relayMode: form.relayMode || defaults.relayMode,
timeoutMs: form.timeoutMs.trim() || defaults.timeoutMs,
redactionPolicy: form.redactionPolicy.trim() || defaults.redactionPolicy
};
}
export function generatedAiProviderId(form: AiProviderFormState): string {
if (form.id.trim()) {
return form.id.trim();
}
return aiProviderKindDefaults[form.kind].id;
}
export function summarizeAiProviders(providers: AiProviderResponse[]): AiProviderMetrics {
return providers.reduce<AiProviderMetrics>(
(metrics, provider) => ({
@@ -55,10 +55,14 @@ describe("AiProvidersPage", () => {
expect(html).toContain("配置流程");
expect(html).toContain("提供商预设");
expect(html).toContain("系统 ID");
expect(html).toContain("自动生成,不需要手填");
expect(html).toContain("高级设置:Base URL、模型、模式、超时");
expect(html).toContain("保存前检查");
expect(html).toContain("测试已保存配置");
expect(html).toContain("发现模型并填入");
expect(html).toContain("secret://providers/...");
expect(html).not.toContain('name="id"');
expect(html).not.toContain("api.example.test");
});
+96 -141
View File
@@ -2,11 +2,15 @@ import { Candy, FlaskConical, MoreHorizontal, Power, Sparkles, WandSparkles, Use
import { type ChangeEvent, type FormEvent, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type { AiProviderResponse, AiProviderStatus } from "../api/types";
import type { AiProviderKind, AiProviderResponse, AiProviderStatus } from "../api/types";
import { ConfirmDialog, ManagementDialog } from "../components/OperationControls";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page";
import {
aiProviderKindDefaults,
applyAiProviderKindDefaults,
completeAiProviderForm,
generatedAiProviderId,
aiProviderToForm,
emptyAiProviderForm,
type AiProviderActionState,
@@ -25,79 +29,19 @@ interface AiProvidersPageProps extends Partial<PageComponentProps> {
}
interface ProviderPreset {
id: string;
kind: AiProviderKind;
label: string;
summary: string;
draft: Partial<AiProviderFormState>;
}
type FormCheckState = { status: "pending" | "succeeded" | "failed"; message: string };
const providerPresets: ProviderPreset[] = [
{
id: "openai-relay",
label: "OpenAI Relay",
summary: "平台中转,适合公网 OpenAI 兼容网关。",
draft: {
id: "ai.openai",
name: "OpenAI Relay",
kind: "openai-compatible",
baseUrl: "https://api.openai.com/v1",
apiKeyRef: "secret://providers/openai",
modelsText: "gpt-4.1, gpt-4.1-mini",
defaultModel: "gpt-4.1-mini",
relayMode: "relay",
redactionPolicy: "default"
}
},
{
id: "claude-relay",
label: "Claude Relay",
summary: "平台托管 Anthropic 兼容配置,密钥只留引用。",
draft: {
id: "ai.claude",
name: "Claude Relay",
kind: "claude",
baseUrl: "https://api.anthropic.com/v1",
apiKeyRef: "secret://providers/anthropic",
modelsText: "claude-sonnet, claude-haiku",
defaultModel: "claude-sonnet",
relayMode: "relay",
redactionPolicy: "default"
}
},
{
id: "gemini-relay",
label: "Gemini Relay",
summary: "平台托管 Google Gemini 配置,保存后发现模型。",
draft: {
id: "ai.gemini",
name: "Gemini Relay",
kind: "gemini",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
apiKeyRef: "secret://providers/gemini",
modelsText: "gemini-pro",
defaultModel: "gemini-pro",
relayMode: "relay",
redactionPolicy: "default"
}
},
{
id: "ollama-local",
label: "Ollama Local",
summary: "本机或内网模型服务,默认走本地模式。",
draft: {
id: "ai.ollama",
name: "Ollama Local",
kind: "ollama",
baseUrl: "http://127.0.0.1:11434/v1",
apiKeyRef: "secret://providers/ollama-local",
modelsText: "llama3.1, qwen2.5",
defaultModel: "llama3.1",
relayMode: "local",
redactionPolicy: "default"
}
}
{ kind: "openai", label: "OpenAI" },
{ kind: "claude", label: "Claude" },
{ kind: "gemini", label: "Gemini" },
{ kind: "ollama", label: "Ollama" },
{ kind: "openai-compatible", label: "兼容网关" },
{ kind: "custom", label: "自定义" }
];
export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
@@ -171,7 +115,12 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
}
function handleInput(event: ChangeEvent<HTMLInputElement | HTMLSelectElement>) {
updateForm(event.target.name as keyof AiProviderFormState, event.target.value);
const key = event.target.name as keyof AiProviderFormState;
if (key === "kind") {
setForm((current) => applyAiProviderKindDefaults(current, event.target.value as AiProviderKind));
} else {
updateForm(key, event.target.value);
}
setFormCheck(null);
}
@@ -200,36 +149,30 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
}
function applyProviderPreset(preset: ProviderPreset) {
setForm((current) => ({
...current,
...preset.draft,
id: formMode === "edit" ? current.id : preset.draft.id ?? current.id
}));
setFormCheck({ status: "pending", message: `已套用 ${preset.label} 预设。保存前请确认 Base URL、secret 引用和默认模型。` });
setForm((current) => applyAiProviderKindDefaults(current, preset.kind));
setFormCheck({ status: "pending", message: `已选择 ${preset.label}。常规配置只需要平台 secret 引用;高级参数已按厂商默认值填入。` });
}
function runFormPreflight() {
const missing: string[] = [];
const models = form.modelsText
const completed = completeAiProviderForm(form);
const models = completed.modelsText
.split(",")
.map((model) => model.trim())
.filter(Boolean);
if (!form.id.trim()) {
missing.push("ID");
}
if (!form.name.trim()) {
if (!completed.name.trim()) {
missing.push("名称");
}
if (!form.baseUrl.trim()) {
if (!completed.baseUrl.trim()) {
missing.push("Base URL");
}
if (!form.apiKeyRef.trim().startsWith("secret://providers/")) {
if (completed.relayMode !== "local" && !completed.apiKeyRef.trim().startsWith("secret://providers/")) {
missing.push("secret://providers/... 密钥引用");
}
if (models.length === 0) {
missing.push("至少一个模型");
}
if (!Number.isFinite(Number.parseInt(form.timeoutMs, 10)) || Number.parseInt(form.timeoutMs, 10) <= 0) {
if (!Number.isFinite(Number.parseInt(completed.timeoutMs, 10)) || Number.parseInt(completed.timeoutMs, 10) <= 0) {
missing.push("有效超时");
}
setFormCheck(
@@ -242,12 +185,14 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setViewState("saving");
const existing = providers.some((provider) => provider.id === form.id.trim());
const completed = completeAiProviderForm(form);
const providerId = generatedAiProviderId(completed);
const existing = providers.some((provider) => provider.id === providerId);
try {
const saved = existing
? await platformApiClient.updateAiProvider(form.id.trim(), aiProviderUpdateRequestFromForm(form))
: await platformApiClient.createAiProvider(aiProviderCreateRequestFromForm(form));
? await platformApiClient.updateAiProvider(providerId, aiProviderUpdateRequestFromForm(completed))
: await platformApiClient.createAiProvider(aiProviderCreateRequestFromForm(completed));
upsertProvider(saved);
setSelectedId(saved.id);
setForm(aiProviderToForm(saved));
@@ -260,7 +205,7 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
} catch (error) {
setViewState("error");
const message = errorMessage(error, "保存失败");
setAction({ providerId: form.id.trim() || selectedId, label: "save", success: false, message });
setAction({ providerId: generatedAiProviderId(form) || selectedId, label: "save", success: false, message });
setFormCheck({ status: "failed", message });
}
}
@@ -359,6 +304,10 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
});
}
const completedForm = completeAiProviderForm(form);
const formDefaults = aiProviderKindDefaults[form.kind];
const secretRequired = completedForm.relayMode !== "local";
return (
<section className="ai-providers-page" aria-labelledby="ai-provider-title">
<header className="page-header ai-provider-header">
@@ -497,72 +446,78 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
<ProviderSetupGuide />
<div className="provider-preset-grid" aria-label="提供商预设">
{providerPresets.map((preset) => (
<button key={preset.id} type="button" className="provider-preset-option" onClick={() => applyProviderPreset(preset)}>
<button key={preset.kind} type="button" className={cx("provider-preset-option", form.kind === preset.kind && "provider-preset-option-active")} onClick={() => applyProviderPreset(preset)}>
<strong>{preset.label}</strong>
<span>{preset.summary}</span>
<span>{aiProviderKindDefaults[preset.kind].requirement}</span>
</button>
))}
</div>
<label>
<span>ID</span>
<input name="id" value={form.id} onChange={handleInput} disabled={formMode === "edit"} />
<small className="field-help"> ID ai.openai</small>
</label>
<label>
<span></span>
<input name="name" value={form.name} onChange={handleInput} />
<small className="field-help">使 ID </small>
</label>
<div className="form-grid">
<label>
<span></span>
<select name="kind" value={form.kind} onChange={handleInput}>
<option value="openai-compatible">OpenAI Compatible</option>
<option value="openai">OpenAI</option>
<option value="claude">Claude</option>
<option value="gemini">Gemini</option>
<option value="ollama">Ollama</option>
<option value="custom">Custom</option>
</select>
</label>
<label>
<span></span>
<select name="relayMode" value={form.relayMode} onChange={handleInput}>
<option value="direct">Direct</option>
<option value="relay">Relay</option>
<option value="local">Local</option>
</select>
</label>
<label>
<span>{secretRequired ? "平台密钥引用" : "密钥引用(本地模式可留空)"}</span>
<input name="apiKeyRef" value={form.apiKeyRef} onChange={handleInput} placeholder={secretRequired ? formDefaults.apiKeyRef : "本地服务通常不需要"} />
<small className="field-help">
{secretRequired ? "填写 secret://providers/...;真实密钥进入平台 secret store,不粘贴到页面。" : "Ollama 本地模式默认只需要 Base URL 和模型名。"}
</small>
</label>
<div className="provider-generated-id" aria-label="自动生成的提供商 ID">
<span> ID</span>
<code>{generatedAiProviderId(form)}</code>
<small></small>
</div>
<label>
<span>Base URL</span>
<input name="baseUrl" value={form.baseUrl} onChange={handleInput} />
<small className="field-help"></small>
</label>
<label>
<span></span>
<input name="apiKeyRef" value={form.apiKeyRef} onChange={handleInput} />
<small className="field-help"> secret://providers/... 引用,不要粘贴 raw API key。</small>
</label>
<label>
<span></span>
<input name="modelsText" value={form.modelsText} onChange={handleInput} />
<small className="field-help"></small>
</label>
<div className="form-grid">
<details className="provider-advanced-settings">
<summary>Base URL</summary>
<div className="form-grid">
<label>
<span></span>
<select name="kind" value={form.kind} onChange={handleInput}>
<option value="openai-compatible">OpenAI Compatible</option>
<option value="openai">OpenAI</option>
<option value="claude">Claude</option>
<option value="gemini">Gemini</option>
<option value="ollama">Ollama</option>
<option value="custom">Custom</option>
</select>
</label>
<label>
<span></span>
<select name="relayMode" value={form.relayMode} onChange={handleInput}>
<option value="direct">Direct</option>
<option value="relay">Relay</option>
<option value="local">Local</option>
</select>
</label>
</div>
<label>
<span></span>
<input name="defaultModel" value={form.defaultModel} onChange={handleInput} />
<span>Base URL</span>
<input name="baseUrl" value={form.baseUrl} onChange={handleInput} />
<small className="field-help">{formDefaults.advancedNote}</small>
</label>
<label>
<span> ms</span>
<input name="timeoutMs" value={form.timeoutMs} onChange={handleInput} inputMode="numeric" />
<span></span>
<input name="modelsText" value={form.modelsText} onChange={handleInput} />
<small className="field-help"></small>
</label>
</div>
<label>
<span></span>
<input name="redactionPolicy" value={form.redactionPolicy} onChange={handleInput} />
<small className="field-help"> default Bearer token </small>
</label>
<div className="form-grid">
<label>
<span></span>
<input name="defaultModel" value={form.defaultModel} onChange={handleInput} />
</label>
<label>
<span> ms</span>
<input name="timeoutMs" value={form.timeoutMs} onChange={handleInput} inputMode="numeric" />
</label>
</div>
<label>
<span></span>
<input name="redactionPolicy" value={form.redactionPolicy} onChange={handleInput} />
<small className="field-help"> default Bearer token </small>
</label>
</details>
<div className="form-helper-actions">
<button type="button" className="theme-upload" onClick={runFormPreflight}>
@@ -605,7 +560,7 @@ function ProviderSetupGuide() {
return (
<div className="form-guidance provider-setup-guide">
<strong></strong>
<span> secret </span>
<span> secret IDBase URL</span>
</div>
);
}
+60
View File
@@ -8,6 +8,9 @@ import { ProfileSettingsPage } from "./ProfileSettingsPage";
import { ServerDetailPage } from "./ServerDetailPage";
import { ServersPage } from "./ServersPage";
import { UsersPage } from "./UsersPage";
import runtimeTaskProgressSource from "../components/RuntimeTaskProgress.tsx?raw";
import serversPageSource from "./ServersPage.tsx?raw";
import serverDetailPageSource from "./ServerDetailPage.tsx?raw";
import type { PageComponentProps } from "../contracts/page";
import { capabilitiesForRoles, type CurrentUserView } from "../contracts/workspace";
import type { OperationTracker } from "../stores/operations";
@@ -77,6 +80,25 @@ describe("first-party console pages", () => {
expect(html).not.toContain("/Users/");
});
it("renders server runtime actions as a compact popover trigger instead of an in-card details stack", () => {
expect(serversPageSource).toContain('aria-haspopup="menu"');
expect(serversPageSource).toContain("createPortal");
expect(serversPageSource).toContain("runtime-action-popover");
expect(serversPageSource).not.toContain("runtime-action-menu");
expect(serversPageSource).not.toContain("<details");
});
it("surfaces runtime actions through progress dialogs with build stages", () => {
expect(serversPageSource).toContain("RuntimeTaskProgressDialog");
expect(serverDetailPageSource).toContain("RuntimeTaskProgressDialog");
expect(runtimeTaskProgressSource).toContain("runtimeBuildStages");
expect(runtimeTaskProgressSource).toContain("拉取代码");
expect(runtimeTaskProgressSource).toContain("安装环境");
expect(runtimeTaskProgressSource).toContain("编译构建");
expect(runtimeTaskProgressSource).toContain("打包成功");
expect(runtimeTaskProgressSource).toContain("构建成功");
});
it("renders server detail sections for daily operations", () => {
const html = renderToStaticMarkup(<ServerDetailPage {...pageProps({ serverId: "server-example-1" })} />);
@@ -125,4 +147,42 @@ describe("first-party console pages", () => {
expect(html).toContain("界面偏好");
expect(html).not.toContain("role=\"dialog\"");
});
it("labels uploaded profile backgrounds as active and built-in presets as fallback", () => {
const originalWindow = globalThis.window;
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
localStorage: {
getItem: (key: string) => {
if (key === "platform-web.theme.palette") {
return "magical-girl";
}
if (key === "platform-web.theme.backgroundPreset") {
return "mecha-grid";
}
if (key === "platform-web.theme.background") {
return "data:image/png;base64,custom";
}
return null;
}
}
}
});
try {
const html = renderToStaticMarkup(<ProfileSettingsPage {...pageProps()} />);
expect(html).toContain("自定义背景");
expect(html).toContain("机甲格纳库");
expect(html).toContain("备用");
expect(html).toContain("当前显示自定义上传背景;机甲格纳库 仅作为移除上传后的备用桌面。");
expect(html).toContain('aria-pressed="false"');
} finally {
Object.defineProperty(globalThis, "window", {
configurable: true,
value: originalWindow
});
}
});
});
+25 -8
View File
@@ -47,6 +47,8 @@ export function ProfileSettingsPage({ session, onNavigate, onLogout, onProfileSa
const activePalette = useMemo(() => themePalettes.find((palette) => palette.id === themeState.paletteId) ?? themePalettes[0], [themeState.paletteId]);
const activeBackground = useMemo(() => themeBackgroundPresets.find((preset) => preset.id === themeState.backgroundPresetId) ?? themeBackgroundPresets[0], [themeState.backgroundPresetId]);
const hasCustomBackground = Boolean(themeState.backgroundImage);
const backgroundMetricValue = hasCustomBackground ? "自定义背景" : activeBackground.label;
async function saveProfile(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
@@ -138,7 +140,7 @@ export function ProfileSettingsPage({ session, onNavigate, onLogout, onProfileSa
metrics={[
{ label: "身份", value: session.roles.length ? String(session.roles.length) : "0", tone: "neutral" },
{ label: "配色", value: activePalette.label, tone: "success" },
{ label: "背景", value: activeBackground.label, tone: "warning" }
{ label: "背景", value: backgroundMetricValue, tone: "warning" }
]}
/>
@@ -221,12 +223,27 @@ export function ProfileSettingsPage({ session, onNavigate, onLogout, onProfileSa
<strong></strong>
</div>
<div className="background-preset-grid profile-background-grid">
{themeBackgroundPresets.map((preset) => (
<button key={preset.id} type="button" className={cx("background-preset-option", preset.id === themeState.backgroundPresetId && "background-preset-option-active")} aria-pressed={preset.id === themeState.backgroundPresetId} title={themeState.backgroundImage ? `${preset.summary},移除上传背景后显示` : preset.summary} onClick={() => selectBackgroundPreset(preset.id)}>
<span className="background-preset-preview" style={{ background: preset.preview }} aria-hidden="true" />
<span className="background-preset-label">{preset.id === themeState.backgroundPresetId ? <Sparkles size={13} /> : <MoonStar size={13} />}{preset.label}</span>
</button>
))}
{themeBackgroundPresets.map((preset) => {
const isFallbackPreset = preset.id === themeState.backgroundPresetId;
const isVisiblePreset = isFallbackPreset && !hasCustomBackground;
return (
<button
key={preset.id}
type="button"
className={cx("background-preset-option", isVisiblePreset && "background-preset-option-active", hasCustomBackground && isFallbackPreset && "background-preset-option-fallback")}
aria-pressed={isVisiblePreset}
title={hasCustomBackground ? `${preset.summary},当前自定义背景正在显示;此预设会在移除上传背景后显示` : preset.summary}
onClick={() => selectBackgroundPreset(preset.id)}
>
<span className="background-preset-preview" style={{ background: preset.preview }} aria-hidden="true" />
<span className="background-preset-label">
{isVisiblePreset ? <Sparkles size={13} /> : <MoonStar size={13} />}
{preset.label}
{hasCustomBackground && isFallbackPreset && <span className="background-preset-fallback-badge"></span>}
</span>
</button>
);
})}
</div>
<div className="theme-background-actions">
<label className="theme-upload" title="上传自定义背景桌面">
@@ -241,7 +258,7 @@ export function ProfileSettingsPage({ session, onNavigate, onLogout, onProfileSa
</button>
)}
</div>
<span className="theme-background-note">{themeState.backgroundImage ? "自定义上传背景正在显示,预设会作为移除后的备用桌面。" : "当前使用内置背景桌面。"}</span>
<span className="theme-background-note">{themeState.backgroundImage ? `当前显示自定义上传背景;${activeBackground.label}作为移除上传后的备用桌面。` : "当前使用内置背景桌面。"}</span>
</section>
</section>
</div>
+5 -3
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { configDiffViewFromPreview } from "./ServerDetailPage";
import serverDetailPageSource from "./ServerDetailPage.tsx?raw";
import artifactTransferSource from "../utils/artifactTransfer.ts?raw";
import type { ServerConfigDiffPreviewResponse } from "../api/types";
const preview: ServerConfigDiffPreviewResponse = {
@@ -104,9 +105,10 @@ describe("ServerDetailPage config write approval", () => {
it("keeps plugin lifecycle and bridge-visible output on platform-owned logical references", () => {
expect(serverDetailPageSource).toContain("parsePluginArtifactReference(result)");
expect(serverDetailPageSource).toContain("platformApiClient.openArtifactDownload(artifact.id)");
expect(serverDetailPageSource).toContain("platformApiClient.readArtifactContent(reference.artifactId");
expect(serverDetailPageSource).toContain("replace(/Bearer\\s+[^\\s]+/gi, \"[token]\")");
expect(serverDetailPageSource).toContain("replace(/sk-[A-Za-z0-9_-]+/g, \"[secret]\")");
expect(serverDetailPageSource).toContain("downloadArtifactReference(reference");
expect(artifactTransferSource).toContain("readContent(reference.artifactId");
expect(artifactTransferSource).toContain("replace(/Bearer\\s+[^\\s]+/gi, \"[token]\")");
expect(artifactTransferSource).toContain("replace(/sk-[A-Za-z0-9_-]+/g, \"[secret]\")");
expect(serverDetailPageSource).not.toContain("storage://bucket");
expect(serverDetailPageSource).not.toContain("runSocket");
expect(serverDetailPageSource).not.toContain("rawApiKey");
+182 -50
View File
@@ -20,6 +20,17 @@ import type {
ServerRuntimeActionsResponse
} from "../api/types";
import { ConfirmDialog, DiffView, UsageMeter } from "../components/OperationControls";
import {
RuntimeTaskProgressDialog,
runtimeBuildStages,
runtimeDependencyStages,
runtimeDownloadStages,
runtimeLogStages,
runtimeUpdateStages,
type RuntimeTaskDialogAction,
type RuntimeTaskStage,
useRuntimeTaskController
} from "../components/RuntimeTaskProgress";
import { DiagnosticSummary, EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page";
import type { PluginBridgeAction, PluginBridgeManifestContract } from "../contracts/pluginBridge";
@@ -45,6 +56,7 @@ import {
} from "../schemas/serverManagement";
import { buildConfigDiff, diffHasChanges } from "../utils/diff";
import { createPluginBridgeDispatcher, createPluginBridgeHostContext, parsePluginArtifactReference } from "../utils/pluginBridgeHost";
import { downloadArtifactReference, safeArtifactError, safeArtifactFilename } from "../utils/artifactTransfer";
import { cx } from "../utils/classes";
import { stateLabel, statusClass } from "./ServersPage";
@@ -605,6 +617,8 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
const [lastClient, setLastClient] = useState<ClientManagerDistributionResponse | null>(null);
const [lastDownload, setLastDownload] = useState<ArtifactDownloadReferenceResponse | null>(null);
const [result, setResult] = useState<{ status: "succeeded" | "failed" | "pending"; label: string } | null>(null);
const runtimeTask = useRuntimeTaskController();
const [runtimeTaskActions, setRuntimeTaskActions] = useState<RuntimeTaskDialogAction[]>([]);
const actionByKey = useMemo(() => {
if (runtimeActions.status !== "ready") {
@@ -621,19 +635,53 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
return actionByKey.get(key)?.reason ?? "平台暂未开放该操作";
}
async function runOperation<T>(intent: string, execute: () => Promise<T>, summarize: (value: T) => string) {
async function runOperation<T>(
intent: string,
execute: () => Promise<T>,
summarize: (value: T) => string,
taskOptions?: {
description: string;
stages: RuntimeTaskStage[];
executeStageIndex?: number;
trackedJobId?: (value: T) => string;
afterSuccess?: (value: T) => void;
}
) {
const operationId = operations.begin({ intent, targetKind: "server", targetId: `${instance.id}:runtime`, requester: session.displayName });
setRuntimeTaskActions([]);
setResult({ status: "pending", label: `${intent} 执行中` });
try {
const value = await execute();
const value = taskOptions?.trackedJobId
? await runtimeTask.runTrackedTask({
title: intent,
description: taskOptions.description,
stages: taskOptions.stages,
start: async () => {
const value = await execute();
return { value, jobId: taskOptions.trackedJobId?.(value) ?? "" };
},
poll: (jobId) => platformApiClient.getJob(jobId)
})
: taskOptions
? await runtimeTask.runTask({
title: intent,
description: taskOptions.description,
stages: taskOptions.stages,
executeStageIndex: taskOptions.executeStageIndex,
execute
})
: await execute();
const label = summarize(value);
operations.succeed(operationId, label);
setResult({ status: "succeeded", label });
runtimeTask.succeedTask(label);
taskOptions?.afterSuccess?.(value);
onChanged();
} catch (error) {
const reason = error instanceof Error ? error.message : `${intent} 失败`;
operations.fail(operationId, reason, operationId);
setResult({ status: "failed", label: reason });
runtimeTask.failTask(reason);
}
}
@@ -647,6 +695,61 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
return null;
}
async function downloadRunArtifact(artifact: { artifactId: string; checksum?: string }) {
setRuntimeTaskActions([]);
try {
const label = await runtimeTask.runTask({
title: "下载 run",
description: `${instance.name} 的 run 包已生成,正在打开 artifact ${artifact.artifactId}`,
stages: runtimeDownloadStages,
executeStageIndex: 1,
execute: async () => {
const reference = await platformApiClient.openArtifactDownload(artifact.artifactId);
setLastDownload(reference);
await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit));
return `run 下载已开始,文件 ${safeArtifactFilename(reference.filename)}`;
}
});
runtimeTask.succeedTask(label);
} catch (error) {
runtimeTask.failTask(error instanceof Error ? error.message : "run 下载失败");
}
}
async function pushRunArtifact(artifact: { artifactId: string; checksum?: string }) {
setRuntimeTaskActions([]);
await runOperation(
"推送 run 更新",
() => platformApiClient.pushRunUpdate(instance.id, runUpdateRequest(instance.id, artifact.artifactId, artifact.checksum)),
(update) => `run 更新任务已排队,job ${update.jobId ?? update.id}`,
{
description: `将 artifact ${artifact.artifactId} 推送到 ${instance.runEndpointId},并等待平台 job 确认。`,
stages: runtimeUpdateStages,
executeStageIndex: 2
}
);
}
async function downloadClientArtifact(profileKeyForDownload: string) {
setRuntimeTaskActions([]);
try {
const label = await runtimeTask.runTask({
title: "下载客户端",
description: `${instance.name} 的客户端管理器已生成,正在创建下载引用。`,
stages: runtimeDownloadStages,
executeStageIndex: 1,
execute: async () => {
const reference = await platformApiClient.downloadLatestClientManager(instance.id, { profileKey: profileKeyForDownload });
await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit));
return `客户端下载已开始,文件 ${safeArtifactFilename(reference.filename)}`;
}
});
runtimeTask.succeedTask(label);
} catch (error) {
runtimeTask.failTask(error instanceof Error ? error.message : "客户端下载失败");
}
}
return (
<article className="console-panel" aria-label="run distribution controls">
<div className="panel-header">
@@ -726,7 +829,19 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
setLastRun(distribution);
return distribution;
},
(distribution) => `run ${distribution.targetOs}/${distribution.targetArch} 已生成artifact ${distribution.artifactId}generation ${distribution.keyGeneration}`
(distribution) => `run ${distribution.targetOs}/${distribution.targetArch} 二进制已构建artifact ${distribution.artifactId}generation ${distribution.keyGeneration}`,
{
description: `${instance.name} 构建 ${targetOs}/${targetArch} run 包,包含拉取代码、安装环境、编译和打包进度。`,
stages: runtimeBuildStages,
trackedJobId: (distribution) => distribution.buildJobId,
afterSuccess: (distribution) => {
const artifact = { artifactId: distribution.artifactId, checksum: distribution.checksum };
setRuntimeTaskActions([
{ label: "下载 run", kind: "primary", onClick: () => void downloadRunArtifact(artifact) },
{ label: "推送更新", onClick: () => void pushRunArtifact(artifact) }
]);
}
}
)
}
/>
@@ -742,9 +857,15 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
async () => {
const reference = await platformApiClient.downloadLatestRunDistribution(instance.id);
setLastDownload(reference);
await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit));
return reference;
},
(reference) => `下载引用已创建artifact ${reference.artifactId}有效期 ${new Date(reference.expiresAt).toLocaleTimeString()}`
(reference) => `run 下载已开始artifact ${reference.artifactId}文件 ${safeArtifactFilename(reference.filename)}`,
{
description: `${instance.name} 创建最新 run 包下载引用,并展示 artifact 定位进度。`,
stages: runtimeDownloadStages,
executeStageIndex: 1
}
)
}
secondaryLabel="推送更新"
@@ -760,7 +881,12 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
}
return platformApiClient.pushRunUpdate(instance.id, runUpdateRequest(instance.id, artifact.artifactId, artifact.checksum));
},
(update) => `run 更新任务已排队,job ${update.jobId ?? update.id}`
(update) => `run 更新任务已排队,job ${update.jobId ?? update.id}`,
{
description: `将最近 run artifact 推送到 ${instance.runEndpointId},并等待平台 job 确认。`,
stages: runtimeUpdateStages,
executeStageIndex: 2
}
)
}
/>
@@ -796,7 +922,15 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
setLastClient(distribution);
return distribution;
},
(distribution) => `客户端管理器已生成artifact ${distribution.artifactId}secret ref ${safeRuntimeRef(distribution.secretRef)}`
(distribution) => `客户端管理器二进制已构建artifact ${distribution.artifactId}secret ref ${safeRuntimeRef(distribution.secretRef)}`,
{
description: `${profileKey} profile 拉取客户端代码、安装环境、编译并生成可下载 artifact。`,
stages: runtimeBuildStages,
trackedJobId: (distribution) => distribution.buildJobId,
afterSuccess: () => {
setRuntimeTaskActions([{ label: "下载客户端", kind: "primary", onClick: () => void downloadClientArtifact(profileKey) }]);
}
}
)
}
secondaryLabel="下载客户端"
@@ -805,8 +939,12 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
onSecondary={() =>
void runOperation(
"下载客户端管理器",
() => platformApiClient.downloadLatestClientManager(instance.id, { profileKey }),
(reference) => `客户端下载引用已创建,artifact ${reference.artifactId}`
async () => {
const reference = await platformApiClient.downloadLatestClientManager(instance.id, { profileKey });
await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit));
return reference;
},
(reference) => `客户端下载已开始,artifact ${reference.artifactId},文件 ${safeArtifactFilename(reference.filename)}`
)
}
/>
@@ -835,7 +973,12 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
void runOperation(
"依赖检查",
() => platformApiClient.checkDependencies(instance.id, dependencyJobRequest(instance.id, probeKey)),
(job) => `依赖检查任务已排队,job ${job.id}`
(job) => `依赖检查任务已排队,job ${job.id}`,
{
description: `使用 ${probeKey} probe 检查 ${instance.name} 的运行依赖。`,
stages: runtimeDependencyStages,
executeStageIndex: 1
}
)
}
secondaryLabel="依赖安装"
@@ -845,7 +988,12 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
void runOperation(
"依赖安装",
() => platformApiClient.installDependencies(instance.id, dependencyJobRequest(instance.id, probeKey, installPlanKey)),
(job) => `依赖安装任务已排队,job ${job.id}`
(job) => `依赖安装任务已排队,job ${job.id}`,
{
description: `使用 ${installPlanKey} 安装计划派发依赖安装任务,并保留 job 追踪。`,
stages: runtimeDependencyStages,
executeStageIndex: 2
}
)
}
/>
@@ -855,7 +1003,21 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
disabled={!canUse("live-logs")}
reason={reasonFor("live-logs")}
actionLabel="实时日志"
onAction={onOpenLogs}
onAction={() =>
void runOperation(
"实时日志",
async () => {
onOpenLogs();
return true;
},
() => "已打开实时日志视图",
{
description: `读取 ${instance.name} 的平台日志源并打开实时日志视图。`,
stages: runtimeLogStages,
executeStageIndex: 1
}
)
}
secondaryLabel="历史回填"
secondaryDisabled={!canUse("historical-logs")}
secondaryReason={reasonFor("historical-logs")}
@@ -863,7 +1025,12 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
void runOperation(
"历史日志回填",
() => platformApiClient.requestLogBackfill(instance.id, logBackfillRequest(instance.id, logSourceKey, checkpointRef)),
(job) => `历史日志回填任务已排队,job ${job.id}`
(job) => `历史日志回填任务已排队,job ${job.id}`,
{
description: `${logSourceKey} 日志源准备历史回填游标并派发后台 job。`,
stages: runtimeLogStages,
executeStageIndex: 1
}
)
}
>
@@ -873,6 +1040,7 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
</label>
</RuntimeActionRow>
</div>
<RuntimeTaskProgressDialog task={runtimeTask.task} onClose={runtimeTask.closeTask} actions={runtimeTaskActions} />
</article>
);
}
@@ -1930,19 +2098,9 @@ function ArtifactDownloadPanel({ serverId, artifacts }: ArtifactDownloadPanelPro
setResult((current) => ({ ...current, [artifact.id]: { status: "pending", label: "正在打开制品", progress: 0 } }));
try {
const reference = await platformApiClient.openArtifactDownload(artifact.id);
const chunks: ArrayBuffer[] = [];
let offset = 0;
while (offset < reference.sizeBytes) {
const chunk = await platformApiClient.readArtifactContent(reference.artifactId, offset, reference.chunkSizeBytes);
chunks.push(chunk.payload);
offset += chunk.payload.byteLength;
const progress = Math.min(100, Math.round((offset / reference.sizeBytes) * 100));
await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit), (progress) => {
setResult((current) => ({ ...current, [artifact.id]: { status: "pending", label: `传输 ${progress}%`, progress } }));
if (chunk.payload.byteLength === 0) {
break;
}
}
openArtifactBlob(reference, chunks);
});
setResult((current) => ({ ...current, [artifact.id]: { status: "succeeded", label: `已打开 ${safeArtifactFilename(reference.filename)}`, progress: 100 } }));
} catch (error) {
setResult((current) => ({ ...current, [artifact.id]: { status: "failed", label: safeArtifactError(error) } }));
@@ -1992,32 +2150,6 @@ function ArtifactDownloadPanel({ serverId, artifacts }: ArtifactDownloadPanelPro
);
}
function openArtifactBlob(reference: ArtifactDownloadReferenceResponse, chunks: ArrayBuffer[]) {
if (typeof document === "undefined" || typeof URL === "undefined") {
return;
}
const blob = new Blob(chunks, { type: reference.contentType });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = safeArtifactFilename(reference.filename);
anchor.rel = "noopener";
document.body.append(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
function safeArtifactFilename(filename: string): string {
const cleaned = filename.replace(/[\\/]/g, "").trim();
return cleaned || "artifact.bin";
}
function safeArtifactError(error: unknown): string {
const message = error instanceof Error ? error.message : "制品传输失败";
return message.replace(/\/Users\/[^\s]+/g, "[path]").replace(/Bearer\s+[^\s]+/gi, "[token]").replace(/sk-[A-Za-z0-9_-]+/g, "[secret]");
}
function formatBytes(value: number): string {
if (value < 1024) {
return `${value} B`;
+314 -56
View File
@@ -1,8 +1,19 @@
import { CakeSlice, Candy, Search, Sparkles } from "lucide-react";
import { type ChangeEvent, type FormEvent, useCallback, useEffect, useMemo, useState } from "react";
import { type CSSProperties, type ChangeEvent, type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { platformApiClient } from "../api/client";
import type { GamePluginResponse, JobResponse, RunEndpointResponse, ServerInstanceResponse, ServerMetricsResponse } from "../api/types";
import {
RuntimeTaskProgressDialog,
type RuntimeTaskDialogAction,
runtimeBuildStages,
runtimeDependencyStages,
runtimeDownloadStages,
runtimeLogStages,
runtimeUpdateStages,
useRuntimeTaskController
} from "../components/RuntimeTaskProgress";
import { UsageMeter } from "../components/OperationControls";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page";
@@ -23,6 +34,7 @@ import {
serverCreateRequestFromForm
} from "../schemas/serverManagement";
import { isPlatformAdmin } from "../contracts/workspace";
import { downloadArtifactReference, safeArtifactFilename } from "../utils/artifactTransfer";
import { cx } from "../utils/classes";
type ListState = "loading" | "ready" | "error";
@@ -47,6 +59,8 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
const [statusFilter, setStatusFilter] = useState<ServerStatusFilter>("all");
const [form, setForm] = useState<ServerCreateFormState>(() => defaultServerCreateForm([], []));
const [showCreate, setShowCreate] = useState(false);
const runtimeTask = useRuntimeTaskController();
const [runtimeTaskActions, setRuntimeTaskActions] = useState<RuntimeTaskDialogAction[]>([]);
const refresh = useCallback(async () => {
setListState("loading");
@@ -126,48 +140,175 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
const defaults = quickRuntimeDefaultsForPlugin(instance.pluginId);
const intent = quickRuntimeActionLabel(action);
const operationId = operations.begin({ intent, targetKind: "server", targetId: `${instance.id}:${action}`, requester: session.displayName });
setRuntimeTaskActions([]);
let generatedRunArtifact: { artifactId: string; checksum?: string } | null = null;
let generatedClientProfile: string | null = null;
try {
let message = "运行操作已提交";
let message: string;
if (action === "generate-run") {
const distribution = await platformApiClient.generateRunDistribution(instance.id, runDistributionGenerateRequest(instance.id, defaults.runOs, "amd64"));
message = `run 包已生成,artifact ${distribution.artifactId}`;
} else if (action === "download-run") {
const reference = await platformApiClient.downloadLatestRunDistribution(instance.id);
message = `run 下载引用已创建,artifact ${reference.artifactId}`;
} else if (action === "push-run-update") {
const reference = await platformApiClient.downloadLatestRunDistribution(instance.id);
const update = await platformApiClient.pushRunUpdate(instance.id, runUpdateRequest(instance.id, reference.artifactId, reference.checksum));
message = `run 更新任务已排队,job ${update.jobId ?? update.id}`;
const distribution = await runtimeTask.runTrackedTask({
title: intent,
description: quickRuntimeTaskDescription(instance, action),
stages: runtimeBuildStages,
start: async () => {
const distribution = await platformApiClient.generateRunDistribution(instance.id, runDistributionGenerateRequest(instance.id, defaults.runOs, "amd64"));
return { value: distribution, jobId: distribution.buildJobId };
},
poll: (jobId) => platformApiClient.getJob(jobId)
});
generatedRunArtifact = { artifactId: distribution.artifactId };
message = `run 二进制已构建并上传,artifact ${distribution.artifactId}`;
} else if (action === "generate-client-manager") {
const distribution = await platformApiClient.generateClientManager(
instance.id,
clientManagerBuildRequest({
serverInstanceId: instance.id,
profileKey: defaults.clientProfileKey,
targetOs: defaults.clientOs,
targetArch: "amd64",
repositoryUrl: defaults.repositoryUrl,
sourceRevision: "main"
})
);
message = `客户端管理器已生成,artifact ${distribution.artifactId}`;
} else if (action === "dependencies-check") {
const job = await platformApiClient.checkDependencies(instance.id, dependencyJobRequest(instance.id, defaults.probeKey));
message = `依赖检查任务已排队,job ${job.id}`;
} else if (action === "dependencies-install") {
const job = await platformApiClient.installDependencies(instance.id, dependencyJobRequest(instance.id, defaults.probeKey, defaults.installPlanKey));
message = `依赖安装任务已排队,job ${job.id}`;
} else if (action === "live-logs") {
onNavigate("serverDetail", { serverId: instance.id });
message = "已打开服务器详情,可切换到日志页查看实时日志";
} else if (action === "historical-logs") {
const job = await platformApiClient.requestLogBackfill(instance.id, logBackfillRequest(instance.id, defaults.logSourceKey));
message = `历史日志回填任务已排队,job ${job.id}`;
const distribution = await runtimeTask.runTrackedTask({
title: intent,
description: quickRuntimeTaskDescription(instance, action),
stages: runtimeBuildStages,
start: async () => {
const distribution = await platformApiClient.generateClientManager(
instance.id,
clientManagerBuildRequest({
serverInstanceId: instance.id,
profileKey: defaults.clientProfileKey,
targetOs: defaults.clientOs,
targetArch: "amd64",
repositoryUrl: defaults.repositoryUrl,
sourceRevision: "main"
})
);
return { value: distribution, jobId: distribution.buildJobId };
},
poll: (jobId) => platformApiClient.getJob(jobId)
});
generatedClientProfile = defaults.clientProfileKey;
message = `客户端二进制已构建并上传,artifact ${distribution.artifactId}`;
} else {
message = await runtimeTask.runTask({
title: intent,
description: quickRuntimeTaskDescription(instance, action),
stages: quickRuntimeStages(action),
executeStageIndex: quickRuntimeExecuteStageIndex(action),
execute: async () => {
if (action === "download-run") {
const reference = await platformApiClient.downloadLatestRunDistribution(instance.id);
await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit));
return `run 下载已开始,artifact ${reference.artifactId},文件 ${safeArtifactFilename(reference.filename)}`;
}
if (action === "push-run-update") {
const reference = await platformApiClient.downloadLatestRunDistribution(instance.id);
const update = await platformApiClient.pushRunUpdate(instance.id, runUpdateRequest(instance.id, reference.artifactId, reference.checksum));
return `run 更新任务已排队,job ${update.jobId ?? update.id}`;
}
if (action === "dependencies-check") {
const job = await platformApiClient.checkDependencies(instance.id, dependencyJobRequest(instance.id, defaults.probeKey));
return `依赖检查任务已排队,job ${job.id}`;
}
if (action === "dependencies-install") {
const job = await platformApiClient.installDependencies(instance.id, dependencyJobRequest(instance.id, defaults.probeKey, defaults.installPlanKey));
return `依赖安装任务已排队,job ${job.id}`;
}
if (action === "live-logs") {
onNavigate("serverDetail", { serverId: instance.id });
return "已打开服务器详情,可切换到日志页查看实时日志";
}
const job = await platformApiClient.requestLogBackfill(instance.id, logBackfillRequest(instance.id, defaults.logSourceKey));
return `历史日志回填任务已排队,job ${job.id}`;
}
});
}
operations.succeed(operationId, message);
runtimeTask.succeedTask(message);
if (generatedRunArtifact) {
const artifact = generatedRunArtifact;
setRuntimeTaskActions([
{
label: "下载 run",
kind: "primary",
onClick: () => void downloadGeneratedRun(instance, artifact)
},
{
label: "推送更新",
onClick: () => void pushGeneratedRunUpdate(instance, artifact)
}
]);
} else if (generatedClientProfile) {
const clientProfile = generatedClientProfile;
setRuntimeTaskActions([
{
label: "下载客户端",
kind: "primary",
onClick: () => void downloadGeneratedClient(instance, clientProfile)
}
]);
}
await refresh();
} catch (error) {
operations.fail(operationId, error instanceof Error ? error.message : "运行操作失败", operationId);
const message = error instanceof Error ? error.message : "运行操作失败";
operations.fail(operationId, message, operationId);
runtimeTask.failTask(message);
}
}
async function downloadGeneratedRun(instance: ServerInstanceResponse, artifact: { artifactId: string; checksum?: string }) {
setRuntimeTaskActions([]);
try {
const message = await runtimeTask.runTask({
title: "下载 run",
description: `${instance.name} 的 run 包已生成,正在打开 artifact ${artifact.artifactId}`,
stages: runtimeDownloadStages,
executeStageIndex: 1,
execute: async () => {
const reference = await platformApiClient.openArtifactDownload(artifact.artifactId);
await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit));
return `run 下载已开始,文件 ${safeArtifactFilename(reference.filename)}`;
}
});
runtimeTask.succeedTask(message);
} catch (error) {
runtimeTask.failTask(error instanceof Error ? error.message : "run 下载失败");
}
}
async function pushGeneratedRunUpdate(instance: ServerInstanceResponse, artifact: { artifactId: string; checksum?: string }) {
setRuntimeTaskActions([]);
const operationId = operations.begin({ intent: "推送 run 更新", targetKind: "server", targetId: `${instance.id}:push-run-update`, requester: session.displayName });
try {
const message = await runtimeTask.runTask({
title: "推送 run 更新",
description: `${instance.name} 将使用刚生成的 artifact ${artifact.artifactId} 派发 run 自更新任务。`,
stages: runtimeUpdateStages,
executeStageIndex: 2,
execute: async () => {
const update = await platformApiClient.pushRunUpdate(instance.id, runUpdateRequest(instance.id, artifact.artifactId, artifact.checksum));
return `run 更新任务已排队,job ${update.jobId ?? update.id}`;
}
});
operations.succeed(operationId, message);
runtimeTask.succeedTask(message);
await refresh();
} catch (error) {
const message = error instanceof Error ? error.message : "推送 run 更新失败";
operations.fail(operationId, message, operationId);
runtimeTask.failTask(message);
}
}
async function downloadGeneratedClient(instance: ServerInstanceResponse, profileKey: string) {
setRuntimeTaskActions([]);
try {
const message = await runtimeTask.runTask({
title: "下载客户端",
description: `${instance.name} 的客户端管理器已生成,正在创建下载引用。`,
stages: runtimeDownloadStages,
executeStageIndex: 1,
execute: async () => {
const reference = await platformApiClient.downloadLatestClientManager(instance.id, { profileKey });
await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit));
return `客户端下载已开始,文件 ${safeArtifactFilename(reference.filename)}`;
}
});
runtimeTask.succeedTask(message);
} catch (error) {
runtimeTask.failTask(error instanceof Error ? error.message : "客户端下载失败");
}
}
@@ -307,6 +448,7 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
))}
</div>
)}
<RuntimeTaskProgressDialog task={runtimeTask.task} onClose={runtimeTask.closeTask} actions={runtimeTaskActions} />
</section>
);
}
@@ -331,6 +473,80 @@ interface ServerCardProps {
function ServerCard({ card, metricsPending, onOpen, onQuickAction }: ServerCardProps) {
const { instance, metrics, pendingJobs } = card;
const online = serverIsOnline(instance.state);
const menuButtonRef = useRef<HTMLButtonElement>(null);
const menuPanelRef = useRef<HTMLDivElement>(null);
const [menuOpen, setMenuOpen] = useState(false);
const [menuStyle, setMenuStyle] = useState<CSSProperties>({});
const closeMenu = useCallback(() => setMenuOpen(false), []);
const openMenu = useCallback(() => {
const trigger = menuButtonRef.current;
if (!trigger) {
setMenuOpen(true);
return;
}
const rect = trigger.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const menuWidth = Math.min(320, Math.max(220, viewportWidth - 24));
const estimatedMenuHeight = 232;
const left = Math.min(Math.max(12, rect.right - menuWidth), Math.max(12, viewportWidth - menuWidth - 12));
const belowTop = rect.bottom + 8;
const top = belowTop + estimatedMenuHeight <= viewportHeight - 12 ? belowTop : Math.max(12, rect.top - estimatedMenuHeight - 8);
setMenuStyle({ left, top, width: menuWidth });
setMenuOpen(true);
}, []);
const toggleMenu = useCallback(() => {
if (menuOpen) {
closeMenu();
return;
}
openMenu();
}, [closeMenu, menuOpen, openMenu]);
useEffect(() => {
if (!menuOpen) {
return undefined;
}
const handlePointerDown = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Node)) {
return;
}
if (menuButtonRef.current?.contains(target) || menuPanelRef.current?.contains(target)) {
return;
}
closeMenu();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
closeMenu();
menuButtonRef.current?.focus();
}
};
document.addEventListener("pointerdown", handlePointerDown, true);
document.addEventListener("keydown", handleKeyDown);
window.addEventListener("resize", closeMenu);
window.addEventListener("scroll", closeMenu, true);
return () => {
document.removeEventListener("pointerdown", handlePointerDown, true);
document.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("resize", closeMenu);
window.removeEventListener("scroll", closeMenu, true);
};
}, [closeMenu, menuOpen]);
const chooseQuickAction = (action: ServerQuickRuntimeAction) => {
closeMenu();
onQuickAction(action);
};
return (
<article className="server-card" aria-label={`${instance.name} 服务器卡片`}>
<div className="server-card-head">
@@ -368,31 +584,42 @@ function ServerCard({ card, metricsPending, onOpen, onQuickAction }: ServerCardP
<Sparkles size={14} />
<span></span>
</button>
<details className="runtime-action-menu">
<summary className="icon-command"></summary>
<div className="action-list">
{serverQuickActions.map((action) => (
<button key={action} type="button" className="theme-upload" onClick={() => onQuickAction(action)}>
<Candy size={13} />
<span>{quickRuntimeActionLabel(action)}</span>
</button>
))}
</div>
</details>
<button ref={menuButtonRef} type="button" className="icon-command" aria-haspopup="menu" aria-expanded={menuOpen} onClick={toggleMenu}>
<span></span>
</button>
</div>
{menuOpen &&
typeof document !== "undefined" &&
createPortal(
<div ref={menuPanelRef} className="runtime-action-popover" style={menuStyle} role="menu" aria-label={`${instance.name} 运行操作`}>
{serverQuickActionGroups.map((group) => (
<section key={group.label} className="runtime-action-group" aria-label={group.label}>
<span className="runtime-action-group-label">{group.label}</span>
<div className="runtime-action-grid">
{group.actions.map((action) => (
<button key={action} type="button" className="runtime-action-item" role="menuitem" onClick={() => chooseQuickAction(action)}>
<span>{quickRuntimeActionLabel(action)}</span>
</button>
))}
</div>
</section>
))}
</div>,
document.body
)}
</article>
);
}
const serverQuickActions: ServerQuickRuntimeAction[] = [
"generate-run",
"download-run",
"push-run-update",
"generate-client-manager",
"dependencies-check",
"dependencies-install",
"live-logs",
"historical-logs"
const serverQuickActionGroups: Array<{ label: string; actions: ServerQuickRuntimeAction[] }> = [
{
label: "运行分发",
actions: ["generate-run", "download-run", "push-run-update", "generate-client-manager"]
},
{
label: "诊断维护",
actions: ["dependencies-check", "dependencies-install", "live-logs", "historical-logs"]
}
];
function quickRuntimeActionLabel(action: ServerQuickRuntimeAction): string {
@@ -416,6 +643,37 @@ function quickRuntimeActionLabel(action: ServerQuickRuntimeAction): string {
}
}
function quickRuntimeStages(action: ServerQuickRuntimeAction) {
if (action === "generate-run" || action === "generate-client-manager") {
return runtimeBuildStages;
}
if (action === "download-run") {
return runtimeDownloadStages;
}
if (action === "push-run-update") {
return runtimeUpdateStages;
}
if (action === "dependencies-check" || action === "dependencies-install") {
return runtimeDependencyStages;
}
return runtimeLogStages;
}
function quickRuntimeExecuteStageIndex(action: ServerQuickRuntimeAction): number {
if (action === "generate-run" || action === "generate-client-manager") {
return 3;
}
if (action === "push-run-update") {
return 2;
}
return 1;
}
function quickRuntimeTaskDescription(instance: ServerInstanceResponse, action: ServerQuickRuntimeAction): string {
const label = quickRuntimeActionLabel(action);
return `${instance.name}${instance.id}${label},通过平台 API 派发并保留可追踪进度。`;
}
function quickRuntimeDefaultsForPlugin(pluginId: string) {
const isScum = pluginId.toLowerCase().includes("scum");
return {
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { emptyAiProviderForm } from "../contracts/aiProviders";
import { aiProviderCreateRequestFromForm, aiProviderUpdateRequestFromForm } from "./aiProviders";
describe("ai provider form schemas", () => {
it("generates provider IDs and defaults for normal OpenAI setup", () => {
const request = aiProviderCreateRequestFromForm({ ...emptyAiProviderForm(), id: "", apiKeyRef: "secret://providers/openai" });
expect(request).toMatchObject({
id: "ai.openai",
name: "OpenAI",
kind: "openai",
baseUrl: "https://api.openai.com/v1",
apiKeyRef: "secret://providers/openai",
defaultModel: "gpt-5.6-terra",
relayMode: "relay"
});
expect(request.models).toContain("gpt-5.6-terra");
});
it("keeps Ollama local mode keyless while still providing required metadata", () => {
const request = aiProviderUpdateRequestFromForm({ ...emptyAiProviderForm(), kind: "ollama", name: "", baseUrl: "", apiKeyRef: "", modelsText: "", defaultModel: "", relayMode: "local" });
expect(request).toMatchObject({
name: "Ollama Local",
kind: "ollama",
baseUrl: "http://127.0.0.1:11434/v1",
apiKeyRef: "",
defaultModel: "gpt-oss:20b",
relayMode: "local"
});
expect(request.models).toEqual(["gpt-oss:20b"]);
});
});
+14 -12
View File
@@ -1,29 +1,31 @@
import type { AiProviderRequest, AiProviderStatusRequest, AiProviderUpdateRequest } from "../api/types";
import type { AiProviderFormState } from "../contracts/aiProviders";
import { completeAiProviderForm, type AiProviderFormState } from "../contracts/aiProviders";
export function aiProviderCreateRequestFromForm(form: AiProviderFormState): AiProviderRequest {
const completed = completeAiProviderForm(form);
return {
id: form.id.trim(),
...aiProviderUpdateRequestFromForm(form)
id: completed.id,
...aiProviderUpdateRequestFromForm(completed)
};
}
export function aiProviderUpdateRequestFromForm(form: AiProviderFormState): AiProviderUpdateRequest {
const models = form.modelsText
const completed = completeAiProviderForm(form);
const models = completed.modelsText
.split(",")
.map((model) => model.trim())
.filter(Boolean);
return {
name: form.name.trim(),
kind: form.kind,
baseUrl: form.baseUrl.trim(),
apiKeyRef: form.apiKeyRef.trim(),
name: completed.name,
kind: completed.kind,
baseUrl: completed.baseUrl,
apiKeyRef: completed.apiKeyRef,
models,
defaultModel: form.defaultModel.trim() || models[0],
relayMode: form.relayMode,
timeoutMs: Number.parseInt(form.timeoutMs, 10),
redactionPolicy: form.redactionPolicy.trim() || "default"
defaultModel: completed.defaultModel || models[0],
relayMode: completed.relayMode,
timeoutMs: Number.parseInt(completed.timeoutMs, 10),
redactionPolicy: completed.redactionPolicy
};
}
+5 -2
View File
@@ -27,6 +27,7 @@ This directory owns the platform_web visual system. Keep the console in a unifie
- `defaultThemeBackgroundId` should point to a built-in mecha desktop preset that works without uploaded imagery.
- Built-in presets use CSS variables named `--workspace-background-pattern-*` and render behind the app shell.
- Uploaded backgrounds use `--workspace-background-image`, set `data-custom-background="true"`, and take visual precedence over the selected preset.
- Magical-girl plus uploaded backgrounds must stay restrained: use muted translucent surfaces, low-opacity frame accessories, and reduced pink/gold glow so busy user imagery remains readable instead of becoming a saturated wash.
- Removing an uploaded background must reveal the selected built-in preset again.
- Theme palette switches must keep uploaded backgrounds intact. Changing from magical-girl to black mecha, or back again, must not clear `--workspace-background-image` or alter the custom-background fallback preset.
- Any new preset must include an `id`, `label`, `summary`, `preview`, and all required `--workspace-background-pattern-*` variables. Current presets are 机甲格纳库 and 粉月魔法阵.
@@ -45,6 +46,9 @@ This directory owns the platform_web visual system. Keep the console in a unifie
- A visual region should have only one ornamental frame at a hierarchy level. If a `.state-view` is nested inside a shared framed parent such as `.console-panel`, `.catalog-card`, `.server-card`, `.resource-table-wrap`, `.provider-table-wrap`, `.server-table-wrap`, `.plugin-group`, or `.operation-item`, the parent owns the frame and the nested state view must render as transparent, borderless content with no `::before` or `::after` accessory.
- Standalone `.state-view` instances may keep their own readable state treatment when they are not inside an already framed surface.
- Menu frames should use `var(--menu-item-bg)`, `var(--menu-item-active-bg)`, `var(--menu-glyph-bg)`, `var(--menu-title-shadow)`, and `var(--menu-active-outline)` so each theme changes active-state treatment, icon material, and rail/sidebar structure.
- Action dropdowns and contextual menus are small operational overlays, not decorative panels. They must stay anchored to the trigger, fit within the viewport, use compact rows, and avoid moving or resizing the parent card, row, grid, or table.
- Do not render a dropdown as a tall vertical tower of large command buttons. Do not let a menu cover server metrics, progress bars, titles, status badges, or adjacent cards. If a server/resource has too many runtime actions for a compact menu, route those actions to a grouped drawer, detail page, or command dialog.
- Menu items may use theme-appropriate icons for recognition, but repeated decorative glyph rails on every action row are forbidden. Icons must clarify action meaning or safety state, not become visual clutter.
- Shared decoration variables are part of the contract: `--frosted-edge`, `--frosted-surface`, `--corner-sparkle`, `--jelly-highlight`, `--sugar-dust`, `--crystal-edge-glow`, and `--jelly-inset`. In mecha themes these become scanner/grid/bevel materials; in magical themes they become star, ribbon, and jelly-glass materials.
- Full-screen ambient motifs use the shared `MagicalParticleLayer` background layer and global particle DOM layer. They should remain non-interactive, theme-colored, reduced-motion aware, and behind operational surfaces. Page code should not create one-off fixed decoration containers.
- Keep framed repeated items at 8px radius or less. Pills and circular avatars are allowed for native pill/circle controls.
@@ -57,5 +61,4 @@ This directory owns the platform_web visual system. Keep the console in a unifie
2. If a new shared pattern is truly needed, add it in `base.css` and describe its intended use here.
3. When placing empty/loading/error states inside an existing shared panel, verify the state view does not introduce a second framed panel or accessory layer.
4. If a new palette or background preset is added, update `tokens.ts`, `tokens.test.ts`, and any CSS contract tests together.
5. Run `npm run typecheck`, `npm test`, `npm run build`, `scripts/check-structure.sh`, and `openspec validate <change> --strict` before claiming completion.
6. For page or interaction changes, perform a browser walkthrough before marking visual acceptance tasks complete. At minimum, switch black mecha -> magical-girl -> black mecha with both built-in and uploaded backgrounds when the change touches theme switching, frame accessories, or custom-background styling.
5. Run the relevant focused checks before claiming completion. Use `npm run typecheck`, `npm test`, `npm run build`, and `scripts/check-structure.sh` when the scope warrants them; run `openspec validate <change> --strict` only when an OpenSpec change was created.
+66
View File
@@ -10,4 +10,70 @@ describe("platform web shared theme CSS", () => {
expect(nestedStateReset).toContain("content: none");
expect(nestedStateReset).toContain("background: transparent");
});
it("keeps server card runtime actions as compact overlay menus", () => {
const themeCss = readFileSync(new URL("./base.css", import.meta.url), "utf8");
const popoverRule = themeCss.slice(themeCss.indexOf(".runtime-action-popover"), themeCss.indexOf("/* ---- catalog / detail lists ---- */"));
expect(popoverRule).toContain("position: fixed");
expect(popoverRule).toContain("z-index: 45");
expect(popoverRule).toContain(".runtime-action-grid");
expect(popoverRule).toContain("grid-template-columns: repeat(2, minmax(0, 1fr))");
});
it("keeps server card stat tiles readable over busy backgrounds", () => {
const themeCss = readFileSync(new URL("./base.css", import.meta.url), "utf8");
const statRule = themeCss.slice(themeCss.indexOf(".server-card-stat {"), themeCss.indexOf(".server-card-meters"));
expect(statRule).toContain("border: 1px solid");
expect(statRule).toContain("--surface-solid");
expect(statRule).toContain("font-weight: 850");
expect(themeCss).toContain(':root[data-custom-background="true"] .server-card-stat');
});
it("keeps magical custom-background signal rows readable without the jelly wash", () => {
const themeCss = readFileSync(new URL("./base.css", import.meta.url), "utf8");
const signalRule = themeCss.slice(
themeCss.indexOf(':root[data-custom-background="true"][data-theme-palette="magical-girl"] .signal-item'),
themeCss.indexOf(':root[data-custom-background="true"][data-theme-palette="magical-girl"] .signal-item strong')
);
expect(signalRule).toContain("rgba(58, 44, 56, 0.72)");
expect(signalRule).toContain("backdrop-filter: blur(12px) saturate(0.92)");
expect(signalRule).not.toContain("var(--jelly-highlight), var(--glass-wash), var(--surface-solid)");
});
it("keeps shared controls off the heavy jelly and corner-sparkle button wash", () => {
const themeCss = readFileSync(new URL("./base.css", import.meta.url), "utf8");
const primaryCommandRule = themeCss.slice(themeCss.indexOf(".primary-command {"), themeCss.indexOf(".action-strip .primary-command"));
expect(themeCss).toContain("--control-surface");
expect(themeCss).toContain("--primary-command-surface");
expect(themeCss).not.toContain("background: var(--jelly-highlight), var(--glass-wash), var(--surface-solid)");
expect(themeCss).not.toContain("linear-gradient(145deg, rgba(255, 255, 255, 0.72), transparent 36%)");
expect(primaryCommandRule).not.toContain("background-position: right 8px top 4px, center, center");
});
it("keeps magical-girl custom-background panel overlays restrained", () => {
const themeCss = readFileSync(new URL("./base.css", import.meta.url), "utf8");
const customMagicalRule = themeCss.slice(
themeCss.indexOf(':root[data-custom-background="true"][data-theme-palette="magical-girl"] {'),
themeCss.indexOf(':root[data-custom-background="true"][data-theme-palette="magical-girl"] .app-sidebar')
);
expect(customMagicalRule).toContain("--frame-accessory-opacity: 0.22");
expect(customMagicalRule).toContain("rgba(22, 22, 29, 0.64)");
expect(customMagicalRule).not.toContain("255, 119, 200, 0.78");
});
it("styles runtime task progress as a dialog with staged status", () => {
const themeCss = readFileSync(new URL("./base.css", import.meta.url), "utf8");
const progressRule = themeCss.slice(themeCss.indexOf(".runtime-task-backdrop"), themeCss.indexOf("/* ---- narrow screens ---- */"));
expect(progressRule).toContain(".runtime-task-panel");
expect(progressRule).toContain(".runtime-task-meter-track");
expect(progressRule).toContain(".runtime-task-stages");
expect(progressRule).toContain(".runtime-task-log");
expect(progressRule).toContain("prefers-reduced-motion");
});
});
+462 -73
View File
@@ -61,6 +61,9 @@
--menu-glyph-bg: linear-gradient(135deg, rgba(72, 230, 255, 0.28), rgba(255, 184, 77, 0.14)), repeating-linear-gradient(90deg, rgba(137, 239, 255, 0.18) 0 1px, transparent 1px 6px), rgba(5, 9, 15, 0.9);
--menu-title-shadow: 0 0 14px rgba(72, 230, 255, 0.5), 0 0 2px rgba(255, 184, 77, 0.8);
--menu-active-outline: linear-gradient(90deg, rgba(72, 230, 255, 0.95), rgba(255, 184, 77, 0.72), rgba(72, 230, 255, 0.95));
--control-surface: linear-gradient(180deg, color-mix(in srgb, var(--surface-solid) 90%, rgba(255, 255, 255, 0.08)), color-mix(in srgb, var(--surface-solid) 76%, var(--accent-soft))), var(--glass-wash);
--control-surface-active: linear-gradient(180deg, color-mix(in srgb, var(--surface-solid) 72%, var(--accent-soft)), color-mix(in srgb, var(--surface-solid) 82%, var(--pink-soft))), var(--glass-wash);
--primary-command-surface: linear-gradient(180deg, color-mix(in srgb, var(--accent) 42%, rgba(255, 255, 255, 0.16)), color-mix(in srgb, var(--pink) 36%, var(--surface-solid)) 68%, color-mix(in srgb, var(--surface-solid) 84%, var(--pink-soft)));
--panel-material: var(--sugar-dust), var(--glass-wash), linear-gradient(135deg, rgba(4, 8, 13, 0.78), rgba(13, 23, 32, 0.64));
--panel-shadow: var(--jelly-inset), inset 0 0 0 1px rgba(119, 237, 255, 0.24), inset 9px 0 0 rgba(72, 230, 255, 0.08), 0 18px 42px rgba(0, 0, 0, 0.5), 0 0 34px rgba(72, 230, 255, 0.14);
--ultimate-effect-alpha: 0.86;
@@ -820,7 +823,7 @@ button {
border: 1px solid var(--line-strong);
border-radius: 8px;
padding: 0 10px;
background: var(--jelly-highlight), var(--glass-wash), var(--surface-solid);
background: var(--control-surface);
color: var(--ink);
font: inherit;
box-shadow: var(--jelly-inset);
@@ -839,14 +842,7 @@ button {
gap: 6px;
border: 1px solid var(--rim-light);
border-radius: 999px;
background:
var(--sugar-dust),
var(--corner-sparkle),
linear-gradient(145deg, rgba(255, 255, 255, 0.72), transparent 36%),
linear-gradient(135deg, var(--accent), var(--pink));
background-size: auto, 44px 44px, auto, auto;
background-position: center, right 8px top 2px, center, center;
background-repeat: no-repeat, no-repeat, no-repeat, no-repeat;
background: var(--primary-command-surface);
color: #ffffff;
cursor: pointer;
font-weight: 700;
@@ -962,13 +958,17 @@ button {
.background-preset-option-active {
border-color: var(--line-strong);
color: var(--accent-deep);
background: var(--corner-sparkle), var(--jelly-highlight), var(--glass-wash), var(--accent-soft);
background-size: 46px 46px, auto, auto, auto;
background-position: right 8px top 4px, center, center, center;
background-repeat: no-repeat;
background: var(--control-surface-active);
box-shadow: inset 0 1px 0 var(--rim-light), inset 0 0 0 1px var(--diamond-line), 0 10px 24px var(--candy-glow);
}
.background-preset-option-fallback {
border-style: dashed;
border-color: color-mix(in srgb, var(--line-strong) 72%, transparent);
color: var(--ink-soft);
background: var(--control-surface);
}
.background-preset-preview {
min-height: 34px;
border: 1px solid var(--crystal-rim);
@@ -985,6 +985,16 @@ button {
font-weight: 700;
}
.background-preset-fallback-badge {
padding: 1px 6px;
border: 1px solid color-mix(in srgb, var(--line-strong) 76%, transparent);
border-radius: 999px;
background: var(--gold-soft);
color: var(--accent-deep);
font-size: 11px;
line-height: 1.35;
}
.theme-background-note {
display: block;
padding: 7px 9px;
@@ -1004,7 +1014,7 @@ button {
padding: 0 10px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--jelly-highlight), var(--glass-wash), var(--surface-solid);
background: var(--control-surface);
color: var(--ink-soft);
cursor: pointer;
font-size: 12px;
@@ -1107,7 +1117,7 @@ button {
gap: 6px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--jelly-highlight), var(--glass-wash), var(--surface-solid);
background: var(--control-surface);
color: var(--ink-soft);
cursor: pointer;
font-weight: 700;
@@ -1152,7 +1162,7 @@ button {
border: 1px solid var(--line-strong);
border-radius: 8px;
padding: 0 10px;
background: var(--jelly-highlight), var(--glass-wash), var(--surface-solid);
background: var(--control-surface);
color: var(--ink);
font: inherit;
box-shadow: var(--jelly-inset);
@@ -1218,7 +1228,7 @@ button {
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--jelly-highlight), var(--glass-wash), var(--surface-solid);
background: var(--control-surface);
color: var(--ink-soft);
box-shadow: inset 0 1px 0 var(--crystal-rim);
font-size: 12.5px;
@@ -1364,7 +1374,7 @@ button {
border: 1px solid var(--line-strong);
border-radius: 8px;
padding: 0 10px;
background: var(--jelly-highlight), var(--glass-wash), var(--surface-solid);
background: var(--control-surface);
color: var(--ink);
font: inherit;
box-shadow: var(--jelly-inset);
@@ -1858,34 +1868,34 @@ button {
}
:root[data-custom-background="true"][data-theme-palette="magical-girl"] {
--frame-accessory-opacity: 0.82;
--surface: rgba(34, 27, 34, 0.5);
--surface-solid: rgba(24, 20, 26, 0.78);
--surface-raised: rgba(44, 34, 43, 0.58);
--line: rgba(255, 185, 226, 0.52);
--line-strong: rgba(255, 236, 249, 0.78);
--glass-wash: linear-gradient(145deg, rgba(255, 236, 249, 0.16), rgba(38, 30, 38, 0.48) 46%, rgba(255, 221, 117, 0.06));
--panel-material: linear-gradient(145deg, rgba(255, 246, 253, 0.14), rgba(39, 31, 39, 0.5) 44%, rgba(24, 21, 27, 0.46));
--panel-shadow: inset 0 1px 0 rgba(255, 246, 253, 0.3), inset 0 0 0 1px rgba(255, 185, 226, 0.12), 0 16px 34px rgba(20, 10, 18, 0.26), 0 0 20px rgba(255, 119, 200, 0.14);
--frame-accessory-opacity: 0.22;
--surface: rgba(22, 22, 29, 0.64);
--surface-solid: rgba(18, 19, 26, 0.88);
--surface-raised: rgba(25, 24, 32, 0.72);
--line: rgba(238, 215, 236, 0.34);
--line-strong: rgba(250, 240, 249, 0.5);
--glass-wash: linear-gradient(145deg, rgba(255, 246, 253, 0.08), rgba(22, 22, 29, 0.66) 48%, rgba(255, 221, 117, 0.025));
--panel-material: linear-gradient(145deg, rgba(255, 246, 253, 0.08), rgba(22, 22, 29, 0.72) 44%, rgba(18, 19, 26, 0.64));
--panel-shadow: inset 0 1px 0 rgba(255, 246, 253, 0.18), inset 0 0 0 1px rgba(255, 185, 226, 0.06), 0 16px 34px rgba(12, 12, 18, 0.3);
}
:root[data-custom-background="true"][data-theme-palette="magical-girl"] .app-sidebar {
border-right-color: rgba(255, 226, 244, 0.42);
border-right-color: rgba(242, 224, 240, 0.26);
background:
linear-gradient(180deg, rgba(52, 37, 50, 0.66), rgba(22, 20, 27, 0.58)),
rgba(20, 18, 24, 0.42);
-webkit-backdrop-filter: blur(14px) saturate(1.04);
backdrop-filter: blur(14px) saturate(1.04);
box-shadow: inset -1px 0 0 rgba(255, 246, 253, 0.18), 10px 0 28px rgba(18, 10, 17, 0.22), 0 0 18px rgba(255, 119, 200, 0.1);
linear-gradient(180deg, rgba(34, 31, 40, 0.72), rgba(18, 19, 26, 0.68)),
rgba(18, 18, 24, 0.5);
-webkit-backdrop-filter: blur(14px) saturate(0.92);
backdrop-filter: blur(14px) saturate(0.92);
box-shadow: inset -1px 0 0 rgba(255, 246, 253, 0.12), 10px 0 28px rgba(12, 12, 18, 0.26);
}
:root[data-custom-background="true"][data-theme-palette="magical-girl"] .page-header > div:first-child {
border-color: rgba(255, 226, 244, 0.28);
border-color: rgba(242, 224, 240, 0.18);
background:
linear-gradient(135deg, rgba(255, 246, 253, 0.13), transparent 36%),
rgba(38, 30, 38, 0.48);
-webkit-backdrop-filter: blur(8px) saturate(1.02);
backdrop-filter: blur(8px) saturate(1.02);
linear-gradient(135deg, rgba(255, 246, 253, 0.075), transparent 36%),
rgba(22, 22, 29, 0.58);
-webkit-backdrop-filter: blur(8px) saturate(0.9);
backdrop-filter: blur(8px) saturate(0.9);
}
:root[data-custom-background="true"] .app-sidebar {
@@ -2013,13 +2023,13 @@ button {
:root[data-custom-background="true"][data-theme-palette="magical-girl"] .provider-table-wrap,
:root[data-custom-background="true"][data-theme-palette="magical-girl"] .server-table-wrap {
background:
radial-gradient(circle at 100% 0, rgba(255, 185, 226, 0.18), transparent 30%),
linear-gradient(135deg, rgba(255, 246, 253, 0.13), transparent 32%),
linear-gradient(180deg, rgba(42, 32, 41, 0.42), rgba(24, 21, 27, 0.36));
border-color: color-mix(in srgb, var(--line-strong) 76%, rgba(255, 255, 255, 0.2));
radial-gradient(circle at 100% 0, rgba(255, 221, 242, 0.07), transparent 32%),
linear-gradient(135deg, rgba(255, 246, 253, 0.065), transparent 34%),
linear-gradient(180deg, rgba(24, 24, 31, 0.66), rgba(18, 19, 26, 0.58));
border-color: color-mix(in srgb, var(--line-strong) 54%, rgba(255, 255, 255, 0.16));
-webkit-backdrop-filter: none;
backdrop-filter: none;
box-shadow: inset 0 1px 0 rgba(255, 246, 253, 0.28), inset 0 0 0 1px rgba(255, 185, 226, 0.1), 0 16px 32px rgba(18, 10, 17, 0.26), 0 0 20px rgba(255, 119, 200, 0.14);
box-shadow: inset 0 1px 0 rgba(255, 246, 253, 0.18), inset 0 0 0 1px rgba(255, 185, 226, 0.045), 0 16px 32px rgba(12, 12, 18, 0.28);
}
:root[data-custom-background="true"][data-theme-palette="magical-girl"] .server-toolbar,
@@ -2057,8 +2067,8 @@ button {
:root[data-custom-background="true"][data-theme-palette="magical-girl"] .catalog-card::before,
:root[data-custom-background="true"][data-theme-palette="magical-girl"] .server-card::before,
:root[data-custom-background="true"][data-theme-palette="magical-girl"] .server-detail-header::before {
background: linear-gradient(180deg, rgba(255, 246, 253, 0.96), rgba(255, 221, 117, 0.72), rgba(255, 119, 200, 0.78));
opacity: 0.84;
background: linear-gradient(180deg, rgba(255, 246, 253, 0.68), rgba(255, 221, 117, 0.32), rgba(255, 143, 208, 0.36));
opacity: 0.46;
}
:root[data-custom-background="true"][data-theme-palette="magical-girl"] .metric-card::after,
@@ -2082,7 +2092,7 @@ button {
background-repeat: no-repeat;
background-position: right -4px top -6px;
opacity: var(--frame-accessory-opacity);
filter: drop-shadow(0 0 4px rgba(255, 119, 200, 0.62)) drop-shadow(0 0 2px rgba(255, 221, 117, 0.46));
filter: drop-shadow(0 0 3px rgba(255, 180, 226, 0.24));
}
:root[data-custom-background="true"] .metric-card::after,
@@ -2198,6 +2208,33 @@ button {
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28), 0 0 18px color-mix(in srgb, var(--accent) 24%, transparent);
}
:root[data-custom-background="true"] .server-card-stat {
border-color: color-mix(in srgb, var(--line-strong) 62%, rgba(255, 255, 255, 0.2));
background:
linear-gradient(180deg, rgba(5, 10, 16, 0.9), rgba(5, 10, 16, 0.78)),
var(--glass-wash);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14), 0 9px 20px rgba(0, 0, 0, 0.28);
}
:root[data-custom-background="true"] .server-card-stat span,
:root[data-custom-background="true"] .runtime-action-group-label {
color: rgba(236, 249, 253, 0.9);
}
:root[data-custom-background="true"] .server-card-stat strong,
:root[data-custom-background="true"] .runtime-action-item {
color: rgba(250, 254, 255, 0.98);
}
:root[data-custom-background="true"] .runtime-action-popover {
border-color: color-mix(in srgb, var(--line-strong) 56%, rgba(255, 255, 255, 0.18));
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.07), transparent 32%),
rgba(5, 10, 16, 0.94);
-webkit-backdrop-filter: blur(14px) saturate(0.86);
backdrop-filter: blur(14px) saturate(0.86);
}
:root[data-custom-background="true"] .page-status,
:root[data-custom-background="true"] .job-chip,
:root[data-custom-background="true"] .status-disabled,
@@ -2522,7 +2559,7 @@ button {
.row-actions button,
.table-link-button {
border: 1px solid var(--line-strong);
background: var(--jelly-highlight), var(--glass-wash), var(--surface-solid);
background: var(--control-surface);
color: var(--ink-soft);
cursor: pointer;
box-shadow: inset 0 1px 0 var(--crystal-rim), inset 0 -1px 0 rgba(255, 255, 255, 0.28), 0 8px 18px var(--glass-shadow);
@@ -2579,13 +2616,7 @@ button {
.primary-command {
width: 100%;
background:
var(--corner-sparkle),
linear-gradient(145deg, rgba(255, 255, 255, 0.72), transparent 36%),
linear-gradient(135deg, var(--accent), var(--pink));
background-size: 48px 48px, auto, auto;
background-position: right 8px top 4px, center, center;
background-repeat: no-repeat;
background: var(--primary-command-surface);
border-color: var(--accent-deep);
color: #ffffff;
font-weight: 700;
@@ -2619,6 +2650,70 @@ button {
border-color: var(--danger);
}
.runtime-action-popover {
position: fixed;
z-index: 45;
display: grid;
gap: 10px;
max-height: min(320px, calc(100vh - 24px));
padding: 10px;
overflow: auto;
border: 1px solid color-mix(in srgb, var(--line-strong) 68%, rgba(255, 255, 255, 0.2));
border-radius: 8px;
background:
var(--corner-sparkle),
linear-gradient(145deg, color-mix(in srgb, var(--surface-solid) 94%, rgba(255, 255, 255, 0.04)), color-mix(in srgb, var(--surface-solid) 86%, var(--accent-soft)) 72%, color-mix(in srgb, var(--surface-solid) 94%, #000000 8%));
background-size: 48px 48px, auto;
background-repeat: no-repeat, no-repeat;
background-position: right 6px top 4px, center;
-webkit-backdrop-filter: blur(18px) saturate(1.1);
backdrop-filter: blur(18px) saturate(1.1);
box-shadow: var(--jelly-inset), inset 0 0 0 1px color-mix(in srgb, var(--diamond-line) 52%, transparent), 0 18px 42px rgba(0, 0, 0, 0.36), 0 0 18px color-mix(in srgb, var(--accent) 18%, transparent);
}
.runtime-action-group {
display: grid;
gap: 6px;
}
.runtime-action-group-label {
color: color-mix(in srgb, var(--ink) 76%, var(--accent-deep));
font-size: 11px;
font-weight: 850;
line-height: 1.1;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
}
.runtime-action-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
}
.runtime-action-item {
min-height: 32px;
padding: 0 9px;
border: 1px solid color-mix(in srgb, var(--line) 76%, rgba(255, 255, 255, 0.12));
border-radius: 7px;
background: var(--menu-item-bg);
color: color-mix(in srgb, var(--ink) 92%, #ffffff);
cursor: pointer;
font-size: 12px;
font-weight: 780;
text-align: left;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--crystal-rim) 52%, transparent);
}
.runtime-action-item:hover,
.runtime-action-item:focus-visible {
border-color: var(--accent);
outline: none;
color: var(--ink);
background: var(--menu-item-active-bg);
box-shadow: inset 0 1px 0 var(--crystal-rim), 0 0 0 2px var(--accent-soft), 0 10px 22px rgba(0, 0, 0, 0.24);
}
/* ---- catalog / detail lists ---- */
.catalog-grid {
@@ -2892,7 +2987,7 @@ button {
padding: 0 9px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--jelly-highlight), var(--glass-wash), var(--surface-solid);
background: var(--control-surface);
color: var(--ink-soft);
cursor: pointer;
font-weight: 700;
@@ -2948,7 +3043,7 @@ button {
.provider-preset-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
@@ -2960,7 +3055,7 @@ button {
padding: 10px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--jelly-highlight), var(--glass-wash), var(--surface-solid);
background: var(--control-surface);
color: var(--ink-soft);
cursor: pointer;
text-align: left;
@@ -2978,12 +3073,68 @@ button {
}
.provider-preset-option:hover,
.provider-preset-option:focus-visible {
.provider-preset-option:focus-visible,
.provider-preset-option-active {
border-color: var(--accent);
outline: none;
box-shadow: inset 0 1px 0 var(--crystal-rim), 0 0 0 2px var(--accent-soft);
}
.provider-generated-id {
display: grid;
gap: 4px;
padding: 10px;
border: 1px dashed var(--line);
border-radius: 8px;
background: color-mix(in srgb, var(--surface-solid) 72%, transparent);
color: var(--ink-soft);
font-size: 12px;
}
.provider-generated-id > span {
font-weight: 800;
}
.provider-generated-id code {
width: max-content;
max-width: 100%;
padding: 3px 6px;
border-radius: 6px;
background: var(--surface-solid);
color: var(--ink);
overflow-wrap: anywhere;
}
.provider-generated-id small {
color: var(--ink-faint);
font-weight: 700;
}
.provider-advanced-settings {
display: grid;
gap: 10px;
padding: 10px;
border: 1px solid var(--line);
border-radius: 8px;
background: color-mix(in srgb, var(--surface-solid) 62%, transparent);
}
.provider-advanced-settings summary {
cursor: pointer;
color: var(--ink);
font-size: 13px;
font-weight: 850;
}
.provider-advanced-settings[open] summary {
margin-bottom: 8px;
}
.provider-advanced-settings > label,
.provider-advanced-settings > .form-grid {
margin-top: 10px;
}
.field-help {
color: var(--ink-faint);
font-size: 11.5px;
@@ -3106,7 +3257,6 @@ button {
background: var(--frosted-surface), var(--glass-tint), var(--surface);
backdrop-filter: blur(22px) saturate(1.28);
text-align: left;
cursor: pointer;
transition: transform 120ms ease, border-color 120ms ease;
box-shadow: var(--jelly-inset), inset 0 0 0 1px var(--diamond-line), 0 18px 42px var(--glass-shadow), 0 0 28px rgba(255, 255, 255, 0.2);
position: relative;
@@ -3149,23 +3299,33 @@ button {
display: grid;
gap: 2px;
padding: 8px;
border: 1px solid color-mix(in srgb, var(--line-strong) 56%, rgba(255, 255, 255, 0.16));
border-radius: 8px;
background: var(--jelly-highlight), var(--glass-wash), var(--accent-soft);
background:
linear-gradient(180deg, color-mix(in srgb, var(--surface-solid) 88%, rgba(255, 255, 255, 0.06)), color-mix(in srgb, var(--surface-solid) 72%, var(--accent-soft))),
var(--glass-wash);
min-height: 52px;
align-content: center;
box-shadow: inset 0 1px 0 var(--crystal-rim);
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--crystal-rim) 68%, transparent), 0 8px 18px rgba(0, 0, 0, 0.18);
min-width: 0;
backdrop-filter: blur(10px) saturate(0.92);
}
.server-card-stat span {
color: var(--ink-faint);
color: color-mix(in srgb, var(--ink) 78%, var(--accent-deep));
font-size: 11px;
font-weight: 800;
line-height: 1.15;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.48);
}
.server-card-stat strong {
font-size: 14px;
color: var(--ink);
color: color-mix(in srgb, var(--ink) 94%, #ffffff);
font-size: 15px;
font-weight: 850;
line-height: 1.16;
overflow-wrap: anywhere;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.56);
}
.server-card-meters {
@@ -3266,7 +3426,7 @@ button {
padding: 0 14px;
border: 1px solid var(--line-strong);
border-radius: 999px;
background: var(--jelly-highlight), var(--glass-wash), var(--surface-solid);
background: var(--control-surface);
color: var(--ink-soft);
cursor: pointer;
white-space: nowrap;
@@ -3279,13 +3439,7 @@ button {
}
.section-tab-active {
background:
var(--corner-sparkle),
linear-gradient(145deg, rgba(255, 255, 255, 0.72), transparent 36%),
linear-gradient(135deg, var(--accent), var(--pink));
background-size: 46px 46px, auto, auto;
background-position: right 8px top 4px, center, center;
background-repeat: no-repeat;
background: var(--primary-command-surface);
border-color: var(--accent-deep);
color: #ffffff;
font-weight: 700;
@@ -3782,16 +3936,41 @@ button {
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--jelly-highlight), var(--glass-wash), var(--surface-solid);
background:
linear-gradient(180deg, color-mix(in srgb, var(--surface-solid) 90%, rgba(255, 255, 255, 0.08)), color-mix(in srgb, var(--surface-solid) 76%, var(--accent-soft))),
var(--glass-wash);
color: var(--ink-soft);
cursor: pointer;
text-align: left;
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--crystal-rim) 54%, transparent), 0 8px 18px color-mix(in srgb, var(--glass-shadow) 58%, transparent);
}
.signal-item:hover,
.signal-item:focus-visible {
border-color: var(--accent);
outline: none;
box-shadow: inset 0 1px 0 var(--crystal-rim), 0 0 0 2px var(--accent-soft), 0 10px 22px color-mix(in srgb, var(--glass-shadow) 62%, transparent);
}
:root[data-custom-background="true"][data-theme-palette="magical-girl"] .signal-item {
border-color: color-mix(in srgb, var(--line-strong) 68%, rgba(255, 255, 255, 0.22));
background:
linear-gradient(90deg, rgba(255, 246, 253, 0.22), rgba(255, 246, 253, 0.1) 36%, rgba(255, 185, 226, 0.08)),
linear-gradient(180deg, rgba(58, 44, 56, 0.72), rgba(36, 29, 39, 0.68));
-webkit-backdrop-filter: blur(12px) saturate(0.92);
backdrop-filter: blur(12px) saturate(0.92);
box-shadow: inset 0 1px 0 rgba(255, 246, 253, 0.32), inset 0 0 0 1px rgba(255, 185, 226, 0.1), 0 10px 22px rgba(18, 10, 17, 0.26);
}
:root[data-custom-background="true"][data-theme-palette="magical-girl"] .signal-item strong {
color: #fffafd;
text-shadow: 0 1px 2px rgba(48, 18, 36, 0.72);
}
:root[data-custom-background="true"][data-theme-palette="magical-girl"] .signal-item p,
:root[data-custom-background="true"][data-theme-palette="magical-girl"] .signal-item .provider-id {
color: rgba(255, 232, 245, 0.9);
text-shadow: 0 1px 2px rgba(48, 18, 36, 0.66);
}
.signal-item strong {
@@ -3893,6 +4072,215 @@ button {
font-weight: 700;
}
/* ---- runtime task progress ---- */
.runtime-task-backdrop {
z-index: 55;
}
.runtime-task-panel {
width: min(720px, 100%);
gap: 16px;
}
.runtime-task-header {
align-items: flex-start;
}
.runtime-task-header > span:first-child {
display: grid;
gap: 4px;
min-width: 0;
}
.runtime-task-header strong {
color: var(--ink);
font-size: 18px;
}
.runtime-task-header small,
.runtime-task-current small,
.runtime-task-stage-copy small {
color: var(--ink-faint);
font-size: 12px;
line-height: 1.45;
}
.runtime-task-meter {
display: grid;
gap: 8px;
}
.runtime-task-meter-row {
display: flex;
justify-content: space-between;
gap: 10px;
color: var(--ink-soft);
font-size: 13px;
}
.runtime-task-meter-row strong {
color: var(--ink);
}
.runtime-task-meter-track {
height: 10px;
display: block;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--line-strong) 72%, transparent);
border-radius: 999px;
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.08), transparent 30%),
color-mix(in srgb, var(--surface-solid) 82%, #000000 18%);
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--crystal-rim) 42%, transparent);
}
.runtime-task-meter-fill {
height: 100%;
display: block;
border-radius: inherit;
background: linear-gradient(90deg, var(--accent), var(--teal), var(--gold));
box-shadow: 0 0 18px color-mix(in srgb, var(--accent) 36%, transparent);
transition: width 260ms ease;
}
.runtime-task-meter-failed {
background: linear-gradient(90deg, var(--danger), #ff9cb6);
}
.runtime-task-current {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 10px;
align-items: center;
padding: 12px;
border: 1px solid color-mix(in srgb, var(--accent) 46%, var(--line));
border-radius: 8px;
background: var(--jelly-highlight), var(--glass-wash), color-mix(in srgb, var(--surface) 82%, var(--accent-soft));
box-shadow: inset 0 1px 0 var(--crystal-rim), 0 12px 28px color-mix(in srgb, var(--accent) 14%, transparent);
}
.runtime-task-current-icon,
.runtime-task-stage-icon {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid color-mix(in srgb, var(--line-strong) 66%, transparent);
border-radius: 8px;
background: color-mix(in srgb, var(--surface-solid) 78%, var(--accent-soft));
color: var(--accent);
box-shadow: inset 0 1px 0 var(--crystal-rim);
}
.runtime-task-current span:last-child,
.runtime-task-stage-copy {
display: grid;
gap: 2px;
min-width: 0;
}
.runtime-task-current strong,
.runtime-task-stage-copy strong {
color: var(--ink);
font-size: 13px;
}
.runtime-task-stages {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.runtime-task-stage {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 9px;
align-items: center;
padding: 10px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--glass-wash), rgba(255, 255, 255, 0.16);
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--crystal-rim) 62%, transparent);
}
.runtime-task-stage-running {
border-color: color-mix(in srgb, var(--accent) 56%, var(--line));
}
.runtime-task-stage-completed .runtime-task-stage-icon {
color: var(--success);
border-color: color-mix(in srgb, var(--success) 52%, var(--line));
}
.runtime-task-stage-failed .runtime-task-stage-icon {
color: var(--danger);
border-color: color-mix(in srgb, var(--danger) 52%, var(--line));
}
.runtime-task-stage-status {
color: var(--ink-faint);
font-size: 11px;
font-weight: 800;
white-space: nowrap;
}
.runtime-task-log {
display: grid;
gap: 4px;
max-height: 132px;
overflow: auto;
padding: 10px;
border: 1px solid color-mix(in srgb, var(--line) 70%, transparent);
border-radius: 8px;
background: var(--code-surface);
color: var(--code-ink);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
}
.runtime-task-log span {
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
overflow-wrap: anywhere;
}
.runtime-task-error {
border-color: color-mix(in srgb, var(--danger) 58%, var(--line));
color: var(--danger);
}
.runtime-task-actions button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.runtime-task-spin {
animation: runtime-task-spin 1s linear infinite;
}
@keyframes runtime-task-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.runtime-task-spin {
animation: none;
}
.runtime-task-meter-fill {
transition: none;
}
}
/* ---- narrow screens ---- */
@media (max-width: 760px) {
@@ -3906,6 +4294,7 @@ button {
}
.management-form,
.runtime-task-stages,
.user-management-item,
.workflow-hint-grid,
.provider-preset-grid,
+50
View File
@@ -0,0 +1,50 @@
import type { ArtifactContentChunk, ArtifactDownloadReferenceResponse } from "../api/types";
export async function downloadArtifactReference(
reference: ArtifactDownloadReferenceResponse,
readContent: (artifactId: string, offset: number, limit?: number) => Promise<ArtifactContentChunk>,
onProgress?: (progress: number) => void
) {
const chunks: ArrayBuffer[] = [];
let offset = 0;
onProgress?.(0);
while (offset < reference.sizeBytes) {
const chunk = await readContent(reference.artifactId, offset, reference.chunkSizeBytes);
chunks.push(chunk.payload);
offset += chunk.payload.byteLength;
onProgress?.(Math.min(100, Math.round((offset / reference.sizeBytes) * 100)));
if (chunk.payload.byteLength === 0) {
break;
}
}
openArtifactBlob(reference, chunks);
onProgress?.(100);
}
export function openArtifactBlob(reference: ArtifactDownloadReferenceResponse, chunks: ArrayBuffer[]) {
if (typeof document === "undefined" || typeof URL === "undefined") {
return;
}
const blob = new Blob(chunks, { type: reference.contentType });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = safeArtifactFilename(reference.filename);
anchor.rel = "noopener";
document.body.append(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
export function safeArtifactFilename(filename: string): string {
const cleaned = filename.replace(/[\\/]/g, "").trim();
return cleaned || "artifact.bin";
}
export function safeArtifactError(error: unknown): string {
const message = error instanceof Error ? error.message : "制品传输失败";
return message.replace(/\/Users\/[^\s]+/g, "[path]").replace(/Bearer\s+[^\s]+/gi, "[token]").replace(/sk-[A-Za-z0-9_-]+/g, "[secret]");
}