feat(*): 插件中心与模型协议 - 完善声明式协议运行时并增加功能开放控制
This commit is contained in:
1 parent
70a6640ecf
commit
af41893678
106 files changed
+8816
-499
No files matched your search
@@ -37,5 +37,5 @@ export function EmailSettingsPage() {
|
||||
}
|
||||
|
||||
export function FeatureAvailabilityPage() {
|
||||
return <AdminPageFrame title="功能开放" description="控制用户工作台入口、渠道与计费模式" scroll><Suspense fallback={<PageFallback label="功能开放配置" />}><FeatureAvailabilityPanel /></Suspense></AdminPageFrame>;
|
||||
return <AdminPageFrame title="功能开放" description="控制用户工作台入口、渠道、插件与计费模式" scroll><Suspense fallback={<PageFallback label="功能开放配置" />}><FeatureAvailabilityPanel /></Suspense></AdminPageFrame>;
|
||||
}
|
||||
@@ -8,7 +8,8 @@ import { ModelIcon } from "@/components/model-picker";
|
||||
import { ModelCapabilityEditor } from "@/components/model-capability-editor";
|
||||
import { CapabilityCardPicker, ProtocolCardPicker, type ModelCapabilityChoice } from "@/components/model-protocol-picker";
|
||||
import { defaultModelCapabilityConfig, normalizeModelCapabilityConfig, type ModelCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { MODEL_PROTOCOLS, modelProtocolCapability, modelProtocolDefinition, modelProtocolLabel, modelProtocolSupportsTokenBilling, type ModelProtocol } from "@/lib/model-protocols";
|
||||
import { modelProtocolCapability, modelProtocolDefinition, modelProtocolLabel, modelProtocolSupportsTokenBilling, type ModelProtocol } from "@/lib/model-protocols";
|
||||
import { fetchProtocolCatalog } from "@/services/api/protocols";
|
||||
import { createAdminChannelModel, deleteAdminChannelModel, fetchAdminChannelModels, listAdminChannelModels, testAdminChannelModel, updateAdminChannelModel, type ChannelModel, type ChannelModelPriceTier } from "@/services/api/wallet";
|
||||
import type { ModelChannel } from "@/stores/use-config-store";
|
||||
import { AdminPageFrame } from "./admin-shell";
|
||||
@@ -21,7 +22,7 @@ type FormValues = {
|
||||
providerModelKey?: string;
|
||||
displayName?: string;
|
||||
capability: EditableCapability;
|
||||
protocol: ModelProtocol;
|
||||
protocol?: ModelProtocol;
|
||||
priceTiers: PriceTierFormValues[];
|
||||
enabled: boolean;
|
||||
capabilityConfig?: ModelCapabilityConfig;
|
||||
@@ -58,6 +59,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
const [status, setStatus] = useState<"all" | "enabled" | "disabled">("all");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [availableProtocols, setAvailableProtocols] = useState<import("@/lib/model-protocols").ModelProtocolDefinition[]>([]);
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
const modelCapability = Form.useWatch("capability", form);
|
||||
const modelProtocol = Form.useWatch("protocol", form);
|
||||
@@ -79,6 +81,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
void fetchProtocolCatalog("admin.system-channel").then(setAvailableProtocols).catch(() => setAvailableProtocols([]));
|
||||
setEditing(null);
|
||||
setEditorOpen(false);
|
||||
setKeyword("");
|
||||
@@ -111,10 +114,10 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
providerModelKey: "",
|
||||
displayName: "",
|
||||
capability: "text",
|
||||
protocol: "chat-completion",
|
||||
protocol: availableProtocols.find((item) => item.capability === "text")?.value,
|
||||
priceTiers: [defaultPriceTier()],
|
||||
enabled: true,
|
||||
capabilityConfig: defaultModelCapabilityConfig("chat-completion", ""),
|
||||
capabilityConfig: defaultModelCapabilityConfig(availableProtocols.find((item) => item.capability === "text")?.value, ""),
|
||||
});
|
||||
setEditorOpen(true);
|
||||
};
|
||||
@@ -220,8 +223,8 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
}
|
||||
if (!changed.capability) return;
|
||||
const current = form.getFieldValue("protocol") as ModelProtocol | undefined;
|
||||
if (modelProtocolCapability(current) !== changed.capability) {
|
||||
const nextProtocol = MODEL_PROTOCOLS.find((item) => item.capability === changed.capability)?.value;
|
||||
if (modelProtocolCapability(current, availableProtocols) !== changed.capability) {
|
||||
const nextProtocol = availableProtocols.find((item) => item.capability === changed.capability)?.value;
|
||||
form.setFieldValue("protocol", nextProtocol);
|
||||
form.setFieldValue("capabilityConfig", changed.capability === "text" || changed.capability === "image" || changed.capability === "video" ? defaultModelCapabilityConfig(nextProtocol, form.getFieldValue("modelKey")) : undefined);
|
||||
}
|
||||
@@ -262,8 +265,8 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
render: (value: ModelProtocol) =>
|
||||
value ? (
|
||||
<div>
|
||||
<div className="text-xs font-medium">{modelProtocolLabel(value)}</div>
|
||||
<div className="truncate text-[var(--fs-tiny)] text-foreground/45">{modelProtocolDefinition(value)?.create}</div>
|
||||
<div className="text-xs font-medium">{modelProtocolLabel(value, availableProtocols)}</div>
|
||||
<div className="truncate text-[var(--fs-tiny)] text-foreground/45">{modelProtocolDefinition(value, availableProtocols)?.create}</div>
|
||||
</div>
|
||||
) : (
|
||||
<AdminStatusBadge label="待配置" tone="warning" />
|
||||
@@ -423,9 +426,9 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
<Form.Item className="mb-0" name="capability" label="模型能力" rules={[{ required: true }]}>
|
||||
<CapabilityCardPicker density="compact" />
|
||||
</Form.Item>
|
||||
<Form.Item className="mb-0" name="protocol" label="请求协议" rules={[{ required: true, message: "请选择模型请求协议" }]}>
|
||||
<ProtocolCardPicker capability={modelCapability} density="compact" />
|
||||
</Form.Item>
|
||||
{availableProtocols.length ? <Form.Item className="mb-0" name="protocol" label="请求协议" rules={[{ required: true, message: "请选择模型请求协议" }]}>
|
||||
<ProtocolCardPicker capability={modelCapability} density="compact" protocols={availableProtocols} />
|
||||
</Form.Item> : null}
|
||||
</div>
|
||||
{modelCapability === "text" || modelCapability === "image" || modelCapability === "video" ? (
|
||||
<Form.Item name="capabilityConfig" rules={[{ required: true, message: `请配置${capabilityLabel(modelCapability)}能力参数` }]}>
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { App, Switch } from "antd";
|
||||
import { Clapperboard, Coins, ListChecks, RadioTower, ToggleLeft, Sparkles, Settings } from "lucide-react";
|
||||
import { Clapperboard, Coins, ListChecks, PlugZap, RadioTower, ToggleLeft, Sparkles, Settings } from "lucide-react";
|
||||
|
||||
import { getAdminFeatureAvailability, updateAdminFeatureAvailability } from "@/services/api/auth";
|
||||
import { useUserStore, type FeatureAvailability } from "@/stores/use-user-store";
|
||||
import { SettingsSectionCard } from "./admin-ui";
|
||||
|
||||
type FeatureKey = "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled" | "customChannelsEnabled" | "frontendModelsEnabled";
|
||||
type FeatureKey = "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled" | "customChannelsEnabled" | "frontendModelsEnabled" | "pluginCenterEnabled" | "systemPluginsVisibleToUsers";
|
||||
|
||||
const userFeatureRows: Array<{ key: FeatureKey; title: string; menu: string; description: string; icon: ReactNode }> = [
|
||||
{ key: "shortDramaEnabled", title: "短剧创作", menu: "/projects", description: "关闭后隐藏短剧入口,并拦截项目列表、详情和项目 API。已有项目数据不会删除。", icon: <Clapperboard className="size-4" /> },
|
||||
{ key: "taskCenterEnabled", title: "任务", menu: "/tasks", description: "关闭后仅隐藏并拦截任务中心页面;生成任务仍会创建、执行、记录和恢复。", icon: <ListChecks className="size-4" /> },
|
||||
{ key: "creditsEnabled", title: "积分中心", menu: "/wallet", description: "关闭后隐藏用户积分入口,新创建的任务和系统渠道请求不再冻结或消费积分。", icon: <Coins className="size-4" /> },
|
||||
{ key: "customChannelsEnabled", title: "自定义渠道", menu: "/settings?section=channels", description: "关闭后隐藏用户自定义渠道入口,并拦截模型目录拉取、渠道中转和新的生成任务。已有渠道配置不会删除。", icon: <RadioTower className="size-4" /> },
|
||||
{ key: "pluginCenterEnabled", title: "插件中心", menu: "/plugins", description: "关闭后普通用户无法进入插件中心或调用插件中心相关接口;管理员仍可进入后台恢复开关。已有插件不会删除。", icon: <PlugZap className="size-4" /> },
|
||||
{ key: "systemPluginsVisibleToUsers", title: "向普通用户显示系统插件(不可编辑)", menu: "/plugins", description: "仅在插件中心开启时生效。关闭后普通用户看不到系统插件,用户插件不受影响。", icon: <PlugZap className="size-4" /> },
|
||||
];
|
||||
|
||||
const adminFeatureRows: Array<{ key: FeatureKey; title: string; menu: string; description: string; icon: ReactNode }> = [
|
||||
@@ -48,6 +50,8 @@ export default function FeatureAvailabilityPanel() {
|
||||
creditsEnabled: next.creditsEnabled,
|
||||
customChannelsEnabled: next.customChannelsEnabled,
|
||||
frontendModelsEnabled: next.frontendModelsEnabled,
|
||||
pluginCenterEnabled: next.pluginCenterEnabled,
|
||||
systemPluginsVisibleToUsers: next.systemPluginsVisibleToUsers,
|
||||
});
|
||||
setFeatures(result.features);
|
||||
setGlobalFeatures(result.features);
|
||||
@@ -108,7 +112,7 @@ export default function FeatureAvailabilityPanel() {
|
||||
<span className="rounded border border-border/70 px-1.5 py-0.5 text-[var(--fs-micro)] text-foreground/45">{item.menu}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={features?.[item.key] === true} loading={!features || saving === item.key} disabled={Boolean(saving && saving !== item.key)} onChange={(checked) => toggle(item.key, checked)} aria-label={`开放${item.title}`} />
|
||||
<Switch checked={features?.[item.key] === true} loading={!features || saving === item.key} disabled={Boolean(saving && saving !== item.key) || (item.key === "systemPluginsVisibleToUsers" && features?.pluginCenterEnabled !== true)} onChange={(checked) => toggle(item.key, checked)} aria-label={`开放${item.title}`} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,7 @@ import { buildImageResolutionOptions, formatImageResolutionSize, imageRatioForSi
|
||||
import { VIDEO_RESOLUTION_OPTIONS } from "@/lib/video-generation-options";
|
||||
import { modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, videoDurationAllowed, videoDurationOptions, type ImageCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { resolveCompatibleModel, mergedImageCapabilityConfig, type ModelRequirements } from "@/lib/model-selection";
|
||||
import { isGenerationTaskCancelled, logicalModelIDForConfig, runBackendGenerationTask, runBackendGenerationTaskBatch, type BackendGenerationResult } from "@/services/api/generation-task";
|
||||
import { backendModelRuntimeRequired, isGenerationTaskCancelled, runBackendGenerationTask, runBackendGenerationTaskBatch, type BackendGenerationResult } from "@/services/api/generation-task";
|
||||
import { requestImageQuestion, type AiTextContentPart } from "@/services/api/image";
|
||||
import { listAddedSkills, type Skill } from "@/services/api/skills";
|
||||
import { subscribeGenerationTasks, type GenerationTask } from "@/services/api/task-center";
|
||||
@@ -538,7 +538,7 @@ export default function CreatePage() {
|
||||
};
|
||||
try {
|
||||
if (mode === "text") {
|
||||
if (logicalModelIDForConfig(requestConfig)) {
|
||||
if (backendModelRuntimeRequired(requestConfig)) {
|
||||
const result = await runGenerationOperationOnce(retryContext?.clientOperationId, () => runBackendGenerationTask({
|
||||
mode: "text",
|
||||
prompt: expandedPrompt,
|
||||
|
||||
+450
-176
@@ -1,14 +1,18 @@
|
||||
import { App, Button, Input, Modal, Select, Switch, Typography } from "antd";
|
||||
import { CalendarDays, CheckCircle2, Clock3, ExternalLink, FolderOpen, PlugZap, Search, Settings2, ShieldCheck, SlidersHorizontal } from "lucide-react";
|
||||
import { AudioLines, CalendarDays, CheckCircle2, Clock3, CloudUpload, ExternalLink, Film, FolderOpen, Image as ImageIcon, MessageSquareText, PlugZap, RefreshCw, Search, Settings2, ShieldCheck, SlidersHorizontal } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
import { listRegisteredPlugins } from "@/lib/plugins/plugin-registry";
|
||||
import "@/lib/plugins/builtin";
|
||||
import { EAGLE_PLUGIN_ID } from "@/lib/plugins/builtin/eagle";
|
||||
import type { PluginManifest, RegisteredPlugin } from "@/lib/plugins/plugin-types";
|
||||
import { getEagleLibrary, type EagleFolder } from "@/services/api/eagle";
|
||||
import { fetchPlugins, setPluginEnabled, uploadPlugin, type BackendPlugin } from "@/services/api/plugins";
|
||||
import { usePluginStore } from "@/stores/use-plugin-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
|
||||
import { PluginDetailsModal, UploadPluginModal } from "./plugin-documentation-modals";
|
||||
import "./plugins.css";
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
@@ -18,6 +22,7 @@ const categoryLabels: Record<string, string> = {
|
||||
"ai-capability": "AI 能力",
|
||||
"import-export": "导入导出",
|
||||
agent: "智能体",
|
||||
protocol: "请求协议",
|
||||
};
|
||||
|
||||
const surfaceLabels: Record<string, string> = {
|
||||
@@ -40,15 +45,29 @@ const permissionLabels: Record<string, string> = {
|
||||
|
||||
const pluginDateFormatter = new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "short", day: "numeric" });
|
||||
|
||||
const protocolSectionMeta = [
|
||||
{ key: "text", label: "文本协议", description: "对话、推理与流式响应", icon: MessageSquareText },
|
||||
{ key: "image", label: "图片协议", description: "生成、编辑与参考图", icon: ImageIcon },
|
||||
{ key: "video", label: "视频协议", description: "创建任务、轮询与媒体结果", icon: Film },
|
||||
{ key: "audio", label: "音频协议", description: "语音与异步音频任务", icon: AudioLines },
|
||||
] as const;
|
||||
|
||||
export default function PluginsPage() {
|
||||
const { message } = App.useApp();
|
||||
const navigate = useNavigate();
|
||||
const user = useUserStore((state) => state.user);
|
||||
const features = useUserStore((state) => state.features);
|
||||
const installations = usePluginStore((state) => state.installations);
|
||||
const ensurePlugin = usePluginStore((state) => state.ensurePlugin);
|
||||
const setEnabled = usePluginStore((state) => state.setEnabled);
|
||||
const updateConfig = usePluginStore((state) => state.updateConfig);
|
||||
const registeredPlugins = useMemo(() => listRegisteredPlugins(), []);
|
||||
const builtinPlugins = useMemo(() => listRegisteredPlugins(), []);
|
||||
const [backendPlugins, setBackendPlugins] = useState<BackendPlugin[]>([]);
|
||||
const [backendPluginsLoading, setBackendPluginsLoading] = useState(false);
|
||||
const [settingsPluginId, setSettingsPluginId] = useState<string | null>(null);
|
||||
const [detailsPluginId, setDetailsPluginId] = useState<string | null>(null);
|
||||
const [detailsRestoreFocus, setDetailsRestoreFocus] = useState(false);
|
||||
const [uploadModalOpen, setUploadModalOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [categoryFilter, setCategoryFilter] = useState("all");
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | "enabled" | "disabled">("all");
|
||||
@@ -61,8 +80,28 @@ export default function PluginsPage() {
|
||||
const [eagleFoldersError, setEagleFoldersError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
for (const plugin of registeredPlugins) ensurePlugin(plugin.manifest);
|
||||
}, [ensurePlugin, registeredPlugins]);
|
||||
for (const plugin of builtinPlugins) ensurePlugin(plugin.manifest);
|
||||
}, [builtinPlugins, ensurePlugin]);
|
||||
|
||||
const reloadBackendPlugins = async () => {
|
||||
setBackendPluginsLoading(true);
|
||||
try {
|
||||
setBackendPlugins(await fetchPlugins());
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "读取插件中心失败");
|
||||
setBackendPlugins([]);
|
||||
} finally {
|
||||
setBackendPluginsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void reloadBackendPlugins();
|
||||
}, []);
|
||||
|
||||
const remotePlugins = useMemo(() => backendPlugins.map(toRegisteredPlugin), [backendPlugins]);
|
||||
const registeredPlugins = useMemo(() => [...builtinPlugins, ...remotePlugins], [builtinPlugins, remotePlugins]);
|
||||
const backendPluginById = useMemo(() => new Map(backendPlugins.map((plugin) => [plugin.manifest.id, plugin])), [backendPlugins]);
|
||||
|
||||
const eagle = installations.find((item) => item.manifest.id === EAGLE_PLUGIN_ID);
|
||||
|
||||
@@ -75,29 +114,86 @@ export default function PluginsPage() {
|
||||
setEagleGeneratedFolderId(typeof folderId === "string" ? folderId : "");
|
||||
}, [eagle?.config.baseUrl, eagle?.config.autoUploadGenerated, eagle?.config.generatedFolderId]);
|
||||
|
||||
const categoryOptions = useMemo(() => [
|
||||
{ value: "all", label: "全部分类" },
|
||||
...Array.from(new Set(registeredPlugins.map((plugin) => plugin.manifest.category))).map((category) => ({ value: category, label: categoryLabels[category] ?? category })),
|
||||
], [registeredPlugins]);
|
||||
|
||||
const filteredPlugins = useMemo(() => {
|
||||
const normalizedSearch = search.trim().toLocaleLowerCase();
|
||||
return registeredPlugins.filter((plugin) => {
|
||||
const isSystemPlugin = plugin.source !== "uploaded";
|
||||
if (user?.role !== "admin" && !features.systemPluginsVisibleToUsers && isSystemPlugin) return false;
|
||||
const installation = installations.find((item) => item.manifest.id === plugin.manifest.id);
|
||||
const enabled = plugin.manifest.kind === "protocol" ? backendPluginById.get(plugin.manifest.id)?.status === "enabled" : Boolean(installation?.enabled);
|
||||
const manifest = plugin.manifest;
|
||||
const searchableText = [manifest.name, manifest.description, manifest.author, manifest.id, categoryLabels[manifest.category]].filter(Boolean).join(" ").toLocaleLowerCase();
|
||||
if (normalizedSearch && !searchableText.includes(normalizedSearch)) return false;
|
||||
if (categoryFilter !== "all" && manifest.category !== categoryFilter) return false;
|
||||
if (categoryFilter !== "all") {
|
||||
const matchesCapability = manifest.kind === "protocol" && manifest.protocol?.categories.includes(categoryFilter as "text" | "image" | "video" | "audio");
|
||||
const matchesApp = categoryFilter === "other" && manifest.kind !== "protocol";
|
||||
if (!matchesCapability && !matchesApp) return false;
|
||||
}
|
||||
if (trustFilter === "trusted" && !manifest.trusted) return false;
|
||||
if (statusFilter === "enabled" && !installation?.enabled) return false;
|
||||
if (statusFilter === "disabled" && installation?.enabled) return false;
|
||||
if (statusFilter === "enabled" && !enabled) return false;
|
||||
if (statusFilter === "disabled" && enabled) return false;
|
||||
return true;
|
||||
});
|
||||
}, [categoryFilter, installations, registeredPlugins, search, statusFilter, trustFilter]);
|
||||
}, [backendPluginById, categoryFilter, features.systemPluginsVisibleToUsers, installations, registeredPlugins, search, statusFilter, trustFilter, user?.role]);
|
||||
|
||||
const pluginSections = useMemo(
|
||||
() => [
|
||||
...protocolSectionMeta.map((section) => ({ ...section, plugins: filteredPlugins.filter((plugin) => plugin.manifest.kind === "protocol" && plugin.manifest.protocol?.categories.includes(section.key)) })),
|
||||
{ key: "other", label: "应用插件", description: "画布、素材与工作流扩展", icon: PlugZap, plugins: filteredPlugins.filter((plugin) => plugin.manifest.kind !== "protocol") },
|
||||
],
|
||||
[filteredPlugins],
|
||||
);
|
||||
|
||||
const categoryCounts = useMemo(() => {
|
||||
const visiblePlugins = registeredPlugins.filter((plugin) => user?.role === "admin" || features.systemPluginsVisibleToUsers || plugin.source === "uploaded");
|
||||
const counts: Record<string, number> = { all: visiblePlugins.length, text: 0, image: 0, video: 0, audio: 0, other: 0 };
|
||||
for (const plugin of visiblePlugins) {
|
||||
if (plugin.manifest.kind !== "protocol") {
|
||||
counts.other += 1;
|
||||
continue;
|
||||
}
|
||||
for (const capability of plugin.manifest.protocol?.categories || []) {
|
||||
counts[capability] = (counts[capability] || 0) + 1;
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}, [features.systemPluginsVisibleToUsers, registeredPlugins, user?.role]);
|
||||
|
||||
const settingsPlugin = settingsPluginId ? registeredPlugins.find((plugin) => plugin.manifest.id === settingsPluginId) : undefined;
|
||||
const settingsInstallation = settingsPlugin ? installations.find((item) => item.manifest.id === settingsPlugin.manifest.id) : undefined;
|
||||
const settingsEnabled = Boolean(settingsInstallation?.enabled);
|
||||
const settingsEnabled = settingsPlugin?.manifest.kind === "protocol" ? backendPluginById.get(settingsPlugin.manifest.id)?.status === "enabled" : Boolean(settingsInstallation?.enabled);
|
||||
const detailsPlugin = detailsPluginId ? registeredPlugins.find((plugin) => plugin.manifest.id === detailsPluginId) : undefined;
|
||||
|
||||
const hasPluginConfiguration = (plugin: RegisteredPlugin) => Boolean(plugin.manifest.configuration?.fields.length);
|
||||
const canConfigurePlugin = (plugin: RegisteredPlugin) => hasPluginConfiguration(plugin) && user?.role === "admin";
|
||||
|
||||
const isPluginEnabled = (plugin: RegisteredPlugin, installation = installations.find((item) => item.manifest.id === plugin.manifest.id)) =>
|
||||
plugin.manifest.kind === "protocol" ? backendPluginById.get(plugin.manifest.id)?.status === "enabled" : Boolean(installation?.enabled);
|
||||
|
||||
const togglePlugin = async (plugin: RegisteredPlugin, enabled: boolean) => {
|
||||
if (plugin.manifest.kind !== "protocol") {
|
||||
setEnabled(plugin.manifest.id, enabled);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const next = await setPluginEnabled(plugin.manifest.id, enabled);
|
||||
setBackendPlugins((items) => items.map((item) => (item.manifest.id === next.manifest.id ? next : item)));
|
||||
message.success(`${plugin.manifest.name}${enabled ? "已启用" : "已停用"}`);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "更新插件状态失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
try {
|
||||
const plugin = await uploadPlugin(file);
|
||||
setBackendPlugins((items) => [...items.filter((item) => item.manifest.id !== plugin.manifest.id), plugin]);
|
||||
setUploadModalOpen(false);
|
||||
message.success("插件已安装并立即生效");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "安装插件失败");
|
||||
}
|
||||
};
|
||||
|
||||
const loadEagleFolders = async (url = eagleBaseUrl) => {
|
||||
setEagleFoldersLoading(true);
|
||||
@@ -126,173 +222,329 @@ export default function PluginsPage() {
|
||||
return (
|
||||
<main className="app-workspace-page plugins-page flex h-full min-h-0 flex-col text-foreground">
|
||||
<div className="app-workspace-scroll min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="plugins-page-content">
|
||||
<div className="plugins-toolbar" aria-label="插件筛选">
|
||||
<Input
|
||||
className="plugins-search"
|
||||
prefix={<Search className="size-4 text-foreground/38" aria-hidden="true" />}
|
||||
value={search}
|
||||
allowClear
|
||||
placeholder="搜索插件名称、描述或作者"
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
<Select className="plugins-filter" value={categoryFilter} options={categoryOptions} onChange={setCategoryFilter} aria-label="按分类筛选" />
|
||||
<Select
|
||||
className="plugins-filter"
|
||||
value={statusFilter}
|
||||
options={[{ value: "all", label: "全部状态" }, { value: "enabled", label: "已启用" }, { value: "disabled", label: "未启用" }]}
|
||||
onChange={(value) => setStatusFilter(value as "all" | "enabled" | "disabled")}
|
||||
aria-label="按状态筛选"
|
||||
/>
|
||||
<Select
|
||||
className="plugins-filter"
|
||||
value={trustFilter}
|
||||
options={[{ value: "all", label: "全部来源" }, { value: "trusted", label: "可信插件" }]}
|
||||
onChange={(value) => setTrustFilter(value as "all" | "trusted")}
|
||||
aria-label="按来源筛选"
|
||||
/>
|
||||
<span className="plugins-filter-icon" aria-hidden="true"><SlidersHorizontal className="size-4" /></span>
|
||||
</div>
|
||||
|
||||
{filteredPlugins.length ? (
|
||||
<div className="plugins-grid">
|
||||
{filteredPlugins.map((plugin) => {
|
||||
const installation = installations.find((item) => item.manifest.id === plugin.manifest.id);
|
||||
const enabled = Boolean(installation?.enabled);
|
||||
const trusted = Boolean(plugin.manifest.trusted);
|
||||
<div className="plugins-page-layout">
|
||||
<aside className="plugins-sidebar" aria-label="插件分类">
|
||||
<div className="plugins-sidebar-heading">
|
||||
<span className="plugins-sidebar-kicker">PLUGIN CENTER</span>
|
||||
<h1>插件中心</h1>
|
||||
<p>统一管理能力插件与模型请求协议。</p>
|
||||
</div>
|
||||
<nav className="plugins-sidebar-nav">
|
||||
<button type="button" className={`plugins-sidebar-item${categoryFilter === "all" ? " is-active" : ""}`} aria-current={categoryFilter === "all" ? "page" : undefined} onClick={() => setCategoryFilter("all")}>
|
||||
<span className="plugins-sidebar-item-label">
|
||||
<PlugZap className="size-4" />
|
||||
全部插件
|
||||
</span>
|
||||
<span>{categoryCounts.all}</span>
|
||||
</button>
|
||||
{protocolSectionMeta.map((section) => {
|
||||
const SectionIcon = section.icon;
|
||||
return (
|
||||
<section key={plugin.manifest.id} className={`plugin-card library-card-surface${trusted ? " is-trusted" : ""}`}>
|
||||
<div className="plugin-card-main">
|
||||
<div className="plugin-card-heading">
|
||||
<span className={`plugin-icon-tile${trusted ? " is-trusted" : ""}`} aria-hidden="true">
|
||||
<PlugZap className="size-5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="plugin-card-title-row">
|
||||
<h3>{plugin.manifest.name}</h3>
|
||||
<span className="plugin-version">v{plugin.manifest.version}</span>
|
||||
</div>
|
||||
<div className="plugin-card-labels">
|
||||
{trusted ? <span className="plugin-trust-label"><ShieldCheck className="size-3.5" />可信插件</span> : null}
|
||||
<span className="plugin-category-label">{categoryLabels[plugin.manifest.category] ?? plugin.manifest.category}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="plugin-card-description">{plugin.manifest.description}</p>
|
||||
|
||||
<div className="plugin-card-meta">
|
||||
<span><CalendarDays className="size-3.5" />发布 {formatPluginDate(plugin.manifest.publishedAt)}</span>
|
||||
<span><Clock3 className="size-3.5" />更新 {formatPluginDate(plugin.manifest.updatedAt)}</span>
|
||||
</div>
|
||||
|
||||
<div className="plugin-card-tags">
|
||||
{plugin.manifest.surfaces.map((surface) => <span key={surface}>{surfaceLabels[surface] ?? surface}</span>)}
|
||||
<span>{plugin.manifest.permissions.length} 项能力</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="plugin-card-actions">
|
||||
<span role="status" className={`settings-channel-status ${enabled ? "is-ready" : ""}`}>
|
||||
<i aria-hidden="true" />
|
||||
{enabled ? "已启用" : "未启用"}
|
||||
</span>
|
||||
<Switch checked={enabled} aria-label={`${plugin.manifest.name}${enabled ? "停用" : "启用"}`} onChange={(checked) => setEnabled(plugin.manifest.id, checked)} />
|
||||
<Button
|
||||
className="plugin-settings-button"
|
||||
icon={<Settings2 className="size-4" />}
|
||||
aria-expanded={settingsPluginId === plugin.manifest.id}
|
||||
aria-haspopup="dialog"
|
||||
onClick={() => setSettingsPluginId(plugin.manifest.id)}
|
||||
>
|
||||
设置
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{installation?.lastError ? <Typography.Text type="danger" className="plugin-error" role="alert">{installation.lastError}</Typography.Text> : null}
|
||||
</section>
|
||||
<button
|
||||
key={section.key}
|
||||
type="button"
|
||||
className={`plugins-sidebar-item${categoryFilter === section.key ? " is-active" : ""}`}
|
||||
aria-current={categoryFilter === section.key ? "page" : undefined}
|
||||
onClick={() => setCategoryFilter(section.key)}
|
||||
>
|
||||
<span className="plugins-sidebar-item-label">
|
||||
<SectionIcon className="size-4" />
|
||||
{section.label}
|
||||
</span>
|
||||
<span>{categoryCounts[section.key] || 0}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="plugins-empty-state">
|
||||
<SlidersHorizontal className="size-7" aria-hidden="true" />
|
||||
<h3>没有匹配的插件</h3>
|
||||
<p>试试清空搜索词,或放宽筛选条件。</p>
|
||||
<Button onClick={() => { setSearch(""); setCategoryFilter("all"); setStatusFilter("all"); setTrustFilter("all"); }}>清除筛选</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
className="workspace-modal workspace-modal-wide plugin-settings-modal"
|
||||
title={settingsPlugin ? `${settingsPlugin.manifest.name} 设置` : null}
|
||||
open={Boolean(settingsPlugin)}
|
||||
centered
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
onCancel={() => setSettingsPluginId(null)}
|
||||
styles={{ body: { maxHeight: "min(72vh, 760px)", overflowY: "auto", overscrollBehavior: "contain" } }}
|
||||
>
|
||||
{settingsPlugin ? (
|
||||
<div className="plugin-settings-panel plugin-settings-modal-panel">
|
||||
<div className="plugin-settings-heading">
|
||||
<div>
|
||||
<p>只展示这个插件实际支持的配置项。</p>
|
||||
</div>
|
||||
{settingsPlugin.manifest.trusted ? <span className="plugin-trust-label"><ShieldCheck className="size-3.5" />可信插件</span> : <span className="plugin-category-label">第三方插件</span>}
|
||||
<button type="button" className={`plugins-sidebar-item${categoryFilter === "other" ? " is-active" : ""}`} aria-current={categoryFilter === "other" ? "page" : undefined} onClick={() => setCategoryFilter("other")}>
|
||||
<span className="plugins-sidebar-item-label">
|
||||
<PlugZap className="size-4" />
|
||||
应用插件
|
||||
</span>
|
||||
<span>{categoryCounts.other}</span>
|
||||
</button>
|
||||
</nav>
|
||||
</aside>
|
||||
<div className="plugins-page-content">
|
||||
<div className="plugins-toolbar" aria-label="插件筛选">
|
||||
<Input
|
||||
className="plugins-search"
|
||||
prefix={<Search className="size-4 text-foreground/38" aria-hidden="true" />}
|
||||
value={search}
|
||||
allowClear
|
||||
placeholder="搜索插件名称、描述或作者"
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
<Select
|
||||
className="plugins-filter"
|
||||
value={statusFilter}
|
||||
options={[
|
||||
{ value: "all", label: "全部状态" },
|
||||
{ value: "enabled", label: "已启用" },
|
||||
{ value: "disabled", label: "未启用" },
|
||||
]}
|
||||
onChange={(value) => setStatusFilter(value as "all" | "enabled" | "disabled")}
|
||||
aria-label="按状态筛选"
|
||||
/>
|
||||
<Select
|
||||
className="plugins-filter"
|
||||
value={trustFilter}
|
||||
options={[
|
||||
{ value: "all", label: "全部来源" },
|
||||
{ value: "trusted", label: "可信插件" },
|
||||
]}
|
||||
onChange={(value) => setTrustFilter(value as "all" | "trusted")}
|
||||
aria-label="按来源筛选"
|
||||
/>
|
||||
<span className="plugins-filter-icon" aria-hidden="true">
|
||||
<SlidersHorizontal className="size-4" />
|
||||
</span>
|
||||
{user?.role === "admin" ? (
|
||||
<div className="plugins-toolbar-actions">
|
||||
<Button icon={<RefreshCw className="size-4" />} loading={backendPluginsLoading} onClick={() => void reloadBackendPlugins()}>
|
||||
刷新插件
|
||||
</Button>
|
||||
<Button type="primary" icon={<CloudUpload className="size-4" />} onClick={() => setUploadModalOpen(true)}>
|
||||
上传插件
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{settingsPlugin.manifest.id === EAGLE_PLUGIN_ID ? (
|
||||
<>
|
||||
<div className="plugin-settings-fields">
|
||||
<div className="min-w-0">
|
||||
<label htmlFor="eagle-base-url">Eagle 本地 API 地址</label>
|
||||
<Input id="eagle-base-url" aria-label="Eagle 本地 API 地址" value={eagleBaseUrl} onChange={(event) => setEagleBaseUrl(event.target.value)} placeholder="http://localhost:41595" />
|
||||
<p>Eagle 必须在本机运行;影策通过插件直接读取和写入 Eagle 原始文件。</p>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="plugin-setting-label-row">
|
||||
<label htmlFor="eagle-auto-upload-generated">自动归档生成结果</label>
|
||||
<Switch id="eagle-auto-upload-generated" checked={eagleAutoUploadGenerated} onChange={setEagleAutoUploadGenerated} aria-label="自动归档生成结果到 Eagle" />
|
||||
{filteredPlugins.length ? (
|
||||
<div className="plugins-sections">
|
||||
{pluginSections.map((section) => {
|
||||
if (!section.plugins.length) return null;
|
||||
const SectionIcon = section.icon;
|
||||
return (
|
||||
<section key={section.key} className="plugin-section">
|
||||
<header className="plugin-section-heading">
|
||||
<span className="plugin-section-icon">
|
||||
<SectionIcon className="size-4" />
|
||||
</span>
|
||||
<div>
|
||||
<h2>{section.label}</h2>
|
||||
<p>{section.description}</p>
|
||||
</div>
|
||||
<p>图片、视频和音频生成成功后,自动写入 Eagle;影策本地素材仍会保留。</p>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="plugin-setting-label-row">
|
||||
<label htmlFor="eagle-generated-folder">生成结果写入文件夹</label>
|
||||
<Button type="link" size="small" loading={eagleFoldersLoading} onClick={() => void loadEagleFolders()}>读取文件夹</Button>
|
||||
</div>
|
||||
<Select
|
||||
id="eagle-generated-folder"
|
||||
aria-label="生成结果写入文件夹"
|
||||
showSearch
|
||||
allowClear
|
||||
value={eagleGeneratedFolderId || undefined}
|
||||
placeholder="Eagle 根目录"
|
||||
optionFilterProp="label"
|
||||
options={[{ value: "__root__", label: "Eagle 根目录" }, ...eagleFolderOptions(eagleFolders)]}
|
||||
onChange={(value) => setEagleGeneratedFolderId(value === "__root__" || !value ? "" : value)}
|
||||
/>
|
||||
<p>{eagleFoldersError || "默认写入 Eagle 根目录;选择文件夹后按 Eagle 原始目录归档。"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="plugin-settings-actions">
|
||||
<Button type="primary" icon={<CheckCircle2 className="size-4" />} onClick={saveEagleConfig}>保存配置</Button>
|
||||
<Button icon={<FolderOpen className="size-4" />} disabled={!settingsEnabled} onClick={() => navigate("/plugins/eagle")}>打开 Eagle 素材库</Button>
|
||||
<Button icon={<ExternalLink className="size-4" />} href="https://api.eagle.cool/" target="_blank">查看 API</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="plugin-settings-empty">该插件暂无可编辑设置项。当前接入位置和权限会根据插件清单自动生效。</div>
|
||||
)}
|
||||
<span className="plugin-section-count">{section.plugins.length}</span>
|
||||
</header>
|
||||
<div className="plugins-grid">
|
||||
{section.plugins.map((plugin) => {
|
||||
const installation = installations.find((item) => item.manifest.id === plugin.manifest.id);
|
||||
const remote = backendPluginById.get(plugin.manifest.id);
|
||||
const enabled = isPluginEnabled(plugin, installation);
|
||||
const trusted = Boolean(plugin.manifest.trusted);
|
||||
const isSystemPlugin = plugin.source !== "uploaded";
|
||||
const canConfigure = canConfigurePlugin(plugin);
|
||||
return (
|
||||
<section key={plugin.manifest.id} className={`plugin-card library-card-surface${trusted ? " is-trusted" : ""}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="plugin-card-main"
|
||||
aria-label={`查看${plugin.manifest.name}文档`}
|
||||
onClick={(event) => {
|
||||
const openedByKeyboard = event.detail === 0;
|
||||
setDetailsRestoreFocus(openedByKeyboard);
|
||||
setDetailsPluginId(plugin.manifest.id);
|
||||
if (!openedByKeyboard) event.currentTarget.blur();
|
||||
}}
|
||||
>
|
||||
<div className="plugin-card-heading">
|
||||
<span className={`plugin-icon-tile${trusted ? " is-trusted" : ""}`} aria-hidden="true">
|
||||
<PlugZap className="size-5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="plugin-card-title-row">
|
||||
<h3>{plugin.manifest.name}</h3>
|
||||
<span className="plugin-version">v{plugin.manifest.version}</span>
|
||||
</div>
|
||||
<div className="plugin-card-labels">
|
||||
<span className={`plugin-source-label${isSystemPlugin ? " is-system" : ""}`}>
|
||||
{isSystemPlugin ? "系统插件" : "用户插件"}
|
||||
</span>
|
||||
{trusted ? (
|
||||
<span className="plugin-trust-label">
|
||||
<ShieldCheck className="size-3.5" />
|
||||
可信插件
|
||||
</span>
|
||||
) : null}
|
||||
<span className="plugin-category-label">{categoryLabels[plugin.manifest.category] ?? plugin.manifest.category}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="plugin-permissions">
|
||||
<div><span>接入位置</span>{settingsPlugin.manifest.surfaces.map((surface) => surfaceLabels[surface] ?? surface).join("、")}</div>
|
||||
<div><span>插件能力</span>{settingsPlugin.manifest.permissions.map((permission) => permissionLabels[permission] ?? permission).join("、")}</div>
|
||||
</div>
|
||||
<p className="plugin-card-description">{plugin.manifest.description}</p>
|
||||
|
||||
<div className="plugin-card-meta">
|
||||
<span>
|
||||
<CalendarDays className="size-3.5" />
|
||||
发布 {formatPluginDate(plugin.manifest.publishedAt)}
|
||||
</span>
|
||||
<span>
|
||||
<Clock3 className="size-3.5" />
|
||||
更新 {formatPluginDate(plugin.manifest.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="plugin-card-tags">
|
||||
{plugin.manifest.surfaces.map((surface) => (
|
||||
<span key={surface}>{surfaceLabels[surface] ?? surface}</span>
|
||||
))}
|
||||
{plugin.manifest.protocol?.categories.map((capability) => (
|
||||
<span key={capability}>{capabilityLabel(capability)}</span>
|
||||
))}
|
||||
{plugin.manifest.protocol?.poll ? <span>异步轮询</span> : null}
|
||||
<span>{plugin.manifest.permissions.length} 项能力</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="plugin-card-actions">
|
||||
<span role="status" className={`settings-channel-status ${enabled ? "is-ready" : ""}`}>
|
||||
<i aria-hidden="true" />
|
||||
{enabled ? "已启用" : "未启用"}
|
||||
</span>
|
||||
<Switch disabled={user?.role !== "admin"} checked={enabled} aria-label={`${plugin.manifest.name}${enabled ? "停用" : "启用"}`} onChange={(checked) => void togglePlugin(plugin, checked)} />
|
||||
{canConfigure ? (
|
||||
<Button
|
||||
className="plugin-settings-button"
|
||||
icon={<Settings2 className="size-4" />}
|
||||
aria-expanded={settingsPluginId === plugin.manifest.id}
|
||||
aria-haspopup="dialog"
|
||||
onClick={() => setSettingsPluginId(plugin.manifest.id)}
|
||||
>
|
||||
设置
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{installation?.lastError || remote?.error ? (
|
||||
<Typography.Text type="danger" className="plugin-error" role="alert">
|
||||
{installation?.lastError || remote?.error}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
) : (
|
||||
<div className="plugins-empty-state">
|
||||
<SlidersHorizontal className="size-7" aria-hidden="true" />
|
||||
<h3>没有匹配的插件</h3>
|
||||
<p>试试清空搜索词,或放宽筛选条件。</p>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSearch("");
|
||||
setCategoryFilter("all");
|
||||
setStatusFilter("all");
|
||||
setTrustFilter("all");
|
||||
}}
|
||||
>
|
||||
清除筛选
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<UploadPluginModal open={uploadModalOpen} onClose={() => setUploadModalOpen(false)} onUpload={(file) => void handleUpload(file)} />
|
||||
<Modal
|
||||
className="workspace-modal workspace-modal-wide plugin-settings-modal"
|
||||
title={settingsPlugin ? `${settingsPlugin.manifest.name} 设置` : null}
|
||||
open={Boolean(settingsPlugin)}
|
||||
centered
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
onCancel={() => setSettingsPluginId(null)}
|
||||
styles={{ body: { maxHeight: "min(72vh, 760px)", overflowY: "auto", overscrollBehavior: "contain" } }}
|
||||
>
|
||||
{settingsPlugin ? (
|
||||
<div className="plugin-settings-panel plugin-settings-modal-panel">
|
||||
<div className="plugin-settings-heading">
|
||||
<div>
|
||||
<p>只展示这个插件实际支持的配置项。</p>
|
||||
</div>
|
||||
{settingsPlugin.manifest.trusted ? (
|
||||
<span className="plugin-trust-label">
|
||||
<ShieldCheck className="size-3.5" />
|
||||
可信插件
|
||||
</span>
|
||||
) : (
|
||||
<span className="plugin-category-label">第三方插件</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{settingsPlugin.manifest.id === EAGLE_PLUGIN_ID ? (
|
||||
<>
|
||||
<div className="plugin-settings-fields">
|
||||
<div className="min-w-0">
|
||||
<label htmlFor="eagle-base-url">Eagle 本地 API 地址</label>
|
||||
<Input id="eagle-base-url" aria-label="Eagle 本地 API 地址" value={eagleBaseUrl} onChange={(event) => setEagleBaseUrl(event.target.value)} placeholder="http://localhost:41595" />
|
||||
<p>Eagle 必须在本机运行;影策通过插件直接读取和写入 Eagle 原始文件。</p>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="plugin-setting-label-row">
|
||||
<label htmlFor="eagle-auto-upload-generated">自动归档生成结果</label>
|
||||
<Switch id="eagle-auto-upload-generated" checked={eagleAutoUploadGenerated} onChange={setEagleAutoUploadGenerated} aria-label="自动归档生成结果到 Eagle" />
|
||||
</div>
|
||||
<p>图片、视频和音频生成成功后,自动写入 Eagle;影策本地素材仍会保留。</p>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="plugin-setting-label-row">
|
||||
<label htmlFor="eagle-generated-folder">生成结果写入文件夹</label>
|
||||
<Button type="link" size="small" loading={eagleFoldersLoading} onClick={() => void loadEagleFolders()}>
|
||||
读取文件夹
|
||||
</Button>
|
||||
</div>
|
||||
<Select
|
||||
id="eagle-generated-folder"
|
||||
aria-label="生成结果写入文件夹"
|
||||
showSearch
|
||||
allowClear
|
||||
value={eagleGeneratedFolderId || undefined}
|
||||
placeholder="Eagle 根目录"
|
||||
optionFilterProp="label"
|
||||
options={[{ value: "__root__", label: "Eagle 根目录" }, ...eagleFolderOptions(eagleFolders)]}
|
||||
onChange={(value) => setEagleGeneratedFolderId(value === "__root__" || !value ? "" : value)}
|
||||
/>
|
||||
<p>{eagleFoldersError || "默认写入 Eagle 根目录;选择文件夹后按 Eagle 原始目录归档。"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="plugin-settings-actions">
|
||||
<Button type="primary" icon={<CheckCircle2 className="size-4" />} onClick={saveEagleConfig}>
|
||||
保存配置
|
||||
</Button>
|
||||
<Button icon={<FolderOpen className="size-4" />} disabled={!settingsEnabled} onClick={() => navigate("/plugins/eagle")}>
|
||||
打开 Eagle 素材库
|
||||
</Button>
|
||||
<Button icon={<ExternalLink className="size-4" />} href="https://api.eagle.cool/" target="_blank">
|
||||
查看 API
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="plugin-settings-empty">
|
||||
{settingsPlugin.manifest.kind === "protocol"
|
||||
? `协议能力:${settingsPlugin.manifest.protocol?.categories.map(capabilityLabel).join("、") || "未声明"};可用场景:${settingsPlugin.manifest.protocol?.scopes.join("、") || "未声明"}。`
|
||||
: "该插件暂无可编辑设置项。当前接入位置和权限会根据插件清单自动生效。"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="plugin-permissions">
|
||||
<div>
|
||||
<span>接入位置</span>
|
||||
{settingsPlugin.manifest.surfaces.map((surface) => surfaceLabels[surface] ?? surface).join("、")}
|
||||
</div>
|
||||
<div>
|
||||
<span>插件能力</span>
|
||||
{settingsPlugin.manifest.permissions.map((permission) => permissionLabels[permission] ?? permission).join("、")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
<PluginDetailsModal plugin={detailsPlugin} restoreFocus={detailsRestoreFocus} onClose={() => setDetailsPluginId(null)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -305,6 +557,30 @@ function formatPluginDate(value?: string) {
|
||||
return Number.isFinite(timestamp) ? pluginDateFormatter.format(timestamp) : "未记录";
|
||||
}
|
||||
|
||||
function toRegisteredPlugin(plugin: BackendPlugin): RegisteredPlugin {
|
||||
const manifest: PluginManifest = {
|
||||
id: plugin.manifest.id,
|
||||
name: plugin.manifest.name,
|
||||
version: plugin.manifest.version,
|
||||
publishedAt: plugin.installedAt,
|
||||
updatedAt: plugin.updatedAt,
|
||||
apiVersion: plugin.manifest.apiVersion,
|
||||
category: "protocol",
|
||||
description: plugin.manifest.description || "模型请求协议适配插件",
|
||||
author: plugin.manifest.author,
|
||||
surfaces: ["hybrid"],
|
||||
permissions: ["generation.run"],
|
||||
trusted: plugin.manifest.trusted,
|
||||
kind: "protocol",
|
||||
protocol: plugin.manifest.protocol,
|
||||
};
|
||||
return { manifest, source: plugin.source };
|
||||
}
|
||||
|
||||
function capabilityLabel(value: string) {
|
||||
return ({ text: "文本", image: "图片", video: "视频", audio: "音频" } as Record<string, string>)[value] || value;
|
||||
}
|
||||
|
||||
function eagleFolderOptions(folders: EagleFolder[]) {
|
||||
const byId = new Map(folders.map((folder) => [folder.id, folder]));
|
||||
const pathFor = (folder: EagleFolder) => {
|
||||
@@ -318,7 +594,5 @@ function eagleFolderOptions(folders: EagleFolder[]) {
|
||||
}
|
||||
return path.join(" / ");
|
||||
};
|
||||
return folders
|
||||
.map((folder) => ({ value: folder.id, label: pathFor(folder) }))
|
||||
.sort((left, right) => left.label.localeCompare(right.label, "zh-CN"));
|
||||
return folders.map((folder) => ({ value: folder.id, label: pathFor(folder) })).sort((left, right) => left.label.localeCompare(right.label, "zh-CN"));
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
# 上传请求协议插件
|
||||
|
||||
当前上传入口只安装**声明式请求协议插件**。它把影策统一的文本、图片、视频或音频请求转换为上游 HTTP API,并解析上游响应。素材源、画布节点、工作流、Agent 工具和 UI 扩展仍由应用代码注册,不能通过这里上传 JSON 安装。
|
||||
|
||||
## 1. 准备清单
|
||||
|
||||
上传文件必须是 UTF-8 JSON,大小不超过 512 KiB。插件 ID 使用小写 kebab-case;请求路径必须是相对路径;清单不得包含域名、Cookie 或 API Key。
|
||||
|
||||
```json
|
||||
{
|
||||
"apiVersion": "v1",
|
||||
"metadata": {
|
||||
"id": "acme-video",
|
||||
"name": "Acme Video API",
|
||||
"version": "1.0.0",
|
||||
"vendor": "Acme",
|
||||
"description": "Acme 视频生成协议适配",
|
||||
"categories": ["video"],
|
||||
"scopes": [
|
||||
"admin.system-channel",
|
||||
"user.custom-channel",
|
||||
"canvas",
|
||||
"creation",
|
||||
"agent"
|
||||
],
|
||||
"contentType": "application/json",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"mapping": "model",
|
||||
"description": "上游模型名"
|
||||
},
|
||||
{
|
||||
"name": "prompt",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"mapping": "input.prompt",
|
||||
"description": "视频描述"
|
||||
},
|
||||
{
|
||||
"name": "duration",
|
||||
"type": "integer",
|
||||
"mapping": "input.seconds",
|
||||
"description": "生成时长,单位为秒"
|
||||
}
|
||||
],
|
||||
"documentation": "# Acme Video API\n\n在这里写完整的 Markdown 接入文档。"
|
||||
},
|
||||
"create": {
|
||||
"method": "POST",
|
||||
"path": "/v1/videos",
|
||||
"fields": {
|
||||
"model": "request.model",
|
||||
"input.prompt": "request.prompt",
|
||||
"input.seconds": "request.duration"
|
||||
}
|
||||
},
|
||||
"poll": {
|
||||
"method": "GET",
|
||||
"path": "/v1/videos/{{taskId}}"
|
||||
},
|
||||
"response": {
|
||||
"taskIdPaths": ["id", "task_id"],
|
||||
"statusPaths": ["status", "data.status"],
|
||||
"messagePaths": ["error.message", "message"],
|
||||
"resultUrlPaths": ["url", "video.url", "data.0.url"],
|
||||
"resultKind": "video"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`fields` 左侧是上游请求字段路径,右侧是影策请求字段。同步协议只需要 `create` 和 `response`;异步协议再声明 `poll`。清单不会执行 JavaScript,也不能使用 `host:` 执行器。
|
||||
|
||||
### 同步文本响应
|
||||
|
||||
文本模型不一定需要创建任务后轮询。如果上游在创建请求中直接返回正文,可以只声明 `create` 和 `response`,例如:
|
||||
|
||||
```json
|
||||
{
|
||||
"create": {
|
||||
"method": "POST",
|
||||
"path": "/v1/chat/completions",
|
||||
"fields": {
|
||||
"model": "request.model",
|
||||
"messages": "request.messages"
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"textPaths": ["choices.0.message.content"],
|
||||
"reasoningPaths": ["choices.0.message.reasoning_content"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `response.textPaths` 按顺序尝试读取文本正文,支持对象路径和数组下标。
|
||||
- `response.reasoningPaths` 是可选的,用于读取推理内容;没有该字段时不会影响正文解析。
|
||||
- 如果没有声明 `statusPaths`,但解析到了文本或媒体结果,宿主会将本次响应视为成功。
|
||||
- `agentResponse` 只负责 Agent 工具请求的响应解析,与普通文本请求的 `response` 相互独立。
|
||||
|
||||
## 2. 编写接入文档
|
||||
|
||||
`metadata.documentation` 是插件详情页展示的 Markdown 正文,不是可选简介。至少写清楚:
|
||||
|
||||
- 接口与鉴权:创建、查询和取消接口的 Method、Path、Header 与 Content-Type。
|
||||
- 模型与参数:可用模型名、类型、必填性、默认值、可选值及字段映射。
|
||||
- 素材限制:数量、格式、大小、时长,以及 URL 可访问性要求。
|
||||
- 请求与响应:提交成功、处理中、完成和失败的真实示例。
|
||||
- 轮询与下载:状态值、建议间隔、超时和结果 URL 有效期。
|
||||
- 错误处理:HTTP 状态、上游错误字段、内容审核和额度限制。
|
||||
|
||||
文档中的路径、模型名和响应字段必须与清单实现一致。详情页支持标题、表格、列表、引用、链接和代码块,并按 GitHub Flavored Markdown 预览。
|
||||
|
||||
## 3. 上传前检查
|
||||
|
||||
| 检查项 | 要求 |
|
||||
| --- | --- |
|
||||
| 标识 | `apiVersion` 为 `v1`,ID 为 kebab-case,名称和版本不为空 |
|
||||
| 能力 | 至少声明一个 `category` 和一个 `scope` |
|
||||
| 请求 | Method 受支持,Path 为相对路径,字段映射来自 `request.*` |
|
||||
| 响应 | 任务 ID、状态、错误和结果地址路径与真实响应一致 |
|
||||
| 文档 | `metadata.documentation` 完整,并与实现同步 |
|
||||
| 安全 | 不包含域名、密钥、Cookie、个人数据或 `host:` 执行器 |
|
||||
|
||||
上传、启用和停用属于管理员写操作。校验失败时插件不会安装;已安装协议停用后,模型配置入口不再提供该协议。
|
||||
|
||||
## 4. Agent 工具请求协议
|
||||
|
||||
如果协议同时服务于画布 Agent,请额外声明 `agent` 和 `agentResponse`。`agent.fields` 的右侧可以访问:
|
||||
|
||||
- `request.model`
|
||||
- `request.extra.agent.chatCompletion.*`
|
||||
- `request.extra.agent.responses.*`
|
||||
|
||||
这样同一个插件可以由字段映射决定第三方的消息、工具定义和工具选择,不需要在前端为某个模型写死 URL 或请求格式。示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"agent": {
|
||||
"method": "POST",
|
||||
"path": "/v1/chat/completions",
|
||||
"fields": {
|
||||
"model": "request.model",
|
||||
"messages": "request.extra.agent.chatCompletion.messages",
|
||||
"tools": "request.extra.agent.chatCompletion.tools",
|
||||
"tool_choice": "request.extra.agent.chatCompletion.tool_choice"
|
||||
}
|
||||
},
|
||||
"agentResponse": {
|
||||
"textPaths": ["choices.0.message.content"],
|
||||
"reasoningPaths": ["choices.0.message.reasoning_content"],
|
||||
"toolCallsPath": "choices.0.message.tool_calls",
|
||||
"toolCallIdPaths": ["id"],
|
||||
"toolCallNamePaths": ["function.name"],
|
||||
"toolCallArgumentsPaths": ["function.arguments"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`agentResponse` 的路径支持对象和数组下标(例如 `choices.0.message.content`)。第三方返回的 `tool_calls[].function.arguments` 可以是 JSON 字符串,也可以是 JSON 对象;宿主会统一转换为 Agent 合同要求的字符串。Agent 请求仍由后端统一负责鉴权、私网/本机上游校验、超时、日志和计费;插件只负责字段映射与结果解析。
|
||||
|
||||
## 5. 运行边界
|
||||
|
||||
声明式插件是请求协议运行时:它负责第三方 HTTP 方法、相对路径、请求字段映射、同步/异步响应解析、轮询状态、任务 ID、错误消息和结果地址。系统渠道和用户自建渠道只要选择了明确的协议,都会经过后端协议运行时,不依赖前端为某个模型硬编码请求格式。
|
||||
|
||||
宿主负责鉴权、Base URL 版本归一化、私网/本机上游安全校验、出站请求、超时、并发、计费、轮询生命周期、结果下载和任务恢复。内置的 multipart、签名鉴权、特殊媒体下载等协议仍由宿主内置 adapter 承担;上传的第三方插件必须使用声明式字段映射,不能上传可执行代码。
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Modal, Upload } from "antd";
|
||||
import { CloudUpload, FileText, ShieldCheck } from "lucide-react";
|
||||
|
||||
import type { RegisteredPlugin } from "@/lib/plugins/plugin-types";
|
||||
|
||||
import pluginDevelopmentGuideMarkdown from "./plugin-development-guide.md?raw";
|
||||
import { getPluginDocumentation } from "./plugin-documentation";
|
||||
import { PluginMarkdown } from "./plugin-markdown";
|
||||
|
||||
type UploadPluginModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onUpload: (file: File) => void;
|
||||
};
|
||||
|
||||
export function UploadPluginModal({ open, onClose, onUpload }: UploadPluginModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
className="workspace-modal workspace-modal-wide plugin-upload-modal"
|
||||
title="上传插件"
|
||||
open={open}
|
||||
centered
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
onCancel={onClose}
|
||||
styles={{ body: { maxHeight: "min(82vh, 900px)", overflowY: "auto", overscrollBehavior: "contain" } }}
|
||||
>
|
||||
<div className="plugin-upload-layout">
|
||||
<section className="plugin-upload-guide">
|
||||
<PluginMarkdown source={pluginDevelopmentGuideMarkdown} />
|
||||
</section>
|
||||
<aside className="plugin-upload-panel">
|
||||
<div className="plugin-upload-panel-heading">
|
||||
<span className="plugin-upload-panel-icon"><CloudUpload className="size-5" /></span>
|
||||
<div>
|
||||
<h2>安装插件包</h2>
|
||||
<p>选择符合清单规范的 JSON 文件,安装后会立即进入插件中心。</p>
|
||||
</div>
|
||||
</div>
|
||||
<Upload.Dragger
|
||||
className="plugin-upload-dropzone"
|
||||
accept=".json,application/json"
|
||||
maxCount={1}
|
||||
showUploadList={false}
|
||||
beforeUpload={(file) => {
|
||||
onUpload(file);
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<CloudUpload className="plugin-upload-dropzone-icon" />
|
||||
<p className="ant-upload-text">点击选择插件文件</p>
|
||||
<p className="ant-upload-hint">仅支持 JSON 清单,大小不超过 512 KiB</p>
|
||||
</Upload.Dragger>
|
||||
<div className="plugin-upload-notice">
|
||||
<ShieldCheck className="size-4" />
|
||||
<span>上传前请确认插件来源可信。协议插件不会在浏览器执行任意脚本,密钥也不会从清单读取。</span>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
type PluginDetailsModalProps = {
|
||||
plugin?: RegisteredPlugin;
|
||||
restoreFocus: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function PluginDetailsModal({ plugin, restoreFocus, onClose }: PluginDetailsModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
className="workspace-modal workspace-modal-wide plugin-details-modal"
|
||||
title={plugin ? (
|
||||
<div className="plugin-details-title">
|
||||
<FileText className="size-4" />
|
||||
<span>{plugin.manifest.name}</span>
|
||||
<span className="plugin-version">v{plugin.manifest.version}</span>
|
||||
</div>
|
||||
) : null}
|
||||
open={Boolean(plugin)}
|
||||
centered
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
focusTriggerAfterClose={restoreFocus}
|
||||
onCancel={onClose}
|
||||
styles={{ body: { maxHeight: "min(78vh, 820px)", overflowY: "auto", overscrollBehavior: "contain" } }}
|
||||
>
|
||||
{plugin ? <PluginMarkdown className="plugin-details-document" source={getPluginDocumentation(plugin.manifest)} /> : null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { PluginManifest } from "@/lib/plugins/plugin-types";
|
||||
|
||||
const permissionLabels: Record<string, string> = {
|
||||
"canvas.read": "读取画布",
|
||||
"canvas.write": "修改画布",
|
||||
"asset.read": "读取素材",
|
||||
"asset.search": "搜索素材",
|
||||
"asset.import": "导入素材",
|
||||
"asset.upload": "上传素材",
|
||||
"generation.run": "调用生成",
|
||||
"external.open": "打开外部详情",
|
||||
};
|
||||
|
||||
export function getPluginDocumentation(manifest: PluginManifest) {
|
||||
const documentation = manifest.protocol?.documentation?.trim();
|
||||
if (documentation) return documentation;
|
||||
if (manifest.documentation?.trim()) return manifest.documentation.trim();
|
||||
|
||||
const capabilities = manifest.permissions.map((permission) => permissionLabels[permission] || permission);
|
||||
return [
|
||||
`# ${manifest.name}`,
|
||||
"",
|
||||
manifest.description || "该插件没有提供简介。",
|
||||
"",
|
||||
"## 插件信息",
|
||||
"",
|
||||
`- 作者:${manifest.author || "未声明"}`,
|
||||
`- 版本:${manifest.version}`,
|
||||
`- 能力:${capabilities.join("、") || "未声明"}`,
|
||||
"",
|
||||
manifest.kind === "protocol"
|
||||
? "> 此请求协议没有提供接入文档。请联系插件作者补充 `metadata.documentation`,不要仅凭清单字段推测上游接口。"
|
||||
: "> 该插件当前没有单独的使用文档。",
|
||||
].join("\n");
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
type PluginMarkdownProps = {
|
||||
source: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function PluginMarkdown({ source, className = "" }: PluginMarkdownProps) {
|
||||
return (
|
||||
<div className={`plugin-markdown ${className}`.trim()}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
a: ({ children, href }) => <a href={href} target="_blank" rel="noreferrer">{children}</a>,
|
||||
table: ({ children }) => (
|
||||
<div className="plugin-markdown-table-wrap">
|
||||
<table>{children}</table>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{source}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,5 @@
|
||||
.plugins-page {
|
||||
--plugins-card-radius: var(--r-xl);
|
||||
}
|
||||
|
||||
.plugins-page-content {
|
||||
width: min(100%, 1440px);
|
||||
margin: 0 auto;
|
||||
--plugins-card-radius: var(--r-md);
|
||||
}
|
||||
|
||||
.plugin-trust-label {
|
||||
@@ -16,16 +11,140 @@
|
||||
}
|
||||
|
||||
.plugins-page-content {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: var(--space-5) var(--space-6) var(--space-8);
|
||||
}
|
||||
|
||||
.plugins-page-layout {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
grid-template-columns: 208px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.plugins-sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
min-width: 0;
|
||||
min-height: calc(100vh - 72px);
|
||||
border-right: 1px solid var(--workspace-border);
|
||||
padding: var(--space-6) var(--space-3) var(--space-8) var(--space-5);
|
||||
}
|
||||
|
||||
.plugins-sidebar-heading {
|
||||
padding: 0 var(--space-2) var(--space-4);
|
||||
}
|
||||
|
||||
.plugins-sidebar-kicker {
|
||||
color: var(--workspace-accent);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.plugins-sidebar-heading h1 {
|
||||
margin-top: var(--space-2);
|
||||
color: var(--foreground);
|
||||
font-size: var(--fs-title);
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.plugins-sidebar-heading p {
|
||||
margin-top: var(--space-2);
|
||||
color: color-mix(in srgb, var(--foreground) 44%, transparent);
|
||||
font-size: var(--fs-label);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.plugins-sidebar-nav {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.plugins-sidebar-item {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--r-sm);
|
||||
background: transparent;
|
||||
padding: 0 var(--space-2);
|
||||
color: color-mix(in srgb, var(--foreground) 53%, transparent);
|
||||
font-size: var(--fs-caption);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--motion-state) ease-out,
|
||||
color var(--motion-state) ease-out;
|
||||
}
|
||||
|
||||
.plugins-sidebar-item:hover {
|
||||
background: color-mix(in srgb, var(--foreground) 6%, transparent);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.plugins-sidebar-item:focus-visible {
|
||||
background: color-mix(in srgb, var(--foreground) 6%, transparent);
|
||||
color: var(--foreground);
|
||||
outline: 2px solid color-mix(in srgb, var(--foreground) 58%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.plugins-sidebar-item.is-active {
|
||||
border-color: color-mix(in srgb, var(--foreground) 11%, transparent);
|
||||
background: color-mix(in srgb, var(--foreground) 7%, transparent);
|
||||
color: var(--foreground);
|
||||
font-weight: 600;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.plugins-sidebar-item > span:last-child {
|
||||
min-width: 22px;
|
||||
border-radius: var(--r-sm);
|
||||
background: transparent;
|
||||
padding: 2px 5px;
|
||||
color: color-mix(in srgb, var(--foreground) 36%, transparent);
|
||||
font-size: var(--fs-label);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.plugins-sidebar-item.is-active > span:last-child {
|
||||
color: color-mix(in srgb, var(--foreground) 62%, transparent);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.plugins-sidebar-item-label {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.plugins-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 1fr) repeat(3, minmax(132px, 0.24fr)) auto;
|
||||
grid-template-columns: minmax(260px, 1fr) repeat(2, minmax(128px, 0.24fr)) auto auto;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.plugins-toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.plugins-search,
|
||||
.plugins-filter {
|
||||
min-width: 0;
|
||||
@@ -56,32 +175,120 @@
|
||||
color: color-mix(in srgb, var(--foreground) 48%, transparent);
|
||||
}
|
||||
|
||||
.plugins-sections {
|
||||
display: grid;
|
||||
gap: var(--space-8);
|
||||
margin-top: var(--space-8);
|
||||
}
|
||||
|
||||
.plugin-section {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.plugin-section-heading {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding-bottom: var(--space-3);
|
||||
border-bottom: 1px solid var(--workspace-border);
|
||||
}
|
||||
|
||||
.plugin-section-icon {
|
||||
display: grid;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border: 1px solid var(--workspace-border);
|
||||
border-radius: var(--r-md);
|
||||
background: color-mix(in srgb, var(--foreground) 5%, transparent);
|
||||
color: color-mix(in srgb, var(--foreground) 68%, transparent);
|
||||
}
|
||||
|
||||
.plugin-section-heading > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.plugin-section-heading h2 {
|
||||
color: var(--foreground);
|
||||
font-size: var(--fs-body-lg);
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.plugin-section-heading p {
|
||||
margin-top: var(--space-half);
|
||||
color: color-mix(in srgb, var(--foreground) 46%, transparent);
|
||||
font-size: var(--fs-label);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.plugin-section-count {
|
||||
display: inline-grid;
|
||||
min-width: 28px;
|
||||
height: 24px;
|
||||
margin-left: auto;
|
||||
place-items: center;
|
||||
border-radius: var(--r-full);
|
||||
background: color-mix(in srgb, var(--foreground) 7%, transparent);
|
||||
color: color-mix(in srgb, var(--foreground) 56%, transparent);
|
||||
font-size: var(--fs-label);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.plugins-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
align-items: stretch;
|
||||
gap: var(--space-4);
|
||||
margin-top: var(--space-5);
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.plugin-card {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 286px;
|
||||
min-height: 232px;
|
||||
flex-direction: column;
|
||||
padding: var(--space-4);
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--plugins-card-radius);
|
||||
transition: transform var(--motion-state) var(--motion-ease-out), background-color var(--motion-state) ease-out;
|
||||
transition: background-color var(--motion-state) ease-out;
|
||||
}
|
||||
|
||||
.plugin-card:hover,
|
||||
.plugin-card:focus-within {
|
||||
transform: translateY(var(--library-hover-offset));
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.plugin-card:focus-within {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.plugin-card-main {
|
||||
appearance: none;
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.plugin-card-main:focus-visible {
|
||||
border-radius: var(--r-md);
|
||||
outline: 1px solid color-mix(in srgb, var(--foreground) 38%, transparent);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.plugin-card-main:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.plugin-card-heading {
|
||||
@@ -93,26 +300,27 @@
|
||||
|
||||
.plugin-icon-tile {
|
||||
display: grid;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border-radius: var(--r-lg);
|
||||
border-radius: var(--r-md);
|
||||
background: color-mix(in srgb, var(--foreground) 7%, transparent);
|
||||
color: color-mix(in srgb, var(--foreground) 65%, transparent);
|
||||
transition: background-color var(--motion-state) ease-out, color var(--motion-state) ease-out, transform var(--motion-state) var(--motion-ease-out);
|
||||
transition:
|
||||
background-color var(--motion-state) ease-out,
|
||||
color var(--motion-state) ease-out;
|
||||
}
|
||||
|
||||
.plugin-card:hover .plugin-icon-tile,
|
||||
.plugin-card:focus-within .plugin-icon-tile {
|
||||
transform: translateY(-2px);
|
||||
background: color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.plugin-icon-tile.is-trusted {
|
||||
background: color-mix(in srgb, var(--status-success) 14%, transparent);
|
||||
color: var(--status-success);
|
||||
background: color-mix(in srgb, var(--foreground) 7%, transparent);
|
||||
color: color-mix(in srgb, var(--foreground) 65%, transparent);
|
||||
}
|
||||
|
||||
.plugin-card-title-row {
|
||||
@@ -155,6 +363,7 @@
|
||||
}
|
||||
|
||||
.plugin-category-label,
|
||||
.plugin-source-label,
|
||||
.plugin-card-tags span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -167,14 +376,25 @@
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.plugin-source-label {
|
||||
background: color-mix(in srgb, var(--foreground) 7%, transparent);
|
||||
color: color-mix(in srgb, var(--foreground) 58%, transparent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.plugin-source-label.is-system {
|
||||
background: color-mix(in srgb, var(--foreground) 7%, transparent);
|
||||
color: color-mix(in srgb, var(--foreground) 58%, transparent);
|
||||
}
|
||||
|
||||
.plugin-trust-label {
|
||||
font-size: var(--fs-micro);
|
||||
}
|
||||
|
||||
.plugin-card-description {
|
||||
display: -webkit-box;
|
||||
min-height: 3.2em;
|
||||
margin-top: var(--space-4);
|
||||
min-height: 2.9em;
|
||||
margin-top: var(--space-3);
|
||||
overflow: hidden;
|
||||
color: color-mix(in srgb, var(--foreground) 52%, transparent);
|
||||
font-size: var(--fs-caption);
|
||||
@@ -185,7 +405,7 @@
|
||||
|
||||
.plugin-card-meta {
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-4);
|
||||
margin-top: var(--space-3);
|
||||
color: color-mix(in srgb, var(--foreground) 43%, transparent);
|
||||
font-size: var(--fs-label);
|
||||
}
|
||||
@@ -207,8 +427,8 @@
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-5);
|
||||
padding-top: var(--space-3);
|
||||
margin-top: var(--space-3);
|
||||
padding-top: var(--space-2);
|
||||
border-top: 1px solid color-mix(in srgb, var(--workspace-border) 72%, transparent);
|
||||
}
|
||||
|
||||
@@ -216,10 +436,291 @@
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.plugin-card-actions .ant-switch {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.plugin-settings-button {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.plugin-details-title {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.plugin-details-title > span:nth-child(2) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workspace-modal.plugin-details-modal {
|
||||
width: min(1240px, calc(100vw - var(--space-4))) !important;
|
||||
}
|
||||
|
||||
.workspace-modal.plugin-upload-modal {
|
||||
width: min(1480px, calc(100vw - var(--space-4))) !important;
|
||||
}
|
||||
|
||||
.plugin-upload-layout {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: minmax(0, 1.55fr) minmax(300px, 0.65fr);
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.plugin-upload-guide {
|
||||
min-width: 0;
|
||||
max-height: min(75vh, 780px);
|
||||
overflow: auto;
|
||||
padding-right: var(--space-4);
|
||||
border-right: 1px solid var(--workspace-border);
|
||||
}
|
||||
|
||||
.plugin-upload-guide .plugin-markdown-table-wrap {
|
||||
margin-block: var(--space-3);
|
||||
}
|
||||
|
||||
.plugin-upload-guide .plugin-markdown table {
|
||||
min-width: 560px;
|
||||
}
|
||||
|
||||
.plugin-upload-panel {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
align-self: start;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--workspace-border);
|
||||
border-radius: var(--r-lg);
|
||||
background: color-mix(in srgb, var(--foreground) 3%, var(--workspace-surface-strong));
|
||||
}
|
||||
|
||||
.plugin-upload-panel-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.plugin-upload-panel-heading h2 {
|
||||
color: var(--foreground);
|
||||
font-size: var(--fs-body-lg);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.plugin-upload-panel-heading p {
|
||||
margin-top: var(--space-1);
|
||||
color: color-mix(in srgb, var(--foreground) 50%, transparent);
|
||||
font-size: var(--fs-label);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.plugin-upload-panel-icon {
|
||||
display: grid;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border-radius: var(--r-md);
|
||||
background: color-mix(in srgb, var(--workspace-accent) 13%, transparent);
|
||||
color: var(--workspace-accent);
|
||||
}
|
||||
|
||||
.plugin-upload-dropzone.ant-upload-wrapper .ant-upload-drag {
|
||||
min-height: 250px;
|
||||
border-color: color-mix(in srgb, var(--workspace-accent) 32%, var(--workspace-border));
|
||||
border-radius: var(--r-md);
|
||||
background: color-mix(in srgb, var(--workspace-accent) 4%, transparent);
|
||||
}
|
||||
|
||||
.plugin-upload-dropzone.ant-upload-wrapper .ant-upload-drag:hover {
|
||||
border-color: var(--workspace-accent);
|
||||
}
|
||||
|
||||
.plugin-upload-dropzone-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin-bottom: var(--space-3);
|
||||
color: var(--workspace-accent);
|
||||
}
|
||||
|
||||
.plugin-upload-dropzone .ant-upload-text {
|
||||
color: var(--foreground);
|
||||
font-size: var(--fs-body);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.plugin-upload-dropzone .ant-upload-hint {
|
||||
color: color-mix(in srgb, var(--foreground) 45%, transparent);
|
||||
font-size: var(--fs-label);
|
||||
}
|
||||
|
||||
.plugin-upload-notice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
color: color-mix(in srgb, var(--foreground) 49%, transparent);
|
||||
font-size: var(--fs-label);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.plugin-upload-notice svg {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 2px;
|
||||
color: var(--status-success);
|
||||
}
|
||||
|
||||
.plugin-markdown {
|
||||
max-width: none;
|
||||
color: color-mix(in srgb, var(--foreground) 72%, transparent);
|
||||
font-size: var(--fs-caption);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.plugin-details-document {
|
||||
width: 100%;
|
||||
padding: var(--space-2) 0 var(--space-6);
|
||||
}
|
||||
|
||||
.plugin-details-document > p,
|
||||
.plugin-details-document > ul,
|
||||
.plugin-details-document > ol,
|
||||
.plugin-details-document > blockquote {
|
||||
max-width: 88ch;
|
||||
}
|
||||
|
||||
.plugin-markdown h1,
|
||||
.plugin-markdown h2,
|
||||
.plugin-markdown h3 {
|
||||
color: var(--foreground);
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.plugin-markdown h1 {
|
||||
margin: 0 0 var(--space-3);
|
||||
font-size: var(--fs-title);
|
||||
}
|
||||
|
||||
.plugin-markdown h2 {
|
||||
margin: var(--space-6) 0 var(--space-3);
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--workspace-border);
|
||||
font-size: var(--fs-body-lg);
|
||||
}
|
||||
|
||||
.plugin-markdown h3 {
|
||||
margin: var(--space-4) 0 var(--space-2);
|
||||
font-size: var(--fs-body);
|
||||
}
|
||||
|
||||
.plugin-markdown p {
|
||||
margin: var(--space-2) 0;
|
||||
}
|
||||
|
||||
.plugin-markdown ul {
|
||||
margin: var(--space-2) 0;
|
||||
padding-left: var(--space-5);
|
||||
}
|
||||
|
||||
.plugin-markdown ol {
|
||||
margin: var(--space-2) 0;
|
||||
padding-left: var(--space-5);
|
||||
}
|
||||
|
||||
.plugin-markdown a {
|
||||
color: var(--workspace-accent);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.plugin-markdown li + li {
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.plugin-markdown strong {
|
||||
color: var(--foreground);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.plugin-markdown code {
|
||||
border-radius: var(--r-sm);
|
||||
background: color-mix(in srgb, var(--foreground) 7%, transparent);
|
||||
padding: 2px 5px;
|
||||
color: color-mix(in srgb, var(--workspace-accent) 78%, var(--foreground));
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
.plugin-markdown pre {
|
||||
overflow-x: auto;
|
||||
margin: var(--space-3) 0;
|
||||
border: 1px solid var(--workspace-border);
|
||||
border-radius: var(--r-md);
|
||||
background: color-mix(in srgb, var(--foreground) 5%, var(--workspace-surface-strong));
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.plugin-markdown pre code {
|
||||
display: block;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--foreground);
|
||||
line-height: 1.6;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.plugin-markdown-table-wrap {
|
||||
overflow-x: auto;
|
||||
margin: var(--space-3) 0;
|
||||
border: 1px solid var(--workspace-border);
|
||||
border-radius: var(--r-md);
|
||||
}
|
||||
|
||||
.plugin-markdown table {
|
||||
width: 100%;
|
||||
min-width: 620px;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--fs-label);
|
||||
}
|
||||
|
||||
.plugin-markdown th,
|
||||
.plugin-markdown td {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-bottom: 1px solid var(--workspace-border);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.plugin-markdown th {
|
||||
background: color-mix(in srgb, var(--foreground) 5%, transparent);
|
||||
color: var(--foreground);
|
||||
font-weight: 650;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.plugin-markdown tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.plugin-markdown blockquote {
|
||||
margin: var(--space-3) 0;
|
||||
border-left: 3px solid var(--workspace-accent);
|
||||
background: color-mix(in srgb, var(--workspace-accent) 7%, transparent);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
color: color-mix(in srgb, var(--foreground) 64%, transparent);
|
||||
}
|
||||
|
||||
.plugin-markdown hr {
|
||||
margin: var(--space-5) 0;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--workspace-border);
|
||||
}
|
||||
|
||||
.plugin-settings-panel {
|
||||
margin-top: var(--space-4);
|
||||
padding-top: var(--space-4);
|
||||
@@ -342,8 +843,45 @@
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.plugins-page-layout {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.plugins-sidebar {
|
||||
position: static;
|
||||
min-height: 0;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--workspace-border);
|
||||
padding: var(--space-4) var(--space-4) 0;
|
||||
}
|
||||
|
||||
.plugins-sidebar-heading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2);
|
||||
padding: 0 var(--space-1) var(--space-3);
|
||||
}
|
||||
|
||||
.plugins-sidebar-heading p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.plugins-sidebar-nav {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
overflow-x: auto;
|
||||
padding-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.plugins-sidebar-item {
|
||||
width: auto;
|
||||
flex: 0 0 auto;
|
||||
padding-inline: var(--space-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.plugins-toolbar {
|
||||
grid-template-columns: minmax(0, 1fr) repeat(3, minmax(120px, 1fr));
|
||||
grid-template-columns: minmax(0, 1fr) repeat(2, minmax(120px, 1fr));
|
||||
}
|
||||
|
||||
.plugins-search {
|
||||
@@ -353,6 +891,23 @@
|
||||
.plugins-filter-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.plugins-toolbar-actions {
|
||||
grid-column: 1 / -1;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.plugin-upload-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.plugin-upload-guide {
|
||||
max-height: 48vh;
|
||||
padding-right: 0;
|
||||
padding-bottom: var(--space-4);
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--workspace-border);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
@@ -360,6 +915,14 @@
|
||||
padding-inline: var(--space-4);
|
||||
}
|
||||
|
||||
.plugins-sidebar-heading {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.plugins-sidebar-heading p {
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.plugins-toolbar {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
@@ -372,9 +935,51 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.plugins-sections {
|
||||
gap: var(--space-6);
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
|
||||
.plugin-section-heading {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.plugin-card {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.plugin-card-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.plugin-upload-panel {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.plugin-upload-dropzone.ant-upload-wrapper .ant-upload-drag {
|
||||
min-height: 210px;
|
||||
}
|
||||
|
||||
.plugin-card-actions .settings-channel-status {
|
||||
width: 100%;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.plugin-settings-button {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.plugins-toolbar-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.plugins-toolbar-actions > * {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.plugins-toolbar-actions .ant-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { App, Button, Drawer, InputNumber, Segmented, Tag } from "antd";
|
||||
import { ChevronRight, FlaskConical, Settings2 } from "lucide-react";
|
||||
|
||||
@@ -6,8 +6,9 @@ import { testChannelModelConnection } from "@/lib/model-connection-test";
|
||||
import { ModelCapabilityEditor } from "@/components/model-capability-editor";
|
||||
import { CapabilityCardPicker, ProtocolCardPicker } from "@/components/model-protocol-picker";
|
||||
import { defaultModelCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { MODEL_PROTOCOLS, modelProtocolCapability, modelProtocolDefinition, modelProtocolSupportsTokenBilling, type ModelProtocol } from "@/lib/model-protocols";
|
||||
import { modelMatchesCapability, modelOptionName, type ModelChannel } from "@/stores/use-config-store";
|
||||
import { modelProtocolCapability, modelProtocolDefinition, modelProtocolSupportsTokenBilling, type ModelProtocol } from "@/lib/model-protocols";
|
||||
import { fetchProtocolCatalog } from "@/services/api/protocols";
|
||||
import { modelOptionName, type ModelChannel } from "@/stores/use-config-store";
|
||||
|
||||
type ModelCost = NonNullable<ModelChannel["modelCosts"]>[number];
|
||||
|
||||
@@ -15,12 +16,16 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
|
||||
const { message } = App.useApp();
|
||||
const [testingModel, setTestingModel] = useState("");
|
||||
const [activeModel, setActiveModel] = useState<string | null>(null);
|
||||
const [availableProtocols, setAvailableProtocols] = useState<import("@/lib/model-protocols").ModelProtocolDefinition[]>([]);
|
||||
useEffect(() => {
|
||||
void fetchProtocolCatalog("user.custom-channel").then(setAvailableProtocols).catch(() => setAvailableProtocols([]));
|
||||
}, []);
|
||||
if (!channel.models.length) return null;
|
||||
|
||||
const updateCost = (model: string, patch: Partial<ModelCost>) => {
|
||||
const current = channel.modelCosts?.find((item) => item.model === model) || {
|
||||
model,
|
||||
capability: modelProtocolCapability(defaultProtocolForModel(channel, model)) || "text",
|
||||
capability: modelProtocolCapability(defaultProtocolForModel(channel, model), availableProtocols) || "text",
|
||||
protocol: defaultProtocolForModel(channel, model),
|
||||
billingMode: "fixed_request" as const,
|
||||
unitPriceMicrocredits: 0,
|
||||
@@ -44,7 +49,7 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
|
||||
|
||||
const activeModelCost = activeModel ? channel.modelCosts?.find((item) => item.model === activeModel) : undefined;
|
||||
const activeProtocol = activeModel ? activeModelCost?.protocol || defaultProtocolForModel(channel, activeModel) : undefined;
|
||||
const activeCapability = activeModel ? activeModelCost?.capability || modelProtocolCapability(activeProtocol) || "text" : undefined;
|
||||
const activeCapability = activeModel ? activeModelCost?.capability || modelProtocolCapability(activeProtocol, availableProtocols) || "text" : undefined;
|
||||
const activeBillingMode = activeModelCost?.billingMode || "fixed_request";
|
||||
const activeTokenBillingSupported = modelProtocolSupportsTokenBilling(activeCapability, activeProtocol);
|
||||
|
||||
@@ -62,7 +67,7 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
|
||||
const model = modelOptionName(rawModel);
|
||||
const cost = channel.modelCosts?.find((item) => item.model === model);
|
||||
const protocol = cost?.protocol || defaultProtocolForModel(channel, model);
|
||||
const capability = cost?.capability || modelProtocolCapability(protocol) || "text";
|
||||
const capability = cost?.capability || modelProtocolCapability(protocol, availableProtocols) || "text";
|
||||
const billingMode = cost?.billingMode || "fixed_request";
|
||||
const displayName = cost?.displayName?.trim() || model;
|
||||
return (
|
||||
@@ -78,8 +83,8 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
|
||||
<Tag className="mr-0 text-[var(--fs-tiny)]" bordered={false}>
|
||||
{capabilityLabel(capability)}
|
||||
</Tag>
|
||||
<span className="truncate font-mono text-[var(--fs-tiny)] text-foreground/40" title={modelProtocolDefinition(protocol)?.create}>
|
||||
{modelProtocolDefinition(protocol)?.create || "待配置请求协议"}
|
||||
<span className="truncate font-mono text-[var(--fs-tiny)] text-foreground/40" title={modelProtocolDefinition(protocol, availableProtocols)?.create}>
|
||||
{modelProtocolDefinition(protocol, availableProtocols)?.create || "待配置请求协议"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -121,7 +126,7 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
|
||||
<CapabilityCardPicker
|
||||
value={activeCapability}
|
||||
onChange={(nextCapability) => {
|
||||
const nextProtocol = MODEL_PROTOCOLS.find((item) => item.value === activeProtocol && item.capability === nextCapability)?.value || MODEL_PROTOCOLS.find((item) => item.capability === nextCapability)?.value;
|
||||
const nextProtocol = availableProtocols.find((item) => item.value === activeProtocol && item.capability === nextCapability)?.value || availableProtocols.find((item) => item.capability === nextCapability)?.value;
|
||||
if (!nextProtocol) return;
|
||||
updateCost(activeModel, {
|
||||
protocol: nextProtocol,
|
||||
@@ -132,14 +137,15 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
<section className="space-y-2">
|
||||
{availableProtocols.length ? <section className="space-y-2">
|
||||
<div className="text-xs font-medium">请求协议</div>
|
||||
<ProtocolCardPicker
|
||||
capability={activeCapability}
|
||||
value={activeProtocol}
|
||||
protocols={availableProtocols}
|
||||
onChange={(nextProtocol) => updateCost(activeModel, { protocol: nextProtocol, billingMode: activeBillingMode === "token" && !modelProtocolSupportsTokenBilling(activeCapability, nextProtocol) ? "fixed_request" : activeBillingMode, capabilityConfig: activeCapability === "image" || activeCapability === "video" ? defaultModelCapabilityConfig(nextProtocol, activeModel) : undefined })}
|
||||
/>
|
||||
</section>
|
||||
</section> : null}
|
||||
{activeCapability === "video" ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium">计费方式</div>
|
||||
@@ -182,12 +188,7 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
|
||||
}
|
||||
|
||||
function defaultProtocolForModel(channel: ModelChannel, model: string): ModelProtocol {
|
||||
if (channel.interfaceType) return channel.interfaceType;
|
||||
if (channel.apiFormat === "gemini" && modelMatchesCapability(model, "video")) return "gemini-veo";
|
||||
if (modelMatchesCapability(model, "video")) return "newapi";
|
||||
if (modelOptionName(model).trim().toLowerCase().startsWith("grok-imagine-image")) return "grok-image";
|
||||
if (modelMatchesCapability(model, "image")) return "openai-image";
|
||||
return "chat-completion";
|
||||
return channel.interfaceType || "";
|
||||
}
|
||||
|
||||
function capabilityLabel(value: ModelCost["capability"]) {
|
||||
|
||||
@@ -427,17 +427,6 @@ export default function TasksPage() {
|
||||
<>
|
||||
<WorkspacePage grid className="library-page task-library-page">
|
||||
<div className="studio-band">
|
||||
<PageHeader
|
||||
title="任务中心"
|
||||
description="跟踪生成任务的排队、进度与失败原因。"
|
||||
meta={<span className="app-projects-header-meta">{filteredTasks.length} 个任务{loading ? " · 同步中" : ""}</span>}
|
||||
actions={(
|
||||
<>
|
||||
<Button icon={<RefreshCw className={`size-3.5 ${loading ? "animate-spin" : ""}`} />} onClick={() => void loadTasks(true)}>刷新</Button>
|
||||
<Button className="library-primary-action" type="primary" icon={<Plus className="size-3.5" />} onClick={() => setCreateOpen(true)}>新建任务</Button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<ListToolbar
|
||||
className="library-toolbar task-library-toolbar"
|
||||
active={Boolean(keyword || projectFilter !== "all" || kindFilter !== "all" || modelFilter !== "all" || statusFilter !== "all")}
|
||||
|
||||
Reference in new issue
Block a user