mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-10-04 02:52:55 +08:00
279 lines
10 KiB
TypeScript
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);
|
|
}
|