feat(*): 模型协议 - 升级声明式协议、扩充官方插件并完善生成任务恢复

This commit is contained in:
ddCat
2026-09-01 16:47:03 +08:00
parent 3f4a9ea3d0
commit 4724f0767b
132 changed files with 4576 additions and 1420 deletions

View File

@@ -45,11 +45,11 @@ 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 (error) return <WorkspacePage><WorkspaceErrorState title="无法确认功能状态" description={error} actionLabel="返回创作台" onRetry={() => navigate("/", { replace: true })} /></WorkspacePage>;
if (!adminBypass && !features[feature]) {
// 管理员页面返回到管理后台首页,用户页面返回到创作台
const isAdminFeature = feature === "frontendModelsEnabled" || (feature === "pluginCenterEnabled" && user?.role === "admin");
const backPath = isAdminFeature ? "/admin" : "/create";
const backPath = isAdminFeature ? "/admin" : "/";
const backLabel = isAdminFeature ? "返回管理后台" : "返回创作台";
return (

View File

@@ -7,8 +7,8 @@ import { motion } from "motion/react";
import { modelDisplayName, modelIcon, normalizeModelOptionValue, resolveModelChannel, resolveModelRequestConfig, selectableModelsByCapability, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
import { canvasThemes } from "@/lib/canvas-theme";
import { nanoid } from "nanoid";
import { requestToolResponse, type ResponseFunctionTool, type ResponseInputMessage, type ResponseToolCall } from "@/services/api/image";
import { backendModelRuntimeRequired, runBackendToolGenerationTask } from "@/services/api/generation-task";
import { type ResponseFunctionTool, type ResponseInputMessage, type ResponseToolCall } from "@/services/api/image";
import { 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";
@@ -32,7 +32,6 @@ import { CanvasNodeType, type CanvasAssistantMessage, type CanvasAssistantPendin
import { useCanvasAgentStore } from "@/stores/canvas/use-canvas-agent-store";
import { canvasAgentPostconditionMessage, previewCanvasAgentOps, summarizeCanvasAgentOps, verifyCanvasAgentOps, type CanvasAgentOp, type CanvasAgentOperationImpact, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
import { buildCanvasAgentContext, findCanvasAgentNodes, getCanvasAgentConnection, getCanvasAgentGenerationTasks, getCanvasAgentNode, getCanvasAgentResources, validateCanvasAgentOps } from "@/lib/canvas/canvas-agent-context";
import { canvasAgentPromptCacheKey } from "@/lib/openai-prompt-cache";
import { resolveStoryboardGenerationContext } from "@/lib/canvas/canvas-storyboard-context";
import { buildCanvasWorkflowOps, looksLikeWorkflowRequest, type CanvasWorkflowInput } from "@/lib/canvas/canvas-agent-workflow";
import { listAddedSkills, type Skill } from "@/services/api/skills";
@@ -674,7 +673,7 @@ export function CanvasAssistantPanel({
const result = await requestOnlineAgentModel({ ...requestConfig, systemPrompt: "" }, messages, "required", userMessage.text, (text) => {
streamed = text;
if (text.trim()) upsertMessage(sessionId, { id: assistantId, role: "assistant", text });
}, canvasAgentPromptCacheKey(sessionId));
});
addOnlineLog("模型工具回复", result);
if (result.toolCalls.length) {
const writableCalls = result.toolCalls.filter(isWritableToolCall);
@@ -733,7 +732,7 @@ export function CanvasAssistantPanel({
const next = await requestOnlineAgentModel({ ...requestConfig, systemPrompt: "" }, nextMessages, "auto", "继续处理画布工具结果", (text) => {
streamed = text;
if (text.trim()) upsertMessage(sessionId, { id: assistantId, role: "assistant", text });
}, canvasAgentPromptCacheKey(sessionId));
});
addOnlineLog(`Agent Tool Loop ${step + 1} 回复`, next);
if (next.toolCalls.length) {
const writableCalls = next.toolCalls.filter(isWritableToolCall);
@@ -1649,12 +1648,8 @@ function toolCallToResponseInput(call: ResponseToolCall): ResponseInputMessage {
return { type: "function_call", call_id: call.id, name: call.function.name, arguments: call.function.arguments, ...(call.thoughtSignature ? { thoughtSignature: call.thoughtSignature } : {}) };
}
async function requestOnlineAgentModel(config: AiConfig, messages: ResponseInputMessage[], toolChoice: "auto" | "required", prompt: string, onDelta: (text: string) => void, promptCacheKey?: string) {
if (backendModelRuntimeRequired(config)) {
const result = await runBackendToolGenerationTask({ prompt, config, messages, tools: ONLINE_AGENT_TOOLS, toolChoice, onDelta });
return result;
}
return requestToolResponse(config, messages, ONLINE_AGENT_TOOLS, toolChoice, onDelta, { promptCacheKey });
async function requestOnlineAgentModel(config: AiConfig, messages: ResponseInputMessage[], toolChoice: "auto" | "required", prompt: string, onDelta: (text: string) => void) {
return runBackendToolGenerationTask({ prompt, config, messages, tools: ONLINE_AGENT_TOOLS, toolChoice, onDelta });
}
function summarizeToolCalls(calls: ResponseToolCall[]) {

View File

@@ -54,7 +54,6 @@ export type CanvasNodeContentProps = {
onToggleBatch?: () => void;
reduceMediaEffects?: boolean;
mediaActive?: boolean;
hydrateMediaPreview?: boolean;
};
export function CanvasNodeContent(props: CanvasNodeContentProps) {
@@ -417,10 +416,11 @@ function EmptyImageContent({ node, theme, isBatchRoot, batchCount, batchExpanded
return content;
}
function VideoNodeContent({ node, theme, reduceMediaEffects, mediaActive = false, hydrateMediaPreview = false }: CanvasNodeContentProps) {
function VideoNodeContent({ node, theme, reduceMediaEffects, mediaActive = false }: CanvasNodeContentProps) {
const playerBoxRef = useRef<HTMLDivElement>(null);
const { updateMediaNode } = useCanvasNodeActions();
const { url, loading } = useNodeResourceUrl(node, mediaActive);
const hasPassivePreview = Boolean(canvasNodeVideoPreviewUrl(node));
const { url, loading } = useNodeResourceUrl(node, mediaActive || !hasPassivePreview);
const subtitleEntries = node.metadata?.subtitleEntries || [];
const subtitleStyle = node.metadata?.subtitleStyle || createDefaultSubtitleStyle();
const [currentTimeMs, setCurrentTimeMs] = useState(0);
@@ -449,7 +449,7 @@ function VideoNodeContent({ node, theme, reduceMediaEffects, mediaActive = false
}, [node.id, node.metadata?.naturalHeight, node.metadata?.naturalWidth, subtitleEntries.length, updateMediaNode, url]);
if (!node.metadata?.content) return <EmptyMediaContent icon={<Video className="size-7 opacity-35" />} label="空视频节点" color={theme.node.placeholder} />;
if (!mediaActive) return <InactiveVideoPreview node={node} theme={theme} hydrateMediaPreview={hydrateMediaPreview} />;
if (!mediaActive) return <InactiveVideoPreview node={node} theme={theme} sourceUrl={url} sourceLoading={loading} />;
if (!url) return <MediaLoadingState icon={<LoaderCircle className="size-5 animate-spin" />} label={loading ? "正在加载视频" : "视频资源不可用"} />;
const sourceRatio = (videoSize?.width || node.metadata?.naturalWidth || node.width) / Math.max(1, videoSize?.height || node.metadata?.naturalHeight || node.height);
@@ -484,18 +484,18 @@ function AudioNodeContent({ node, theme }: CanvasNodeContentProps) {
return <CanvasAudioPlayer node={node} theme={theme} />;
}
function InactiveVideoPreview({ node, theme, hydrateMediaPreview = false }: Pick<CanvasNodeContentProps, "node" | "theme" | "hydrateMediaPreview">) {
function InactiveVideoPreview({ node, theme, sourceUrl, sourceLoading }: Pick<CanvasNodeContentProps, "node" | "theme"> & { sourceUrl: string; sourceLoading: boolean }) {
const previewUrl = canvasNodeVideoPreviewUrl(node);
const { updateMetadata } = useCanvasNodeActions();
const updateMetadataRef = useRef(updateMetadata);
const [hydrating, setHydrating] = useState(false);
const [hydrating, setHydrating] = useState(() => !previewUrl && Boolean(node.metadata?.content));
useEffect(() => {
updateMetadataRef.current = updateMetadata;
}, [updateMetadata]);
useEffect(() => {
if (previewUrl || !hydrateMediaPreview || !node.metadata?.content || !updateMetadataRef.current) {
if (previewUrl || !node.metadata?.content || !updateMetadataRef.current) {
setHydrating(false);
return;
}
@@ -510,12 +510,32 @@ function InactiveVideoPreview({ node, theme, hydrateMediaPreview = false }: Pick
if (!controller.signal.aborted) setHydrating(false);
});
return () => controller.abort();
}, [hydrateMediaPreview, node.id, node.metadata?.content, node.metadata?.storageKey, previewUrl]);
}, [node.id, node.metadata?.content, node.metadata?.storageKey, previewUrl]);
if (previewUrl) {
return <div className="relative size-full overflow-hidden rounded-[var(--node-radius)] bg-black"><img src={previewUrl} alt={`${node.title || "视频"} 静态预览`} loading="lazy" decoding="async" draggable={false} className="pointer-events-none size-full select-none object-contain" /></div>;
}
return <InactiveMediaCard icon={<Video className="size-7" />} title={node.title || "视频"} hint={hydrating ? "正在生成首帧" : "选择后加载视频"} theme={theme} />;
if (sourceUrl) {
return <div className="relative size-full overflow-hidden rounded-[var(--node-radius)] bg-black">
<video
src={sourceUrl}
aria-label={`${node.title || "视频"} 首帧预览`}
muted
playsInline
preload="auto"
draggable={false}
className="pointer-events-none size-full select-none object-contain"
onLoadedMetadata={(event) => primePassiveVideoFrame(event.currentTarget)}
onLoadedData={(event) => primePassiveVideoFrame(event.currentTarget)}
/>
{hydrating ? <span className="pointer-events-none absolute bottom-3 left-1/2 -translate-x-1/2 rounded-full bg-black/55 px-2 py-1 text-[var(--fs-tiny)] text-white/70 backdrop-blur">正在保存首帧</span> : null}
</div>;
}
return <InactiveMediaCard icon={<Video className="size-7" />} title={node.title || "视频"} hint={sourceLoading || hydrating ? "正在加载首帧" : "视频资源暂不可用"} theme={theme} />;
}
function primePassiveVideoFrame(video: HTMLVideoElement) {
if (video.currentTime === 0 && Number.isFinite(video.duration) && video.duration > 0) video.currentTime = Math.min(0.001, video.duration);
}
function InactiveMediaCard({ icon, title, hint, theme }: { icon: ReactNode; title: string; hint: string; theme: CanvasTheme }) {

View File

@@ -182,7 +182,7 @@ function buildComposerGenerationContext(
if (input.type === "text") textBlocks.push(`【${label}】\n${input.text || ""}`);
else selectedInputs.push(input);
}
nextPrompt += input.type === "text" ? `【${label}】` : label;
nextPrompt += input.type === "text" ? `【${label}】` : `@${label}`;
} else nextPrompt += match[0];
lastIndex = match.index + match[0].length;
}

View File

@@ -24,7 +24,6 @@ type CanvasNodeProps = {
scale: number;
isSelected: boolean;
mediaActive?: boolean;
hydrateMediaPreview?: boolean;
isRelated: boolean;
isFocusRelated: boolean;
isConnectionTarget: boolean;
@@ -70,7 +69,6 @@ export const CanvasNode = React.memo(function CanvasNode({
scale,
isSelected,
mediaActive = false,
hydrateMediaPreview = false,
isRelated,
isFocusRelated,
isConnectionTarget,
@@ -376,7 +374,6 @@ export const CanvasNode = React.memo(function CanvasNode({
onToggleBatch={() => onToggleBatch?.(data.id)}
reduceMediaEffects={reduceMediaEffects}
mediaActive={mediaActive}
hydrateMediaPreview={hydrateMediaPreview}
/>
</div>

View File

@@ -20,7 +20,7 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) {
const hideChrome = pathname.startsWith("/admin") || /^\/canvas\/[^/]+/.test(pathname);
const showGlobalTopBar = !hideChrome;
const spatialWorkbench = isSpatialWorkbenchPath(pathname);
const creationWorkspace = pathname === "/create";
const creationWorkspace = pathname === "/";
const isMobileViewport = () => window.innerWidth < 1024;

View File

@@ -27,8 +27,7 @@ export function WorkspaceCommandPalette({ open, onClose }: { open: boolean; onCl
return { id: slug, title: tool?.label ?? slug, icon: tool?.icon ?? Home, to };
};
return [
{ id: "home", title: "首页", icon: Home, to: "/home" },
toolEntry("create", "/create"),
{ id: "home", title: "首页", icon: Home, to: "/" },
...(features.shortDramaEnabled ? [toolEntry("projects", "/projects")] : []),
toolEntry("canvas", "/canvas"),
...(features.taskCenterEnabled ? [toolEntry("tasks", "/tasks")] : []),

View File

@@ -44,8 +44,7 @@ function buildNav(features: FeatureAvailability, balance: string, isAdmin: boole
const groups: WorkspaceNavGroup[] = [
{
items: [
{ id: "home", title: "首页", icon: Home, to: "/home" },
toolItem("create", "/create"),
{ id: "home", title: "首页", icon: Home, to: "/" },
...(features.shortDramaEnabled ? [toolItem("projects", "/projects")] : []),
toolItem("canvas", "/canvas"),
...(features.taskCenterEnabled ? [toolItem("tasks", "/tasks")] : []),
@@ -136,7 +135,7 @@ function WorkspaceSwitcher({ collapsed, onNavigate, onExpand }: { collapsed: boo
</div>
<div className="mx-2 my-1 h-px bg-[var(--workspace-border)]" />
{[
{ label: "首页", to: "/home" },
{ label: "首页", to: "/" },
{ label: "画布", to: "/canvas" },
{ label: "设置", to: "/settings" },
].map((entry) => (
@@ -364,7 +363,7 @@ export function WorkspaceSidebarNav({ collapsed, onNavigate, onOpenSearch, onExp
const { groups, footer } = useMemo(() => buildNav(features, balance, user?.role === "admin"), [features, balance, user?.role]);
const slug = pathname.split("/").filter(Boolean)[0] || "create";
const slug = pathname.split("/").filter(Boolean)[0] || "home";
const section = searchParams.get("section");
const activeId = slug === "settings" && section ? `settings:${section}` : slug;

View File

@@ -25,6 +25,15 @@ export type CanvasGenerationRequestFingerprintInput = {
context: Pick<NodeGenerationContext, "referenceImages" | "referenceVideos" | "referenceAudios" | "characterReferences" | "resolvedCharacterVersions" | "resolvedCharacterVoices">;
};
/**
* 编辑器保留带 @ 槽位的原始提示词,任务请求单独保存经过技能/风格处理的模型提示词。
* 模型提示词也保留 @图片1 等槽位标记。
* 两者不能互相覆盖,否则刷新后富引用会退化为普通“图片1”文本。
*/
export function canvasGenerationPromptMetadata(composerContent: string, effectivePrompt: string) {
return { composerContent, prompt: effectivePrompt };
}
export function canvasGenerationRequestFingerprint(input: CanvasGenerationRequestFingerprintInput) {
const serialized = canonicalize({
version: 1,

View File

@@ -144,7 +144,7 @@ export function AdminShell() {
<div className="admin-sidebar-footer shrink-0">
<Tooltip mouseEnterDelay={0.1} title={collapsed ? "返回创作台" : undefined} placement="right" rootClassName="app-workspace-sidebar-tooltip">
<NavLink
to="/home"
to="/"
aria-label={collapsed ? "返回创作台" : undefined}
className={cn("app-workspace-nav-link group flex h-8 items-center text-foreground/62 transition-colors hover:bg-surface-hover hover:text-foreground", collapsed ? "justify-center px-0" : "gap-2.5 px-2.5")}
>
@@ -260,7 +260,7 @@ function MobileAdminNavigation() {
</Dropdown>
<div className="flex shrink-0 items-center gap-1">
<Tooltip title="返回创作台">
<Link to="/home" className="admin-mobile-navigation-action" aria-label="返回创作台">
<Link to="/" className="admin-mobile-navigation-action" aria-label="返回创作台">
<Home className="size-4" aria-hidden="true" />
</Link>
</Tooltip>

View File

@@ -91,7 +91,7 @@ function LogDetail({ log, querying, onQueryProviderTask }: { log: ApiCallLog; qu
{canQueryProviderTask ? (
<div className="flex justify-end">
<Button icon={<RefreshCw className="size-4" />} loading={querying} onClick={onQueryProviderTask}>
手动查询任务
{querying ? "正在下载并入库" : "手动查询任务"}
</Button>
</div>
) : null}

View File

@@ -212,6 +212,6 @@ function Notice({ icon, children }: { icon: ReactNode; children: ReactNode }) {
}
function safeNext(value: string | null) {
if (!value || !value.startsWith("/") || value.startsWith("//")) return "/create";
if (!value || !value.startsWith("/") || value.startsWith("//")) return "/";
return value;
}

View File

@@ -111,6 +111,6 @@ function AuthField({ label, htmlFor, action, children }: { label: string; htmlFo
}
function safeNext(value: string | null) {
if (!value || !value.startsWith("/") || value.startsWith("//")) return "/create";
if (!value || !value.startsWith("/") || value.startsWith("//")) return "/";
return value;
}

View File

@@ -121,6 +121,6 @@ function Notice({ icon, tone, children }: { icon: ReactNode; tone: "blue" | "amb
}
function safeNext(value: string | null) {
if (!value || !value.startsWith("/") || value.startsWith("//")) return "/create";
if (!value || !value.startsWith("/") || value.startsWith("//")) return "/";
return value;
}

View File

@@ -5,6 +5,7 @@ import { canGenerateImageInPlace, findAvailableGenerationGroupPosition, imageGen
import { buildImageGenerationNodeTitle } from "@/lib/canvas/canvas-generation-title";
import { nodeSizeFromRatio } from "@/lib/canvas/canvas-node-size";
import { canvasImageReferenceLimitError, buildImageGenerationMetadata, getGenerationCount, isGenerationCanceled, runCanvasGenerationTaskToConsumer } from "@/lib/canvas/canvas-project-generation";
import { canvasGenerationPromptMetadata } from "@/lib/canvas/canvas-generation-submission";
import { CONTENT_MODERATION_ERROR_CODE, generationFailureMetadata, type GenerationFailureMetadata } from "@/lib/generation-error";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
@@ -89,7 +90,7 @@ export async function executeImageGeneration({
height: rootHeight,
metadata: {
...(reuseSourceNode ? sourceNode?.metadata || {} : {}),
prompt: effectivePrompt,
...canvasGenerationPromptMetadata(prompt, effectivePrompt),
status: NODE_STATUS_LOADING,
size: generationConfig.size,
isBatchRoot: count > 1,
@@ -115,7 +116,7 @@ export async function executeImageGeneration({
width: outputNodeSize.width,
height: outputNodeSize.height,
metadata: {
prompt: effectivePrompt,
...canvasGenerationPromptMetadata(prompt, effectivePrompt),
status: NODE_STATUS_LOADING,
size: generationConfig.size,
batchRootId: count > 1 && !directCopiedBatch ? rootId : undefined,
@@ -134,7 +135,7 @@ export async function executeImageGeneration({
const nextNodes: CanvasNodeData[] = [
...canvasNodes.map((node) => {
if (node.id !== nodeId) return node;
if (isConfigNode) return { ...node, metadata: { ...node.metadata, prompt: effectivePrompt, status: NODE_STATUS_LOADING, errorDetails: undefined } };
if (isConfigNode) return { ...node, metadata: { ...node.metadata, ...canvasGenerationPromptMetadata(prompt, effectivePrompt), status: NODE_STATUS_LOADING, errorDetails: undefined } };
if (reuseSourceNode) return { ...node, position: rootNode.position, width: rootNode.width, height: rootNode.height, title: rootNode.title, metadata: { ...node.metadata, ...rootNode.metadata, errorDetails: undefined } };
if (isImageNode) return node;
return {
@@ -143,7 +144,7 @@ export async function executeImageGeneration({
title: prompt.slice(0, 32) || "Prompt",
width: parentConfig.width,
height: parentConfig.height,
metadata: { ...node.metadata, content: prompt, richText: undefined, prompt, status: NODE_STATUS_SUCCESS, fontSize: 14, errorDetails: undefined },
metadata: { ...node.metadata, content: prompt, richText: undefined, prompt, composerContent: prompt, status: NODE_STATUS_SUCCESS, fontSize: 14, errorDetails: undefined },
};
}),
...(reuseSourceNode || directCopiedBatch ? [] : [rootNode]),

View File

@@ -5,6 +5,7 @@ import { canGenerateMediaInPlace } from "@/lib/canvas/canvas-generation-layout";
import { nodeSizeFromRatio } from "@/lib/canvas/canvas-node-size";
import { nextCanvasVersionLabel } from "@/lib/canvas/canvas-layout";
import { buildAudioGenerationMetadata, buildVideoGenerationMetadata, generationReferenceUrls, runCanvasGenerationTaskToConsumer } from "@/lib/canvas/canvas-project-generation";
import { canvasGenerationPromptMetadata } from "@/lib/canvas/canvas-generation-submission";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import type { CanvasGenerationExecution } from "./canvas-generation-executor-types";
@@ -15,6 +16,7 @@ const NODE_STATUS_SUCCESS = "success" as const;
export async function executeVideoGeneration({
nodeId,
sourceNode,
prompt,
effectivePrompt,
generationConfig,
generationContext,
@@ -49,7 +51,7 @@ export async function executeVideoGeneration({
height: reuseSourceNode ? sourceNode!.height : spec.height,
metadata: {
...(reuseSourceNode ? sourceNode?.metadata || {} : {}),
prompt: effectivePrompt,
...canvasGenerationPromptMetadata(prompt, effectivePrompt),
status: NODE_STATUS_LOADING,
errorDetails: undefined,
generationErrorCode: undefined,
@@ -130,6 +132,7 @@ export async function executeVideoGeneration({
export async function executeAudioGeneration({
nodeId,
sourceNode,
prompt,
effectivePrompt,
generationConfig,
generationContext,
@@ -157,7 +160,7 @@ export async function executeAudioGeneration({
position: isEmptyAudioNode ? sourceNode.position : { x: parent.x + (sourceNode?.width || spec.width) + 96, y: parent.y + ((sourceNode?.height || spec.height) - spec.height) / 2 },
width: isEmptyAudioNode ? sourceNode.width : spec.width,
height: isEmptyAudioNode ? sourceNode.height : spec.height,
metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, ...buildAudioGenerationMetadata(generationConfig), ...skillMetadata },
metadata: { ...canvasGenerationPromptMetadata(prompt, effectivePrompt), status: NODE_STATUS_LOADING, ...buildAudioGenerationMetadata(generationConfig), ...skillMetadata },
};
registerPendingNodeIds([audioId]);
setNodes((current) =>

View File

@@ -144,7 +144,6 @@ export const CanvasProjectWorldLayers = memo(function CanvasProjectWorldLayers(p
scale={viewportScale}
isSelected={props.selectedNodeIds.has(node.id)}
mediaActive={activeMediaNodeId === node.id}
hydrateMediaPreview={activeMediaNodeId === null && !props.reduceMediaEffects}
isRelated={props.relatedNodeIds.has(node.id)}
isFocusRelated={props.activeNodeId === node.id}
isConnectionTarget={props.connectionTargetNodeId === node.id || props.batchConnectionPreview?.targetNodeId === node.id}

View File

@@ -2,6 +2,7 @@ import { nanoid } from "nanoid";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { getGenerationCount, runCanvasGenerationTaskToConsumer } from "@/lib/canvas/canvas-project-generation";
import { canvasGenerationPromptMetadata } from "@/lib/canvas/canvas-generation-submission";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import type { CanvasGenerationExecution } from "./canvas-generation-executor-types";
@@ -51,9 +52,9 @@ export async function executeTextGeneration({
},
width: textConfig.width,
height: textConfig.height,
metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, fontSize: 14, ...skillMetadata },
metadata: { ...canvasGenerationPromptMetadata(prompt, effectivePrompt), status: NODE_STATUS_LOADING, fontSize: 14, ...skillMetadata },
}));
setNodes((current) => [...current.map((node) => (node.id === nodeId && isConfigNode ? { ...node, metadata: { ...node.metadata, prompt: effectivePrompt, status: NODE_STATUS_LOADING, errorDetails: undefined } } : node)), ...childNodes]);
setNodes((current) => [...current.map((node) => (node.id === nodeId && isConfigNode ? { ...node, metadata: { ...node.metadata, ...canvasGenerationPromptMetadata(prompt, effectivePrompt), status: NODE_STATUS_LOADING, errorDetails: undefined } } : node)), ...childNodes]);
setConnections((current) => [...current, ...childIds.map((childId) => ({ id: nanoid(), fromNodeId: nodeId, toNodeId: childId }))]);
}

View File

@@ -4,7 +4,7 @@ import { App } from "antd";
import { buildNodeGenerationContext, hydrateNodeGenerationContext } from "@/components/canvas/canvas-node-generation";
import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
import { buildGenerationConfig, isGenerationCanceled } from "@/lib/canvas/canvas-project-generation";
import { canvasGenerationRequestFingerprint, runCanvasGenerationSubmissionOnce } from "@/lib/canvas/canvas-generation-submission";
import { canvasGenerationPromptMetadata, canvasGenerationRequestFingerprint, runCanvasGenerationSubmissionOnce } from "@/lib/canvas/canvas-generation-submission";
import { isGenerationTaskCapacityError } from "@/lib/canvas/canvas-generation-batch";
import { buildPortraitTexturePrompt } from "@/lib/canvas/canvas-portrait-texture";
import { resolveCanvasStyleExecution } from "@/lib/canvas/canvas-style-execution";
@@ -242,6 +242,7 @@ export function useCanvasGenerationExecutor({
metadata: {
...node.metadata,
prompt,
composerContent: prompt,
status: NODE_STATUS_LOADING,
taskStage: "正在准备生成任务",
taskProgress: 0,
@@ -266,7 +267,7 @@ export function useCanvasGenerationExecutor({
node.id === nodeId
? {
...node,
metadata: { ...node.metadata, prompt: statusPrompt, status: NODE_STATUS_LOADING, errorDetails: undefined, generationErrorCode: undefined, resourceReloadAvailable: undefined, failedPromptFingerprint: undefined },
metadata: { ...node.metadata, ...canvasGenerationPromptMetadata(prompt, statusPrompt), status: NODE_STATUS_LOADING, errorDetails: undefined, generationErrorCode: undefined, resourceReloadAvailable: undefined, failedPromptFingerprint: undefined },
}
: node,
),

View File

@@ -25,13 +25,11 @@ import { buildImageResolutionOptions, formatImageResolutionSize, imageRatioForSi
import { formatVideoResolutionLabel as videoResolutionLabel, VIDEO_RESOLUTION_OPTIONS } from "@/lib/video-generation-options";
import { modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, videoDurationAllowed, videoDurationOptions, type ImageCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities";
import { inferVideoOperation, resolveCompatibleModel, mergedImageCapabilityConfig, type ModelRequirements } from "@/lib/model-selection";
import { backendModelRuntimeRequired, isGenerationTaskCancelled, runBackendGenerationTask, runBackendGenerationTaskBatch, type BackendGenerationResult } from "@/services/api/generation-task";
import { requestImageQuestion, type AiTextContentPart } from "@/services/api/image";
import { isGenerationTaskCancelled, runBackendGenerationTask, runBackendGenerationTaskBatch, type BackendGenerationResult } from "@/services/api/generation-task";
import { listAddedSkills, type Skill } from "@/services/api/skills";
import { subscribeGenerationTasks, type GenerationTask } from "@/services/api/task-center";
import { createTextReplayPublisher } from "@/lib/creation-text-replay";
import { isLocalDreaminaWaitStopped, localDreaminaCancellationMessage } from "@/services/local-dreamina-task-projection";
import { getMediaBlob, uploadMediaFile } from "@/services/file-storage";
import { uploadMediaFile } from "@/services/file-storage";
import { uploadImage } from "@/services/image-storage";
import { consumeGenerationTaskMessage, generationTaskMaterializedUrls, materializeGenerationTaskAssets, projectGenerationTaskResult } from "@/services/project-asset-sync";
import { applyGenerationConsumerEffect } from "@/services/generation-consumer-dedupe";
@@ -100,7 +98,6 @@ const qualityOptions = [
];
const resolutionOptions = VIDEO_RESOLUTION_OPTIONS.map((value) => ({ value: String(value), label: videoResolutionLabel(value) }));
const countOptions = ["1", "2", "3", "4"];
const TEXT_ATTACHMENT_MAX_BYTES = 20 * 1024 * 1024;
const conversationTimeFormatter = new Intl.DateTimeFormat("zh-CN", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false });
const messageTimeFormatter = new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false });
@@ -634,43 +631,22 @@ export default function CreatePage() {
};
try {
if (mode === "text") {
if (backendModelRuntimeRequired(requestConfig)) {
const result = await runGenerationOperationOnce(retryContext?.clientOperationId, () => runBackendGenerationTask({
mode: "text",
prompt: expandedPrompt,
config: requestConfig,
referenceImages,
referenceVideos,
referenceAudios,
textHistory: (activeConversation.messages || []).filter((item) => item.content.trim()).map((item) => ({ role: item.role, content: item.content })),
signal: requestLifecycle.signal,
metadata: { source: "create-page", conversationId: activeConversation.id, messageId: assistantMessage.id, ...referenceMetadata },
onTaskUpdate: bindTask,
onTextDelta: (text) => updateOriginAssistant((item) => ({ ...item, content: text })),
...retryContext,
}));
if (!result.text?.trim()) throw new Error("后端任务没有返回文本");
updateOriginAssistant((item) => ({ ...item, content: result.text || "" }));
} else {
const history = await Promise.all([...(activeConversation.messages || []), userMessage].map(async (item) => ({
role: item.role,
content: item.role === "user"
? await buildTextMessageContent(item)
: item.content,
})));
const replayPublisher = createTextReplayPublisher(requestConfig, text);
void replayPublisher.start();
let finalText = "";
await requestImageQuestion(requestConfig, history, (full) => {
finalText = full;
updateOriginAssistant((item) => ({ ...item, content: full }));
replayPublisher.publish(full);
}, {
signal: requestLifecycle.signal,
onReasoning: (reasoning) => updateOriginAssistant((item) => ({ ...item, reasoning })),
});
replayPublisher.finish(finalText);
}
const result = await runGenerationOperationOnce(retryContext?.clientOperationId, () => runBackendGenerationTask({
mode: "text",
prompt: expandedPrompt,
config: requestConfig,
referenceImages,
referenceVideos,
referenceAudios,
textHistory: (activeConversation.messages || []).filter((item) => item.content.trim()).map((item) => ({ role: item.role, content: item.content })),
signal: requestLifecycle.signal,
metadata: { source: "create-page", conversationId: activeConversation.id, messageId: assistantMessage.id, ...referenceMetadata },
onTaskUpdate: bindTask,
onTextDelta: (value) => updateOriginAssistant((item) => ({ ...item, content: value })),
...retryContext,
}));
if (!result.text?.trim()) throw new Error("后端任务没有返回文本");
updateOriginAssistant((item) => ({ ...item, content: result.text || "", reasoning: result.reasoning }));
} else if (mode === "image") {
const taskCount = Math.max(1, Math.min(imageProfile.maxOutputs, Math.floor(Number(count) || 1)));
const settled = await runGenerationOperationOnce(retryContext?.clientOperationId, () => runBackendGenerationTaskBatch({
@@ -1795,44 +1771,6 @@ function conversationPreviewMessage(conversation: CreationConversation) {
return fallback;
}
async function buildTextMessageContent(item: CreationMessage) {
const content = expandCreationPrompt(item.content, item.references || [], item.attachments || []);
const attachments = item.attachments || [];
if (!attachments.length) return content;
const parts: AiTextContentPart[] = [{ type: "text", text: content }];
for (const attachment of attachments) {
if (isImageAttachment(attachment)) {
parts.push({ type: "image_url", image_url: { url: attachment.dataUrl || attachment.url || "" } });
continue;
}
const url = await creationAttachmentDataUrl(attachment);
parts.push({ type: "file_url", file_url: { url, name: attachment.name || "附件", mimeType: attachment.type || "application/octet-stream" } });
}
return parts;
}
async function creationAttachmentDataUrl(attachment: CreationAttachment) {
if ((attachment.bytes || 0) > TEXT_ATTACHMENT_MAX_BYTES) throw new Error(`${attachment.name} 超过 20MB,当前文本模型附件需要压缩后再上传`);
const attachmentUrl = attachment.url || "";
if (attachmentUrl.startsWith("data:")) return attachmentUrl;
const blob = attachment.storageKey ? await getMediaBlob(attachment.storageKey) : null;
if (blob) {
if (blob.size > TEXT_ATTACHMENT_MAX_BYTES) throw new Error(`${attachment.name} 超过 20MB,当前文本模型附件需要压缩后再上传`);
return blobToDataUrl(blob);
}
if (/^https:\/\//i.test(attachmentUrl)) return attachmentUrl;
throw new Error(`${attachment.name} 无法读取,请重新上传后再试`);
}
function blobToDataUrl(blob: Blob) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(reader.error || new Error("附件读取失败"));
reader.readAsDataURL(blob);
});
}
function isVideoAttachment(attachment: CreationAttachment): attachment is CreationAttachment & { url: string } {
return creationAttachmentKind(attachment) === "video";
}

View File

@@ -1,594 +0,0 @@
import { useMemo, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { App, Button, Input, Pagination, Segmented, Select } from "antd";
import { ArrowRight, FolderKanban, Images, LayoutGrid, ListTodo, Plus, Search, Sparkles } from "lucide-react";
import { Link, useNavigate } from "react-router";
import { Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, ResponsiveContainer, Tooltip as ChartTooltip, XAxis, YAxis } from "recharts";
import { resolveCanvasStylePreset, resolveProjectCanvasStyle } from "@/components/canvas/canvas-style-picker-modal";
import { WorkspaceErrorState, WorkspaceLoadingState } from "@/components/layout/workspace-state";
import { parseStyleProfile } from "@/lib/canvas/style-profile";
import { projectSummaryCompletion, projectSummaryStage } from "@/lib/project-workbench";
import { listProjects, type ProjectSummary } from "@/services/api/projects";
import { listGenerationTasks } from "@/services/api/task-center";
import { createCanvasProjectWithRemoteSync } from "@/services/user-data-sync";
import { useAssetStore, type AssetKind } from "@/stores/use-asset-store";
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { useUserStore } from "@/stores/use-user-store";
const workflow = [
{ title: "整理故事", description: "导入小说、粘贴文本或创建章节" },
{ title: "确认设定", description: "整理角色、场景、画风和参考资料" },
{ title: "制作镜头", description: "生成分镜、图片和视频候选" },
{ title: "检查结果", description: "比较版本、处理失败并整理导出" },
];
const sourceTypeLabels: Record<string, string> = {
blank: "空白开始",
novel: "导入小说",
text: "粘贴文本",
};
const ASSET_KINDS: Array<{ key: AssetKind; label: string }> = [
{ key: "image", label: "图片" },
{ key: "video", label: "视频" },
{ key: "audio", label: "音频" },
{ key: "text", label: "文本" },
{ key: "entity", label: "实体" },
{ key: "model", label: "模型" },
];
const CHART_ACCENT = "var(--workspace-accent)";
const CHART_MUTED = "color-mix(in srgb, var(--workspace-accent) 30%, transparent)";
export default function IndexPage() {
const { message } = App.useApp();
const navigate = useNavigate();
const canvasHydrated = useCanvasStore((state) => state.hydrated);
const canvasProjects = useCanvasStore((state) => state.projects);
const assets = useAssetStore((state) => state.assets);
const user = useUserStore((state) => state.user);
const userHydrated = useUserStore((state) => state.hydrated);
const shortDramaEnabled = useUserStore((state) => state.features.shortDramaEnabled);
const taskCenterEnabled = useUserStore((state) => state.features.taskCenterEnabled);
const domainProjectsQuery = useQuery({
queryKey: ["projects"],
queryFn: () => listProjects(),
enabled: Boolean(user && shortDramaEnabled),
});
const domainProjects = useMemo(() => [...(domainProjectsQuery.data?.projects || [])].sort((left, right) => right.project.updatedAt.localeCompare(left.project.updatedAt)), [domainProjectsQuery.data]);
// 仪表盘需要生成记录做活跃度与任务统计;后端不可用时降级为空数据,不影响其他区块。
const tasksQuery = useQuery({
queryKey: ["home-dashboard-tasks"],
queryFn: () => listGenerationTasks(300).catch(() => []),
enabled: Boolean(user && taskCenterEnabled),
});
const tasks = useMemo(() => tasksQuery.data || [], [tasksQuery.data]);
const createIndependentCanvas = () => {
if (!canvasHydrated) return;
if (!user) {
navigate(`/login?next=${encodeURIComponent("/canvas?mode=new")}`);
return;
}
void createCanvasProjectWithRemoteSync(`自由画布 ${canvasProjects.length + 1}`).then(({ id, syncError }) => {
if (syncError) message.warning(syncError instanceof Error ? `画布已在本地创建,云端同步失败:${syncError.message}` : "画布已在本地创建,云端同步失败");
navigate(`/canvas/${id}`);
});
};
const loadingUserWorkspace = !userHydrated || (Boolean(user && shortDramaEnabled) && domainProjectsQuery.isLoading);
return (
<main className="app-user-content app-workspace-canvas app-workspace-scroll h-full overflow-y-auto text-foreground">
<div className="app-home-dashboard w-full px-4 pb-14 pt-5 sm:px-6 lg:px-8">
{loadingUserWorkspace ? (
<WorkspaceLoadingState className="mt-3" label="正在恢复工作台" detail="读取项目、素材和最近任务" rows={5} />
) : user && shortDramaEnabled && domainProjectsQuery.isError ? (
<WorkspaceErrorState title="项目工作台加载失败" description={domainProjectsQuery.error instanceof Error ? domainProjectsQuery.error.message : "暂时无法读取项目列表。"} onRetry={() => void domainProjectsQuery.refetch()} />
) : (
<HomeDashboard
user={user}
shortDramaEnabled={shortDramaEnabled}
domainProjects={domainProjects}
canvasProjects={canvasProjects}
assets={assets}
tasks={tasks}
canvasHydrated={canvasHydrated}
onCreateIndependentCanvas={createIndependentCanvas}
/>
)}
</div>
</main>
);
}
function HomeDashboard({
user,
shortDramaEnabled,
domainProjects,
canvasProjects,
assets,
tasks,
canvasHydrated,
onCreateIndependentCanvas,
}: {
user: ReturnType<typeof useUserStore.getState>["user"];
shortDramaEnabled: boolean;
domainProjects: ProjectSummary[];
canvasProjects: ReturnType<typeof useCanvasStore.getState>["projects"];
assets: ReturnType<typeof useAssetStore.getState>["assets"];
tasks: Awaited<ReturnType<typeof listGenerationTasks>>;
canvasHydrated: boolean;
onCreateIndependentCanvas: () => void;
}) {
const hasProjects = Boolean(domainProjects.length);
const displayName = user?.displayName || user?.username || "创作者";
const activeProjects = useMemo(() => domainProjects.filter(({ project }) => project.status !== "archived"), [domainProjects]);
const archivedCount = domainProjects.length - activeProjects.length;
const totalUnits = useMemo(() => activeProjects.reduce((sum, row) => sum + row.unitCount, 0), [activeProjects]);
const activeProject = activeProjects[0];
const independentCanvases = useMemo(() => canvasProjects.filter((project) => !project.projectId), [canvasProjects]);
const latestCanvasUpdate = useMemo(() => canvasProjects.reduce<string | null>((latest, project) => (latest === null || project.updatedAt > latest ? project.updatedAt : latest), null), [canvasProjects]);
const weekWindow = Date.now() - 7 * 24 * 60 * 60 * 1000;
const weekTasks = useMemo(() => tasks.filter((task) => new Date(task.createdAt).getTime() >= weekWindow), [tasks, weekWindow]);
const weekSucceeded = useMemo(() => weekTasks.filter((task) => task.status === "succeeded").length, [weekTasks]);
const weekFailed = useMemo(() => weekTasks.filter((task) => task.status === "failed" || task.status === "cancelled").length, [weekTasks]);
const weekRunning = useMemo(() => weekTasks.filter((task) => task.status === "queued" || task.status === "running").length, [weekTasks]);
const assetKindCounts = useMemo(() => {
const counts: Partial<Record<AssetKind, number>> = {};
for (const asset of assets) counts[asset.kind] = (counts[asset.kind] || 0) + 1;
return counts;
}, [assets]);
const [rangeDays, setRangeDays] = useState(14);
const activityData = useMemo(
() =>
buildActivityBuckets(
rangeDays,
tasks.map((task) => new Date(task.createdAt).getTime()),
canvasProjects.map((project) => new Date(project.updatedAt).getTime()),
),
[rangeDays, tasks, canvasProjects],
);
const assetChartEntries = useMemo(() => assetChartData(assets), [assets]);
const assetTotal = useMemo(() => assets.length, [assets]);
const [keyword, setKeyword] = useState("");
const [status, setStatus] = useState<"all" | "active" | "archived">("all");
const [sort, setSort] = useState<"updated" | "progress" | "name">("updated");
const [page, setPage] = useState(1);
const rows = useMemo(() => {
const normalizedKeyword = keyword.trim().toLowerCase();
return domainProjects
.filter(({ project }) => status === "all" || project.status === status)
.filter(({ project }) => !normalizedKeyword || `${project.name} ${project.description || ""} ${projectStyleTitle(project)}`.toLowerCase().includes(normalizedKeyword))
.sort((left, right) => {
if (sort === "name") return left.project.name.localeCompare(right.project.name, "zh-CN");
if (sort === "progress") return projectSummaryCompletion(right) - projectSummaryCompletion(left);
return right.project.updatedAt.localeCompare(left.project.updatedAt);
});
}, [domainProjects, keyword, sort, status]);
const pageSize = 5;
const safePage = Math.min(page, Math.max(1, Math.ceil(rows.length / pageSize)));
const pageRows = rows.slice((safePage - 1) * pageSize, safePage * pageSize);
const projectHref = user ? "/projects?create=1" : `/login?next=${encodeURIComponent("/projects?create=1")}`;
return (
<>
<header className="home-welcome" aria-label="欢迎区">
<div className="min-w-0">
<h1 className="home-welcome-title">{hasProjects ? `欢迎回来,${displayName}` : `欢迎使用影策,${displayName}`}</h1>
<p className="home-welcome-sub">{hasProjects ? "回到最近制作,或从一句话故事开始一部新的短剧。" : "从一个故事开始:整理章节、确认设定、制作镜头,直到可交付的结果。"}</p>
</div>
<div className="home-welcome-actions">
{shortDramaEnabled ? (
<Link
className="inline-flex h-10 items-center gap-2 rounded-md bg-foreground px-4 text-sm font-medium text-background transition-opacity hover:opacity-85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground/25"
to={projectHref}
>
<Plus className="size-4" />
开始创作
</Link>
) : null}
<Button size="large" disabled={!canvasHydrated} icon={<LayoutGrid className="size-4" />} onClick={onCreateIndependentCanvas}>
打开画布
</Button>
</div>
</header>
<section className="home-stats-grid" aria-label="工作台统计">
{shortDramaEnabled ? (
<StatCard icon={<FolderKanban className="size-4" />} label="进行中项目" value={activeProjects.length} hint={activeProjects.length ? `共 ${totalUnits} 章 · ${archivedCount} 个已归档` : "创建项目开始制作"} to="/projects" />
) : null}
<StatCard icon={<LayoutGrid className="size-4" />} label="自由画布" value={independentCanvases.length} hint={latestCanvasUpdate ? `最近更新 ${formatRelativeTime(latestCanvasUpdate)}` : "新建独立创作空间"} to="/canvas" />
<StatCard icon={<Images className="size-4" />} label="素材资产" value={assets.length} hint={assetSummaryHint(assetKindCounts)} to="/assets" />
<StatCard icon={<ListTodo className="size-4" />} label="本周任务" value={weekTasks.length} hint={weekTasks.length ? `成功 ${weekSucceeded} · 失败 ${weekFailed} · 进行中 ${weekRunning}` : "本周还没有生成任务"} to="/tasks" />
</section>
<section className="home-charts-grid mt-3" aria-label="创作数据">
<div className="home-panel">
<div className="home-panel-head">
<div>
<h2 className="home-panel-title">创作活跃度</h2>
<p className="home-panel-sub">按天统计生成任务与画布更新</p>
</div>
<Segmented
size="small"
options={[
{ label: "近 7 天", value: 7 },
{ label: "近 14 天", value: 14 },
{ label: "近 30 天", value: 30 },
]}
value={rangeDays}
onChange={(value) => setRangeDays(value as number)}
/>
</div>
<div className="home-panel-body">
{activityData.some((item) => item.tasks > 0 || item.canvases > 0) ? (
<ResponsiveContainer width="100%" height={232}>
<BarChart data={activityData} margin={{ top: 12, right: 8, left: -18, bottom: 0 }}>
<CartesianGrid vertical={false} stroke="var(--workspace-border)" strokeOpacity={0.6} />
<XAxis
dataKey="label"
tickLine={false}
axisLine={{ stroke: "var(--workspace-border)" }}
tickMargin={6}
interval={rangeDays > 14 ? 4 : rangeDays > 7 ? 2 : 0}
tick={{ fill: "var(--foreground)", opacity: 0.45, fontSize: 11 }}
/>
<YAxis tickLine={false} axisLine={false} allowDecimals={false} tick={{ fill: "var(--foreground)", opacity: 0.45, fontSize: 10 }} />
<ChartTooltip
cursor={{ fill: "var(--workspace-accent)", opacity: 0.05 }}
contentStyle={tooltipStyle}
itemStyle={{ color: "var(--foreground)", fontSize: 12 }}
labelStyle={{ color: "var(--foreground)", fontWeight: 600, fontSize: 12 }}
/>
<Bar dataKey="tasks" name="生成任务" fill={CHART_ACCENT} radius={[4, 4, 0, 0]} maxBarSize={14} />
<Bar dataKey="canvases" name="画布更新" fill={CHART_MUTED} radius={[4, 4, 0, 0]} maxBarSize={14} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="home-chart-empty">还没有生成记录,去画布或项目开始第一次创作。</div>
)}
</div>
</div>
<div className="home-panel">
<div className="home-panel-head">
<div>
<h2 className="home-panel-title">素材构成</h2>
<p className="home-panel-sub">按类型统计当前素材库</p>
</div>
</div>
<div className="home-panel-body">
{assetTotal ? (
<div className="flex min-h-[232px] items-center gap-2 py-2">
<div className="relative h-[200px] w-[56%] shrink-0">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={assetChartEntries} dataKey="value" nameKey="name" innerRadius="60%" outerRadius="88%" paddingAngle={2} cornerRadius={3} stroke="none">
{assetChartEntries.map((entry) => (
<Cell key={entry.name} fill={entry.color} />
))}
</Pie>
<ChartTooltip contentStyle={tooltipStyle} itemStyle={{ color: "var(--foreground)", fontSize: 12 }} labelStyle={{ color: "var(--foreground)", fontWeight: 600, fontSize: 12 }} />
</PieChart>
</ResponsiveContainer>
<div className="home-donut-center">
<strong>{assetTotal}</strong>
<span>项素材</span>
</div>
</div>
<ul className="home-donut-legend">
{assetChartEntries.map((entry) => (
<li key={entry.name}>
<i style={{ background: entry.color }} />
<span>{entry.name}</span>
<em>{entry.value}</em>
</li>
))}
</ul>
</div>
) : (
<div className="home-chart-empty">还没有素材资产,生成的图片、视频和音频会沉淀在这里。</div>
)}
</div>
</div>
</section>
<section className="home-lower-grid mt-3" aria-label="最近项目与创作入口">
<div className="home-panel">
<div className="home-panel-head">
<div>
<h2 className="home-panel-title">最近项目</h2>
<p className="home-panel-sub">按更新时间排列,支持搜索、筛选与排序</p>
</div>
<Link to="/projects" className="inline-flex items-center gap-1.5 text-xs text-foreground/50 hover:text-foreground">
查看全部
<ArrowRight className="size-3.5" />
</Link>
</div>
<div className="home-recent-toolbar">
<Input
allowClear
className="home-recent-search"
prefix={<Search className="size-4 text-foreground/40" />}
value={keyword}
placeholder="搜索项目名称或画风"
onChange={(event) => {
setKeyword(event.target.value);
setPage(1);
}}
aria-label="搜索项目"
/>
<Select
size="middle"
className="w-32"
value={status}
onChange={(value) => {
setStatus(value as "all" | "active" | "archived");
setPage(1);
}}
options={[
{ label: "全部状态", value: "all" },
{ label: "进行中", value: "active" },
{ label: "已归档", value: "archived" },
]}
/>
<Select
size="middle"
className="w-32"
value={sort}
onChange={(value) => {
setSort(value as "updated" | "progress" | "name");
setPage(1);
}}
options={[
{ label: "最近更新", value: "updated" },
{ label: "章节进度", value: "progress" },
{ label: "项目名称", value: "name" },
]}
/>
</div>
<div className="home-recent-scroll">
<div className="home-recent-table">
<div className="home-recent-col-head" aria-hidden="true">
<span>项目</span>
<span>阶段</span>
<span>章节进度</span>
<span>更新时间</span>
<span />
</div>
{rows.length ? (
pageRows.map((row) => <RecentProjectRow key={row.project.id} row={row} />)
) : (
<div className="home-recent-empty">
{domainProjects.length ? (
<>
<Search className="size-5 text-foreground/35" />
<p className="text-sm font-medium">没有匹配的项目</p>
<p className="text-xs leading-5 text-foreground/45">调整搜索词或状态筛选后再试。</p>
</>
) : shortDramaEnabled ? (
<>
<Sparkles className="size-5 text-foreground/35" />
<p className="text-sm font-medium">创建第一个故事项目</p>
<p className="max-w-sm text-xs leading-5 text-foreground/45">项目会集中保存章节、项目画布、角色场景和制作进度,从这里开始一部短剧。</p>
<Link
className="inline-flex h-9 items-center gap-2 rounded-md bg-foreground px-3.5 text-sm font-medium text-background transition-opacity hover:opacity-85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground/25"
to={projectHref}
>
<Plus className="size-3.5" />
创建项目
</Link>
</>
) : (
<>
<Sparkles className="size-5 text-foreground/35" />
<p className="text-sm font-medium">项目功能尚未启用</p>
<p className="text-xs leading-5 text-foreground/45">可以从自由画布开始创作,或联系管理员开启短剧项目。</p>
</>
)}
</div>
)}
</div>
</div>
<div className="home-panel-foot">
<span className="text-xs text-foreground/45">
共 {rows.length} 个项目{rows.length ? `,每页 ${pageSize} 条` : ""}
</span>
{rows.length > pageSize ? <Pagination size="small" current={safePage} pageSize={pageSize} total={rows.length} showSizeChanger={false} onChange={setPage} /> : null}
</div>
</div>
<div className="home-side-stack">
<div className="home-panel">
<div className="home-panel-head">
<div>
<h2 className="home-panel-title">快捷创建</h2>
<p className="home-panel-sub">常用创作入口</p>
</div>
</div>
<div className="home-panel-body home-quick-create">
{shortDramaEnabled ? <QuickItem icon={<FolderKanban className="size-4" />} title="创建短剧项目" description="从空白、小说或文本建立章节流程" href={projectHref} /> : null}
<QuickItem icon={<LayoutGrid className="size-4" />} title="打开自由画布" description="适合快速试图与提示词实验" onClick={onCreateIndependentCanvas} disabled={!canvasHydrated} />
<QuickItem icon={<Images className="size-4" />} title="进入素材库" description="整理角色、场景与媒体资产" href="/assets" />
</div>
</div>
<div className="home-panel">
<div className="home-panel-head">
<div>
<h2 className="home-panel-title">从工作流开始</h2>
<p className="home-panel-sub">整理故事 → 确认设定 → 制作镜头 → 检查结果</p>
</div>
</div>
<div className="home-panel-body">
<div className="home-workflow">
{workflow.map((item, index) => (
<div key={item.title} className="home-workflow-step">
<span className="home-workflow-num">0{index + 1}</span>
<span className="min-w-0">
<strong className="home-workflow-title block">{item.title}</strong>
<span className="home-workflow-desc block">{item.description}</span>
</span>
</div>
))}
</div>
<p className="home-workflow-hint">{activeProject ? `${projectSummaryStage(activeProject).label} · ${projectSummaryCompletion(activeProject)}%` : "从一句话故事开始,逐步推进到可交付的镜头。"}</p>
</div>
</div>
</div>
</section>
</>
);
}
function StatCard({ icon, label, value, hint, to }: { icon: ReactNode; label: string; value: number; hint: string; to: string }) {
return (
<Link to={to} className="home-stat-card group focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<span className="home-stat-top">
<span className="home-stat-icon">{icon}</span>
<span className="home-stat-value">{value}</span>
</span>
<span>
<span className="home-stat-label block">{label}</span>
<span className="home-stat-hint block">{hint}</span>
</span>
</Link>
);
}
function RecentProjectRow({ row }: { row: ProjectSummary }) {
const completion = projectSummaryCompletion(row);
const stage = projectSummaryStage(row);
const archived = row.project.status === "archived";
return (
<Link to={`/projects/${row.project.id}/overview`} className="home-recent-row group focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-foreground/20">
<span className="min-w-0">
<span className="flex min-w-0 items-center gap-2">
<strong className="truncate text-sm font-medium">{row.project.name}</strong>
{archived ? <em className="home-stage-pill">已归档</em> : null}
</span>
<span className="mt-1 block truncate text-xs text-foreground/42">
{projectStyleTitle(row.project)} · {sourceTypeLabel(row.project.sourceType)} · {row.canvasCount} 画布 · {row.assetCount} 资产
</span>
</span>
<span className="min-w-0">
<span className="home-stage-pill">{stage.label}</span>
</span>
<span className="min-w-0">
<span className="flex items-center justify-between text-[var(--fs-tiny)] text-foreground/42">
<span>
{row.completedUnitCount}/{row.unitCount} 章
</span>
<span>{completion}%</span>
</span>
<span className="home-progress-track mt-1.5">
<span className="home-progress-fill" style={{ width: `${completion}%` }} />
</span>
</span>
<span className="text-xs text-foreground/45">{formatRelativeTime(row.project.updatedAt)}</span>
<ArrowRight className="size-4 text-foreground/25 transition-transform group-hover:translate-x-0.5 group-hover:text-foreground/60" />
</Link>
);
}
function QuickItem({ icon, title, description, href, onClick, disabled }: { icon: ReactNode; title: string; description: string; href?: string; onClick?: () => void; disabled?: boolean }) {
const content = (
<>
<span className="home-quick-icon">{icon}</span>
<span className="min-w-0">
<strong className="home-quick-title block">{title}</strong>
<span className="home-quick-desc block">{description}</span>
</span>
<ArrowRight className="size-4 text-foreground/25 transition-transform group-hover:translate-x-0.5 group-hover:text-foreground/50" />
</>
);
const className = "home-quick-item group focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60";
return href ? (
<Link to={href} className={className}>
{content}
</Link>
) : (
<button type="button" className={className} onClick={onClick} disabled={disabled}>
{content}
</button>
);
}
function projectStyleTitle(project: ProjectSummary["project"]) {
const projectStyle = resolveProjectCanvasStyle(project.stylePresetId, project.styleProfileJson);
return projectStyle?.title || parseStyleProfile(project.styleProfileJson)?.title || resolveCanvasStylePreset(project.stylePresetId)?.title || (project.stylePresetId ? "自定义画风" : "未设置画风");
}
function sourceTypeLabel(value: string) {
return sourceTypeLabels[value] || "其他来源";
}
function assetSummaryHint(counts: Partial<Record<AssetKind, number>>) {
const parts = ASSET_KINDS.map(({ key, label }) => ({ label, count: counts[key] || 0 })).filter(({ count }) => count > 0);
if (!parts.length) return "从画布或项目中沉淀素材";
return parts
.slice(0, 3)
.map(({ label, count }) => `${label} ${count}`)
.join(" · ");
}
function assetChartData(assets: ReturnType<typeof useAssetStore.getState>["assets"]) {
const colors = [
CHART_ACCENT,
"color-mix(in srgb, var(--workspace-accent) 66%, transparent)",
"color-mix(in srgb, var(--workspace-accent) 44%, transparent)",
"color-mix(in srgb, var(--workspace-accent) 28%, transparent)",
"color-mix(in srgb, var(--workspace-accent) 16%, transparent)",
];
const counts = new Map<string, number>();
for (const asset of assets) counts.set(asset.kind, (counts.get(asset.kind) || 0) + 1);
const entries = ASSET_KINDS.filter(({ key }) => (counts.get(key) || 0) > 0);
return entries.map(({ key, label }, index) => ({ name: label, value: counts.get(key) || 0, color: colors[index % colors.length] }));
}
function buildActivityBuckets(days: number, taskDates: number[], canvasDates: number[]) {
const buckets: Array<{ label: string; tasks: number; canvases: number }> = [];
const today = new Date();
today.setHours(0, 0, 0, 0);
for (let offset = days - 1; offset >= 0; offset -= 1) {
const day = new Date(today);
day.setDate(today.getDate() - offset);
const start = day.getTime();
const end = start + 24 * 60 * 60 * 1000;
buckets.push({
label: `${day.getMonth() + 1}/${day.getDate()}`,
tasks: taskDates.filter((time) => time >= start && time < end).length,
canvases: canvasDates.filter((time) => time >= start && time < end).length,
});
}
return buckets;
}
const tooltipStyle = {
background: "var(--workspace-surface-strong)",
border: "1px solid var(--workspace-border)",
borderRadius: "var(--r-md)",
boxShadow: "var(--elevation-card-hover)",
fontSize: 12,
};
function formatRelativeTime(value: string) {
const diffMinutes = Math.round((new Date(value).getTime() - Date.now()) / 60_000);
const formatter = new Intl.RelativeTimeFormat("zh-CN", { numeric: "auto" });
if (Math.abs(diffMinutes) < 60) return formatter.format(diffMinutes, "minute");
const diffHours = Math.round(diffMinutes / 60);
if (Math.abs(diffHours) < 24) return formatter.format(diffHours, "hour");
const diffDays = Math.round(diffHours / 24);
if (Math.abs(diffDays) < 30) return formatter.format(diffDays, "day");
return new Date(value).toLocaleDateString("zh-CN", { month: "2-digit", day: "2-digit" });
}

View File

@@ -12,7 +12,7 @@ import { ModelPicker } from "@/components/model-picker";
import { createStyleProfileSnapshot, parseStyleProfile, serializeStyleProfile } from "@/lib/canvas/style-profile";
import { projectSummaryCompletion, projectSummaryStage } from "@/lib/project-workbench";
import { settingsPath } from "@/lib/settings-navigation";
import { requestImageQuestion } from "@/services/api/image";
import { runBackendGenerationTask } from "@/services/api/generation-task";
import { createProject, deleteProject, importProjectUnits, listProjects, type ProjectSummary } from "@/services/api/projects";
import { modelDisplayName, useEffectiveConfig } from "@/stores/use-config-store";
@@ -89,16 +89,16 @@ export default function ProjectsPage() {
try {
const project = await createUniqueProjectName(story, selectedStyle);
setGenerationStatus("AI 正在生成故事大纲与章节…");
const answer = await requestImageQuestion(
{ ...effectiveConfig, model: textModel, imageModel: textModel, videoModel: textModel, textModel },
[
{ role: "system", content: `你是短剧编剧。根据用户的一句话故事,生成一部短剧的标题、一句话简介和 ${generateChapterCount} 个章节。生成要求:叙事采用${generateStructure}结构,每章约 ${generateWordCount} 字,使用${generatePerspective}视角,整体基调${generateTone},主要角色约 ${generateCharacterScale},章节篇幅${generateChapterLength}。只输出一个 JSON 对象,不要输出 markdown 代码块或其他文字。JSON 结构:{"title":"剧名","synopsis":"一句话简介","chapters":[{"title":"章节标题","content":"本章情节"}]}` },
{ role: "user", content: story },
],
(text) => {
setGenerationPreview(text);
},
);
const systemPrompt = `你是短剧编剧。根据用户的一句话故事,生成一部短剧的标题、一句话简介和 ${generateChapterCount} 个章节。生成要求:叙事采用${generateStructure}结构,每章约 ${generateWordCount} 字,使用${generatePerspective}视角,整体基调${generateTone},主要角色约 ${generateCharacterScale},章节篇幅${generateChapterLength}。只输出一个 JSON 对象,不要输出 markdown 代码块或其他文字。JSON 结构:{"title":"剧名","synopsis":"一句话简介","chapters":[{"title":"章节标题","content":"本章情节"}]}`;
const result = await runBackendGenerationTask({
projectId: project.project.id,
mode: "text",
prompt: story,
config: { ...effectiveConfig, model: textModel, imageModel: textModel, videoModel: textModel, textModel, systemPrompt },
metadata: { source: "project-story-generator", projectId: project.project.id },
onTextDelta: setGenerationPreview,
});
const answer = result.text || "";
const parsed = parseGeneratedStory(answer);
if (!parsed.chapters.length) throw new Error("AI 没有返回有效的章节内容,请重试");
setGenerationStatus(`正在导入 ${parsed.chapters.length} 个章节…`);

View File

@@ -16,17 +16,6 @@ import { buildBackendToolRequests, type ResponseFunctionTool, type ResponseInput
export { logicalModelIDForConfig };
// 只要模型明确选择了请求协议,就统一交给后端协议运行时执行:逻辑模型
// 由逻辑模型路由解析协议,系统渠道由 channelId 解析真实渠道模型,用户
// 自建渠道则由所选协议插件负责第三方字段映射和结果解析。没有显式协议
// 的旧配置继续保留前端直连兼容路径。
export function backendModelRuntimeRequired(config: AiConfig) {
if ((config.taskWorkflowProvider || "model") !== "model") return true;
if (logicalModelIDForConfig(config)) return true;
const requestConfig = resolveModelRequestConfig(config, config.model);
return Boolean(requestConfig.channelId || requestConfig.interfaceType);
}
export type BackendGenerationMode = "text" | "image" | "video" | "audio";
export type BackendGenerationResult = {
@@ -47,7 +36,7 @@ type BackendGenerationTaskOptions = {
referenceImages?: ReferenceImage[];
referenceVideos?: ReferenceVideo[];
referenceAudios?: ReferenceAudio[];
textHistory?: Array<{ role: "user" | "assistant"; content: string }>;
textHistory?: Array<{ role: "user" | "assistant" | "system"; content: string }>;
mask?: ReferenceImage;
signal?: AbortSignal;
metadata?: Record<string, unknown>;
@@ -119,6 +108,7 @@ export async function runBackendGenerationTask(
dependencies,
);
}
assertBackendRuntimeConfigured(config, mode);
const prepared = await prepareGenerationReferences({ referenceImages, referenceVideos, referenceAudios, mask });
throwIfAborted(signal);
return createAndWaitGenerationTask({ projectId, mode, prompt, config, referenceImages, referenceVideos, referenceAudios, textHistory, signal, metadata, onTaskUpdate }, prepared, dependencies);
@@ -133,6 +123,7 @@ export async function submitBackendGenerationTask(
throwIfAborted(options.signal);
assertClientPromptLimit(options.mode, options.prompt, options.config, options.metadata);
if (usesLocalDreamina(options.config)) throw new Error("本机即梦任务暂不支持后台提交");
assertBackendRuntimeConfigured(options.config, options.mode);
const prepared = await prepareGenerationReferences(options);
throwIfAborted(options.signal);
return createBackendGenerationTask(options, prepared, dependencies);
@@ -161,7 +152,7 @@ export async function runBackendToolGenerationTask(options: {
mode: "text",
prompt: options.prompt,
config: backendProviderConfig(options.config),
agentRequests: buildBackendToolRequests(options.messages, options.tools, options.toolChoice),
agentRequests: buildBackendToolRequests(options.messages, options.tools, options.toolChoice, options.config),
metadata: { source: "canvas-online-agent" },
},
});
@@ -390,6 +381,13 @@ function usesLocalDreamina(config: AiConfig) {
return (config.taskWorkflowProvider || "model") === "model" && isLocalDreaminaModel(config.model);
}
function assertBackendRuntimeConfigured(config: AiConfig, mode: BackendGenerationMode) {
if (resolveGenerationWorkflowExecution(config, mode)) return;
if (logicalModelIDForConfig(config)) return;
const requestConfig = resolveModelRequestConfig(config, config.model);
if (!requestConfig.channelId && !requestConfig.interfaceType) throw new Error("当前模型未选择可用请求协议,请先在模型设置中选择协议插件");
}
function throwIfAborted(signal?: AbortSignal) {
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
}
@@ -445,13 +443,29 @@ async function createBackendGenerationTask(options: BackendGenerationTaskOptions
referenceVideos: prepared.referenceVideos,
referenceAudios: prepared.referenceAudios,
mask: prepared.mask,
metadata,
metadata: generationMetadata(config, metadata),
},
});
onTaskUpdate?.(task);
return task;
}
function generationMetadata(config: AiConfig, metadata?: Record<string, unknown>) {
const channel = resolveModelChannel(config, config.model);
const model = modelOptionName(config.model);
const modelCost = channel.modelCosts?.find((item) => item.model === model);
const protocol = modelCost?.protocol || channel.interfaceType;
const defaults = modelCost?.defaultOptions;
if (!protocol || !defaults || !Object.keys(defaults).length) return metadata;
const existing = metadata?.providerOptions && typeof metadata.providerOptions === "object" && !Array.isArray(metadata.providerOptions)
? metadata.providerOptions as Record<string, unknown>
: {};
const namespace = existing[protocol] && typeof existing[protocol] === "object" && !Array.isArray(existing[protocol])
? existing[protocol] as Record<string, unknown>
: {};
return { ...metadata, providerOptions: { ...existing, [protocol]: { ...defaults, ...namespace } } };
}
async function prepareBackendMediaReference(media: ReferenceVideo | ReferenceAudio) {
if (resourceIdFromStorageKey(media.storageKey)) return backendMediaReference(media, { storageKey: media.storageKey });
const url = media.url || "";
@@ -530,6 +544,7 @@ export function backendProviderConfig(config: AiConfig, mode: BackendGenerationM
audioFormat: config.audioFormat,
audioSpeed: config.audioSpeed,
audioInstructions: config.audioInstructions,
systemPrompt: config.systemPrompt,
};
if (logicalModelIDForConfig(config)) return generationOptions;
return {
@@ -543,7 +558,7 @@ export function backendProviderConfig(config: AiConfig, mode: BackendGenerationM
model: requestConfig.model,
...generationOptions,
capabilityConfig: modelCapabilityConfigFor(config, requestConfig.model),
systemPrompt: "",
systemPrompt: config.systemPrompt,
};
}
@@ -583,7 +598,7 @@ function workflowProviderConfig(config: AiConfig, requestConfig: ReturnType<type
runningHubWalletApiKey: "",
runningHubUploadApiKey: runningHubActive ? config.runningHub.uploadApiKey || "" : "",
capabilityConfig: modelCapabilityConfigFor(config, requestConfig.model),
systemPrompt: "",
systemPrompt: config.systemPrompt,
};
}

View File

@@ -35,6 +35,8 @@ export type ToolChoice = "auto" | "required" | { type: "function"; name: string
export type BackendToolRequests = {
responses: Record<string, unknown>;
chatCompletion: Record<string, unknown>;
claude?: Record<string, unknown>;
gemini?: Record<string, unknown>;
};
export type ResponseMessageContent = AiTextMessage["content"] | string;

View File

@@ -101,8 +101,8 @@ export function toChatCompletionToolChoice(toolChoice: ToolChoice) {
return typeof toolChoice === "object" ? { type: "function", function: { name: toolChoice.name } } : toolChoice;
}
export function buildBackendToolRequests(messages: ResponseInputMessage[], tools: ResponseFunctionTool[], toolChoice: ToolChoice): BackendToolRequests {
return {
export function buildBackendToolRequests(messages: ResponseInputMessage[], tools: ResponseFunctionTool[], toolChoice: ToolChoice, config?: AiConfig): BackendToolRequests {
const requests: BackendToolRequests = {
responses: {
input: toResponseInput(messages),
tools: tools.map(toResponseTool),
@@ -116,6 +116,14 @@ export function buildBackendToolRequests(messages: ResponseInputMessage[], tools
parallel_tool_calls: false,
},
};
if (config) {
requests.claude = {
...toClaudeBody(config, messages, tools),
tool_choice: typeof toolChoice === "object" ? { type: "tool", name: toolChoice.name } : { type: toolChoice === "required" ? "any" : "auto" },
};
requests.gemini = toGeminiBody(config, messages, toGeminiToolOptions(tools, toolChoice));
}
return requests;
}
export function toGeminiBody(config: AiConfig, messages: ResponseInputMessage[], extra?: Record<string, unknown>) {

View File

@@ -14,13 +14,16 @@ export function hydrateCanvasVideoPreview(node: CanvasNodeData, signal?: AbortSi
const existing = previewRequests.get(requestKey);
if (existing) return existing;
const request = generateCanvasVideoPreview(node, signal).catch((error: unknown) => {
if (error instanceof DOMException && error.name === "AbortError") {
const request = generateCanvasVideoPreview(node, signal)
.then((preview) => {
if (!preview) previewRequests.delete(requestKey);
return preview;
})
.catch((error: unknown) => {
previewRequests.delete(requestKey);
throw error;
}
return null;
});
if (error instanceof DOMException && error.name === "AbortError") throw error;
return null;
});
previewRequests.set(requestKey, request);
return request;
}