feat(platform): 插件协议系统 - 支持声明式渠道、工作流与参考媒体映射
This commit is contained in:
@@ -10,7 +10,7 @@ import { canvasNodeAssetCategory } from "@/lib/canvas/canvas-node-asset";
|
||||
import { isCanvasFolderNode } from "@/lib/canvas/canvas-frame";
|
||||
import { resolveAddNodeMenuCommands, type AddNodeMenuContext } from "@/lib/canvas/tool-registry";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { CanvasNodeType, type CanvasNodeData, type CanvasWorkspaceMode, type ContextMenuState, type Position } from "@/types/canvas";
|
||||
import { CanvasNodeType, type CanvasNodeData, type CanvasNodeTypeId, type CanvasWorkspaceMode, type ContextMenuState, type Position } from "@/types/canvas";
|
||||
|
||||
type CanvasAssetCategory = NonNullable<NonNullable<CanvasNodeData["metadata"]>["assetCategory"]>;
|
||||
|
||||
@@ -33,7 +33,7 @@ type CanvasNodeContextMenuProps = {
|
||||
canRedo: boolean;
|
||||
canPaste: boolean;
|
||||
onClose: () => void;
|
||||
onAddNode: (type: CanvasNodeType) => void;
|
||||
onAddNode: (type: CanvasNodeTypeId) => void;
|
||||
onAddFolder: () => void;
|
||||
onChooseStyle: () => void;
|
||||
onOpenDirector: (position: Position) => void;
|
||||
@@ -258,7 +258,7 @@ export function CanvasNodeContextMenu({
|
||||
);
|
||||
}
|
||||
|
||||
function AddNodeContextMenu({ parentPosition, workspaceMode, isProjectLinked, reducedMotion, onAddNode, onAddFolder, onChooseStyle, onOpenDirector, onUpload, onOpenAssets, onOpenProjectCharacters }: { parentPosition: { left: number; top: number }; workspaceMode: CanvasWorkspaceMode; isProjectLinked: boolean; reducedMotion: boolean; onAddNode: (type: CanvasNodeType) => void; onAddFolder: () => void; onChooseStyle: () => void; onOpenDirector: () => void; onUpload: () => void; onOpenAssets: () => void; onOpenProjectCharacters: () => void }) {
|
||||
function AddNodeContextMenu({ parentPosition, workspaceMode, isProjectLinked, reducedMotion, onAddNode, onAddFolder, onChooseStyle, onOpenDirector, onUpload, onOpenAssets, onOpenProjectCharacters }: { parentPosition: { left: number; top: number }; workspaceMode: CanvasWorkspaceMode; isProjectLinked: boolean; reducedMotion: boolean; onAddNode: (type: CanvasNodeTypeId) => void; onAddFolder: () => void; onChooseStyle: () => void; onOpenDirector: () => void; onUpload: () => void; onOpenAssets: () => void; onOpenProjectCharacters: () => void }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const left = getSubmenuLeft(parentPosition.left);
|
||||
const createContext: AddNodeMenuContext = {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import type { GenerationTask } from "@/services/api/task-center";
|
||||
import { cacheResourceObjectUrl, getCachedResourceObjectUrl } from "@/services/resource-blob-cache";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
import { getNodeDefinition } from "@/lib/canvas/node-registry";
|
||||
import { createDefaultSubtitleStyle } from "@/types/timeline";
|
||||
import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea";
|
||||
import { useCanvasNodeActions } from "./canvas-node-action-context";
|
||||
@@ -61,11 +62,34 @@ export function CanvasNodeContent(props: CanvasNodeContentProps) {
|
||||
if (props.node.metadata?.status === "loading") return <LoadingContent node={props.node} theme={props.theme} onOpenTaskDetails={props.onOpenTaskDetails} />;
|
||||
if (props.node.metadata?.status === "error") return <ErrorContent node={props.node} theme={props.theme} onRetry={props.onRetry} onReloadResource={props.onReloadResource} />;
|
||||
|
||||
const pluginDefinition = getNodeDefinition(props.node.type)?.plugin;
|
||||
if (pluginDefinition) return <PluginCanvasNodeContent {...props} renderer={pluginDefinition.renderer} schema={pluginDefinition.schema} />;
|
||||
const Renderer = nodeContentRenderers[props.node.type];
|
||||
return Renderer ? <Renderer {...props} /> : <UnknownNodeContent theme={props.theme} />;
|
||||
}
|
||||
|
||||
const nodeContentRenderers = {
|
||||
function PluginCanvasNodeContent({ node, theme, renderer, schema }: CanvasNodeContentProps & { renderer: "declarative" | "sandbox"; schema: Record<string, unknown> }) {
|
||||
if (renderer === "sandbox") {
|
||||
return <div className="flex h-full w-full items-center justify-center p-4 text-center text-xs" style={{ color: theme.node.placeholder }}>
|
||||
插件节点等待隔离运行时
|
||||
</div>;
|
||||
}
|
||||
const data = node.metadata?.pluginData || {};
|
||||
const fields = Object.keys(schema.properties && typeof schema.properties === "object" ? schema.properties as Record<string, unknown> : schema);
|
||||
return <div className="flex h-full w-full flex-col gap-3 overflow-auto p-4 pt-10 text-xs" style={{ color: theme.node.text }}>
|
||||
{fields.length ? fields.map((field) => <div key={field} className="flex flex-col gap-1">
|
||||
<span className="font-medium opacity-60">{field}</span>
|
||||
<span className="whitespace-pre-wrap break-words opacity-90">{formatPluginValue(data[field] ?? (field === "content" ? node.metadata?.content : undefined))}</span>
|
||||
</div>) : <span className="whitespace-pre-wrap break-words">{node.metadata?.content || "插件节点"}</span>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function formatPluginValue(value: unknown) {
|
||||
if (value === undefined || value === null || value === "") return "未设置";
|
||||
return typeof value === "string" ? value : JSON.stringify(value);
|
||||
}
|
||||
|
||||
const nodeContentRenderers: Partial<Record<string, (props: CanvasNodeContentProps) => ReactNode>> = {
|
||||
[CanvasNodeType.Text]: TextContent,
|
||||
[CanvasNodeType.Script]: UnknownNodeContent,
|
||||
[CanvasNodeType.Skill]: SkillContent,
|
||||
@@ -82,7 +106,7 @@ const nodeContentRenderers = {
|
||||
[CanvasNodeType.Compare]: CompareNodeContent,
|
||||
[CanvasNodeType.Chart]: ChartNodeContent,
|
||||
[CanvasNodeType.ColorGrade]: ColorGradeNodeContent,
|
||||
} satisfies Record<CanvasNodeType, (props: CanvasNodeContentProps) => ReactNode>;
|
||||
};
|
||||
|
||||
function DrawingContent({ node, theme, drawingProjectId }: CanvasNodeContentProps) {
|
||||
const shapeCount = node.metadata?.drawingShapeCount || 0;
|
||||
|
||||
@@ -8,7 +8,7 @@ import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { storyboardMinNodeHeight } from "@/lib/canvas/canvas-storyboard-layout";
|
||||
import { resourceStorageLabel, resourceStorageLocation, resourceStorageTitle } from "@/lib/canvas/resource-storage-status";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { CanvasNodeType, type CanvasNodeData, type Position } from "@/types/canvas";
|
||||
import { CanvasNodeType, type CanvasNodeData, type CanvasNodeTypeId, type Position } from "@/types/canvas";
|
||||
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
import { getNodeMinSize, shouldKeepAspectRatio } from "@/lib/canvas/node-registry";
|
||||
import { CanvasNodeContent, CanvasNodeImageInfo } from "./canvas-node-content";
|
||||
@@ -660,7 +660,7 @@ function NodeExternalHeader({ node, scale, active, editable, editing, draft, the
|
||||
);
|
||||
}
|
||||
|
||||
function nodeTypeIcon(type: CanvasNodeType) {
|
||||
function nodeTypeIcon(type: CanvasNodeTypeId) {
|
||||
if (type === CanvasNodeType.Image) return ImageIcon;
|
||||
if (type === CanvasNodeType.Video) return Video;
|
||||
if (type === CanvasNodeType.Audio) return Music2;
|
||||
|
||||
@@ -13,7 +13,7 @@ import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style";
|
||||
import { canvasThemes, type CanvasBackgroundMode, type CanvasColorTheme, type CanvasTheme } from "@/lib/canvas-theme";
|
||||
import { defaultToolbarPrefs, readToolbarPrefs, resolveAddNodeMenuCommands, resolveToolbarEntries, type AddNodeMenuCommand, type ToolContext, type ToolbarHandlers, type ToolbarPrefs } from "@/lib/canvas/tool-registry";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import type { CanvasNodeType, CanvasToolMode, CanvasWorkspaceMode } from "@/types/canvas";
|
||||
import type { CanvasNodeTypeId, CanvasToolMode, CanvasWorkspaceMode } from "@/types/canvas";
|
||||
|
||||
export function CanvasToolbar({
|
||||
selectedCount,
|
||||
@@ -65,7 +65,7 @@ export function CanvasToolbar({
|
||||
onAddFrame: () => void;
|
||||
onAddFolder: () => void;
|
||||
onAddDrawing: () => void;
|
||||
onAddExtensionNode: (type: CanvasNodeType) => void;
|
||||
onAddExtensionNode: (type: CanvasNodeTypeId) => void;
|
||||
onOpenDirector: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getDataUrlByteSize } from "@/lib/image-utils";
|
||||
import type { Asset, AssetCategory, NewAsset } from "@/stores/use-asset-store";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
import { CanvasNodeType, type CanvasNodeData, type CanvasNodeTypeId } from "@/types/canvas";
|
||||
|
||||
export type CanvasAssetSource = "canvas-generation" | "canvas-upload" | "canvas-manual";
|
||||
|
||||
@@ -124,7 +124,7 @@ export function canvasNodeAssetCategory(node: CanvasNodeData): AssetCategory {
|
||||
return "other";
|
||||
}
|
||||
|
||||
function canvasAssetFallbackTitle(type: CanvasNodeType) {
|
||||
function canvasAssetFallbackTitle(type: CanvasNodeTypeId) {
|
||||
if (type === CanvasNodeType.Image) return "画布图片";
|
||||
if (type === CanvasNodeType.Video) return "画布视频";
|
||||
if (type === CanvasNodeType.Audio) return "画布音频";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
import { CanvasNodeType, isBuiltinCanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
export const MEDIA_NODE_MIN_SIZE = { width: 420, height: 236 } as const;
|
||||
export const VIDEO_NODE_MAX_SIZE = { width: 720, height: 520 } as const;
|
||||
@@ -34,10 +34,11 @@ export function ensureMediaNodeMinimumSize(node: CanvasNodeData) {
|
||||
const title = node.title === "New Generation" ? "图片" : node.title === "Video" ? "视频" : node.title;
|
||||
let width = node.width;
|
||||
let height = node.height;
|
||||
const emptyStage = NODE_DEFAULT_SIZE[node.type];
|
||||
const emptyStage = isBuiltinCanvasNodeType(node.type) ? NODE_DEFAULT_SIZE[node.type] : undefined;
|
||||
const shouldPromoteEmptyStage = !node.metadata?.content
|
||||
&& !node.metadata?.freeResize
|
||||
&& !node.metadata?.locked
|
||||
&& emptyStage !== undefined
|
||||
&& width * height < emptyStage.width * emptyStage.height;
|
||||
if (shouldPromoteEmptyStage) {
|
||||
width = emptyStage.width;
|
||||
|
||||
@@ -7,9 +7,10 @@ import type { NodeGenerationInput } from "@/components/canvas/canvas-node-genera
|
||||
import { isFrameNode } from "@/lib/canvas/canvas-frame";
|
||||
import { nodeSizeFromRatio } from "@/lib/canvas/canvas-node-size";
|
||||
import { canvasResourceMentionToken, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
import { getNodeDefinition } from "@/lib/canvas/node-registry";
|
||||
import { scopedLocalStorage } from "@/lib/user-scope";
|
||||
import type { GenerationTask } from "@/services/api/task-center";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type CanvasWorkspaceMode, type ConnectionHandle, type Position, type StoryboardColumn, type StoryboardRow } from "@/types/canvas";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type CanvasNodeTypeId, type CanvasWorkspaceMode, type ConnectionHandle, type Position, type StoryboardColumn, type StoryboardRow } from "@/types/canvas";
|
||||
|
||||
const CANVAS_WORKSPACE_MODE_STORAGE_KEY = "canvas-workspace-mode-v1";
|
||||
|
||||
@@ -32,8 +33,11 @@ export function persistCanvasWorkspaceMode(mode: CanvasWorkspaceMode) {
|
||||
}
|
||||
|
||||
|
||||
export function createCanvasNode(type: CanvasNodeType, position: Position, metadata?: CanvasNodeMetadata): CanvasNodeData {
|
||||
const spec = getNodeSpec(type);
|
||||
export function createCanvasNode(type: CanvasNodeTypeId, position: Position, metadata?: CanvasNodeMetadata): CanvasNodeData {
|
||||
const builtinSpec = type in NODE_DEFAULT_SIZE ? getNodeSpec(type as CanvasNodeType) : undefined;
|
||||
const pluginDefinition = getNodeDefinition(type);
|
||||
const spec = builtinSpec || (pluginDefinition ? { width: pluginDefinition.defaultSize.width, height: pluginDefinition.defaultSize.height, title: pluginDefinition.defaultTitle, metadata: pluginDefinition.defaultMetadata } : undefined);
|
||||
if (!spec) throw new Error(`未注册的画布节点类型:${type}`);
|
||||
const id = `${type}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
|
||||
return {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getNodeResourceKind } from "@/lib/canvas/node-registry";
|
||||
import { seedanceReferenceLabel } from "@/lib/seedance-video";
|
||||
import type { Skill } from "@/services/api/skills";
|
||||
import type { Asset, AssetCategory } from "@/stores/use-asset-store";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeTypeId } from "@/types/canvas";
|
||||
|
||||
export type CanvasResourceKind = "image" | "video" | "audio" | "text" | "skill" | "character";
|
||||
|
||||
@@ -17,7 +17,7 @@ export type CanvasResourceReference = {
|
||||
storageKey?: string;
|
||||
text?: string;
|
||||
active: boolean;
|
||||
sourceType?: CanvasNodeType;
|
||||
sourceType?: CanvasNodeTypeId;
|
||||
skill?: Skill;
|
||||
assetId?: string;
|
||||
category?: AssetCategory;
|
||||
|
||||
@@ -16,8 +16,7 @@ const DEFAULT_MIN_SIZE = { width: 220, height: 160 } as const;
|
||||
* 尺寸、默认标题与初始 metadata 不在这里重复——它们的数据源是 constant/canvas.ts 的
|
||||
* NODE_SPECS,下方装配时统一读取。
|
||||
*
|
||||
* `satisfies Record<CanvasNodeType, …>` 是刻意的:往 CanvasNodeType 加成员后若不补定义,
|
||||
* 这里会编译失败,而不是在运行期悄悄回落成兜底值。
|
||||
* 内置节点保持集中定义;第三方节点通过同一 registry 以命名空间 ID 动态注册。
|
||||
*/
|
||||
const BUILTIN_NODE_TRAITS = {
|
||||
[CanvasNodeType.Image]: {
|
||||
@@ -166,7 +165,7 @@ const BUILTIN_NODE_TRAITS = {
|
||||
resourceKind: () => "image",
|
||||
inputKind: "image",
|
||||
},
|
||||
} satisfies Record<CanvasNodeType, Omit<CanvasNodeDefinition, "type" | "defaultTitle" | "defaultSize" | "defaultMetadata">>;
|
||||
} satisfies Record<string, Omit<CanvasNodeDefinition, "type" | "defaultTitle" | "defaultSize" | "defaultMetadata">>;
|
||||
|
||||
export const BUILTIN_NODE_DEFINITIONS: CanvasNodeDefinition[] = (Object.keys(BUILTIN_NODE_TRAITS) as CanvasNodeType[]).map((type) => {
|
||||
const spec = NODE_SPECS[type];
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export type { CanvasNodeDefinition, CanvasNodeInputKind } from "./node-definition";
|
||||
export { getNodeDefinition, getNodeGenerationMode, getNodeIcon, getNodeInputKind, getNodeLabel, getNodeListLabel, getNodeMinSize, getNodeOwnerId, getNodeResourceKind, listCreatableNodeDefinitions, listNodeDefinitions, registerNodeDefinitions, shouldKeepAspectRatio, unregisterNodeDefinitions } from "./node-registry";
|
||||
export { getNodeDefinition, getNodeGenerationMode, getNodeIcon, getNodeInputKind, getNodeLabel, getNodeListLabel, getNodeMinSize, getNodeOwnerId, getNodeResourceKind, listCreatableNodeDefinitions, listNodeDefinitions, registerNodeDefinitions, registerPluginCanvasNodes, shouldKeepAspectRatio, unregisterNodeDefinitions } from "./node-registry";
|
||||
import "./definitions";
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import type { CanvasResourceKind } from "@/lib/canvas/canvas-resource-references";
|
||||
import type { CanvasGenerationMode, CanvasNodeData, CanvasNodeMetadata, CanvasNodeType } from "@/types/canvas";
|
||||
import type { CanvasGenerationMode, CanvasNodeData, CanvasNodeMetadata, CanvasNodeTypeId } from "@/types/canvas";
|
||||
import type { PluginCanvasNodeContribution } from "@/lib/plugins/plugin-types";
|
||||
|
||||
/** 作为上游输入被容量校验计数时归入的类别 */
|
||||
export type CanvasNodeInputKind = "image" | "video" | "audio" | "text";
|
||||
@@ -13,7 +14,7 @@ export type CanvasNodeInputKind = "image" | "video" | "audio" | "text";
|
||||
* 避免同一份知识散落到创建菜单、搜索弹窗、拉伸逻辑等多处后各自漂移。
|
||||
*/
|
||||
export type CanvasNodeDefinition = {
|
||||
type: CanvasNodeType;
|
||||
type: CanvasNodeTypeId;
|
||||
/** UI 短标签——创建菜单等处显示,如「文本」 */
|
||||
label: string;
|
||||
/** 列表/搜索标签,缺省派生为 `${label}节点` */
|
||||
@@ -45,4 +46,23 @@ export type CanvasNodeDefinition = {
|
||||
* 不设表示不参与计数(生成配置、背板)。与 resourceKind 不同,计数不看内容。
|
||||
*/
|
||||
inputKind?: CanvasNodeInputKind;
|
||||
plugin?: {
|
||||
pluginId: string;
|
||||
renderer: PluginCanvasNodeContribution["renderer"];
|
||||
schema: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
export function canvasNodeDefinitionFromPlugin(pluginId: string, contribution: PluginCanvasNodeContribution): CanvasNodeDefinition {
|
||||
return {
|
||||
type: contribution.id,
|
||||
label: contribution.label,
|
||||
icon: null,
|
||||
defaultTitle: contribution.defaultTitle,
|
||||
defaultSize: contribution.defaultSize,
|
||||
defaultMetadata: { pluginId, pluginNodeId: contribution.id, pluginData: {}, content: "" },
|
||||
minSize: { width: Math.min(contribution.defaultSize.width, 220), height: Math.min(contribution.defaultSize.height, 160) },
|
||||
showInCreateMenu: true,
|
||||
plugin: { pluginId, renderer: contribution.renderer, schema: contribution.schema },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { CanvasNodeData, CanvasNodeType } from "@/types/canvas";
|
||||
import type { CanvasNodeData, CanvasNodeTypeId } from "@/types/canvas";
|
||||
import type { PluginCanvasNodeContribution } from "@/lib/plugins/plugin-types";
|
||||
|
||||
import type { CanvasNodeDefinition } from "./node-definition";
|
||||
import { canvasNodeDefinitionFromPlugin, type CanvasNodeDefinition } from "./node-definition";
|
||||
|
||||
/** 模块级注册表 */
|
||||
const definitions = new Map<CanvasNodeType, CanvasNodeDefinition>();
|
||||
const definitions = new Map<CanvasNodeTypeId, CanvasNodeDefinition>();
|
||||
/** 节点类型 → 归属方。内置节点归属 "builtin",为后续扩展留出隔离位。 */
|
||||
const ownerByType = new Map<CanvasNodeType, string>();
|
||||
const ownerByType = new Map<CanvasNodeTypeId, string>();
|
||||
|
||||
/** 未注册类型的兜底最小尺寸——与非媒体节点的历史下限一致 */
|
||||
const FALLBACK_MIN_SIZE = { width: 220, height: 160 } as const;
|
||||
@@ -18,6 +19,11 @@ export function registerNodeDefinitions(defs: CanvasNodeDefinition[], ownerId =
|
||||
}
|
||||
}
|
||||
|
||||
/** Registers schema-driven canvas nodes from the unified plugin manifest. */
|
||||
export function registerPluginCanvasNodes(pluginId: string, nodes: PluginCanvasNodeContribution[]) {
|
||||
registerNodeDefinitions(nodes.map((node) => canvasNodeDefinitionFromPlugin(pluginId, node)), pluginId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注销某归属方的全部节点定义。
|
||||
* 只删归属该 ownerId 的条目——内置节点不会被其他归属方的增删波及。
|
||||
@@ -30,11 +36,11 @@ export function unregisterNodeDefinitions(ownerId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export function getNodeDefinition(type: CanvasNodeType) {
|
||||
export function getNodeDefinition(type: CanvasNodeTypeId) {
|
||||
return definitions.get(type);
|
||||
}
|
||||
|
||||
export function getNodeOwnerId(type: CanvasNodeType) {
|
||||
export function getNodeOwnerId(type: CanvasNodeTypeId) {
|
||||
return ownerByType.get(type) || "builtin";
|
||||
}
|
||||
|
||||
@@ -48,23 +54,23 @@ export function listCreatableNodeDefinitions() {
|
||||
}
|
||||
|
||||
/** UI 短标签 */
|
||||
export function getNodeLabel(type: CanvasNodeType) {
|
||||
export function getNodeLabel(type: CanvasNodeTypeId) {
|
||||
return definitions.get(type)?.label || "未知节点";
|
||||
}
|
||||
|
||||
/** 列表/搜索标签,缺省派生自 label */
|
||||
export function getNodeListLabel(type: CanvasNodeType) {
|
||||
export function getNodeListLabel(type: CanvasNodeTypeId) {
|
||||
const def = definitions.get(type);
|
||||
if (!def) return "未知节点";
|
||||
return def.listLabel || `${def.label}节点`;
|
||||
}
|
||||
|
||||
export function getNodeIcon(type: CanvasNodeType) {
|
||||
export function getNodeIcon(type: CanvasNodeTypeId) {
|
||||
return definitions.get(type)?.icon ?? null;
|
||||
}
|
||||
|
||||
/** 手动拉伸的最小尺寸 */
|
||||
export function getNodeMinSize(type: CanvasNodeType) {
|
||||
export function getNodeMinSize(type: CanvasNodeTypeId) {
|
||||
return definitions.get(type)?.minSize ?? FALLBACK_MIN_SIZE;
|
||||
}
|
||||
|
||||
@@ -87,6 +93,6 @@ export function getNodeGenerationMode(node: CanvasNodeData) {
|
||||
}
|
||||
|
||||
/** 该节点作为上游输入被计数时的类别,不参与计数返回 undefined */
|
||||
export function getNodeInputKind(type: CanvasNodeType) {
|
||||
export function getNodeInputKind(type: CanvasNodeTypeId) {
|
||||
return definitions.get(type)?.inputKind;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Clapperboard, Folder, FolderOpen, Image as ImageIcon, Layers3, Music2,
|
||||
|
||||
import { getNodeIcon, getNodeLabel } from "@/lib/canvas/node-registry";
|
||||
import { registerAddNodeMenuCommands, type AddNodeMenuCommand } from "@/lib/canvas/tool-registry";
|
||||
import { CanvasNodeType } from "@/types/canvas";
|
||||
import { CanvasNodeType, type CanvasNodeTypeId } from "@/types/canvas";
|
||||
|
||||
/** 真正创建节点的命令,文案与图标统一取自节点注册表。 */
|
||||
function nodeCommand(type: CanvasNodeType, rest: Omit<AddNodeMenuCommand, "id" | "label" | "icon" | "section">): AddNodeMenuCommand {
|
||||
@@ -13,7 +13,7 @@ function nodeCommand(type: CanvasNodeType, rest: Omit<AddNodeMenuCommand, "id" |
|
||||
* 扩展节点命令:落在独立的「展示与加工」分区,不挤占上方调好的四列创作网格。
|
||||
* 创建逻辑一律是 createNode(type),所以统一走 onAddExtensionNode 而不是各开一个 handler。
|
||||
*/
|
||||
function extensionCommand(type: CanvasNodeType, defaultOrder: number): AddNodeMenuCommand {
|
||||
function extensionCommand(type: CanvasNodeTypeId, defaultOrder: number): AddNodeMenuCommand {
|
||||
return { id: type, label: getNodeLabel(type), icon: getNodeIcon(type), section: "extension", defaultOrder, run: (ctx) => ctx.handlers.onAddExtensionNode(type) };
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { MouseEvent } from "react";
|
||||
|
||||
import type { CanvasAlignmentMode } from "@/lib/canvas/canvas-layout";
|
||||
import type { CanvasBackgroundMode } from "@/lib/canvas-theme";
|
||||
import type { CanvasNodeData, CanvasNodeMetadata, CanvasNodeType, CanvasToolMode, CanvasWorkspaceMode } from "@/types/canvas";
|
||||
import type { CanvasNodeData, CanvasNodeMetadata, CanvasNodeTypeId, CanvasToolMode, CanvasWorkspaceMode } from "@/types/canvas";
|
||||
|
||||
/** 工具栏标识——每个工具栏有独立的注册表与偏好 */
|
||||
export type ToolbarId = "main" | "selection" | "node-hover" | "add-node-menu";
|
||||
@@ -43,7 +43,7 @@ export type ToolbarHandlers = {
|
||||
* 不给每种扩展节点单开一个 onAddXxx —— 那会让 ToolbarHandlers 随节点数线性膨胀,
|
||||
* 而它们的创建逻辑完全一致(都只是 createNode(type))。
|
||||
*/
|
||||
onAddExtensionNode: (type: CanvasNodeType) => void;
|
||||
onAddExtensionNode: (type: CanvasNodeTypeId) => void;
|
||||
onChooseStyle: () => void;
|
||||
onOpenDirector: () => void;
|
||||
// 主工具栏——资源
|
||||
@@ -102,7 +102,7 @@ export type ToolbarHandlers = {
|
||||
/** 工具运行时可见的上下文。工具定义通过纯函数读取状态 */
|
||||
export type ToolContext = {
|
||||
selectedCount: number;
|
||||
selectedNodeTypes: Set<CanvasNodeType>;
|
||||
selectedNodeTypes: Set<CanvasNodeTypeId>;
|
||||
selectedVideoCount: number;
|
||||
canvasTool: CanvasToolMode;
|
||||
workspaceMode: CanvasWorkspaceMode;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export type ModelProtocol = string;
|
||||
export type ProtocolCapability = "text" | "image" | "video" | "audio";
|
||||
export type ModelProtocolDefinition = { value: ModelProtocol; label: string; vendor?: string; capability: ProtocolCapability; create: string; contentType: string; poll?: string; media: string; enabled?: boolean };
|
||||
export type ModelProtocolWorkflow = { id: string; label: string; providerId: string; capability: ProtocolCapability; parameters: Array<{ name: string; type: string; required?: boolean; description?: string; values?: string[]; mapping?: string }>; defaults?: Record<string, string | number | boolean> };
|
||||
export type ModelProtocolDefinition = { value: ModelProtocol; label: string; vendor?: string; capability: ProtocolCapability; create: string; contentType: string; poll?: string; media: string; enabled?: boolean; baseUrl?: string; workflows?: ModelProtocolWorkflow[] };
|
||||
|
||||
export function protocolGroups(protocols: ModelProtocolDefinition[]) {
|
||||
return (["text", "image", "video", "audio"] as ProtocolCapability[]).map((capability) => ({ label: { text: "文本", image: "图片", video: "视频", audio: "音频" }[capability], options: protocols.filter((item) => item.capability === capability && item.enabled !== false).map((item) => ({ label: `${item.label} · ${item.create.replace(/^POST /, "")}`, value: item.value })) }));
|
||||
|
||||
@@ -250,7 +250,11 @@ export function resolveModelGenerationDefaults(
|
||||
const channel = resolveModelChannel(config, model);
|
||||
const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(model));
|
||||
const isManagedModel = Boolean(cost?.logicalModelId || cost?.logicalCapabilitySpec);
|
||||
const source = (key: keyof ModelGenerationDefaults) => explicit[key] ?? (isManagedModel ? undefined : fallback[key]);
|
||||
// A channel model capability profile is an explicit per-model contract too.
|
||||
// The persisted global values are legacy defaults and must not override a
|
||||
// model's configured duration, ratio, or resolution on a new canvas node.
|
||||
const hasModelCapabilityProfile = Boolean(cost?.capabilityConfig);
|
||||
const source = (key: keyof ModelGenerationDefaults) => explicit[key] ?? (isManagedModel || hasModelCapabilityProfile ? undefined : fallback[key]);
|
||||
const profile = modelCapabilityConfigFor(config, model);
|
||||
|
||||
if (capability === "image" && profile.image) {
|
||||
|
||||
@@ -96,15 +96,21 @@ export const eagleAssetPlugin: RegisteredPlugin = {
|
||||
version: "0.3.0",
|
||||
publishedAt: "2026-08-21",
|
||||
updatedAt: "2026-08-22",
|
||||
apiVersion: "1",
|
||||
category: "asset-source",
|
||||
apiVersion: "yingce.plugin/v1",
|
||||
description: "把 Eagle 作为影策的外部素材来源,直接浏览原始文件夹并读写 Eagle 文件。",
|
||||
documentation: eaglePluginDocumentation,
|
||||
author: "影策社区",
|
||||
surfaces: ["asset-source"],
|
||||
permissions: ["asset.read", "asset.search", "asset.upload", "external.open"],
|
||||
trusted: true,
|
||||
configuration: { fields: ["baseUrl", "autoUploadGenerated", "generatedFolderId"] },
|
||||
configuration: {
|
||||
fields: [
|
||||
{ name: "baseUrl", type: "url", label: "Eagle Base URL", required: true, default: EAGLE_DEFAULT_BASE_URL },
|
||||
{ name: "autoUploadGenerated", type: "boolean", label: "自动上传生成结果", default: true },
|
||||
{ name: "generatedFolderId", type: "string", label: "生成结果文件夹" },
|
||||
],
|
||||
},
|
||||
runtime: { web: "trusted-backend" },
|
||||
contributes: { assetSources: ["eagle"] },
|
||||
},
|
||||
createAssetSource: ({ config }: PluginHostContext) => {
|
||||
const configuredBaseUrl = config.baseUrl;
|
||||
|
||||
@@ -59,13 +59,13 @@ const manifest: PluginManifest = {
|
||||
id: PROMPT_OPTIMIZER_PLUGIN_ID,
|
||||
name: "AI 提示词优化器",
|
||||
version: "0.3.0",
|
||||
apiVersion: "1",
|
||||
category: "ai-capability",
|
||||
apiVersion: "yingce.plugin/v1",
|
||||
description: "把模糊的生图想法整理成可执行、可比较、适配当前模型的提示词。",
|
||||
author: "影策团队",
|
||||
surfaces: ["hybrid"],
|
||||
permissions: ["canvas.read", "canvas.write", "ai.text"],
|
||||
trusted: true,
|
||||
runtime: { web: "trusted-backend" },
|
||||
contributes: { aiCapabilities: ["prompt-optimizer"] },
|
||||
};
|
||||
|
||||
const systemPrompt = [
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { PluginManifest, RegisteredPlugin } from "./plugin-types";
|
||||
import { registerPluginCanvasNodes, unregisterNodeDefinitions } from "@/lib/canvas/node-registry";
|
||||
|
||||
const registeredPlugins = new Map<string, RegisteredPlugin>();
|
||||
|
||||
function assertManifest(manifest: PluginManifest) {
|
||||
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(manifest.id)) throw new Error("插件 ID 必须使用 kebab-case");
|
||||
if (!manifest.name.trim() || !manifest.version.trim() || !manifest.apiVersion.trim()) throw new Error("插件清单缺少名称、版本或 API 版本");
|
||||
if (!manifest.surfaces.length) throw new Error("插件至少需要声明一种界面形态");
|
||||
if (manifest.apiVersion !== "yingce.plugin/v1") throw new Error(`不支持的插件 API 版本:${manifest.apiVersion}`);
|
||||
if (new Set(manifest.permissions).size !== manifest.permissions.length) throw new Error("插件权限不能重复");
|
||||
if (!manifest.contributes || Object.values(manifest.contributes).every((value) => !value || value.length === 0)) throw new Error("插件至少需要声明一种贡献能力");
|
||||
}
|
||||
|
||||
export function registerPlugin(plugin: RegisteredPlugin) {
|
||||
@@ -15,10 +17,14 @@ export function registerPlugin(plugin: RegisteredPlugin) {
|
||||
if (existing && existing.manifest.version !== plugin.manifest.version) {
|
||||
throw new Error(`插件 ${plugin.manifest.id} 已注册其他版本`);
|
||||
}
|
||||
if (plugin.manifest.contributes.canvasNodes?.length) {
|
||||
registerPluginCanvasNodes(plugin.manifest.id, plugin.manifest.contributes.canvasNodes);
|
||||
}
|
||||
registeredPlugins.set(plugin.manifest.id, plugin);
|
||||
}
|
||||
|
||||
export function unregisterPlugin(pluginId: string) {
|
||||
unregisterNodeDefinitions(pluginId);
|
||||
registeredPlugins.delete(pluginId);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,90 @@
|
||||
import type { Asset } from "@/stores/use-asset-store";
|
||||
|
||||
export const PLUGIN_API_VERSION = "1" as const;
|
||||
export const PLUGIN_API_VERSION = "yingce.plugin/v1" as const;
|
||||
|
||||
export type PluginCategory = "asset-source" | "canvas-node" | "workflow" | "ai-capability" | "import-export" | "agent" | "protocol";
|
||||
export type PluginSurface = "node" | "fullscreen" | "hybrid" | "asset-source";
|
||||
export type PluginContributionKind = "provider" | "workflow" | "canvas-node" | "transform" | "command" | "asset-source" | "usage-observer" | "ai-capability" | "agent" | "import-export";
|
||||
export type PluginSurface = "node" | "fullscreen" | "hybrid" | "asset-source" | "settings";
|
||||
export type ProtocolCapability = "text" | "image" | "video" | "audio";
|
||||
export type ProtocolScope = "admin.system-channel" | "user.custom-channel" | "canvas" | "creation" | "agent" | string;
|
||||
export type ProtocolPluginInfo = {
|
||||
categories: ProtocolCapability[];
|
||||
scopes: ProtocolScope[];
|
||||
create?: string;
|
||||
poll?: string;
|
||||
cancel?: string;
|
||||
export type PluginRuntime = "declarative" | "sandbox" | "worker" | "trusted-backend";
|
||||
export type PluginField = {
|
||||
name: string;
|
||||
type: "string" | "number" | "boolean" | "secret" | "url" | "select" | "json";
|
||||
label?: string;
|
||||
required?: boolean;
|
||||
default?: string | number | boolean;
|
||||
description?: string;
|
||||
values?: string[];
|
||||
};
|
||||
export type PluginParameter = {
|
||||
name: string;
|
||||
type: string;
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
values?: string[];
|
||||
mapping?: string;
|
||||
};
|
||||
export type PluginProviderOperation = {
|
||||
method: "GET" | "POST" | "PUT" | "DELETE";
|
||||
path: string;
|
||||
contentType?: string;
|
||||
documentation?: string;
|
||||
parameters?: Array<{ name: string; type: string; required?: boolean; description?: string; values?: string[]; mapping?: string }>;
|
||||
fields?: Record<string, string>;
|
||||
};
|
||||
export type PluginProviderContribution = {
|
||||
id: string;
|
||||
label: string;
|
||||
capabilities: ProtocolCapability[];
|
||||
scopes: ProtocolScope[];
|
||||
baseUrl?: string;
|
||||
auth?: { type: "bearer" | "api-key" | "custom"; field: string; header?: string };
|
||||
parameters?: PluginParameter[];
|
||||
create: PluginProviderOperation;
|
||||
poll?: PluginProviderOperation;
|
||||
cancel?: PluginProviderOperation;
|
||||
response: {
|
||||
taskIdPaths?: string[];
|
||||
statusPaths?: string[];
|
||||
messagePaths?: string[];
|
||||
textPaths?: string[];
|
||||
reasoningPaths?: string[];
|
||||
resultPaths?: string[];
|
||||
resultKind?: "image" | "video" | "audio";
|
||||
resultEphemeral?: boolean;
|
||||
};
|
||||
};
|
||||
export type PluginWorkflowContribution = {
|
||||
id: string;
|
||||
label: string;
|
||||
providerId: string;
|
||||
capability: ProtocolCapability;
|
||||
parameters: PluginParameter[];
|
||||
defaults?: Record<string, string | number | boolean>;
|
||||
};
|
||||
export type PluginCanvasNodeContribution = {
|
||||
id: string;
|
||||
label: string;
|
||||
defaultTitle: string;
|
||||
defaultSize: { width: number; height: number };
|
||||
schema: Record<string, unknown>;
|
||||
renderer: "declarative" | "sandbox";
|
||||
};
|
||||
export type PluginTransformContribution = {
|
||||
id: string;
|
||||
input: "media" | "generation";
|
||||
output: "provider-request" | "media";
|
||||
runtime: PluginRuntime;
|
||||
};
|
||||
export type PluginContributions = {
|
||||
providers?: PluginProviderContribution[];
|
||||
workflows?: PluginWorkflowContribution[];
|
||||
canvasNodes?: PluginCanvasNodeContribution[];
|
||||
transforms?: PluginTransformContribution[];
|
||||
commands?: Array<{ id: string; label: string }>;
|
||||
assetSources?: string[];
|
||||
usageObservers?: string[];
|
||||
aiCapabilities?: string[];
|
||||
agents?: string[];
|
||||
importExport?: string[];
|
||||
};
|
||||
export type PluginPermission =
|
||||
| "canvas.read"
|
||||
@@ -25,28 +95,27 @@ export type PluginPermission =
|
||||
| "asset.upload"
|
||||
| "generation.run"
|
||||
| "ai.text"
|
||||
| "media.read"
|
||||
| "usage.read"
|
||||
| "external.open";
|
||||
|
||||
export type PluginManifest = {
|
||||
apiVersion: typeof PLUGIN_API_VERSION;
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
publishedAt?: string;
|
||||
updatedAt?: string;
|
||||
apiVersion: string;
|
||||
category: PluginCategory;
|
||||
description: string;
|
||||
documentation?: string;
|
||||
author?: string;
|
||||
entry?: string;
|
||||
surfaces: PluginSurface[];
|
||||
surfaces?: PluginSurface[];
|
||||
permissions: PluginPermission[];
|
||||
trusted?: boolean;
|
||||
kind?: "ui" | "protocol";
|
||||
configuration?: {
|
||||
fields: string[];
|
||||
};
|
||||
protocol?: ProtocolPluginInfo;
|
||||
configuration?: { fields: PluginField[] };
|
||||
runtime?: { backend?: PluginRuntime; web?: PluginRuntime };
|
||||
contributes: PluginContributions;
|
||||
};
|
||||
|
||||
export type PluginStorage = {
|
||||
@@ -103,6 +172,12 @@ export type PluginHostServices = {
|
||||
ai?: {
|
||||
text?: PluginAiTextService;
|
||||
};
|
||||
media?: {
|
||||
resolve: (reference: { url?: string; dataUrl?: string; kind?: string }, signal?: AbortSignal) => Promise<{ dataUrl: string; mimeType: string }>;
|
||||
};
|
||||
usage?: {
|
||||
list: (scope?: string) => Promise<ReadonlyArray<Record<string, unknown>>>;
|
||||
};
|
||||
};
|
||||
|
||||
export type PluginHostContext = {
|
||||
|
||||
@@ -9,7 +9,7 @@ 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 { modelProtocolCapability, modelProtocolDefinition, modelProtocolLabel, modelProtocolSupportsTokenBilling, type ModelProtocol } from "@/lib/model-protocols";
|
||||
import { fetchProtocolCatalog } from "@/services/api/protocols";
|
||||
import { fetchPluginProviderCatalog } from "@/services/api/plugin-catalog";
|
||||
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";
|
||||
@@ -81,7 +81,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
void fetchProtocolCatalog("admin.system-channel").then(setAvailableProtocols).catch(() => setAvailableProtocols([]));
|
||||
void fetchPluginProviderCatalog("admin.system-channel").then(setAvailableProtocols).catch(() => setAvailableProtocols([]));
|
||||
setEditing(null);
|
||||
setEditorOpen(false);
|
||||
setKeyword("");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CanvasNodeContextMenu } from "@/components/canvas/canvas-context-menu";
|
||||
import { CanvasNodeType, type CanvasNodeData, type CanvasWorkspaceMode, type ContextMenuState, type Position } from "@/types/canvas";
|
||||
import { CanvasNodeType, type CanvasNodeData, type CanvasNodeTypeId, type CanvasWorkspaceMode, type ContextMenuState, type Position } from "@/types/canvas";
|
||||
|
||||
type CanvasAssetCategory = NonNullable<NonNullable<CanvasNodeData["metadata"]>["assetCategory"]>;
|
||||
|
||||
@@ -13,7 +13,7 @@ type CanvasProjectContextMenuProps = {
|
||||
canPaste: boolean;
|
||||
screenToCanvas: (clientX: number, clientY: number) => Position;
|
||||
onClose: () => void;
|
||||
onAddNode: (type: CanvasNodeType, position: Position) => void;
|
||||
onAddNode: (type: CanvasNodeTypeId, position: Position) => void;
|
||||
onAddFolder: (position: Position) => void;
|
||||
onChooseStyle: () => void;
|
||||
onOpenDirector: (position?: Position) => void;
|
||||
|
||||
@@ -926,6 +926,7 @@ function InfiniteCanvasPage() {
|
||||
setContextMenu(null);
|
||||
setHoveredNodeId(null);
|
||||
setToolbarNodeId(null);
|
||||
setDialogNodeId(null);
|
||||
}, []);
|
||||
|
||||
const { alignmentGuides, cancelSelectionBox, deselectCanvas, dragPreview, frameDropTargetId, handleCanvasMouseDown, handleNodeMouseDown, isNodeDragging, nodeDraggingRef, selectionBoundsElementRef, selectionBox } = useCanvasSelectionController({
|
||||
|
||||
@@ -32,7 +32,7 @@ import type { GenerationTask } from "@/services/api/task-center";
|
||||
import { resolveImageUrl } from "@/services/image-storage";
|
||||
import { resolveModelRequestConfig, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
import type { Asset } from "@/stores/use-asset-store";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeTypeId } from "@/types/canvas";
|
||||
|
||||
type UseCanvasGenerationRetryOptions = {
|
||||
projectId: string;
|
||||
@@ -387,7 +387,7 @@ export function useCanvasGenerationRetry({
|
||||
}
|
||||
|
||||
// 生成类型必须由明确的节点契约决定,未知节点不能降级成图片任务。
|
||||
function retryModeForNode(type: CanvasNodeType): CanvasNodeGenerationMode | null {
|
||||
function retryModeForNode(type: CanvasNodeTypeId): CanvasNodeGenerationMode | null {
|
||||
if (type === CanvasNodeType.Text) return "text";
|
||||
if (type === CanvasNodeType.Image) return "image";
|
||||
if (type === CanvasNodeType.Video) return "video";
|
||||
|
||||
@@ -8,7 +8,7 @@ import { FOLDER_COLLAPSED_HEIGHT, FOLDER_COLLAPSED_WIDTH, FRAME_HEADER_HEIGHT, g
|
||||
import { alignCanvasNodes, layoutCanvasFlow, layoutCanvasNodes, nextCanvasVersionLabel, type CanvasAlignmentMode } from "@/lib/canvas/canvas-layout";
|
||||
import { createCanvasNode, isHiddenBatchChild, removeCanvasNodes } from "@/lib/canvas/canvas-project-domain";
|
||||
import { isolateCopiedNodeMetadata } from "@/lib/canvas/canvas-node-copy";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasFolderStyle, type CanvasFolderTheme, type CanvasNodeData, type ContextMenuState, type Position } from "@/types/canvas";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasFolderStyle, type CanvasFolderTheme, type CanvasNodeData, type CanvasNodeTypeId, type ContextMenuState, type Position } from "@/types/canvas";
|
||||
import { cloneCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage";
|
||||
import { isDrawingEngineAvailable, type CanvasDrawingEngine } from "@/lib/canvas/canvas-drawing-engine";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
@@ -122,7 +122,7 @@ export function useCanvasNodeOperations({
|
||||
setSelectedConnectionId(null);
|
||||
}, [selectedNodeIdsRef, setSelectedConnectionId, setSelectedNodeIds]);
|
||||
|
||||
const createNode = useCallback((type: CanvasNodeType, position?: Position) => {
|
||||
const createNode = useCallback((type: CanvasNodeTypeId, position?: Position) => {
|
||||
if (type === CanvasNodeType.Drawing && !isDrawingEngineAvailable(defaultDrawingEngine, tldrawLicenseKey)) {
|
||||
message.error("当前生产构建未配置 tldraw License Key,不能创建 tldraw 绘图");
|
||||
return;
|
||||
|
||||
@@ -571,7 +571,7 @@ export function useCanvasUpload({
|
||||
|| (targetNode.type === CanvasNodeType.Image && file.type.startsWith("image/"))
|
||||
|| (targetNode.type === CanvasNodeType.Video && file.type.startsWith("video/"))
|
||||
|| (targetNode.type === CanvasNodeType.Audio && isAudioFile(file))
|
||||
|| ![CanvasNodeType.Image, CanvasNodeType.Video, CanvasNodeType.Audio].includes(targetNode.type);
|
||||
|| (targetNode.type !== CanvasNodeType.Image && targetNode.type !== CanvasNodeType.Video && targetNode.type !== CanvasNodeType.Audio);
|
||||
if (!compatible) {
|
||||
message.warning("请选择与当前节点相同类型的媒体文件");
|
||||
return;
|
||||
|
||||
@@ -17,13 +17,15 @@ import { PluginDetailsModal, UploadPluginModal } from "./plugin-documentation-mo
|
||||
import "./plugins.css";
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
"asset-source": "素材来源",
|
||||
provider: "模型渠道",
|
||||
"canvas-node": "画布节点",
|
||||
workflow: "工作流",
|
||||
transform: "媒体转换",
|
||||
"asset-source": "素材来源",
|
||||
"ai-capability": "AI 能力",
|
||||
"import-export": "导入导出",
|
||||
"usage-observer": "用量观察",
|
||||
agent: "智能体",
|
||||
protocol: "请求协议",
|
||||
"import-export": "导入导出",
|
||||
};
|
||||
|
||||
const surfaceLabels: Record<string, string> = {
|
||||
@@ -121,13 +123,14 @@ export default function PluginsPage() {
|
||||
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 enabled = 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();
|
||||
const contributionKinds = contributionKindsFor(manifest);
|
||||
const searchableText = [manifest.name, manifest.description, manifest.author, manifest.id, ...contributionKinds.map((kind) => categoryLabels[kind] || kind)].filter(Boolean).join(" ").toLocaleLowerCase();
|
||||
if (normalizedSearch && !searchableText.includes(normalizedSearch)) 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";
|
||||
const matchesCapability = providerCapabilitiesFor(manifest).includes(categoryFilter as "text" | "image" | "video" | "audio");
|
||||
const matchesApp = categoryFilter === "other" && contributionKinds.length > 0 && !matchesCapability;
|
||||
if (!matchesCapability && !matchesApp) return false;
|
||||
}
|
||||
if (trustFilter === "trusted" && !manifest.trusted) return false;
|
||||
@@ -139,8 +142,8 @@ export default function PluginsPage() {
|
||||
|
||||
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") },
|
||||
...protocolSectionMeta.map((section) => ({ ...section, plugins: filteredPlugins.filter((plugin) => providerCapabilitiesFor(plugin.manifest).includes(section.key)) })),
|
||||
{ key: "other", label: "应用插件", description: "画布、素材与工作流扩展", icon: PlugZap, plugins: filteredPlugins.filter((plugin) => !providerCapabilitiesFor(plugin.manifest).length) },
|
||||
],
|
||||
[filteredPlugins],
|
||||
);
|
||||
@@ -149,11 +152,9 @@ export default function PluginsPage() {
|
||||
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 || []) {
|
||||
const capabilities = providerCapabilitiesFor(plugin.manifest);
|
||||
if (!capabilities.length) counts.other += 1;
|
||||
for (const capability of capabilities) {
|
||||
counts[capability] = (counts[capability] || 0) + 1;
|
||||
}
|
||||
}
|
||||
@@ -162,23 +163,23 @@ export default function PluginsPage() {
|
||||
|
||||
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 = settingsPlugin?.manifest.kind === "protocol" ? backendPluginById.get(settingsPlugin.manifest.id)?.status === "enabled" : Boolean(settingsInstallation?.enabled);
|
||||
const settingsEnabled = settingsPlugin ? backendPluginById.get(settingsPlugin.manifest.id)?.status === "enabled" || Boolean(settingsInstallation?.enabled) : false;
|
||||
const detailsPlugin = detailsPluginId ? registeredPlugins.find((plugin) => plugin.manifest.id === detailsPluginId) : undefined;
|
||||
|
||||
const hasPluginConfiguration = (plugin: RegisteredPlugin) => Boolean(plugin.manifest.configuration?.fields.length);
|
||||
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);
|
||||
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)));
|
||||
if (backendPluginById.has(plugin.manifest.id)) {
|
||||
const next = await setPluginEnabled(plugin.manifest.id, enabled);
|
||||
setBackendPlugins((items) => items.map((item) => (item.manifest.id === next.manifest.id ? next : item)));
|
||||
} else {
|
||||
setEnabled(plugin.manifest.id, enabled);
|
||||
}
|
||||
message.success(`${plugin.manifest.name}${enabled ? "已启用" : "已停用"}`);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "更新插件状态失败");
|
||||
@@ -228,7 +229,7 @@ export default function PluginsPage() {
|
||||
<div className="plugins-sidebar-heading">
|
||||
<span className="plugins-sidebar-kicker">PLUGIN CENTER</span>
|
||||
<h1>插件中心</h1>
|
||||
<p>统一管理能力插件与模型请求协议。</p>
|
||||
<p>统一管理 provider、工作流、画布节点和其他扩展能力。</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")}>
|
||||
@@ -368,7 +369,7 @@ export default function PluginsPage() {
|
||||
可信插件
|
||||
</span>
|
||||
) : null}
|
||||
<span className="plugin-category-label">{categoryLabels[plugin.manifest.category] ?? plugin.manifest.category}</span>
|
||||
<span className="plugin-category-label">{contributionKindsFor(plugin.manifest).map((kind) => categoryLabels[kind] ?? kind).join(" · ")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -387,13 +388,13 @@ export default function PluginsPage() {
|
||||
</div>
|
||||
|
||||
<div className="plugin-card-tags">
|
||||
{plugin.manifest.surfaces.map((surface) => (
|
||||
{(plugin.manifest.surfaces || []).map((surface) => (
|
||||
<span key={surface}>{surfaceLabels[surface] ?? surface}</span>
|
||||
))}
|
||||
{plugin.manifest.protocol?.categories.map((capability) => (
|
||||
{providerCapabilitiesFor(plugin.manifest).map((capability) => (
|
||||
<span key={capability}>{capabilityLabel(capability)}</span>
|
||||
))}
|
||||
{plugin.manifest.protocol?.poll ? <span>异步轮询</span> : null}
|
||||
{plugin.manifest.contributes.providers?.some((provider) => provider.poll) ? <span>异步轮询</span> : null}
|
||||
<span>{plugin.manifest.permissions.length} 项能力</span>
|
||||
</div>
|
||||
</button>
|
||||
@@ -530,16 +531,14 @@ export default function PluginsPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="plugin-settings-empty">
|
||||
{settingsPlugin.manifest.kind === "protocol"
|
||||
? `协议能力:${settingsPlugin.manifest.protocol?.categories.map(capabilityLabel).join("、") || "未声明"};可用场景:${settingsPlugin.manifest.protocol?.scopes.join("、") || "未声明"}。`
|
||||
: "该插件暂无可编辑设置项。当前接入位置和权限会根据插件清单自动生效。"}
|
||||
{`贡献能力:${contributionKindsFor(settingsPlugin.manifest).map((kind) => categoryLabels[kind] || kind).join("、") || "未声明"}。当前接入位置和权限会根据插件清单自动生效。`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="plugin-permissions">
|
||||
<div>
|
||||
<span>接入位置</span>
|
||||
{settingsPlugin.manifest.surfaces.map((surface) => surfaceLabels[surface] ?? surface).join("、")}
|
||||
{(settingsPlugin.manifest.surfaces || []).map((surface) => surfaceLabels[surface] ?? surface).join("、") || "由贡献点决定"}
|
||||
</div>
|
||||
<div>
|
||||
<span>插件能力</span>
|
||||
@@ -564,23 +563,26 @@ function formatPluginDate(value?: string) {
|
||||
}
|
||||
|
||||
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 };
|
||||
return { manifest: plugin.manifest, source: plugin.source };
|
||||
}
|
||||
|
||||
function contributionKindsFor(manifest: PluginManifest): string[] {
|
||||
const contributions = manifest.contributes;
|
||||
const kinds: string[] = [];
|
||||
if (contributions.providers?.length) kinds.push("provider");
|
||||
if (contributions.workflows?.length) kinds.push("workflow");
|
||||
if (contributions.canvasNodes?.length) kinds.push("canvas-node");
|
||||
if (contributions.transforms?.length) kinds.push("transform");
|
||||
if (contributions.assetSources?.length) kinds.push("asset-source");
|
||||
if (contributions.aiCapabilities?.length) kinds.push("ai-capability");
|
||||
if (contributions.usageObservers?.length) kinds.push("usage-observer");
|
||||
if (contributions.agents?.length) kinds.push("agent");
|
||||
if (contributions.importExport?.length) kinds.push("import-export");
|
||||
return kinds;
|
||||
}
|
||||
|
||||
function providerCapabilitiesFor(manifest: PluginManifest) {
|
||||
return [...new Set((manifest.contributes.providers || []).flatMap((provider) => provider.capabilities))];
|
||||
}
|
||||
|
||||
function capabilityLabel(value: string) {
|
||||
|
||||
@@ -1,167 +1,79 @@
|
||||
# 上传请求协议插件
|
||||
# 上传统一插件包
|
||||
|
||||
当前上传入口只安装**声明式请求协议插件**。它把影策统一的文本、图片、视频或音频请求转换为上游 HTTP API,并解析上游响应。素材源、画布节点、工作流、Agent 工具和 UI 扩展仍由应用代码注册,不能通过这里上传 JSON 安装。
|
||||
影策只有一种插件:一个版本化 Manifest 描述一个插件包可以向宿主贡献的全部能力。Provider、工作流、画布节点、媒体转换、素材源、Agent 和命令都写在同一个 `contributes` 下;运行时可以不同,但不会再出现“协议插件”和“UI 插件”两套清单。
|
||||
|
||||
## 1. 准备清单
|
||||
## 1. 包格式
|
||||
|
||||
上传文件必须是 UTF-8 JSON,大小不超过 512 KiB。插件 ID 使用小写 kebab-case;请求路径必须是相对路径;清单不得包含域名、Cookie 或 API Key。
|
||||
上传文件必须是 `.yingce-plugin` ZIP 包,大小不超过 16 MiB。包内必须有根目录 `manifest.json`;不能上传裸 JSON。清单和可选的 Web 运行时代码、静态资源属于同一个版本、权限和生命周期:
|
||||
|
||||
```text
|
||||
my-plugin.yingce-plugin
|
||||
├── manifest.json
|
||||
├── web/entry.js # 可选;必须配合 runtime.web=sandbox 或 worker
|
||||
├── web/assets/... # 可选静态资源
|
||||
└── docs/... # 可选文档
|
||||
```
|
||||
|
||||
后端 Provider 只使用清单中的声明式映射,不执行包内代码。UI/功能代码必须声明 `entry`,由宿主隔离运行时加载;不能把脚本注入主应用页面。当前版本先完成包校验、存储和入口合同,未授权的 Web 代码不会自动进入主页面。
|
||||
|
||||
## 2. 最小清单
|
||||
|
||||
`manifest.json` 必须是 UTF-8 JSON。插件 ID 使用小写 kebab-case。Manifest 不得包含 Cookie、Token 或 API Key。`entry` 只能指向包内 `web/` 文件。
|
||||
|
||||
```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"
|
||||
"apiVersion": "yingce.plugin/v1",
|
||||
"id": "acme-comfyui", "name": "Acme ComfyUI", "version": "1.0.0", "author": "Acme", "description": "通过工作流贡献提供视频生成能力",
|
||||
"permissions": ["generation.run", "media.read"],
|
||||
"configuration": { "fields": [{ "name": "apiKey", "type": "secret", "label": "API Token", "required": true }] },
|
||||
"contributes": {
|
||||
"providers": [{
|
||||
"id": "acme-comfyui", "label": "Acme ComfyUI", "capabilities": ["video"], "scopes": ["user.custom-channel", "canvas"],
|
||||
"baseUrl": "https://api.example.com", "auth": { "type": "bearer", "field": "apiKey" },
|
||||
"create": { "method": "POST", "path": "/workflows/{{model}}", "fields": { "prompt": "request.prompt", "duration": "request.duration" } },
|
||||
"poll": { "method": "GET", "path": "/tasks/{{taskId}}" },
|
||||
"response": { "taskIdPaths": ["data.task_id"], "statusPaths": ["data.status"], "resultPaths": ["data.results"], "resultKind": "video", "resultEphemeral": true }
|
||||
}],
|
||||
"workflows": [{
|
||||
"id": "minimax-h3", "label": "MiniMax H3 文生视频", "providerId": "acme-comfyui", "capability": "video",
|
||||
"parameters": [{ "name": "duration", "type": "number", "required": true }, { "name": "resolution", "type": "string", "values": ["720p", "1080p"] }],
|
||||
"defaults": { "duration": 5, "resolution": "720p" }
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`fields` 左侧是上游请求字段路径,右侧是影策请求字段。同步协议只需要 `create` 和 `response`;异步协议再声明 `poll`。清单不会执行 JavaScript,也不能使用 `host:` 执行器。
|
||||
|
||||
### 同步文本响应
|
||||
|
||||
文本模型不一定需要创建任务后轮询。如果上游在创建请求中直接返回正文,可以只声明 `create` 和 `response`,例如:
|
||||
带 Web 功能的插件在同一份清单中增加:
|
||||
|
||||
```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"]
|
||||
}
|
||||
"entry": "web/entry.js",
|
||||
"surfaces": ["fullscreen"],
|
||||
"runtime": { "web": "sandbox" }
|
||||
}
|
||||
```
|
||||
|
||||
- `response.textPaths` 按顺序尝试读取文本正文,支持对象路径和数组下标。
|
||||
- `response.reasoningPaths` 是可选的,用于读取推理内容;没有该字段时不会影响正文解析。
|
||||
- 如果没有声明 `statusPaths`,但解析到了文本或媒体结果,宿主会将本次响应视为成功。
|
||||
- `agentResponse` 只负责 Agent 工具请求的响应解析,与普通文本请求的 `response` 相互独立。
|
||||
## 3. Contribution 规则
|
||||
|
||||
## 2. 编写接入文档
|
||||
- `providers` 声明上游能力和字段映射。鉴权、Base URL、超时、私网校验、轮询、下载和计费由宿主负责。
|
||||
- `workflows` 声明一个 provider 下的具体工作流或模型入口。AutoDL/ComfyUI 等工作流型 API 必须在这里扩展,不能为每个工作流新增宿主代码。
|
||||
- `canvasNodes` 声明节点 ID、默认尺寸和 schema。`renderer` 为 `declarative` 时使用宿主 schema renderer;`sandbox` 由隔离运行时承载。
|
||||
- `transforms` 声明媒体或生成请求转换,必须匹配清单权限和受控 runtime。
|
||||
- `assetSources`、`usageObservers`、`agents`、`commands` 和 `importExport` 是同一清单的其他贡献面。
|
||||
|
||||
`metadata.documentation` 是插件详情页展示的 Markdown 正文,不是可选简介。至少写清楚:
|
||||
一个插件可以同时声明多种贡献,但 ID 必须在插件包内唯一,并且所有贡献共享同一个安装、启停、权限和版本生命周期。
|
||||
|
||||
- 接口与鉴权:创建、查询和取消接口的 Method、Path、Header 与 Content-Type。
|
||||
- 模型与参数:可用模型名、类型、必填性、默认值、可选值及字段映射。
|
||||
- 素材限制:数量、格式、大小、时长,以及 URL 可访问性要求。
|
||||
- 请求与响应:提交成功、处理中、完成和失败的真实示例。
|
||||
- 轮询与下载:状态值、建议间隔、超时和结果 URL 有效期。
|
||||
- 错误处理:HTTP 状态、上游错误字段、内容审核和额度限制。
|
||||
## 4. Provider 与工作流
|
||||
|
||||
文档中的路径、模型名和响应字段必须与清单实现一致。详情页支持标题、表格、列表、引用、链接和代码块,并按 GitHub Flavored Markdown 预览。
|
||||
请求字段右侧只能引用影策统一请求,例如 `request.prompt`、`request.model`、`request.duration`、`request.extra`。声明式字段也支持安全的对象/数组路径(例如 `request.images.0.url`)和字符串变换(`|trim`、`|lower`、`|upper`),不执行插件代码。字段解析为空时会省略该字段,适合映射可选参考素材。Provider 如果把 `requiresPublicMediaUrls` 设为 `true`,宿主会在请求前把用户资源转换为短期签名公网 URL。响应映射支持对象路径和数组下标,并可用 `errorPaths` 声明上游错误码路径。同步 provider 只需要 `create` 和 `response`;异步 provider 再声明 `poll`。
|
||||
|
||||
## 3. 上传前检查
|
||||
结果 URL 如果是短期地址,必须设置 `resultEphemeral: true`。宿主会在任务完成后立即下载并保存资源,画布和任务记录只保存影策资源引用。
|
||||
|
||||
| 检查项 | 要求 |
|
||||
| --- | --- |
|
||||
| 标识 | `apiVersion` 为 `v1`,ID 为 kebab-case,名称和版本不为空 |
|
||||
| 能力 | 至少声明一个 `category` 和一个 `scope` |
|
||||
| 请求 | Method 受支持,Path 为相对路径,字段映射来自 `request.*` |
|
||||
| 响应 | 任务 ID、状态、错误和结果地址路径与真实响应一致 |
|
||||
| 文档 | `metadata.documentation` 完整,并与实现同步 |
|
||||
| 安全 | 不包含域名、密钥、Cookie、个人数据或 `host:` 执行器 |
|
||||
## 5. 安全与运行边界
|
||||
|
||||
上传、启用和停用属于管理员写操作。校验失败时插件不会安装;已安装协议停用后,模型配置入口不再提供该协议。
|
||||
- API Key 只在渠道配置中输入和保存,不进入 Manifest、URL、日志或任务正文。
|
||||
- 上传插件不能请求 `host:` 执行器,不能执行任意浏览器脚本。
|
||||
- 外部请求必须通过后端 provider runtime,宿主统一执行 SSRF、防重放、超时、并发、错误和计费策略。
|
||||
- 插件声明的权限是最小权限;没有对应权限的 contribution 不会被激活。
|
||||
|
||||
## 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 承担;上传的第三方插件必须使用声明式字段映射,不能上传可执行代码。
|
||||
上传、启用、停用和卸载都作用于同一个插件 registry record。清单校验失败时整包不会安装,不会留下半激活的 provider、节点或工作流。
|
||||
|
||||
@@ -34,12 +34,12 @@ export function UploadPluginModal({ open, onClose, onUpload }: UploadPluginModal
|
||||
<span className="plugin-upload-panel-icon"><CloudUpload className="size-5" /></span>
|
||||
<div>
|
||||
<h2>安装插件包</h2>
|
||||
<p>选择符合清单规范的 JSON 文件,安装后会立即进入插件中心。</p>
|
||||
<p>选择统一影策插件包,安装后会立即进入插件中心。</p>
|
||||
</div>
|
||||
</div>
|
||||
<Upload.Dragger
|
||||
className="plugin-upload-dropzone"
|
||||
accept=".json,application/json"
|
||||
accept=".yingce-plugin,application/zip"
|
||||
maxCount={1}
|
||||
showUploadList={false}
|
||||
beforeUpload={(file) => {
|
||||
@@ -49,11 +49,11 @@ export function UploadPluginModal({ open, onClose, onUpload }: UploadPluginModal
|
||||
>
|
||||
<CloudUpload className="plugin-upload-dropzone-icon" />
|
||||
<p className="ant-upload-text">点击选择插件文件</p>
|
||||
<p className="ant-upload-hint">仅支持 JSON 清单,大小不超过 512 KiB</p>
|
||||
<p className="ant-upload-hint">仅支持 .yingce-plugin 包,大小不超过 16 MiB</p>
|
||||
</Upload.Dragger>
|
||||
<div className="plugin-upload-notice">
|
||||
<ShieldCheck className="size-4" />
|
||||
<span>上传前请确认插件来源可信。协议插件不会在浏览器执行任意脚本,密钥也不会从清单读取。</span>
|
||||
<span>上传前请确认插件来源可信。Web 入口只能进入声明的隔离运行时,不会获得主页面权限;密钥也不会从清单读取。</span>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -12,8 +12,6 @@ const permissionLabels: Record<string, string> = {
|
||||
};
|
||||
|
||||
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);
|
||||
@@ -28,8 +26,8 @@ export function getPluginDocumentation(manifest: PluginManifest) {
|
||||
`- 版本:${manifest.version}`,
|
||||
`- 能力:${capabilities.join("、") || "未声明"}`,
|
||||
"",
|
||||
manifest.kind === "protocol"
|
||||
? "> 此请求协议没有提供接入文档。请联系插件作者补充 `metadata.documentation`,不要仅凭清单字段推测上游接口。"
|
||||
manifest.contributes.providers?.length
|
||||
? "> 此插件没有提供接入文档。请联系插件作者补充 `documentation`,不要仅凭清单字段推测上游接口。"
|
||||
: "> 该插件当前没有单独的使用文档。",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { App, Button, Form, Input, Popconfirm, Segmented, Select, Switch, Tooltip } from "antd";
|
||||
import { ChevronDown, ChevronUp, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { ChannelHeadersEditor, validateChannelHeaders } from "@/components/channel-headers-editor";
|
||||
import { WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { mergeFetchedChannelModelCosts } from "@/lib/channel-model-catalog";
|
||||
import { desktopLocalChannelFormState, desktopLocalChannelPayloadValue, DESKTOP_LOCAL_CHANNEL_EXAMPLE_BASE_URL } from "@/lib/desktop-local-channel";
|
||||
import { fetchChannelModels } from "@/services/api/image";
|
||||
import { fetchPluginProviderCatalog } from "@/services/api/plugin-catalog";
|
||||
import { defaultModelCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import type { ModelProtocolDefinition } from "@/lib/model-protocols";
|
||||
import {
|
||||
createModelChannel,
|
||||
defaultBaseUrlForApiFormat,
|
||||
@@ -26,10 +29,15 @@ export function ChannelSettingsPane({ onOpenModels }: { onOpenModels: () => void
|
||||
const replaceConfig = useConfigStore((state) => state.replaceConfig);
|
||||
const [loadingChannelIds, setLoadingChannelIds] = useState<string[]>([]);
|
||||
const [collapsedChannelIds, setCollapsedChannelIds] = useState<Set<string>>(new Set());
|
||||
const [providerCatalog, setProviderCatalog] = useState<ModelProtocolDefinition[]>([]);
|
||||
const desktopLocalChannelsEnabled = useUserStore((state) => state.features.desktopLocalChannelsEnabled);
|
||||
const desktopLocalChannelHostname = typeof window === "undefined" ? "" : window.location.hostname;
|
||||
const userChannels = config.channels.filter((channel) => channel.scope !== "system");
|
||||
|
||||
useEffect(() => {
|
||||
void fetchPluginProviderCatalog("user.custom-channel").then(setProviderCatalog).catch(() => setProviderCatalog([]));
|
||||
}, []);
|
||||
|
||||
const updateChannels = (channels: ModelChannel[], baseConfig = config) => {
|
||||
replaceConfig(withChannels(baseConfig, channels));
|
||||
};
|
||||
@@ -55,6 +63,33 @@ export function ChannelSettingsPane({ onOpenModels }: { onOpenModels: () => void
|
||||
updateChannel(channel.id, { apiFormat, interfaceType: undefined, baseUrl });
|
||||
};
|
||||
|
||||
const updateChannelProvider = (channel: ModelChannel, providerId: string) => {
|
||||
const provider = providerCatalog.find((item) => item.value === providerId);
|
||||
if (!provider) return;
|
||||
const workflows = provider.workflows || [];
|
||||
const models = workflows.length ? workflows.map((workflow) => workflow.id) : channel.models;
|
||||
const modelCosts = workflows.length
|
||||
? workflows.map((workflow) => ({
|
||||
model: workflow.id,
|
||||
displayName: workflow.label,
|
||||
capability: workflow.capability,
|
||||
protocol: provider.value,
|
||||
billingMode: "fixed_request" as const,
|
||||
unitPriceMicrocredits: 0,
|
||||
capabilityConfig: workflow.capability === "image" || workflow.capability === "video" ? defaultModelCapabilityConfig(provider.value, workflow.id) : undefined,
|
||||
defaultOptions: workflow.defaults,
|
||||
}))
|
||||
: channel.modelCosts;
|
||||
updateChannel(channel.id, {
|
||||
interfaceType: provider.value,
|
||||
apiFormat: provider.value.startsWith("gemini") ? "gemini" : "openai",
|
||||
baseUrl: provider.baseUrl || channel.baseUrl,
|
||||
models,
|
||||
modelCosts,
|
||||
});
|
||||
message.success(workflows.length ? `已载入 ${workflows.length} 个工作流` : `${provider.label} 已应用到当前渠道`);
|
||||
};
|
||||
|
||||
const addChannel = () => {
|
||||
const channel = createModelChannel({ name: `渠道 ${userChannels.length + 1}` });
|
||||
updateChannels([...config.channels, channel]);
|
||||
@@ -211,7 +246,10 @@ export function ChannelSettingsPane({ onOpenModels }: { onOpenModels: () => void
|
||||
<div className="grid gap-x-3 gap-y-2 lg:grid-cols-12">
|
||||
<div className="settings-field-group-label lg:col-span-12">连接信息</div>
|
||||
<Form.Item label="渠道名称" htmlFor={`channel-${channel.id}-name`} className="mb-0 lg:col-span-3"><Input id={`channel-${channel.id}-name`} value={channel.name} placeholder="例如:我的 NewAPI" onChange={(event) => updateChannel(channel.id, { name: event.target.value })} onBlur={(event) => updateChannel(channel.id, { name: event.target.value.trim() || "未命名渠道" })} /></Form.Item>
|
||||
<Form.Item label="渠道连接类型" className="mb-0 lg:col-span-3" extra="仅用于拉取模型目录;模型能力和请求协议在下方统一配置。"><Segmented<UserChannelConnection> block value={channelConnectionMode(channel)} options={[{ label: "OpenAI 兼容", value: "openai" }, { label: "Gemini 原生", value: "gemini" }]} onChange={(value) => updateChannelConnection(channel, value)} /></Form.Item>
|
||||
<Form.Item label="插件 Provider" className="mb-0 lg:col-span-4" extra="Provider 会带入 Base URL、鉴权和工作流;无需手工选择请求协议。">
|
||||
<Select showSearch optionFilterProp="label" value={channel.interfaceType || undefined} placeholder="选择已安装插件 Provider" options={providerCatalog.map((provider) => ({ value: provider.value, label: `${provider.label} · ${provider.capability}` }))} onChange={(value) => updateChannelProvider(channel, value)} />
|
||||
</Form.Item>
|
||||
<Form.Item label="目录连接类型" className="mb-0 lg:col-span-2" extra="仅影响模型目录拉取。"><Segmented<UserChannelConnection> block value={channelConnectionMode(channel)} options={[{ label: "OpenAI", value: "openai" }, { label: "Gemini", value: "gemini" }]} onChange={(value) => updateChannelConnection(channel, value)} /></Form.Item>
|
||||
<UserLocalChannelFields
|
||||
channel={channel}
|
||||
visible={userLocalChannelFormOwner(desktopLocalChannelsEnabled, desktopLocalChannelHostname, channel.allowLocalChannel).visible}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ModelCapabilityEditor } from "@/components/model-capability-editor";
|
||||
import { CapabilityCardPicker, ProtocolCardPicker } from "@/components/model-protocol-picker";
|
||||
import { defaultModelCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { modelProtocolCapability, modelProtocolDefinition, modelProtocolSupportsTokenBilling, type ModelProtocol } from "@/lib/model-protocols";
|
||||
import { fetchProtocolCatalog } from "@/services/api/protocols";
|
||||
import { fetchPluginProviderCatalog } from "@/services/api/plugin-catalog";
|
||||
import { modelOptionName, type ModelChannel } from "@/stores/use-config-store";
|
||||
|
||||
type ModelCost = NonNullable<ModelChannel["modelCosts"]>[number];
|
||||
@@ -18,7 +18,7 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
|
||||
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([]));
|
||||
void fetchPluginProviderCatalog("user.custom-channel").then(setAvailableProtocols).catch(() => setAvailableProtocols([]));
|
||||
}, []);
|
||||
if (!channel.models.length) return null;
|
||||
|
||||
|
||||
46
web/src/services/api/plugin-catalog.ts
Normal file
46
web/src/services/api/plugin-catalog.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { apiClient, request } from "@/services/api/request";
|
||||
import type { ModelProtocolDefinition, ProtocolCapability } from "@/lib/model-protocols";
|
||||
|
||||
type PluginProviderCatalogItem = {
|
||||
id: string;
|
||||
version: string;
|
||||
name: string;
|
||||
vendor: string;
|
||||
categories: string[];
|
||||
scopes: string[];
|
||||
create?: string;
|
||||
poll?: string;
|
||||
contentType?: string;
|
||||
enabled: boolean;
|
||||
unavailableReason?: string;
|
||||
baseUrl?: string;
|
||||
workflows?: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
providerId: string;
|
||||
capability: ProtocolCapability;
|
||||
parameters: Array<{ name: string; type: string; required?: boolean; description?: string; values?: string[]; mapping?: string }>;
|
||||
defaults?: Record<string, string | number | boolean>;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function fetchPluginProviderCatalog(scope: string, capability?: ProtocolCapability) {
|
||||
const result = await request<{ providers: PluginProviderCatalogItem[] }>(apiClient.get("/plugins/catalog", { params: { scope, capability } }));
|
||||
return result.providers.filter((item) => item.enabled && !item.unavailableReason).map(toProviderDefinition);
|
||||
}
|
||||
|
||||
function toProviderDefinition(item: PluginProviderCatalogItem): ModelProtocolDefinition {
|
||||
return {
|
||||
value: item.id,
|
||||
label: item.name,
|
||||
vendor: item.vendor,
|
||||
capability: (item.categories[0] || "text") as ProtocolCapability,
|
||||
create: item.create || "",
|
||||
poll: item.poll,
|
||||
contentType: item.contentType || "application/json",
|
||||
media: `${item.vendor} · ${item.version}`,
|
||||
enabled: item.enabled && !item.unavailableReason,
|
||||
baseUrl: item.baseUrl,
|
||||
workflows: item.workflows || [],
|
||||
};
|
||||
}
|
||||
@@ -1,32 +1,11 @@
|
||||
import { apiClient, request } from "@/services/api/request";
|
||||
import type { PluginManifest, ProtocolCapability, ProtocolScope } from "@/lib/plugins/plugin-types";
|
||||
import type { PluginManifest } from "@/lib/plugins/plugin-types";
|
||||
|
||||
export type BackendPlugin = {
|
||||
manifest: {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
apiVersion: string;
|
||||
category: "protocol";
|
||||
description?: string;
|
||||
author?: string;
|
||||
surfaces: string[];
|
||||
permissions: string[];
|
||||
trusted: boolean;
|
||||
kind: "protocol";
|
||||
protocol: {
|
||||
categories: ProtocolCapability[];
|
||||
scopes: ProtocolScope[];
|
||||
create?: string;
|
||||
poll?: string;
|
||||
cancel?: string;
|
||||
contentType?: string;
|
||||
documentation?: string;
|
||||
parameters?: NonNullable<PluginManifest["protocol"]>["parameters"];
|
||||
};
|
||||
};
|
||||
manifest: PluginManifest;
|
||||
source: "bundled" | "uploaded" | string;
|
||||
fileName: string;
|
||||
package: string;
|
||||
sha256: string;
|
||||
installedAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { apiClient, request } from "@/services/api/request";
|
||||
import type { ModelProtocolDefinition, ProtocolCapability } from "@/lib/model-protocols";
|
||||
|
||||
type ProtocolCatalogItem = {
|
||||
id: string;
|
||||
version: string;
|
||||
name: string;
|
||||
vendor: string;
|
||||
categories: string[];
|
||||
scopes: string[];
|
||||
create?: string;
|
||||
poll?: string;
|
||||
contentType?: string;
|
||||
enabled: boolean;
|
||||
unavailableReason?: string;
|
||||
};
|
||||
|
||||
export async function fetchProtocolCatalog(scope: string, capability?: ProtocolCapability) {
|
||||
const result = await request<{ protocols: ProtocolCatalogItem[] }>(apiClient.get("/protocols", { params: { scope, capability } }));
|
||||
return result.protocols.filter((item) => item.enabled && !item.unavailableReason).map(toProtocolDefinition);
|
||||
}
|
||||
|
||||
function toProtocolDefinition(item: ProtocolCatalogItem): ModelProtocolDefinition {
|
||||
return {
|
||||
value: item.id,
|
||||
label: item.name,
|
||||
vendor: item.vendor,
|
||||
capability: (item.categories[0] || "text") as ProtocolCapability,
|
||||
create: item.create || "",
|
||||
poll: item.poll,
|
||||
contentType: item.contentType || "application/json",
|
||||
media: `${item.vendor} · ${item.version}`,
|
||||
enabled: item.enabled && !item.unavailableReason,
|
||||
};
|
||||
}
|
||||
@@ -24,8 +24,6 @@ export enum CanvasNodeType {
|
||||
Video = "video",
|
||||
Audio = "audio",
|
||||
Frame = "frame",
|
||||
// 扩展节点:展示与加工。新增一个成员后,编译器会逐个点出还缺哪张表
|
||||
// (NODE_DEFAULT_SIZE / NODE_SPECS / 节点注册表定义 / nodeContentRenderers)。
|
||||
Markdown = "markdown",
|
||||
Svg = "svg",
|
||||
Html = "html",
|
||||
@@ -35,6 +33,14 @@ export enum CanvasNodeType {
|
||||
ColorGrade = "colorgrade",
|
||||
}
|
||||
|
||||
/** Runtime IDs contributed by plugins share the persisted node type field. */
|
||||
export type PluginCanvasNodeType = string & { readonly __pluginCanvasNodeType?: unique symbol };
|
||||
export type CanvasNodeTypeId = CanvasNodeType | PluginCanvasNodeType;
|
||||
|
||||
export function isBuiltinCanvasNodeType(type: CanvasNodeTypeId): type is CanvasNodeType {
|
||||
return Object.values(CanvasNodeType).includes(type as CanvasNodeType);
|
||||
}
|
||||
|
||||
export type CanvasNodeStatus = "idle" | "success" | "loading" | "error";
|
||||
export type CanvasMediaPerformanceMode = "auto" | "quality" | "performance";
|
||||
export type CanvasWorkspaceMode = "simple" | "professional";
|
||||
@@ -163,6 +169,10 @@ export type CanvasSkillSnapshot = {
|
||||
};
|
||||
|
||||
export type CanvasNodeMetadata = {
|
||||
/** Namespaced extension ownership for nodes contributed by a unified plugin. */
|
||||
pluginId?: string;
|
||||
pluginNodeId?: string;
|
||||
pluginData?: Record<string, unknown>;
|
||||
importSource?:
|
||||
| {
|
||||
provider: "libtv";
|
||||
@@ -386,7 +396,7 @@ export type CanvasNodeMetadata = {
|
||||
|
||||
export type CanvasNodeData = {
|
||||
id: string;
|
||||
type: CanvasNodeType;
|
||||
type: CanvasNodeTypeId;
|
||||
title: string;
|
||||
position: Position;
|
||||
width: number;
|
||||
@@ -415,7 +425,7 @@ export type CanvasDisplayConnection = {
|
||||
|
||||
export type CanvasAssistantReference = {
|
||||
id: string;
|
||||
type: CanvasNodeType;
|
||||
type: CanvasNodeTypeId;
|
||||
title: string;
|
||||
dataUrl?: string;
|
||||
storageKey?: string;
|
||||
|
||||
@@ -129,6 +129,28 @@ describe("逻辑模型选择", () => {
|
||||
expect(resolveModelGenerationDefaults(config, model, "video", {}, { videoSeconds: "6" }).videoSeconds).toBe("15");
|
||||
});
|
||||
|
||||
|
||||
test("渠道视频能力配置优先于旧的全局 6 秒和 1:1 配置", () => {
|
||||
const model = "autodl-channel::MiniMax H3";
|
||||
const profile = defaultModelCapabilityConfig("autodl-comfyui", "MiniMax H3");
|
||||
profile.video!.duration = { selection: "range", min: 1, max: 15, step: 1, default: 15 };
|
||||
profile.video!.defaultRatio = "16:9";
|
||||
const config: AiConfig = {
|
||||
...defaultConfig,
|
||||
videoSeconds: "6",
|
||||
size: "1:1",
|
||||
channels: [{ id: "autodl-channel", name: "AutoDL", baseUrl: "https://autodl.art", apiKey: "system", apiFormat: "openai", scope: "system", models: ["MiniMax H3"], modelCosts: [{ model: "MiniMax H3", capability: "video", protocol: "autodl-comfyui", billingMode: "fixed_request", unitPriceMicrocredits: 1, capabilityConfig: profile }] }],
|
||||
models: [model],
|
||||
videoModels: [model],
|
||||
videoModel: model,
|
||||
model,
|
||||
};
|
||||
|
||||
const defaults = resolveModelGenerationDefaults(config, model, "video", {}, { videoSeconds: "6", size: "1:1" });
|
||||
expect(defaults.videoSeconds).toBe("15");
|
||||
expect(defaults.size).toBe("16:9");
|
||||
});
|
||||
|
||||
test("后台标注的视频模型不因内部标识缺少视频关键词而回退", () => {
|
||||
const config = policyConfig();
|
||||
const selectedModel = config.videoModels[0]!;
|
||||
|
||||
Reference in New Issue
Block a user