feat(*): 模型协议 - 升级声明式协议、扩充官方插件并完善生成任务恢复
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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")] : []),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }))]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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} 个章节…`);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user