feat(*): 插件中心与模型协议 - 完善声明式协议运行时并增加功能开放控制

This commit is contained in:
ddCat
2026-08-23 23:19:27 +08:00
parent 7cc6021f6e
commit 919037e5d9
106 changed files with 8817 additions and 500 deletions

View File

@@ -7,21 +7,24 @@ import { WorkspaceErrorState, WorkspaceLoadingState, WorkspaceState } from "@/co
import { refreshFeatureAvailability } from "@/lib/user-session";
import { useUserStore } from "@/stores/use-user-store";
type FeatureKey = "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled" | "frontendModelsEnabled";
type FeatureKey = "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled" | "frontendModelsEnabled" | "pluginCenterEnabled";
const featureNames: Record<FeatureKey, string> = {
shortDramaEnabled: "短剧创作",
taskCenterEnabled: "任务中心",
creditsEnabled: "积分中心",
frontendModelsEnabled: "前台模型",
pluginCenterEnabled: "插件中心",
};
let featureAvailabilityCheckedOnce = false;
export function RequireFeature({ feature, children }: { feature: FeatureKey; children: ReactNode }) {
const navigate = useNavigate();
const user = useUserStore((state) => state.user);
const features = useUserStore((state) => state.features);
const [checking, setChecking] = useState(() => !useUserStore.getState().features[feature]);
const adminBypass = feature === "pluginCenterEnabled" && user?.role === "admin";
const [checking, setChecking] = useState(() => !adminBypass && !useUserStore.getState().features[feature]);
const [error, setError] = useState("");
useEffect(() => {
@@ -43,9 +46,9 @@ export function RequireFeature({ feature, children }: { feature: FeatureKey; chi
if (checking) return <WorkspacePage><WorkspaceLoadingState label="正在确认功能状态" detail={featureNames[feature]} rows={3} /></WorkspacePage>;
if (error) return <WorkspacePage><WorkspaceErrorState title="无法确认功能状态" description={error} actionLabel="返回创作台" onRetry={() => navigate("/create", { replace: true })} /></WorkspacePage>;
if (!features[feature]) {
if (!adminBypass && !features[feature]) {
// 管理员页面返回到管理后台首页,用户页面返回到创作台
const isAdminFeature = feature === "frontendModelsEnabled";
const isAdminFeature = feature === "frontendModelsEnabled" || (feature === "pluginCenterEnabled" && user?.role === "admin");
const backPath = isAdminFeature ? "/admin" : "/create";
const backLabel = isAdminFeature ? "返回管理后台" : "返回创作台";

View File

@@ -8,7 +8,7 @@ import { modelDisplayName, modelIcon, normalizeModelOptionValue, resolveModelCha
import { canvasThemes } from "@/lib/canvas-theme";
import { nanoid } from "nanoid";
import { requestToolResponse, type ResponseFunctionTool, type ResponseInputMessage, type ResponseToolCall } from "@/services/api/image";
import { logicalModelIDForConfig, runBackendToolGenerationTask } from "@/services/api/generation-task";
import { backendModelRuntimeRequired, runBackendToolGenerationTask } from "@/services/api/generation-task";
import { imageToDataUrl } from "@/services/image-storage";
import { isCanvasGenerationDurableAckError, persistCanvasCinematicSessionContinuationEffect } from "@/services/canvas-generation-consumer";
import { consumeGenerationTaskAgent } from "@/services/project-asset-sync";
@@ -30,6 +30,7 @@ import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { CanvasNodeType, type CanvasAssistantMessage, type CanvasAssistantPendingBackendSession, type CanvasAssistantReference, type CanvasAssistantSession, type CanvasNodeData } from "@/types/canvas";
import { useCanvasAgentStore } from "@/stores/canvas/use-canvas-agent-store";
import { previewCanvasAgentOps, summarizeCanvasAgentOps, type CanvasAgentOp, type CanvasAgentOperationImpact, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
import { buildCanvasAgentContext, findCanvasAgentNodes, getCanvasAgentResources, validateCanvasAgentOps } from "@/lib/canvas/canvas-agent-context";
import { canvasAgentPromptCacheKey } from "@/lib/openai-prompt-cache";
import { resolveStoryboardGenerationContext } from "@/lib/canvas/canvas-storyboard-context";
@@ -37,7 +38,7 @@ export const CANVAS_AGENT_PANEL_MOTION_MS = 500;
const PANEL_MOTION_SECONDS = CANVAS_AGENT_PANEL_MOTION_MS / 1000;
const ONLINE_AGENT_MAX_STEPS = 4;
const ONLINE_AGENT_PROMPT =
"你是影策网页内置在线画布助手。当前画布 JSON 会随用户消息提供。首轮必须调用工具:只读问题调用 canvas_get_state,需要改动画布时调用和本地 Agent 一致的 infinite-canvas 工具。需要生成内容时直接调用 canvas_generate_text、canvas_generate_image、canvas_generate_video、canvas_generate_audio 或 canvas_create_generation_flow;需要精确批量操作时调用 canvas_apply_ops。不要输出 JSON ops,不要编造执行结果。工具参数涉及已有节点时必须使用当前画布 JSON 中真实存在的 id;缺少必要 id 或用户意图不明确时直接说明需要用户明确选择或说明,不要猜测。工具返回结果后,再根据真实结果回答用户。";
"你是影策网页内置在线画布助手。首轮必须先调用 canvas_get_context;涉及已有节点时用 canvas_find_nodes 获取真实 id,涉及媒体参考时用 canvas_get_resources。复杂写操作先 canvas_validate_ops,再执行 canvas_apply_ops。不要输出 JSON ops、不要猜 id、不要把未就绪资源当作可用素材、不要编造执行结果;工具返回真实结果后再回答。";
const JSON_RECORD_SCHEMA = { type: "object", additionalProperties: true };
const POSITION_SCHEMA = { type: "object", properties: { x: { type: "number" }, y: { type: "number" } }, required: ["x", "y"], additionalProperties: false };
const VIEWPORT_SCHEMA = { type: "object", properties: { x: { type: "number" }, y: { type: "number" }, k: { type: "number" } }, required: ["x", "y", "k"], additionalProperties: false };
@@ -85,7 +86,7 @@ const CANVAS_OP_SCHEMA = {
required: ["type"],
additionalProperties: false,
};
const ONLINE_READ_TOOLS = new Set(["canvas_get_state", "canvas_get_selection", "canvas_export_snapshot"]);
const ONLINE_READ_TOOLS = new Set(["canvas_get_state", "canvas_get_context", "canvas_find_nodes", "canvas_get_resources", "canvas_validate_ops", "canvas_get_selection", "canvas_export_snapshot"]);
function toolDefinition(name: string, description: string, properties: Record<string, unknown>, required: string[] = [], strict = false): ResponseFunctionTool {
return { type: "function", function: { name, description, parameters: { type: "object", properties, required, additionalProperties: false }, strict } };
@@ -111,12 +112,16 @@ function generationToolDefinition(name: string, description: string, mode?: "tex
const ONLINE_AGENT_TOOLS: ResponseFunctionTool[] = [
toolDefinition("canvas_get_state", "读取当前网页画布的节点、连线、选区和视口。", {}),
toolDefinition("canvas_get_context", "读取语义化画布上下文、真实节点 id、连接关系、资源就绪状态和状态哈希。", {}),
toolDefinition("canvas_find_nodes", "按标题、内容、提示词、类型、状态或资产检索真实节点。", { query: { type: "string" }, ids: { type: "array", items: { type: "string" } }, types: { type: "array", items: { type: "string" } }, statuses: { type: "array", items: { type: "string" } }, resourceOnly: { type: "boolean" }, limit: { type: "number" } }),
toolDefinition("canvas_get_resources", "读取画布媒体资源引用、类型、尺寸、大小、时长和就绪状态,不返回媒体 URL。", { nodeIds: { type: "array", items: { type: "string" } }, status: { type: "string" }, limit: { type: "number" } }),
toolDefinition("canvas_validate_ops", "在写入前校验节点 id、连接关系和批量操作参数。", { ops: { type: "array", items: CANVAS_OP_SCHEMA } }, ["ops"]),
toolDefinition("canvas_get_selection", "读取当前网页画布选中的节点。", {}),
toolDefinition("canvas_export_snapshot", "导出当前画布快照,用于理解布局。", {}),
toolDefinition(
"canvas_apply_ops",
"批量操作当前网页画布。ops 支持 add_node、update_node、delete_node、delete_connections、connect_nodes、set_viewport、select_nodes、run_generation。",
{ ops: { type: "array", items: CANVAS_OP_SCHEMA } },
"批量操作当前网页画布。复杂写操作应先 canvas_validate_ops;可传 canvas_get_context 返回的 expectedStateHash 防止基于过期状态写入。",
{ ops: { type: "array", items: CANVAS_OP_SCHEMA }, expectedStateHash: { type: "string" } },
["ops"],
false,
),
@@ -708,7 +713,16 @@ export function CanvasAssistantPanel({
const executeOnlineTool = async (sessionId: string, name: string, args: Record<string, unknown>, messageId?: string): Promise<OnlineToolResult> => {
const current = snapshotRef.current;
try {
const expectedStateHash = typeof args.expectedStateHash === "string" ? args.expectedStateHash : "";
if (expectedStateHash && expectedStateHash !== buildCanvasAgentContext(current).stateHash) return { ok: false, message: "画布状态已变化,请重新读取 canvas_get_context 后再执行写操作。" };
if (name === "canvas_get_state") return { ok: true, message: describeCanvasSnapshot(current), data: compactSnapshot(current) };
if (name === "canvas_get_context") return { ok: true, message: "已读取语义化画布上下文。", data: buildCanvasAgentContext(current) };
if (name === "canvas_find_nodes") return { ok: true, message: "已按条件检索真实节点。", data: findCanvasAgentNodes(current, args as Parameters<typeof findCanvasAgentNodes>[1]) };
if (name === "canvas_get_resources") return { ok: true, message: "已读取画布资源清单。", data: getCanvasAgentResources(current, args as Parameters<typeof getCanvasAgentResources>[1]) };
if (name === "canvas_validate_ops") {
const result = validateCanvasAgentOps(current, requireOps(args.ops));
return { ok: result.ok, message: result.ok ? "操作校验通过。" : "操作校验失败。", data: result };
}
if (name === "canvas_export_snapshot") return { ok: true, message: describeCanvasSnapshot(current), data: compactSnapshot(current) };
if (name === "canvas_get_selection") {
const ids = new Set(current.selectedNodeIds || []);
@@ -1579,7 +1593,7 @@ function toolCallToResponseInput(call: ResponseToolCall): ResponseInputMessage {
}
async function requestOnlineAgentModel(config: AiConfig, messages: ResponseInputMessage[], toolChoice: "auto" | "required", prompt: string, onDelta: (text: string) => void, promptCacheKey?: string) {
if (logicalModelIDForConfig(config)) {
if (backendModelRuntimeRequired(config)) {
const result = await runBackendToolGenerationTask({ prompt, config, messages, tools: ONLINE_AGENT_TOOLS, toolChoice });
if (result.content.trim()) onDelta(result.content);
return result;
@@ -1876,6 +1890,7 @@ function compactMetadata(metadata: CanvasNodeData["metadata"]) {
function backendAgentProviderConfig(config: ReturnType<typeof resolveModelRequestConfig>) {
return {
channelId: config.channelId,
apiFormat: config.apiFormat,
interfaceType: config.interfaceType,
baseUrl: config.baseUrl,

View File

@@ -23,6 +23,7 @@ import {
type AgentThreadSummary,
} from "@/stores/canvas/use-canvas-agent-store";
import { previewCanvasAgentOps, summarizeCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
import { buildCanvasAgentContext, findCanvasAgentNodes, getCanvasAgentResources, validateCanvasAgentOps } from "@/lib/canvas/canvas-agent-context";
import { buildCanvasResourceReferences } from "@/lib/canvas/canvas-resource-references";
import { listAddedSkills, type Skill } from "@/services/api/skills";
import { isProjectAgentReadTool, isProjectAgentToolName, runProjectAgentTool } from "@/services/api/project-agent-tools";
@@ -383,9 +384,17 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({
const result =
payload.name === "canvas_apply_ops"
? await onApplyOpsRef.current((input.ops || []) as CanvasAgentOp[], { source: "local", conversationId: activeThreadId || clientIdRef.current, messageId: payload.requestId })
: projectToolName
? await runProjectAgentTool(projectToolName, input, snapshotRef.current.domainProjectId)
: snapshotRef.current;
: payload.name === "canvas_get_context"
? buildCanvasAgentContext(snapshotRef.current)
: payload.name === "canvas_find_nodes"
? findCanvasAgentNodes(snapshotRef.current, input as Parameters<typeof findCanvasAgentNodes>[1])
: payload.name === "canvas_get_resources"
? getCanvasAgentResources(snapshotRef.current, input as Parameters<typeof getCanvasAgentResources>[1])
: payload.name === "canvas_validate_ops"
? validateCanvasAgentOps(snapshotRef.current, (input.ops || []) as CanvasAgentOp[])
: projectToolName
? await runProjectAgentTool(projectToolName, input, snapshotRef.current.domainProjectId)
: snapshotRef.current;
await postToolResult(clientIdRef.current, { requestId: payload.requestId, result });
if (payload.name === "canvas_apply_ops") syncState(clientIdRef.current, result as CanvasAgentSnapshot);
setAgentState({ activity: "工具完成", waiting: true });

View File

@@ -56,7 +56,7 @@ function buildNav(features: FeatureAvailability, balance: string, isAdmin: boole
heading: "工作台管理",
items: [
toolItem("skills", "/skills"),
toolItem("plugins", "/plugins"),
...(features.pluginCenterEnabled || isAdmin ? [toolItem("plugins", "/plugins")] : []),
...(features.creditsEnabled ? [{ ...toolItem("wallet", "/wallet"), badge: balance }] : []),
],
},

View File

@@ -3,7 +3,7 @@ import { Button, Modal } from "antd";
import { useEffect, useState, type ReactNode } from "react";
import { ModelIcon } from "@/components/model-picker";
import { MODEL_PROTOCOLS, modelProtocolLabel, type ModelProtocol, type ModelProtocolDefinition, type ProtocolCapability } from "@/lib/model-protocols";
import { modelProtocolLabel, type ModelProtocol, type ModelProtocolDefinition, type ProtocolCapability } from "@/lib/model-protocols";
import { cn } from "@/lib/utils";
export type ModelCapabilityChoice = ProtocolCapability;
@@ -57,11 +57,11 @@ export function CapabilityCardPicker({ value, onChange, density = "comfortable"
);
}
export function ProtocolCardPicker({ capability, value, onChange, density = "comfortable" }: { capability?: ModelCapabilityChoice; value?: ModelProtocol; onChange?: (value: ModelProtocol) => void; density?: PickerDensity }) {
const protocols = MODEL_PROTOCOLS.filter((item) => item.capability === capability);
export function ProtocolCardPicker({ capability, value, onChange, density = "comfortable", protocols = [] }: { capability?: ModelCapabilityChoice; value?: ModelProtocol; onChange?: (value: ModelProtocol) => void; density?: PickerDensity; protocols?: ModelProtocolDefinition[] }) {
const availableProtocols = protocols.filter((item) => item.capability === capability && item.enabled !== false);
return (
<div className={cn("grid grid-cols-1 gap-2 sm:grid-cols-2", density === "compact" && "gap-1.5 xl:grid-cols-3")} role="radiogroup" aria-label="模型请求协议">
{protocols.map((protocol) => {
{availableProtocols.map((protocol) => {
const selected = value === protocol.value;
return (
<button
@@ -104,7 +104,7 @@ export function ProtocolCardPicker({ capability, value, onChange, density = "com
);
}
export function ModelCapabilityProtocolModal({ value, onChange }: { value: { capability: ModelCapabilityChoice; protocol: ModelProtocol }; onChange: (value: { capability: ModelCapabilityChoice; protocol: ModelProtocol }) => void }) {
export function ModelCapabilityProtocolModal({ value, onChange, protocols = [] }: { value: { capability: ModelCapabilityChoice; protocol: ModelProtocol }; onChange: (value: { capability: ModelCapabilityChoice; protocol: ModelProtocol }) => void; protocols?: ModelProtocolDefinition[] }) {
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState(value);
@@ -113,7 +113,7 @@ export function ModelCapabilityProtocolModal({ value, onChange }: { value: { cap
}, [open, value]);
const updateCapability = (capability: ModelCapabilityChoice) => {
const protocol = draft.protocol && MODEL_PROTOCOLS.some((item) => item.value === draft.protocol && item.capability === capability) ? draft.protocol : MODEL_PROTOCOLS.find((item) => item.capability === capability)?.value || "chat-completion";
const protocol = draft.protocol && protocols.some((item) => item.value === draft.protocol && item.capability === capability) ? draft.protocol : protocols.find((item) => item.capability === capability)?.value || "";
setDraft({ capability, protocol });
};
@@ -129,7 +129,7 @@ export function ModelCapabilityProtocolModal({ value, onChange }: { value: { cap
}}
>
<span className="max-w-[min(56vw,360px)] truncate">
{capabilityLabel(value.capability)} · {modelProtocolLabel(value.protocol)}
{capabilityLabel(value.capability)} · {modelProtocolLabel(value.protocol, protocols)}
</span>
</Button>
<Modal
@@ -153,7 +153,7 @@ export function ModelCapabilityProtocolModal({ value, onChange }: { value: { cap
</section>
<section>
<div className="mb-2 text-xs font-semibold text-foreground/65">请求协议</div>
<ProtocolCardPicker capability={draft.capability} value={draft.protocol} onChange={(protocol) => setDraft((current) => ({ ...current, protocol }))} />
<ProtocolCardPicker capability={draft.capability} value={draft.protocol} protocols={protocols} onChange={(protocol) => setDraft((current) => ({ ...current, protocol }))} />
</section>
</div>
</Modal>
@@ -163,26 +163,26 @@ export function ModelCapabilityProtocolModal({ value, onChange }: { value: { cap
function ProtocolBrandMark({ protocol, compact = false }: { protocol: ModelProtocolDefinition; compact?: boolean }) {
const iconSize = compact ? "size-6" : "size-8";
if (protocol.value === "chat-completion") return <BrandIconRow models={["openai", "deepseek", "glm"]} compact={compact} />;
if (protocol.value.startsWith("volcengine-jimeng-"))
const vendor = `${protocol.vendor || ""} ${protocol.label}`.toLowerCase();
if (vendor.includes("jimeng") || vendor.includes("即梦"))
return (
<span className={cn("grid shrink-0 place-items-center rounded-md bg-muted text-foreground/65", iconSize)}>
<Sparkles className={compact ? "size-3" : "size-4"} />
</span>
);
if (protocol.value.startsWith("volcengine-"))
if (vendor.includes("volcengine") || vendor.includes("火山方舟"))
return (
<span className={cn("grid shrink-0 place-items-center rounded-md bg-muted text-foreground/65", iconSize)}>
<Flame className={compact ? "size-3" : "size-4"} />
</span>
);
if (protocol.value.startsWith("newapi-channel-") || protocol.value === "novita-video")
if (vendor.includes("newapi") || vendor.includes("novita"))
return (
<span className={cn("grid shrink-0 place-items-center rounded-md bg-muted text-foreground/65", iconSize)}>
<Network className={compact ? "size-3" : "size-4"} />
</span>
);
const brand = protocol.value === "gemini-veo" || protocol.value === "gemini-image" ? "gemini" : protocol.value === "grok-image" || protocol.value === "xai-video" ? "grok" : "openai";
const brand = vendor.includes("gemini") || vendor.includes("google") ? "gemini" : vendor.includes("grok") || vendor.includes("xai") ? "grok" : vendor.includes("openai") ? "openai" : "openai";
return <BrandIconRow models={[brand]} compact={compact} />;
}