mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-10-05 20:44:07 +08:00
- Replaced the native progress element with a custom-styled progress bar for improved visual feedback during downloads. - Added new styles for progress bar and fill animations to enhance user experience. - Updated the component to use ARIA roles for better accessibility.
325 lines
13 KiB
TypeScript
325 lines
13 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { api, pluginText, type PluginDescriptor, type PluginImportFile, type PluginRuntimePhase, type PluginRuntimeStatus } from "../../shared/api";
|
|
import { useI18n } from "../../i18n/store";
|
|
import { PageContent } from "../../shell/layout/PageContent";
|
|
import { appStore, useAppStore } from "../../shared/store/appStore";
|
|
import { ActionMenu } from "../../shared/ui/ActionMenu";
|
|
import { Button } from "../../shared/ui/Button";
|
|
import { Card } from "../../shared/ui/Card";
|
|
import { Modal } from "../../shared/ui/Modal";
|
|
import { useMessage } from "../../shared/ui/message";
|
|
import { TruncatedButton } from "../../shared/ui/TruncatedButton";
|
|
import { PluginAddPanel, PluginSettingsPanel } from "./PluginResourcePanels";
|
|
import styles from "./PluginManagementPage.module.scss";
|
|
|
|
export function PluginManagementPage() {
|
|
const { pluginRuntime, plugins } = useAppStore();
|
|
const [progressOpen, setProgressOpen] = useState(false);
|
|
const [starting, setStarting] = useState(false);
|
|
const [selected, setSelected] = useState<{ pluginId: string; mode: "add" | "settings" } | null>(null);
|
|
const cancelRequested = useRef(false);
|
|
const selectedPlugin = selected ? plugins.find((plugin) => plugin.id === selected.pluginId) ?? null : null;
|
|
|
|
useEffect(() => {
|
|
if (!pluginRuntime) void appStore.refreshPluginRuntime();
|
|
}, [pluginRuntime]);
|
|
|
|
useEffect(() => {
|
|
if (pluginRuntime?.state !== "initializing") return;
|
|
if (!cancelRequested.current) setProgressOpen(true);
|
|
const timer = window.setInterval(() => void appStore.refreshPluginRuntime(), 300);
|
|
return () => window.clearInterval(timer);
|
|
}, [pluginRuntime?.state]);
|
|
|
|
const initialize = async () => {
|
|
if (starting) return;
|
|
cancelRequested.current = false;
|
|
setStarting(true);
|
|
setProgressOpen(true);
|
|
const status = await appStore.initializePluginRuntime();
|
|
setStarting(false);
|
|
if (!status) {
|
|
setProgressOpen(false);
|
|
} else if (cancelRequested.current && status.state === "initializing") {
|
|
void appStore.cancelPluginRuntimeInitialization();
|
|
}
|
|
};
|
|
|
|
const closeProgress = () => {
|
|
setProgressOpen(false);
|
|
cancelRequested.current = true;
|
|
if (pluginRuntime?.state === "initializing") {
|
|
void appStore.cancelPluginRuntimeInitialization();
|
|
}
|
|
};
|
|
|
|
const content = pluginRuntime?.state === "ready"
|
|
? <PluginCards plugins={plugins} onOpen={(pluginId, mode) => setSelected({ pluginId, mode })} />
|
|
: <RuntimeGate status={pluginRuntime} starting={starting} onInitialize={() => void initialize()} />;
|
|
const estimatedHeight = plugins.length > 0
|
|
? Math.max(320, Math.ceil(plugins.length / 3) * 180)
|
|
: 320;
|
|
|
|
return <>
|
|
<PageContent
|
|
title={t("插件配置")}
|
|
sections={[{ key: "installed-plugins", estimatedHeight, content }]}
|
|
/>
|
|
<RuntimeProgressModal
|
|
open={progressOpen}
|
|
status={pluginRuntime}
|
|
starting={starting}
|
|
onClose={closeProgress}
|
|
/>
|
|
<Modal
|
|
fullHeight
|
|
open={selectedPlugin !== null}
|
|
title={selected?.mode === "settings"
|
|
? t("{name} 账号管理", { name: selectedPlugin?.name ?? "" })
|
|
: t("添加 {name} 账号", { name: selectedPlugin?.name ?? "" })}
|
|
onClose={() => setSelected(null)}
|
|
>
|
|
{selected?.mode === "add" && selectedPlugin && <PluginAddPanel plugin={selectedPlugin} onConfigured={() => setSelected(null)} />}
|
|
{selected?.mode === "settings" && selectedPlugin && <PluginSettingsPanel plugin={selectedPlugin} />}
|
|
</Modal>
|
|
</>;
|
|
}
|
|
|
|
function RuntimeGate({ status, starting, onInitialize }: { status: PluginRuntimeStatus | null; starting: boolean; onInitialize: () => void }) {
|
|
const checking = status === null;
|
|
const initializing = starting || status?.state === "initializing";
|
|
const failed = status?.state === "failed";
|
|
const unsupported = status?.state === "unsupported";
|
|
const title = checking
|
|
? t("正在检查插件运行时")
|
|
: failed
|
|
? t("插件运行时初始化失败")
|
|
: unsupported
|
|
? t("当前系统不支持插件运行时")
|
|
: t("需要先初始化插件运行时");
|
|
const description = failed
|
|
? t("请重试初始化")
|
|
: unsupported
|
|
? status.error ?? t("当前操作系统或 CPU 架构暂不受支持")
|
|
: t("初始化将下载并安装插件运行时。");
|
|
|
|
return <div className={styles.gate}>
|
|
<strong>{title}</strong>
|
|
<span>{description}</span>
|
|
{!unsupported && <Button variant="primary" disabled={checking || initializing} onClick={onInitialize}>
|
|
{checking ? t("检查中…") : initializing ? t("初始化中…") : failed ? t("重新初始化插件") : t("初始化插件")}
|
|
</Button>}
|
|
</div>;
|
|
}
|
|
|
|
function PluginCards({ plugins, onOpen }: {
|
|
plugins: PluginDescriptor[];
|
|
onOpen: (pluginId: string, mode: "add" | "settings") => void;
|
|
}) {
|
|
if (plugins.length === 0) {
|
|
return <div className={styles.empty}>
|
|
<strong>{t("还没有安装插件")}</strong>
|
|
<span>{t("安装插件后会显示在这里。")}</span>
|
|
</div>;
|
|
}
|
|
return <div className={styles.pluginGrid}>
|
|
{plugins.map((plugin) => <PluginCard key={plugin.id} plugin={plugin} onOpen={onOpen} />)}
|
|
</div>;
|
|
}
|
|
|
|
function PluginCard({ plugin, onOpen }: {
|
|
plugin: PluginDescriptor;
|
|
onOpen: (pluginId: string, mode: "add" | "settings") => void;
|
|
}) {
|
|
const { locale } = useI18n();
|
|
const { ports } = useAppStore();
|
|
const message = useMessage();
|
|
const importInput = useRef<HTMLInputElement>(null);
|
|
const [importing, setImporting] = useState(false);
|
|
const configured = plugin.providers.some((provider) => provider.configured);
|
|
const accountCount = plugin.resources.reduce((count, resource) => count + resource.resources.length, 0);
|
|
const modelCount = plugin.providers.reduce((count, provider) => count + provider.models.length, 0);
|
|
const subtitle = plugin.providers.map((provider) => pluginText(provider.displayName, locale)).join(" · ") || plugin.id;
|
|
const importResource = plugin.resources.find((resource) => resource.import);
|
|
const exportResource = plugin.resources.find((resource) => resource.resources.length > 0);
|
|
|
|
const importFiles = async (files: FileList | null) => {
|
|
if (!files?.length || !importResource) return;
|
|
setImporting(true);
|
|
try {
|
|
const entries: PluginImportFile[] = await Promise.all(
|
|
[...files].map(async (file) => ({ name: file.name, content: await file.text() })),
|
|
);
|
|
const result = await api.importPluginResources(plugin.id, importResource.type, entries);
|
|
await appStore.refreshPlugins();
|
|
const summary = t("导入完成:新增 {added},更新 {updated}", { added: result.added, updated: result.updated });
|
|
if (result.modelSyncError) {
|
|
message(t("账号已保存,但同步模型失败:{error}", { error: result.modelSyncError }), { duration: 5000 });
|
|
} else if (result.warnings.length > 0) {
|
|
message(`${summary} · ${result.warnings.join("; ")}`, { duration: 5000 });
|
|
} else {
|
|
message(summary);
|
|
}
|
|
} catch (cause) {
|
|
message(cause instanceof Error ? cause.message : String(cause), { duration: 5000 });
|
|
} finally {
|
|
setImporting(false);
|
|
if (importInput.current) importInput.current.value = "";
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Card className={styles.pluginCard}>
|
|
<div className={styles.pluginCardTop}>
|
|
<img className={styles.pluginIcon} src={plugin.icon} />
|
|
<div className={styles.pluginIdentity}>
|
|
<span className={styles.pluginName}>{plugin.name}</span>
|
|
<span className={styles.pluginId}>{subtitle}</span>
|
|
</div>
|
|
<span
|
|
className={`${styles.stateBadge} ${configured ? styles.stateReady : ""}`}
|
|
>
|
|
{configured ? t("已配置") : t("未配置")}
|
|
</span>
|
|
</div>
|
|
<div className={styles.pluginMeta}>
|
|
<span>
|
|
{t("{accounts} 个账号 · {models} 个模型", {
|
|
accounts: accountCount,
|
|
models: modelCount,
|
|
})}
|
|
</span>
|
|
<span className={styles.pluginAuthor}>
|
|
{[`v${plugin.version}`, plugin.author].filter(Boolean).join(" · ")}
|
|
</span>
|
|
</div>
|
|
<div className={styles.cardActions}>
|
|
<TruncatedButton
|
|
size="small"
|
|
variant="primary"
|
|
label={t("添加账号")}
|
|
onClick={() => onOpen(plugin.id, "add")}
|
|
/>
|
|
{configured && (
|
|
<TruncatedButton
|
|
size="small"
|
|
label={t("账号管理")}
|
|
onClick={() => onOpen(plugin.id, "settings")}
|
|
/>
|
|
)}
|
|
{(importResource || exportResource) && (
|
|
<span className={styles.moreAction}>
|
|
<ActionMenu
|
|
label={t("更多")}
|
|
items={[
|
|
...(importResource
|
|
? [
|
|
{
|
|
id: "import",
|
|
label: importing ? t("正在导入…") : t("批量导入"),
|
|
disabled: importing,
|
|
onSelect: () => importInput.current?.click(),
|
|
},
|
|
]
|
|
: []),
|
|
...(exportResource
|
|
? [
|
|
{
|
|
id: "export",
|
|
label: t("批量导出"),
|
|
onSelect: () =>
|
|
void api.openExternalUrl(
|
|
api.pluginResourceExportUrl(
|
|
ports.service_port,
|
|
plugin.id,
|
|
exportResource.type,
|
|
),
|
|
),
|
|
},
|
|
]
|
|
: []),
|
|
]}
|
|
/>
|
|
</span>
|
|
)}
|
|
{importResource && (
|
|
<input
|
|
ref={importInput}
|
|
type="file"
|
|
hidden
|
|
accept={importResource.import?.accept.join(",")}
|
|
multiple={importResource.import?.multiple ?? false}
|
|
onChange={(event) => void importFiles(event.target.files)}
|
|
/>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function RuntimeProgressModal({ open, status, starting, onClose }: { open: boolean; status: PluginRuntimeStatus | null; starting: boolean; onClose: () => void }) {
|
|
const initializing = starting || status?.state === "initializing";
|
|
const downloaded = status?.downloaded_bytes ?? 0;
|
|
const total = status?.total_bytes ?? null;
|
|
const percent = total && total > 0 ? Math.min(100, Math.round((downloaded / total) * 100)) : null;
|
|
const stage = status?.state === "ready"
|
|
? t("插件运行时初始化完成")
|
|
: status?.state === "failed"
|
|
? t("插件运行时初始化失败")
|
|
: phaseText(status?.phase ?? null);
|
|
|
|
return <Modal
|
|
open={open}
|
|
title={t("初始化插件运行时")}
|
|
closeLabel={status?.state === "ready" ? t("完成") : initializing ? t("取消") : t("关闭")}
|
|
onClose={onClose}
|
|
>
|
|
<div className={styles.progressContent} aria-live="polite">
|
|
<strong>{stage}</strong>
|
|
{status?.phase === "downloading" && <>
|
|
<div
|
|
className={styles.progressBar}
|
|
role="progressbar"
|
|
aria-label={t("下载进度")}
|
|
aria-valuemin={0}
|
|
aria-valuemax={100}
|
|
aria-valuenow={percent ?? undefined}
|
|
>
|
|
<div
|
|
className={styles.progressFill}
|
|
style={{ width: `${percent ?? 100}%` }}
|
|
/>
|
|
</div>
|
|
<span>
|
|
{total ? t("已下载 {downloaded} / {total}", { downloaded: formatBytes(downloaded), total: formatBytes(total) }) : t("已下载 {downloaded}", { downloaded: formatBytes(downloaded) })}
|
|
</span>
|
|
</>}
|
|
{status?.state === "failed" && <span className={styles.error}>{t("请重试初始化")}</span>}
|
|
{status?.state === "ready" && <span>{t("插件运行时 {version} 已安装,可以开始使用插件。", { version: status.version })}</span>}
|
|
</div>
|
|
</Modal>;
|
|
}
|
|
|
|
function phaseText(phase: PluginRuntimePhase | null) {
|
|
switch (phase) {
|
|
case "checking": return t("正在检查插件运行时");
|
|
case "downloading": return t("正在下载插件运行时");
|
|
case "verifying": return t("正在验证插件运行时下载文件");
|
|
case "installing": return t("正在安装插件运行时");
|
|
case "validating": return t("正在验证插件运行时");
|
|
default: return t("正在准备插件运行时");
|
|
}
|
|
}
|
|
|
|
function formatBytes(bytes: number) {
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
const units = ["KB", "MB", "GB"];
|
|
let value = bytes / 1024;
|
|
let unit = 0;
|
|
while (value >= 1024 && unit < units.length - 1) {
|
|
value /= 1024;
|
|
unit += 1;
|
|
}
|
|
return `${value < 10 ? value.toFixed(1) : value.toFixed(0)} ${units[unit]}`;
|
|
}
|