Files
cursor-byok/apps/desktop/src/features/plugins/PluginResourcePanels.tsx
T
2026-08-30 20:00:53 +08:00

279 lines
10 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from "react";
import {
api,
pluginText,
type PluginAddMethod,
type PluginDescriptor,
type PluginOAuthBegin,
type PluginProviderDescriptor,
type PluginResourceDescriptor,
type PluginResourceView,
} from "../../shared/api";
import { useI18n } from "../../i18n/store";
import { appStore } from "../../shared/store/appStore";
import { Button } from "../../shared/ui/Button";
import { Card } from "../../shared/ui/Card";
import { FormField, TextInput } from "../../shared/ui/FormControls";
import styles from "./PluginResourcePanels.module.scss";
const PAGE_SIZE = 10;
export function PluginAddPanel({ plugin, onConfigured }: { plugin: PluginDescriptor; onConfigured: () => void }) {
return <div className={styles.panel}>
{plugin.resources.map((resource) => <ResourceAddSection
key={resource.type}
plugin={plugin}
resource={resource}
onConfigured={onConfigured}
/>)}
{plugin.resources.length === 0 && <span className={styles.empty}>{t("该插件不需要添加资源")}</span>}
</div>;
}
function ResourceAddSection({ plugin, resource, onConfigured }: {
plugin: PluginDescriptor;
resource: PluginResourceDescriptor;
onConfigured: () => void;
}) {
return <>
{resource.add.map((method) => <OAuthMethodCard
key={method.id}
pluginId={plugin.id}
resourceType={resource.type}
method={method}
onConfigured={onConfigured}
/>)}
</>;
}
function OAuthMethodCard({ pluginId, resourceType, method, onConfigured }: {
pluginId: string;
resourceType: string;
method: PluginAddMethod;
onConfigured: () => void;
}) {
const { locale } = useI18n();
const [status, setStatus] = useState<"idle" | "starting" | "polling" | "success" | "error">("idle");
const [begun, setBegun] = useState<PluginOAuthBegin | null>(null);
const [error, setError] = useState<string | null>(null);
const stopped = useRef(false);
useEffect(() => () => { stopped.current = true; }, []);
useEffect(() => {
if (!begun || status !== "polling") return;
let timer = 0;
const poll = async (intervalMs: number) => {
if (stopped.current) return;
try {
const result = await api.pluginOAuthPoll(begun.sessionId);
if (stopped.current) return;
if (result.status === "pending") {
timer = window.setTimeout(() => void poll(result.pollIntervalMs), Math.max(1000, result.pollIntervalMs));
return;
}
if (result.status === "completed") {
await appStore.refreshPlugins();
if (result.modelSyncError) {
setStatus("error");
setError(t("账号已保存,但同步模型失败:{error}", { error: result.modelSyncError }));
return;
}
setStatus("success");
onConfigured();
return;
}
setStatus("error");
setError(result.message || t("授权被拒绝或已失败。"));
} catch (cause) {
if (stopped.current) return;
setError(errorText(cause));
timer = window.setTimeout(() => void poll(intervalMs), Math.max(1000, intervalMs));
}
};
timer = window.setTimeout(() => void poll(begun.pollIntervalMs), Math.max(1000, begun.pollIntervalMs));
return () => window.clearTimeout(timer);
}, [begun, onConfigured, status]);
const start = async () => {
setStatus("starting");
setError(null);
try {
const next = await api.pluginOAuthBegin(pluginId, resourceType, method.id);
setBegun(next);
setStatus("polling");
await api.copyCursorText(next.userCode).catch(() => undefined);
await api.openExternalUrl(next.verificationUrlComplete || next.verificationUrl);
} catch (cause) {
setStatus("error");
setError(errorText(cause));
}
};
return <Card className={styles.methodCard}>
<strong>{pluginText(method.displayName, locale)}</strong>
{method.description && <span>{pluginText(method.description, locale)}</span>}
{begun && status === "polling" && <div className={styles.deviceCode}>
<small>{t("设备验证码")}</small>
<button type="button" onClick={() => void api.copyCursorText(begun.userCode)}>{begun.userCode}</button>
</div>}
<div className={styles.actions}>
<Button variant="primary" disabled={status === "starting" || status === "polling"} onClick={() => void start()}>
{status === "starting" ? t("正在申请授权码…") : status === "polling" ? t("等待网页端确认授权中…") : t("开始登录")}
</Button>
{begun && status === "polling" && <Button onClick={() => void api.openExternalUrl(begun.verificationUrlComplete || begun.verificationUrl)}>{t("打开授权网页")}</Button>}
</div>
{status === "success" && <span className={styles.success}>{t("账号已保存,模型目录已同步。")}</span>}
{error && <span className={styles.error} role="alert">{error}</span>}
</Card>;
}
export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) {
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const run = async (key: string, task: () => Promise<void>) => {
setBusy(key);
setError(null);
try {
await task();
await appStore.refreshPlugins();
} catch (cause) {
setError(errorText(cause));
} finally {
setBusy(null);
}
};
return <div className={styles.panel}>
{plugin.providers.map((provider) => <ProviderRow
key={provider.id}
provider={provider}
busy={busy !== null}
syncing={busy === `sync:${provider.id}`}
onSync={() => void run(`sync:${provider.id}`, async () => {
await api.syncPluginModels(plugin.id, provider.id);
})}
/>)}
{plugin.resources.map((resource) => <ResourceList
key={resource.type}
resource={resource}
busy={busy !== null}
onRefresh={(item) => void run(`refresh:${item.id}`, async () => {
await api.refreshPluginResource(plugin.id, resource.type, item.id);
})}
onDelete={(item) => void run(`delete:${item.id}`, async () => {
await api.deletePluginResource(plugin.id, resource.type, item.id);
})}
/>)}
{error && <span className={styles.error} role="alert">{error}</span>}
</div>;
}
function ProviderRow({ provider, busy, syncing, onSync }: {
provider: PluginProviderDescriptor;
busy: boolean;
syncing: boolean;
onSync: () => void;
}) {
const { locale } = useI18n();
return <Card className={styles.providerRow}>
<div>
<strong>{pluginText(provider.displayName, locale)}</strong>
<span>
{provider.providerType}
{" · "}
{provider.models.length > 0 ? t("{count} 个模型", { count: provider.models.length }) : t("尚未同步模型")}
{" · "}
{provider.configured ? t("可调用") : t("未就绪")}
</span>
</div>
{provider.hasModels && <Button size="small" disabled={busy} onClick={onSync}>
{syncing ? t("正在同步…") : t("同步模型")}
</Button>}
</Card>;
}
function ResourceList({ resource, busy, onRefresh, onDelete }: {
resource: PluginResourceDescriptor;
busy: boolean;
onRefresh: (item: PluginResourceView) => void;
onDelete: (item: PluginResourceView) => void;
}) {
const { locale } = useI18n();
const [query, setQuery] = useState("");
const [page, setPage] = useState(1);
const filtered = useMemo(
() => resource.resources.filter((item) => item.displayName.toLowerCase().includes(query.trim().toLowerCase())),
[resource.resources, query],
);
const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const visible = filtered.slice((Math.min(page, pageCount) - 1) * PAGE_SIZE, Math.min(page, pageCount) * PAGE_SIZE);
useEffect(() => setPage(1), [query]);
return <FormField label={pluginText(resource.displayName, locale)}>
<div className={styles.resourceSection}>
{resource.resources.length > PAGE_SIZE && <div className={styles.toolbar}>
<TextInput aria-label={t("搜索资源")} placeholder={t("搜索资源")} value={query} onChange={(event) => setQuery(event.target.value)} />
</div>}
<div className={styles.resourceList}>
{visible.map((item) => <ResourceRow
key={item.id}
item={item}
canRefresh={resource.canRefresh}
disabled={busy}
onRefresh={() => onRefresh(item)}
onDelete={() => onDelete(item)}
/>)}
{visible.length === 0 && <span className={styles.empty}>{t("还没有资源,请先添加。")}</span>}
</div>
{pageCount > 1 && <div className={styles.pagination}>
<Button size="small" disabled={page <= 1} onClick={() => setPage((current) => current - 1)}>{t("上一页")}</Button>
<span>{t("第 {page} / {total} 页", { page: Math.min(page, pageCount), total: pageCount })}</span>
<Button size="small" disabled={page >= pageCount} onClick={() => setPage((current) => current + 1)}>{t("下一页")}</Button>
</div>}
</div>
</FormField>;
}
function ResourceRow({ item, canRefresh, disabled, onRefresh, onDelete }: {
item: PluginResourceView;
canRefresh: boolean;
disabled: boolean;
onRefresh: () => void;
onDelete: () => void;
}) {
const { locale } = useI18n();
return <Card className={styles.resourceRow}>
<div>
<strong>{item.displayName}</strong>
{item.description && <span>{pluginText(item.description, locale)}</span>}
{item.metrics.map((metric) => <span key={metric.id}>
{metric.unit === "percent"
? t("{label} 剩余 {percent}%", { label: pluginText(metric.label, locale), percent: Math.round(metric.value) })
: `${pluginText(metric.label, locale)}: ${metric.value}`}
</span>)}
</div>
<div className={styles.actions}>
<StateBadge state={item.state} />
{canRefresh && <Button size="small" disabled={disabled} onClick={onRefresh}>{t("刷新")}</Button>}
<Button size="small" disabled={disabled} onClick={onDelete}>{t("删除")}</Button>
</div>
</Card>;
}
function StateBadge({ state }: { state: PluginResourceView["state"] }) {
if (state.status === "cooling") {
return <span className={styles.cooling} title={state.message ?? undefined}>{t("冷却中")}</span>;
}
if (state.status === "invalid") {
return <span className={styles.invalid} title={state.message ?? undefined}>{t("已失效")}</span>;
}
return <span className={styles.ready}>{t("可用")}</span>;
}
function errorText(cause: unknown) {
return cause instanceof Error ? cause.message : String(cause);
}