feat: plugin system

This commit is contained in:
leookun
2026-08-30 20:00:53 +08:00
parent 1609b57433
commit e6130e01a7
78 changed files with 9010 additions and 643 deletions
@@ -1,11 +1,11 @@
import type { IconifyIcon } from "@iconify/react/offline";
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import Sortable from "sortablejs";
import type { Model } from "../../shared/api";
import type { Model, PluginModelDescriptor } from "../../shared/api";
import { Button } from "../../shared/ui/Button";
import { Card } from "../../shared/ui/Card";
import { Icon } from "../../shared/ui/Icon";
import { claudeIcon, dragIcon, flatColorOrganizationIcon, openAiIcon } from "../../shared/ui/icons";
import { chevronDownIcon, chevronRightIcon, claudeIcon, dragIcon, flatColorOrganizationIcon, openAiIcon } from "../../shared/ui/icons";
import { CursorModelTestResult, type CursorModelTestState } from "./CursorModelTestResult";
import styles from "./CursorSettings.module.scss";
@@ -20,6 +20,7 @@ export type CursorModelGroup = {
type CursorModelCardsProps = {
models: Model[];
pluginModels: PluginModelDescriptor[];
grouping: CursorModelGrouping;
disabled: boolean;
testingModelHashes: Set<string>;
@@ -28,10 +29,12 @@ type CursorModelCardsProps = {
onEdit: (model: Model) => void;
onDuplicate: (model: Model) => void;
onDelete: (model: Model) => void;
onTestPluginModel: (model: PluginModelDescriptor) => void;
onPluginSettings: (model: PluginModelDescriptor) => void;
onReorder: (modelHashes: string[]) => void;
};
type ModelGridProps = Omit<CursorModelCardsProps, "grouping"> & {
type ModelGridProps = Omit<CursorModelCardsProps, "grouping" | "pluginModels" | "onTestPluginModel" | "onPluginSettings"> & {
sortable: boolean;
};
@@ -50,18 +53,126 @@ export function cursorModelGroups(models: Model[], grouping: Exclude<CursorModel
}
export function CursorModelCards(props: CursorModelCardsProps) {
if (props.grouping === "flat") return <div style={{ paddingTop: "10px" }}>
<ModelGrid {...props} sortable />
</div>;
const builtins = props.grouping === "flat"
? <div style={{ paddingTop: "10px" }}><ModelGrid {...props} sortable /></div>
: <div className={styles.modelGroups}>
{cursorModelGroups(props.models, props.grouping).map((group) => <CollapsibleGroup
key={group.key}
label={group.label}
icon={group.icon}
>
{group.models.map((model) => <ModelListRow
key={model.model_hash}
model={model}
disabled={props.disabled}
testing={props.testingModelHashes.has(model.model_hash)}
result={props.testResults.get(model.model_hash)}
onTest={() => props.onTest(model)}
onEdit={() => props.onEdit(model)}
onDuplicate={() => props.onDuplicate(model)}
onDelete={() => props.onDelete(model)}
/>)}
</CollapsibleGroup>)}
</div>;
return <div className={styles.modelGroups}>
{cursorModelGroups(props.models, props.grouping).map((group) => <section className={styles.modelGroup} key={group.key}>
<div className={styles.modelGroupHeader}>
<Icon icon={group.icon} size="1.1em" />
<span>{group.label}</span>
</div>
<ModelGrid {...props} models={group.models} sortable={false} />
</section>)}
{builtins}
{pluginGroups(props.pluginModels).map((group) => <CollapsibleGroup
key={group.pluginId}
label={group.pluginName}
iconSrc={group.icon}
>
{group.models.map((model) => <PluginModelRow
key={model.id}
model={model}
disabled={props.disabled}
testing={props.testingModelHashes.has(model.id)}
result={props.testResults.get(model.id)}
onTest={() => props.onTestPluginModel(model)}
onSettings={() => props.onPluginSettings(model)}
/>)}
</CollapsibleGroup>)}
</div>;
}
function pluginGroups(models: PluginModelDescriptor[]) {
const groups: { pluginId: string; pluginName: string; icon: string; models: PluginModelDescriptor[] }[] = [];
for (const model of models) {
let group = groups.find((candidate) => candidate.pluginId === model.pluginId);
if (!group) {
group = { pluginId: model.pluginId, pluginName: model.pluginName, icon: model.icon, models: [] };
groups.push(group);
}
group.models.push(model);
}
return groups;
}
function CollapsibleGroup({ label, icon, iconSrc, children }: {
label: string;
icon?: IconifyIcon;
iconSrc?: string;
children: ReactNode;
}) {
const [open, setOpen] = useState(true);
return <Card className={styles.groupCard}>
<button
type="button"
className={styles.groupToggle}
aria-expanded={open}
onClick={() => setOpen((current) => !current)}
>
{icon && <Icon icon={icon} size="1.1em" />}
{iconSrc && <Icon src={iconSrc} size="1.1em" />}
<span className={styles.groupLabel}>{label}</span>
<Icon icon={open ? chevronDownIcon : chevronRightIcon} size="1em" />
</button>
{open && <div className={styles.modelList}>{children}</div>}
</Card>;
}
function ModelListRow({ model, disabled, testing, result, onTest, onEdit, onDuplicate, onDelete }: {
model: Model;
disabled: boolean;
testing: boolean;
result: CursorModelTestState | undefined;
onTest: () => void;
onEdit: () => void;
onDuplicate: () => void;
onDelete: () => void;
}) {
return <div className={styles.modelRow}>
<div className={styles.modelRowName}>
<span className={styles.modelRowNameText}>{model.display_name}</span>
<span className={styles.modelRowModelId}>{model.model_id}</span>
</div>
<CursorModelTestResult compact state={result} testing={testing} />
<div className={styles.modelCardActions}>
<Button size="small" disabled={disabled && !testing} onClick={onTest}>{testing ? t("取消测试") : t("测试")}</Button>
<Button size="small" disabled={disabled} onClick={onEdit}>{t("编辑")}</Button>
<Button size="small" disabled={disabled} onClick={onDuplicate}>{t("复制")}</Button>
<Button size="small" className={styles.deleteButton} disabled={disabled} onClick={onDelete}>{t("删除")}</Button>
</div>
</div>;
}
function PluginModelRow({ model, disabled, testing, result, onTest, onSettings }: {
model: PluginModelDescriptor;
disabled: boolean;
testing: boolean;
result: CursorModelTestState | undefined;
onTest: () => void;
onSettings: () => void;
}) {
return <div className={styles.modelRow}>
<div className={styles.modelRowName}>
<span className={styles.modelRowNameText}>{model.displayName}</span>
<span className={styles.modelRowModelId}>{model.modelId}</span>
</div>
<CursorModelTestResult compact state={result} testing={testing} />
<div className={styles.modelCardActions}>
<Button size="small" disabled={disabled && !testing} onClick={onTest}>{testing ? t("取消测试") : t("测试")}</Button>
<Button size="small" disabled={disabled} onClick={onSettings}>{t("设置")}</Button>
</div>
</div>;
}