feat(*): 插件中心与模型协议 - 完善声明式协议运行时并增加功能开放控制
This commit is contained in:
@@ -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 ? "返回管理后台" : "返回创作台";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 }] : []),
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user