Merge origin/master into feat/dialog-generator-cancel-generation
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
ThunderboltOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import "../../styles/pages/agent.css";
|
||||
import WorkspacePageShell from "../../components/WorkspacePageShell";
|
||||
import type { WebGenerationPreviewTask } from "../../types";
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type JSX } from "react";
|
||||
import "../../styles/pages/assets.css";
|
||||
import { assetClient, type ServerAssetItem } from "../../api/assetClient";
|
||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||
import { useDebounce } from "../../hooks/useDebounce";
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
interface CanvasMarkingPopoverProps {
|
||||
value?: string;
|
||||
placeholder: string;
|
||||
onChange: (value: string) => void;
|
||||
onClear: () => void;
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
export function CanvasMarkingPopover({
|
||||
value,
|
||||
placeholder,
|
||||
onChange,
|
||||
onClear,
|
||||
onDone,
|
||||
}: CanvasMarkingPopoverProps) {
|
||||
return (
|
||||
<div
|
||||
className="studio-canvas-marking-popover"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<textarea
|
||||
className="studio-canvas-marking-input"
|
||||
placeholder={placeholder}
|
||||
value={value || ""}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
<div className="studio-canvas-marking-actions">
|
||||
{value ? (
|
||||
<button type="button" className="studio-canvas-marking-clear" onClick={onClear}>
|
||||
清除
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="studio-canvas-marking-done" onClick={onDone}>
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+174
-339
@@ -28,10 +28,13 @@
|
||||
import {
|
||||
ReactFlow,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import "../../styles/pages/canvas.css";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type MouseEvent, type WheelEvent } from "react";
|
||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||
import { assetClient, type ServerAssetItem } from "../../api/assetClient";
|
||||
import { communityClient } from "../../api/communityClient";
|
||||
import { modelCapabilitiesClient } from "../../api/modelCapabilitiesClient";
|
||||
import type { CreatePreviewTaskInput } from "../../api/webGenerationGateway";
|
||||
import WorkspacePageShell from "../../components/WorkspacePageShell";
|
||||
import type {
|
||||
@@ -52,6 +55,7 @@ import { useCanvasHistory, type CanvasHistorySnapshot } from "./useCanvasHistory
|
||||
import { useCanvasKeyboard } from "./useCanvasKeyboard";
|
||||
import { useCanvasNodeDrag } from "./useCanvasNodeDrag";
|
||||
import { useCanvasGeneration, addCanvasGenKeepalive, removeCanvasGenKeepalive } from "./useCanvasGeneration";
|
||||
import { useCanvasAssetSummary, useCanvasVisibleNodes } from "./useCanvasDerivedState";
|
||||
import {
|
||||
toHappyHorseDisplayModel,
|
||||
} from "../../utils/happyHorseRouting";
|
||||
@@ -118,7 +122,7 @@ import {
|
||||
defaultVideoModel,
|
||||
image4kCapableModels,
|
||||
imageFocusRatioOptions,
|
||||
imageModelOptions,
|
||||
imageModelOptions as fallbackCanvasImageModelOptions,
|
||||
imageRatioOptions,
|
||||
textModelOptions,
|
||||
videoDurationOptions,
|
||||
@@ -182,6 +186,8 @@ import {
|
||||
} from "./canvasWorkflowDeserialize";
|
||||
import { CanvasNodeToolbar, CanvasNodeVideoPlayer, CanvasSelectChip } from "./canvasComponents";
|
||||
import type { CanvasNodeToolbarAction } from "./canvasComponents";
|
||||
import { CanvasMarkingPopover } from "./CanvasMarkingPopover";
|
||||
import { CanvasPromptMentionTextarea, CanvasTextPromptComposer } from "./CanvasTextPromptComposer";
|
||||
import { CanvasMultiGridPanel, CanvasUpscalePanel, CanvasInpaintPanel } from "./canvasToolPanels";
|
||||
import { CanvasSmoothedProgressRing } from "./CanvasSmoothedProgressRing";
|
||||
|
||||
@@ -193,7 +199,6 @@ const canvasEnterpriseVideoModelOptions: CanvasOption[] = ENTERPRISE_VIDEO_MODEL
|
||||
// --- Canvas generation keep-alive (survives page refresh / view switch) ---
|
||||
|
||||
const MENTION_TOKEN_RE = /@(?:图片|视频|文本)\d+/g;
|
||||
const MENTION_BOUNDARY_RE = /\s|[,。、;:!??(){}[\]<>]/;
|
||||
|
||||
function buildNodeMentionOptions(
|
||||
kind: CanvasNodeKind,
|
||||
@@ -354,6 +359,8 @@ function CanvasPage({
|
||||
const [projectNameEditing, setProjectNameEditing] = useState(false);
|
||||
const [videoNodeMenu, setVideoNodeMenu] = useState<{ left: number; top: number; nodeId: string } | null>(null);
|
||||
const [videoNodes, setVideoNodes] = useState<CanvasVideoNode[]>([]);
|
||||
const [canvasImageModelOptions, setCanvasImageModelOptions] = useState<CanvasOption[]>(fallbackCanvasImageModelOptions);
|
||||
const [canvasVideoModelOptions, setCanvasVideoModelOptions] = useState<CanvasOption[]>(canvasEnterpriseVideoModelOptions);
|
||||
const [selectedNode, setSelectedNode] = useState<CanvasSelectedNode | null>(null);
|
||||
const [selectedNodes, setSelectedNodes] = useState<CanvasSelectedNode[]>([]);
|
||||
const [selectionContextMenu, setSelectionContextMenu] = useState<CanvasFloatingMenuPosition | null>(null);
|
||||
@@ -396,10 +403,12 @@ function CanvasPage({
|
||||
const suppressNextPaneClickRef = useRef(false);
|
||||
const canvasAutoSaveTimerRef = useRef<number | null>(null);
|
||||
const canvasAutoSaveIdleHandleRef = useRef<number | null>(null);
|
||||
const canvasAutoSaveRetryTimerRef = useRef<number | null>(null);
|
||||
const canvasAutoSaveInFlightRef = useRef(false);
|
||||
const canvasAutoSavePendingRef = useRef(false);
|
||||
const lastAutoSavedWorkflowFingerprintRef = useRef("");
|
||||
const canvasAutoSaveHydrationRef = useRef(true);
|
||||
const textNodeMentionFocusTimerRef = useRef<number | null>(null);
|
||||
const textNodeIdRef = useRef(9);
|
||||
const imageNodeIdRef = useRef(1);
|
||||
const videoNodeIdRef = useRef(1);
|
||||
@@ -460,9 +469,39 @@ function CanvasPage({
|
||||
callbacksRef: dragCallbacksRef,
|
||||
suppressNextPaneClickRef,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (!isAuthenticated) {
|
||||
setCanvasImageModelOptions(fallbackCanvasImageModelOptions);
|
||||
setCanvasVideoModelOptions(canvasEnterpriseVideoModelOptions);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
modelCapabilitiesClient
|
||||
.get()
|
||||
.then((capabilities) => {
|
||||
if (cancelled) return;
|
||||
setCanvasImageModelOptions(capabilities.imageModels.length ? capabilities.imageModels : fallbackCanvasImageModelOptions);
|
||||
setCanvasVideoModelOptions(capabilities.videoModels.length ? capabilities.videoModels : canvasEnterpriseVideoModelOptions);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setCanvasImageModelOptions(fallbackCanvasImageModelOptions);
|
||||
setCanvasVideoModelOptions(canvasEnterpriseVideoModelOptions);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isAuthenticated]);
|
||||
|
||||
const visibleImageModelOptions = useMemo(
|
||||
() => filterImageModelOptionsForSession(imageModelOptions, session),
|
||||
[session],
|
||||
() => filterImageModelOptionsForSession(canvasImageModelOptions, session),
|
||||
[canvasImageModelOptions, session],
|
||||
);
|
||||
const fallbackVisibleImageModel = visibleImageModelOptions[0]?.value || defaultImageModel;
|
||||
const resolveVisibleImageModel = useCallback(
|
||||
@@ -488,7 +527,11 @@ function CanvasPage({
|
||||
else if (kind === "video") updateVideoNodePrompt(nodeId, nextValue);
|
||||
else updateTextNodePrompt(nodeId, nextValue);
|
||||
closeTextNodeMention(nodeId);
|
||||
setTimeout(() => {
|
||||
if (textNodeMentionFocusTimerRef.current !== null) {
|
||||
window.clearTimeout(textNodeMentionFocusTimerRef.current);
|
||||
}
|
||||
textNodeMentionFocusTimerRef.current = window.setTimeout(() => {
|
||||
textNodeMentionFocusTimerRef.current = null;
|
||||
if (textarea) {
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(nextCaret, nextCaret);
|
||||
@@ -524,10 +567,22 @@ function CanvasPage({
|
||||
const [autoSaveStatus, setAutoSaveStatus] = useState<"saved" | "saving" | "error" | "idle">("idle");
|
||||
const autoSaveStatusTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (canvasAutoSaveTimerRef.current !== null) window.clearTimeout(canvasAutoSaveTimerRef.current);
|
||||
if (canvasAutoSaveRetryTimerRef.current !== null) window.clearTimeout(canvasAutoSaveRetryTimerRef.current);
|
||||
if (autoSaveStatusTimerRef.current !== null) window.clearTimeout(autoSaveStatusTimerRef.current);
|
||||
if (textNodeMentionFocusTimerRef.current !== null) window.clearTimeout(textNodeMentionFocusTimerRef.current);
|
||||
if (canvasAutoSaveIdleHandleRef.current !== null && "cancelIdleCallback" in window) {
|
||||
window.cancelIdleCallback(canvasAutoSaveIdleHandleRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Save immediately when user leaves page or switches tab (placed after runCanvasAutoSave definition)
|
||||
// — see useEffect below near runCanvasAutoSave
|
||||
|
||||
const canvasAssets = serverAssets.filter((asset) => asset.imageUrl);
|
||||
const { canvasAssets, assetCountsByCategory } = useCanvasAssetSummary(serverAssets);
|
||||
const shouldShowEmptyProjectState =
|
||||
projectsLoaded && projects.length === 0 && !projectId && workflow.source === "blank" && workflow.nodes.length === 0;
|
||||
const isWaitingForProjects = isAuthenticated && !projectsLoaded;
|
||||
@@ -2704,13 +2759,17 @@ function CanvasPage({
|
||||
setConnectorDrag(null);
|
||||
};
|
||||
|
||||
const collapsedPackageNodeKeys = new Set(
|
||||
nodePackages.flatMap((nodePackage) =>
|
||||
nodePackage.collapsed ? nodePackage.nodeIds.map((node) => getCanvasSelectionKey(node)) : []
|
||||
)
|
||||
);
|
||||
const isNodeCollapsedInPackage = (kind: CanvasNodeKind, id: string) =>
|
||||
collapsedPackageNodeKeys.has(getCanvasSelectionKey({ kind, id }));
|
||||
const {
|
||||
isNodeCollapsedInPackage,
|
||||
visibleTextNodes,
|
||||
visibleImageNodes,
|
||||
visibleVideoNodes,
|
||||
} = useCanvasVisibleNodes({
|
||||
textNodes,
|
||||
imageNodes,
|
||||
videoNodes,
|
||||
nodePackages,
|
||||
});
|
||||
const isLinkCollapsedInPackage = (link: { sourceKind: CanvasNodeKind; sourceNodeId: string; targetKind: CanvasNodeKind; targetNodeId: string }) =>
|
||||
isNodeCollapsedInPackage(link.sourceKind, link.sourceNodeId) ||
|
||||
isNodeCollapsedInPackage(link.targetKind, link.targetNodeId);
|
||||
@@ -3243,7 +3302,13 @@ function CanvasPage({
|
||||
canvasAutoSaveInFlightRef.current = false;
|
||||
if (canvasAutoSavePendingRef.current) {
|
||||
canvasAutoSavePendingRef.current = false;
|
||||
window.setTimeout(() => void runCanvasAutoSave(), canvasAutoSaveIdleTimeoutMs);
|
||||
if (canvasAutoSaveRetryTimerRef.current !== null) {
|
||||
window.clearTimeout(canvasAutoSaveRetryTimerRef.current);
|
||||
}
|
||||
canvasAutoSaveRetryTimerRef.current = window.setTimeout(() => {
|
||||
canvasAutoSaveRetryTimerRef.current = null;
|
||||
void runCanvasAutoSave();
|
||||
}, canvasAutoSaveIdleTimeoutMs);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
@@ -3312,7 +3377,13 @@ function CanvasPage({
|
||||
);
|
||||
return;
|
||||
}
|
||||
window.setTimeout(() => void runCanvasAutoSave(), canvasAutoSaveIdleTimeoutMs);
|
||||
if (canvasAutoSaveRetryTimerRef.current !== null) {
|
||||
window.clearTimeout(canvasAutoSaveRetryTimerRef.current);
|
||||
}
|
||||
canvasAutoSaveRetryTimerRef.current = window.setTimeout(() => {
|
||||
canvasAutoSaveRetryTimerRef.current = null;
|
||||
void runCanvasAutoSave();
|
||||
}, canvasAutoSaveIdleTimeoutMs);
|
||||
}, canvasAutoSaveDebounceMs);
|
||||
|
||||
return () => {
|
||||
@@ -3324,6 +3395,10 @@ function CanvasPage({
|
||||
window.cancelIdleCallback(canvasAutoSaveIdleHandleRef.current);
|
||||
canvasAutoSaveIdleHandleRef.current = null;
|
||||
}
|
||||
if (canvasAutoSaveRetryTimerRef.current !== null) {
|
||||
window.clearTimeout(canvasAutoSaveRetryTimerRef.current);
|
||||
canvasAutoSaveRetryTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [
|
||||
isAuthenticated,
|
||||
@@ -3571,9 +3646,14 @@ function CanvasPage({
|
||||
return;
|
||||
}
|
||||
const toDelete = selectedNode ? [selectedNode] : selectedNodes;
|
||||
const textIds = new Set(toDelete.filter((n) => n.kind === "text").map((n) => n.id));
|
||||
const imageIds = new Set(toDelete.filter((n) => n.kind === "image").map((n) => n.id));
|
||||
const videoIds = new Set(toDelete.filter((n) => n.kind === "video").map((n) => n.id));
|
||||
const textIds = new Set<string>();
|
||||
const imageIds = new Set<string>();
|
||||
const videoIds = new Set<string>();
|
||||
for (const node of toDelete) {
|
||||
if (node.kind === "text") textIds.add(node.id);
|
||||
else if (node.kind === "image") imageIds.add(node.id);
|
||||
else if (node.kind === "video") videoIds.add(node.id);
|
||||
}
|
||||
if (textIds.size) setTextNodes((nodes) => nodes.filter((n) => !textIds.has(n.id)));
|
||||
if (imageIds.size) setImageNodes((nodes) => nodes.filter((n) => !imageIds.has(n.id)));
|
||||
if (videoIds.size) setVideoNodes((nodes) => nodes.filter((n) => !videoIds.has(n.id)));
|
||||
@@ -4054,7 +4134,7 @@ function CanvasPage({
|
||||
) : null}
|
||||
</svg>
|
||||
) : null}
|
||||
{textNodes.filter((textNode) => !isNodeCollapsedInPackage("text", textNode.id)).map((textNode) => {
|
||||
{visibleTextNodes.map((textNode) => {
|
||||
const textNodeSelected = isSelectedNode("text", textNode.id);
|
||||
const textNodeActive = isActiveSelectedNode("text", textNode.id);
|
||||
const textNodeResizing = nodeResizeDrag?.kind === "text" && nodeResizeDrag.nodeId === textNode.id;
|
||||
@@ -4203,132 +4283,26 @@ function CanvasPage({
|
||||
onMouseDown={(event) => handleNodeResizeStart(event, "text", textNode.id, textNode.size)}
|
||||
/>
|
||||
</div>
|
||||
{textNodeActive && !isCanvasNodeMoving ? (() => {
|
||||
const mentionOptions = buildNodeMentionOptions("text", textNode.id, imageNodes, videoNodes, textNodes, getIncomingCanvasPorts, manualLinks);
|
||||
const mentionState = textNodeMentionStates[textNode.id] || { open: false, query: "", start: 0, caret: 0, activeIndex: 0 };
|
||||
const filteredMentions = mentionState.open
|
||||
? mentionOptions.filter((o) => !mentionState.query || o.searchText.includes(mentionState.query.toLowerCase()))
|
||||
: [];
|
||||
|
||||
const handlePromptChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = e.target.value;
|
||||
const caret = e.target.selectionStart || 0;
|
||||
updateTextNodePrompt(textNode.id, value);
|
||||
|
||||
// Detect @-mention trigger
|
||||
const beforeCaret = value.slice(0, caret);
|
||||
const atIdx = beforeCaret.lastIndexOf("@");
|
||||
if (atIdx >= 0) {
|
||||
const query = beforeCaret.slice(atIdx + 1);
|
||||
if (!MENTION_BOUNDARY_RE.test(query) && !query.includes(" ")) {
|
||||
setTextNodeMentionStates((prev) => ({ ...prev, [textNode.id]: { open: true, query, start: atIdx, caret, activeIndex: 0 } }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
closeTextNodeMention(textNode.id);
|
||||
};
|
||||
|
||||
const handlePromptKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (!mentionState.open || filteredMentions.length === 0) return;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setTextNodeMentionStates((prev) => ({ ...prev, [textNode.id]: { ...mentionState, activeIndex: (mentionState.activeIndex + 1) % filteredMentions.length } }));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setTextNodeMentionStates((prev) => ({ ...prev, [textNode.id]: { ...mentionState, activeIndex: (mentionState.activeIndex - 1 + filteredMentions.length) % filteredMentions.length } }));
|
||||
} else if (e.key === "Enter" || e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
const opt = filteredMentions[mentionState.activeIndex];
|
||||
if (opt) {
|
||||
const ta = e.currentTarget;
|
||||
insertTextNodeMention(textNode.id, opt, ta);
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
closeTextNodeMention(textNode.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePromptSelect = (e: React.SyntheticEvent<HTMLTextAreaElement>) => {
|
||||
const ta = e.currentTarget;
|
||||
const caret = ta.selectionStart || 0;
|
||||
setTextNodeMentionStates((prev) => {
|
||||
const cur = prev[textNode.id];
|
||||
if (!cur?.open) return prev;
|
||||
return { ...prev, [textNode.id]: { ...cur, caret } };
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`studio-canvas-text-composer${textComposerDragNodeId === textNode.id ? " is-drag-over" : ""}`}
|
||||
onDragEnter={(e) => handleTextComposerDragEnter(e, textNode.id)}
|
||||
onDragOver={handleTextComposerDragOver}
|
||||
onDragLeave={handleTextComposerDragLeave}
|
||||
onDrop={(e) => handleTextComposerDrop(e, textNode)}
|
||||
>
|
||||
<div className="studio-canvas-text-composer__input-wrap">
|
||||
<textarea
|
||||
value={textNode.prompt}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onChange={handlePromptChange}
|
||||
onKeyDown={handlePromptKeyDown}
|
||||
onSelect={handlePromptSelect}
|
||||
placeholder="写下你想讲的故事、场景或角色设定。@引用连接的节点"
|
||||
/>
|
||||
{mentionState.open ? (
|
||||
<div className="studio-canvas-mention-panel">
|
||||
{filteredMentions.length > 0 ? filteredMentions.map((opt, idx) => (
|
||||
<button
|
||||
key={opt.token}
|
||||
type="button"
|
||||
className={`studio-canvas-mention-item${idx === mentionState.activeIndex ? " is-active" : ""}`}
|
||||
onMouseDown={(e) => { e.preventDefault(); const ta = e.currentTarget.closest(".studio-canvas-text-composer")?.querySelector("textarea") || null; insertTextNodeMention(textNode.id, opt, ta as HTMLTextAreaElement | null); }}
|
||||
>
|
||||
<span className="studio-canvas-mention-thumb">
|
||||
{opt.kind === "image" && opt.previewUrl ? <img src={opt.previewUrl} alt="" /> : opt.kind === "image" ? "🖼" : opt.kind === "video" ? "🎬" : "📝"}
|
||||
</span>
|
||||
<span className="studio-canvas-mention-label">{opt.nodeTitle}</span>
|
||||
<span className="studio-canvas-mention-token">{opt.token}</span>
|
||||
</button>
|
||||
)) : (
|
||||
<div className="studio-canvas-mention-item" style={{ opacity: 0.5, pointerEvents: "none" }}>
|
||||
<span className="studio-canvas-mention-label">没有可引用的连接节点</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="studio-canvas-text-composer__footer">
|
||||
<button
|
||||
type="button"
|
||||
className={`studio-canvas-text-composer__send studio-canvas-generate-button${textNodeCanGenerate && !textNodeGenerating ? " is-ready" : ""}`}
|
||||
title={textNodeGenerating ? "生成中" : "生成"}
|
||||
disabled={textNodeGenerating || !textNodeCanGenerate}
|
||||
aria-busy={textNodeGenerating}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!textNodeGenerating && textNodeCanGenerate) {
|
||||
void handleGenerateTextNode(textNode.id);
|
||||
}
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<SendOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})() : null}
|
||||
{textNodeActive && !isCanvasNodeMoving ? (
|
||||
<CanvasTextPromptComposer
|
||||
nodeId={textNode.id}
|
||||
prompt={textNode.prompt}
|
||||
canGenerate={textNodeCanGenerate}
|
||||
isGenerating={textNodeGenerating}
|
||||
mentionOptions={buildNodeMentionOptions("text", textNode.id, imageNodes, videoNodes, textNodes, getIncomingCanvasPorts, manualLinks)}
|
||||
mentionState={textNodeMentionStates[textNode.id]}
|
||||
onPromptChange={updateTextNodePrompt}
|
||||
onMentionStateChange={setTextNodeMentionStates}
|
||||
onCloseMention={closeTextNodeMention}
|
||||
onInsertMention={insertTextNodeMention}
|
||||
onGenerate={handleGenerateTextNode}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{imageNodes.filter((imageNode) => !isNodeCollapsedInPackage("image", imageNode.id)).map((imageNode) => {
|
||||
{visibleImageNodes.map((imageNode) => {
|
||||
const imageNodeSelected = isSelectedNode("image", imageNode.id);
|
||||
const imageNodeActive = isActiveSelectedNode("image", imageNode.id);
|
||||
const imageNodeResizing = nodeResizeDrag?.kind === "image" && nodeResizeDrag.nodeId === imageNode.id;
|
||||
@@ -4586,38 +4560,7 @@ function CanvasPage({
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{imageNodeActive && !isCanvasNodeMoving && !imageNodeFocusActive ? (() => {
|
||||
const imgMentionOptions = buildNodeMentionOptions("image", imageNode.id, imageNodes, videoNodes, textNodes, getIncomingCanvasPorts, manualLinks);
|
||||
const imgMentionState = textNodeMentionStates[imageNode.id] || { open: false, query: "", start: 0, caret: 0, activeIndex: 0 };
|
||||
const imgFilteredMentions = imgMentionState.open
|
||||
? imgMentionOptions.filter((o) => !imgMentionState.query || o.searchText.includes(imgMentionState.query.toLowerCase()))
|
||||
: [];
|
||||
|
||||
const handleImagePromptChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = e.target.value;
|
||||
const caret = e.target.selectionStart || 0;
|
||||
updateImageNodePrompt(imageNode.id, value);
|
||||
const beforeCaret = value.slice(0, caret);
|
||||
const atIdx = beforeCaret.lastIndexOf("@");
|
||||
if (atIdx >= 0) {
|
||||
const query = beforeCaret.slice(atIdx + 1);
|
||||
if (!MENTION_BOUNDARY_RE.test(query) && !query.includes(" ")) {
|
||||
setTextNodeMentionStates((prev) => ({ ...prev, [imageNode.id]: { open: true, query, start: atIdx, caret, activeIndex: 0 } }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
closeTextNodeMention(imageNode.id);
|
||||
};
|
||||
|
||||
const handleImagePromptKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (!imgMentionState.open || imgFilteredMentions.length === 0) return;
|
||||
if (e.key === "ArrowDown") { e.preventDefault(); setTextNodeMentionStates((prev) => ({ ...prev, [imageNode.id]: { ...imgMentionState, activeIndex: (imgMentionState.activeIndex + 1) % imgFilteredMentions.length } })); }
|
||||
else if (e.key === "ArrowUp") { e.preventDefault(); setTextNodeMentionStates((prev) => ({ ...prev, [imageNode.id]: { ...imgMentionState, activeIndex: (imgMentionState.activeIndex - 1 + imgFilteredMentions.length) % imgFilteredMentions.length } })); }
|
||||
else if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); const opt = imgFilteredMentions[imgMentionState.activeIndex]; if (opt) insertTextNodeMention(imageNode.id, opt, e.currentTarget, "image"); }
|
||||
else if (e.key === "Escape") { e.preventDefault(); closeTextNodeMention(imageNode.id); }
|
||||
};
|
||||
|
||||
return (
|
||||
{imageNodeActive && !isCanvasNodeMoving && !imageNodeFocusActive ? (
|
||||
<div className="studio-canvas-image-composer">
|
||||
<div className="studio-canvas-image-composer__tools">
|
||||
<button
|
||||
@@ -4656,47 +4599,23 @@ function CanvasPage({
|
||||
>
|
||||
<FileImageOutlined /><span>标记</span>
|
||||
</button>
|
||||
{markingPopoverNodeId === imageNode.id && (
|
||||
<div
|
||||
className="studio-canvas-marking-popover"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<textarea
|
||||
className="studio-canvas-marking-input"
|
||||
placeholder="描述标记内容,如:主角站在桥上,远处是城市天际线"
|
||||
value={imageNode.marking || ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setImageNodes((nodes) =>
|
||||
nodes.map((n) => (n.id === imageNode.id ? { ...n, marking: val } : n)),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div className="studio-canvas-marking-actions">
|
||||
{imageNode.marking && (
|
||||
<button
|
||||
type="button"
|
||||
className="studio-canvas-marking-clear"
|
||||
onClick={() => {
|
||||
setImageNodes((nodes) =>
|
||||
nodes.map((n) => (n.id === imageNode.id ? { ...n, marking: "" } : n)),
|
||||
);
|
||||
}}
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="studio-canvas-marking-done"
|
||||
onClick={() => setMarkingPopoverNodeId(null)}
|
||||
>
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{markingPopoverNodeId === imageNode.id ? (
|
||||
<CanvasMarkingPopover
|
||||
value={imageNode.marking}
|
||||
placeholder="描述标记内容,如:主角站在桥上,远处是城市天际线"
|
||||
onChange={(value) => {
|
||||
setImageNodes((nodes) =>
|
||||
nodes.map((node) => (node.id === imageNode.id ? { ...node, marking: value } : node)),
|
||||
);
|
||||
}}
|
||||
onClear={() => {
|
||||
setImageNodes((nodes) =>
|
||||
nodes.map((node) => (node.id === imageNode.id ? { ...node, marking: "" } : node)),
|
||||
);
|
||||
}}
|
||||
onDone={() => setMarkingPopoverNodeId(null)}
|
||||
/>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
title="多宫格生成"
|
||||
@@ -4738,28 +4657,18 @@ function CanvasPage({
|
||||
</button>
|
||||
<button type="button" className="studio-canvas-image-composer__expand" aria-label="展开">↗</button>
|
||||
</div>
|
||||
<div className="studio-canvas-text-composer__input-wrap">
|
||||
<textarea
|
||||
<CanvasPromptMentionTextarea
|
||||
nodeId={imageNode.id}
|
||||
value={imageNode.prompt}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onChange={handleImagePromptChange}
|
||||
onKeyDown={handleImagePromptKeyDown}
|
||||
placeholder="描述你想要生成的画面内容,按/呼出指令,@引用素材"
|
||||
mentionOptions={buildNodeMentionOptions("image", imageNode.id, imageNodes, videoNodes, textNodes, getIncomingCanvasPorts, manualLinks)}
|
||||
mentionState={textNodeMentionStates[imageNode.id]}
|
||||
mentionKind="image"
|
||||
onPromptChange={updateImageNodePrompt}
|
||||
onMentionStateChange={setTextNodeMentionStates}
|
||||
onCloseMention={closeTextNodeMention}
|
||||
onInsertMention={insertTextNodeMention}
|
||||
/>
|
||||
{imgMentionState.open && (
|
||||
<div className="studio-canvas-mention-panel">
|
||||
{imgFilteredMentions.length > 0 ? imgFilteredMentions.map((opt, idx) => (
|
||||
<button key={opt.token} type="button" className={`studio-canvas-mention-item${idx === imgMentionState.activeIndex ? " is-active" : ""}`} onMouseDown={(e) => { e.preventDefault(); insertTextNodeMention(imageNode.id, opt, e.currentTarget.closest(".studio-canvas-image-composer")?.querySelector("textarea") || null, "image"); }}>
|
||||
<span className="studio-canvas-mention-thumb">{opt.kind === "image" && opt.previewUrl ? <img src={opt.previewUrl} alt="" /> : opt.kind === "image" ? "🖼" : opt.kind === "video" ? "🎬" : "📝"}</span>
|
||||
<span className="studio-canvas-mention-label">{opt.nodeTitle}</span>
|
||||
<span className="studio-canvas-mention-token">{opt.token}</span>
|
||||
</button>
|
||||
)) : (
|
||||
<div className="studio-canvas-mention-item" style={{ opacity: 0.5, pointerEvents: "none" }}><span className="studio-canvas-mention-label">没有可引用的连接节点</span></div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="studio-canvas-image-composer__footer">
|
||||
<CanvasSelectChip
|
||||
ariaLabel="选择生图模型"
|
||||
@@ -4827,12 +4736,12 @@ function CanvasPage({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
); })() : null}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{videoNodes.filter((videoNode) => !isNodeCollapsedInPackage("video", videoNode.id)).map((videoNode) => {
|
||||
{visibleVideoNodes.map((videoNode) => {
|
||||
const videoNodeSelected = isSelectedNode("video", videoNode.id);
|
||||
const videoNodeActive = isActiveSelectedNode("video", videoNode.id);
|
||||
const videoNodeResizing = nodeResizeDrag?.kind === "video" && nodeResizeDrag.nodeId === videoNode.id;
|
||||
@@ -4982,38 +4891,7 @@ function CanvasPage({
|
||||
onMouseDown={(event) => handleNodeResizeStart(event, "video", videoNode.id, videoNode.size)}
|
||||
/>
|
||||
</div>
|
||||
{videoNodeActive && !isCanvasNodeMoving ? (() => {
|
||||
const vidMentionOptions = buildNodeMentionOptions("video", videoNode.id, imageNodes, videoNodes, textNodes, getIncomingCanvasPorts, manualLinks);
|
||||
const vidMentionState = textNodeMentionStates[videoNode.id] || { open: false, query: "", start: 0, caret: 0, activeIndex: 0 };
|
||||
const vidFilteredMentions = vidMentionState.open
|
||||
? vidMentionOptions.filter((o) => !vidMentionState.query || o.searchText.includes(vidMentionState.query.toLowerCase()))
|
||||
: [];
|
||||
|
||||
const handleVideoPromptChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = e.target.value;
|
||||
const caret = e.target.selectionStart || 0;
|
||||
updateVideoNodePrompt(videoNode.id, value);
|
||||
const beforeCaret = value.slice(0, caret);
|
||||
const atIdx = beforeCaret.lastIndexOf("@");
|
||||
if (atIdx >= 0) {
|
||||
const query = beforeCaret.slice(atIdx + 1);
|
||||
if (!MENTION_BOUNDARY_RE.test(query) && !query.includes(" ")) {
|
||||
setTextNodeMentionStates((prev) => ({ ...prev, [videoNode.id]: { open: true, query, start: atIdx, caret, activeIndex: 0 } }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
closeTextNodeMention(videoNode.id);
|
||||
};
|
||||
|
||||
const handleVideoPromptKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (!vidMentionState.open || vidFilteredMentions.length === 0) return;
|
||||
if (e.key === "ArrowDown") { e.preventDefault(); setTextNodeMentionStates((prev) => ({ ...prev, [videoNode.id]: { ...vidMentionState, activeIndex: (vidMentionState.activeIndex + 1) % vidFilteredMentions.length } })); }
|
||||
else if (e.key === "ArrowUp") { e.preventDefault(); setTextNodeMentionStates((prev) => ({ ...prev, [videoNode.id]: { ...vidMentionState, activeIndex: (vidMentionState.activeIndex - 1 + vidFilteredMentions.length) % vidFilteredMentions.length } })); }
|
||||
else if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); const opt = vidFilteredMentions[vidMentionState.activeIndex]; if (opt) insertTextNodeMention(videoNode.id, opt, e.currentTarget, "video"); }
|
||||
else if (e.key === "Escape") { e.preventDefault(); closeTextNodeMention(videoNode.id); }
|
||||
};
|
||||
|
||||
return (
|
||||
{videoNodeActive && !isCanvasNodeMoving ? (
|
||||
<div className="studio-canvas-video-composer">
|
||||
<div className="studio-canvas-video-composer__tabs studio-canvas-video-composer__mode-tabs">
|
||||
<button
|
||||
@@ -5068,47 +4946,23 @@ function CanvasPage({
|
||||
>
|
||||
运镜{videoNode.cameraMotion ? ` ${CAMERA_MOTION_PRESETS.find((p) => p.value === videoNode.cameraMotion)?.label || ""}` : ""}
|
||||
</button>
|
||||
{markingPopoverNodeId === videoNode.id && (
|
||||
<div
|
||||
className="studio-canvas-marking-popover"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<textarea
|
||||
className="studio-canvas-marking-input"
|
||||
placeholder="描述标记内容,如:主角在城市街头行走"
|
||||
value={videoNode.marking || ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setVideoNodes((nodes) =>
|
||||
nodes.map((n) => (n.id === videoNode.id ? { ...n, marking: val } : n)),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div className="studio-canvas-marking-actions">
|
||||
{videoNode.marking && (
|
||||
<button
|
||||
type="button"
|
||||
className="studio-canvas-marking-clear"
|
||||
onClick={() => {
|
||||
setVideoNodes((nodes) =>
|
||||
nodes.map((n) => (n.id === videoNode.id ? { ...n, marking: "" } : n)),
|
||||
);
|
||||
}}
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="studio-canvas-marking-done"
|
||||
onClick={() => setMarkingPopoverNodeId(null)}
|
||||
>
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{markingPopoverNodeId === videoNode.id ? (
|
||||
<CanvasMarkingPopover
|
||||
value={videoNode.marking}
|
||||
placeholder="描述标记内容,如:主角在城市街头行走"
|
||||
onChange={(value) => {
|
||||
setVideoNodes((nodes) =>
|
||||
nodes.map((node) => (node.id === videoNode.id ? { ...node, marking: value } : node)),
|
||||
);
|
||||
}}
|
||||
onClear={() => {
|
||||
setVideoNodes((nodes) =>
|
||||
nodes.map((node) => (node.id === videoNode.id ? { ...node, marking: "" } : node)),
|
||||
);
|
||||
}}
|
||||
onDone={() => setMarkingPopoverNodeId(null)}
|
||||
/>
|
||||
) : null}
|
||||
{cameraMotionDropdownNodeId === videoNode.id && (
|
||||
<div
|
||||
className="studio-canvas-camera-dropdown"
|
||||
@@ -5135,43 +4989,24 @@ function CanvasPage({
|
||||
<button type="button">角色库</button>
|
||||
<button type="button" className="is-active">文本</button>
|
||||
</div>
|
||||
<div className="studio-canvas-text-composer__input-wrap">
|
||||
<textarea
|
||||
<CanvasPromptMentionTextarea
|
||||
nodeId={videoNode.id}
|
||||
value={videoNode.prompt}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onChange={handleVideoPromptChange}
|
||||
onKeyDown={handleVideoPromptKeyDown}
|
||||
placeholder="根据文字描述生成视频。"
|
||||
mentionOptions={buildNodeMentionOptions("video", videoNode.id, imageNodes, videoNodes, textNodes, getIncomingCanvasPorts, manualLinks)}
|
||||
mentionState={textNodeMentionStates[videoNode.id]}
|
||||
mentionKind="video"
|
||||
onPromptChange={updateVideoNodePrompt}
|
||||
onMentionStateChange={setTextNodeMentionStates}
|
||||
onCloseMention={closeTextNodeMention}
|
||||
onInsertMention={insertTextNodeMention}
|
||||
/>
|
||||
{vidMentionState.open ? (
|
||||
<div className="studio-canvas-mention-panel">
|
||||
{vidFilteredMentions.length > 0 ? vidFilteredMentions.map((opt, idx) => (
|
||||
<button
|
||||
key={opt.token}
|
||||
type="button"
|
||||
className={`studio-canvas-mention-item${idx === vidMentionState.activeIndex ? " is-active" : ""}`}
|
||||
onMouseDown={(ev) => { ev.preventDefault(); insertTextNodeMention(videoNode.id, opt, ev.currentTarget.closest(".studio-canvas-text-composer__input-wrap")?.querySelector("textarea")!, "video"); }}
|
||||
>
|
||||
<span className={`studio-canvas-mention-icon studio-canvas-mention-icon--${opt.kind}`}>
|
||||
{opt.kind === "image" ? "🖼" : opt.kind === "video" ? "🎬" : "📝"}
|
||||
</span>
|
||||
<span className="studio-canvas-mention-label">{opt.nodeTitle}</span>
|
||||
<span className="studio-canvas-mention-token">{opt.token}</span>
|
||||
</button>
|
||||
)) : (
|
||||
<div className="studio-canvas-mention-item" style={{ opacity: 0.5, pointerEvents: "none" }}>
|
||||
<span className="studio-canvas-mention-label">没有可引用的连接节点</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="studio-canvas-video-composer__footer studio-canvas-video-composer__settings">
|
||||
<CanvasSelectChip
|
||||
ariaLabel="选择视频模型"
|
||||
className="canvas-select-chip--model studio-canvas-composer-chip"
|
||||
value={toHappyHorseDisplayModel(videoNode.model || defaultVideoModel)}
|
||||
options={canvasEnterpriseVideoModelOptions}
|
||||
options={canvasVideoModelOptions}
|
||||
open={canvasSelectMenu === `${videoNode.id}:video-model`}
|
||||
onToggle={() =>
|
||||
setCanvasSelectMenu((current) =>
|
||||
@@ -5249,7 +5084,7 @@ function CanvasPage({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
); })() : null}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -5543,7 +5378,7 @@ function CanvasPage({
|
||||
onClick={() => setSelectedExistingCategory(category.key)}
|
||||
>
|
||||
{category.label}
|
||||
<span>{serverAssets.filter((asset) => asset.type === category.key).length} 个素材</span>
|
||||
<span>{assetCountsByCategory.get(category.key) ?? 0} 个素材</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { SendOutlined } from "@ant-design/icons";
|
||||
import { useRef, type CSSProperties, type Dispatch, type SetStateAction } from "react";
|
||||
import type { CanvasNodeKind, CanvasPromptMentionOption, CanvasPromptMentionState } from "./canvasTypes";
|
||||
|
||||
const MENTION_BOUNDARY_RE = /\s|[,。、;:!??(){}[\]<>]/;
|
||||
const EMPTY_MENTION_STYLE: CSSProperties = { opacity: 0.5, pointerEvents: "none" };
|
||||
const DEFAULT_MENTION_STATE: CanvasPromptMentionState = {
|
||||
open: false,
|
||||
query: "",
|
||||
start: 0,
|
||||
caret: 0,
|
||||
activeIndex: 0,
|
||||
};
|
||||
|
||||
interface CanvasPromptMentionTextareaProps {
|
||||
nodeId: string;
|
||||
value: string;
|
||||
placeholder: string;
|
||||
mentionOptions: CanvasPromptMentionOption[];
|
||||
mentionState?: CanvasPromptMentionState;
|
||||
onPromptChange: (nodeId: string, prompt: string) => void;
|
||||
onMentionStateChange: Dispatch<SetStateAction<Record<string, CanvasPromptMentionState>>>;
|
||||
onCloseMention: (nodeId: string) => void;
|
||||
onInsertMention: (
|
||||
nodeId: string,
|
||||
option: CanvasPromptMentionOption,
|
||||
textarea: HTMLTextAreaElement | null,
|
||||
kind?: CanvasNodeKind,
|
||||
) => void;
|
||||
mentionKind?: CanvasNodeKind;
|
||||
}
|
||||
|
||||
export function CanvasPromptMentionTextarea({
|
||||
nodeId,
|
||||
value,
|
||||
placeholder,
|
||||
mentionOptions,
|
||||
mentionState = DEFAULT_MENTION_STATE,
|
||||
onPromptChange,
|
||||
onMentionStateChange,
|
||||
onCloseMention,
|
||||
onInsertMention,
|
||||
mentionKind,
|
||||
}: CanvasPromptMentionTextareaProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const filteredMentions = mentionState.open
|
||||
? mentionOptions.filter((option) => !mentionState.query || option.searchText.includes(mentionState.query.toLowerCase()))
|
||||
: [];
|
||||
|
||||
const handlePromptChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = event.target.value;
|
||||
const caret = event.target.selectionStart || 0;
|
||||
onPromptChange(nodeId, value);
|
||||
|
||||
const beforeCaret = value.slice(0, caret);
|
||||
const atIndex = beforeCaret.lastIndexOf("@");
|
||||
if (atIndex >= 0) {
|
||||
const query = beforeCaret.slice(atIndex + 1);
|
||||
if (!MENTION_BOUNDARY_RE.test(query) && !query.includes(" ")) {
|
||||
onMentionStateChange((prev) => ({
|
||||
...prev,
|
||||
[nodeId]: { open: true, query, start: atIndex, caret, activeIndex: 0 },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
}
|
||||
onCloseMention(nodeId);
|
||||
};
|
||||
|
||||
const handlePromptKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (!mentionState.open || filteredMentions.length === 0) return;
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
onMentionStateChange((prev) => ({
|
||||
...prev,
|
||||
[nodeId]: { ...mentionState, activeIndex: (mentionState.activeIndex + 1) % filteredMentions.length },
|
||||
}));
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
onMentionStateChange((prev) => ({
|
||||
...prev,
|
||||
[nodeId]: {
|
||||
...mentionState,
|
||||
activeIndex: (mentionState.activeIndex - 1 + filteredMentions.length) % filteredMentions.length,
|
||||
},
|
||||
}));
|
||||
} else if (event.key === "Enter" || event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
const option = filteredMentions[mentionState.activeIndex];
|
||||
if (option) {
|
||||
onInsertMention(nodeId, option, event.currentTarget, mentionKind);
|
||||
}
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
onCloseMention(nodeId);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePromptSelect = (event: React.SyntheticEvent<HTMLTextAreaElement>) => {
|
||||
const caret = event.currentTarget.selectionStart || 0;
|
||||
onMentionStateChange((prev) => {
|
||||
const current = prev[nodeId];
|
||||
if (!current?.open) return prev;
|
||||
return { ...prev, [nodeId]: { ...current, caret } };
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="studio-canvas-text-composer__input-wrap">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onChange={handlePromptChange}
|
||||
onKeyDown={handlePromptKeyDown}
|
||||
onSelect={handlePromptSelect}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
{mentionState.open ? (
|
||||
<div className="studio-canvas-mention-panel">
|
||||
{filteredMentions.length > 0 ? filteredMentions.map((option, index) => (
|
||||
<button
|
||||
key={option.token}
|
||||
type="button"
|
||||
className={`studio-canvas-mention-item${index === mentionState.activeIndex ? " is-active" : ""}`}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
onInsertMention(nodeId, option, textareaRef.current, mentionKind);
|
||||
}}
|
||||
>
|
||||
<span className="studio-canvas-mention-thumb">
|
||||
{option.kind === "image" && option.previewUrl ? (
|
||||
<img src={option.previewUrl} alt="" />
|
||||
) : option.kind === "image" ? "🖼" : option.kind === "video" ? "🎬" : "📝"}
|
||||
</span>
|
||||
<span className="studio-canvas-mention-label">{option.nodeTitle}</span>
|
||||
<span className="studio-canvas-mention-token">{option.token}</span>
|
||||
</button>
|
||||
)) : (
|
||||
<div className="studio-canvas-mention-item" style={EMPTY_MENTION_STYLE}>
|
||||
<span className="studio-canvas-mention-label">没有可引用的连接节点</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CanvasTextPromptComposerProps {
|
||||
nodeId: string;
|
||||
prompt: string;
|
||||
canGenerate: boolean;
|
||||
isGenerating: boolean;
|
||||
mentionOptions: CanvasPromptMentionOption[];
|
||||
mentionState?: CanvasPromptMentionState;
|
||||
onPromptChange: (nodeId: string, prompt: string) => void;
|
||||
onMentionStateChange: Dispatch<SetStateAction<Record<string, CanvasPromptMentionState>>>;
|
||||
onCloseMention: (nodeId: string) => void;
|
||||
onInsertMention: (
|
||||
nodeId: string,
|
||||
option: CanvasPromptMentionOption,
|
||||
textarea: HTMLTextAreaElement | null,
|
||||
kind?: CanvasNodeKind,
|
||||
) => void;
|
||||
onGenerate: (nodeId: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function CanvasTextPromptComposer({
|
||||
nodeId,
|
||||
prompt,
|
||||
canGenerate,
|
||||
isGenerating,
|
||||
mentionOptions,
|
||||
mentionState,
|
||||
onPromptChange,
|
||||
onMentionStateChange,
|
||||
onCloseMention,
|
||||
onInsertMention,
|
||||
onGenerate,
|
||||
}: CanvasTextPromptComposerProps) {
|
||||
return (
|
||||
<div className="studio-canvas-text-composer">
|
||||
<CanvasPromptMentionTextarea
|
||||
nodeId={nodeId}
|
||||
value={prompt}
|
||||
placeholder="写下你想讲的故事、场景或角色设定。@引用连接的节点"
|
||||
mentionOptions={mentionOptions}
|
||||
mentionState={mentionState}
|
||||
onPromptChange={onPromptChange}
|
||||
onMentionStateChange={onMentionStateChange}
|
||||
onCloseMention={onCloseMention}
|
||||
onInsertMention={onInsertMention}
|
||||
/>
|
||||
<div className="studio-canvas-text-composer__footer">
|
||||
<button
|
||||
type="button"
|
||||
className={`studio-canvas-text-composer__send studio-canvas-generate-button${canGenerate && !isGenerating ? " is-ready" : ""}`}
|
||||
title={isGenerating ? "生成中" : "生成"}
|
||||
disabled={isGenerating || !canGenerate}
|
||||
aria-busy={isGenerating}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!isGenerating && canGenerate) {
|
||||
void onGenerate(nodeId);
|
||||
}
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<SendOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type { ServerAssetItem } from "../../api/assetClient";
|
||||
import type {
|
||||
CanvasImageNode,
|
||||
CanvasNodeKind,
|
||||
CanvasNodePackage,
|
||||
CanvasTextNode,
|
||||
CanvasVideoNode,
|
||||
} from "./canvasTypes";
|
||||
import { getCanvasSelectionKey } from "./canvasUtils";
|
||||
|
||||
export function useCanvasAssetSummary(serverAssets: ServerAssetItem[]) {
|
||||
return useMemo(() => {
|
||||
const canvasAssets: ServerAssetItem[] = [];
|
||||
const assetCountsByCategory = new Map<string, number>();
|
||||
|
||||
for (const asset of serverAssets) {
|
||||
if (asset.imageUrl) {
|
||||
canvasAssets.push(asset);
|
||||
}
|
||||
assetCountsByCategory.set(asset.type, (assetCountsByCategory.get(asset.type) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return { canvasAssets, assetCountsByCategory };
|
||||
}, [serverAssets]);
|
||||
}
|
||||
|
||||
export function useCanvasVisibleNodes({
|
||||
textNodes,
|
||||
imageNodes,
|
||||
videoNodes,
|
||||
nodePackages,
|
||||
}: {
|
||||
textNodes: CanvasTextNode[];
|
||||
imageNodes: CanvasImageNode[];
|
||||
videoNodes: CanvasVideoNode[];
|
||||
nodePackages: CanvasNodePackage[];
|
||||
}) {
|
||||
const collapsedPackageNodeKeys = useMemo(
|
||||
() => new Set(
|
||||
nodePackages.flatMap((nodePackage) =>
|
||||
nodePackage.collapsed ? nodePackage.nodeIds.map((node) => getCanvasSelectionKey(node)) : []
|
||||
)
|
||||
),
|
||||
[nodePackages],
|
||||
);
|
||||
|
||||
const isNodeCollapsedInPackage = useCallback(
|
||||
(kind: CanvasNodeKind, id: string) =>
|
||||
collapsedPackageNodeKeys.has(getCanvasSelectionKey({ kind, id })),
|
||||
[collapsedPackageNodeKeys],
|
||||
);
|
||||
|
||||
const visibleTextNodes = useMemo(
|
||||
() => textNodes.filter((textNode) => !collapsedPackageNodeKeys.has(getCanvasSelectionKey({ kind: "text", id: textNode.id }))),
|
||||
[collapsedPackageNodeKeys, textNodes],
|
||||
);
|
||||
const visibleImageNodes = useMemo(
|
||||
() => imageNodes.filter((imageNode) => !collapsedPackageNodeKeys.has(getCanvasSelectionKey({ kind: "image", id: imageNode.id }))),
|
||||
[collapsedPackageNodeKeys, imageNodes],
|
||||
);
|
||||
const visibleVideoNodes = useMemo(
|
||||
() => videoNodes.filter((videoNode) => !collapsedPackageNodeKeys.has(getCanvasSelectionKey({ kind: "video", id: videoNode.id }))),
|
||||
[collapsedPackageNodeKeys, videoNodes],
|
||||
);
|
||||
|
||||
return {
|
||||
collapsedPackageNodeKeys,
|
||||
isNodeCollapsedInPackage,
|
||||
visibleTextNodes,
|
||||
visibleImageNodes,
|
||||
visibleVideoNodes,
|
||||
};
|
||||
}
|
||||
@@ -82,11 +82,16 @@ export function useCanvasNodeDrag(params: UseCanvasNodeDragParams) {
|
||||
const cy = pos.y + size.height / 2;
|
||||
const right = pos.x + size.width;
|
||||
const bottom = pos.y + size.height;
|
||||
const others = [
|
||||
...textNodesRef.current.filter((n) => n.id !== draggedId).map((n) => ({ pos: n.position, size: n.size })),
|
||||
...imageNodesRef.current.filter((n) => n.id !== draggedId).map((n) => ({ pos: n.position, size: n.size })),
|
||||
...videoNodesRef.current.filter((n) => n.id !== draggedId).map((n) => ({ pos: n.position, size: n.size })),
|
||||
];
|
||||
const others: Array<{ pos: CanvasPoint; size: CanvasNodeSize }> = [];
|
||||
for (const node of textNodesRef.current) {
|
||||
if (node.id !== draggedId) others.push({ pos: node.position, size: node.size });
|
||||
}
|
||||
for (const node of imageNodesRef.current) {
|
||||
if (node.id !== draggedId) others.push({ pos: node.position, size: node.size });
|
||||
}
|
||||
for (const node of videoNodesRef.current) {
|
||||
if (node.id !== draggedId) others.push({ pos: node.position, size: node.size });
|
||||
}
|
||||
for (const other of others) {
|
||||
const ocx = other.pos.x + other.size.width / 2;
|
||||
const ocy = other.pos.y + other.size.height / 2;
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
VideoCameraOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useCallback, useEffect, useRef, useState, type DragEvent } from "react";
|
||||
import "../../styles/pages/more-tools.css";
|
||||
import "../../styles/pages/image-workbench.css";
|
||||
import StudioToolLayout from "../../components/StudioToolLayout";
|
||||
import type { WebImageWorkbenchTool, WebViewKey } from "../../types";
|
||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
SearchOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import "../../styles/pages/community.css";
|
||||
import { useDebounce } from "../../hooks/useDebounce";
|
||||
import { communityClient, type ServerCommunityCase } from "../../api/communityClient";
|
||||
import WorkspacePageShell from "../../components/WorkspacePageShell";
|
||||
|
||||
@@ -91,7 +91,11 @@ export function getCommunityCaseSurface(item: Pick<ServerCommunityCase, "metadat
|
||||
);
|
||||
if (explicitSurface !== "unknown") return explicitSurface;
|
||||
|
||||
const tags = item.tags.map((tag) => tag.trim()).filter(Boolean);
|
||||
const tags: string[] = [];
|
||||
for (const rawTag of item.tags) {
|
||||
const tag = rawTag.trim();
|
||||
if (tag) tags.push(tag);
|
||||
}
|
||||
if (tags.some((tag) => tag.includes("生成页面社区") || tag === "Web生成")) return "generation";
|
||||
if (tags.some((tag) => tag.includes("画布页面社区") || tag.includes("工作流"))) return "canvas";
|
||||
if (getWorkflowFromCase(item)) return "canvas";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { FileTextOutlined, SafetyOutlined } from "@ant-design/icons";
|
||||
import "../../styles/pages/compliance.css";
|
||||
|
||||
type ComplianceKind = "agreement" | "privacy";
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, type TouchEvent as ReactTouchEvent } from "react";
|
||||
import { ApartmentOutlined, DownOutlined, RobotOutlined, ThunderboltOutlined } from "@ant-design/icons";
|
||||
import "../../styles/pages/dialog-generator.css";
|
||||
|
||||
type DialogStyle = "style1" | "style2" | "style3" | "style4";
|
||||
type GenerationMode = "dialog" | "video";
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
VideoCameraOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useMemo, useRef, useState, type CSSProperties, type PointerEvent, type ReactNode } from "react";
|
||||
import "../../styles/pages/avatar-console.css";
|
||||
import type { WebViewKey } from "../../types";
|
||||
import {
|
||||
bringAvatarEditorLayerForward,
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useCallback, useEffect, useRef, useState, type DragEvent } from "react";
|
||||
import "../../styles/pages/more-tools.css";
|
||||
import "../../styles/pages/image-workbench.css";
|
||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||
import { uploadAssetWithProgress } from "../../api/uploadWithProgress";
|
||||
import { waitForTask } from "../../api/taskSubscription";
|
||||
|
||||
@@ -12,7 +12,9 @@ import {
|
||||
SettingOutlined,
|
||||
SkinOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useEffect, useRef, useState, type CSSProperties, type ChangeEvent, type DragEvent, type ReactNode } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ChangeEvent, type DragEvent, type ReactNode } from "react";
|
||||
import "../../styles/pages/ecommerce.css";
|
||||
import "../../styles/pages/local-theme-parity.css";
|
||||
import { ossAssets } from "../../data/ossAssets";
|
||||
import { EcommerceProgressBar } from "./EcommerceProgressBar";
|
||||
import ImageMentionMenu, { getImageMentionQuery, insertImageMentionValue, type MentionImageOption } from "./ImageMentionMenu";
|
||||
@@ -587,6 +589,7 @@ const cloneSetCountOptions: Array<{
|
||||
{ key: "white", title: "白底图", desc: "白底主图,多角度呈现商品细节" },
|
||||
{ key: "scene", title: "场景图", desc: "展示商品的生活使用场景和人物搭配" },
|
||||
];
|
||||
const cloneSetCountKeys = cloneSetCountOptions.map((option) => option.key);
|
||||
const defaultCloneSetCounts: Record<CloneSetCountKey, number> = {
|
||||
selling: 3,
|
||||
white: 1,
|
||||
@@ -938,25 +941,58 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
const [selectedDetailModules, setSelectedDetailModules] = useState<string[]>(defaultDetailModuleIds);
|
||||
const [detailStatus, setDetailStatus] = useState<DetailStatus>("idle");
|
||||
const [detailResultUrl, setDetailResultUrl] = useState<string | null>(null);
|
||||
const productSetRatioOptions = getPlatformRatioOptions(productSetPlatform, productSetOutput);
|
||||
const hotUploadedRatioOption = cloneOutput === "hot" ? formatUploadedImageRatio(cloneReferenceImages[0]) : null;
|
||||
const baseCloneRatioOptions = getPlatformRatioOptions(platform, cloneOutput);
|
||||
const cloneRatioOptions = hotUploadedRatioOption
|
||||
? getUniqueRatioOptions([...baseCloneRatioOptions, hotUploadedRatioOption])
|
||||
: baseCloneRatioOptions;
|
||||
const productSetLanguageOptions = getPlatformLanguageOptions(productSetPlatform, productSetMarket);
|
||||
const cloneLanguageOptions = getPlatformLanguageOptions(platform, market);
|
||||
const detailLanguageOptions = getPlatformLanguageOptions(detailPlatform, detailMarket);
|
||||
const productSetRatioOptions = useMemo(
|
||||
() => getPlatformRatioOptions(productSetPlatform, productSetOutput),
|
||||
[productSetOutput, productSetPlatform],
|
||||
);
|
||||
const hotUploadedRatioOption = useMemo(
|
||||
() => cloneOutput === "hot" ? formatUploadedImageRatio(cloneReferenceImages[0]) : null,
|
||||
[cloneOutput, cloneReferenceImages],
|
||||
);
|
||||
const baseCloneRatioOptions = useMemo(
|
||||
() => getPlatformRatioOptions(platform, cloneOutput),
|
||||
[cloneOutput, platform],
|
||||
);
|
||||
const cloneRatioOptions = useMemo(
|
||||
() => hotUploadedRatioOption
|
||||
? getUniqueRatioOptions([...baseCloneRatioOptions, hotUploadedRatioOption])
|
||||
: baseCloneRatioOptions,
|
||||
[baseCloneRatioOptions, hotUploadedRatioOption],
|
||||
);
|
||||
const productSetLanguageOptions = useMemo(
|
||||
() => getPlatformLanguageOptions(productSetPlatform, productSetMarket),
|
||||
[productSetMarket, productSetPlatform],
|
||||
);
|
||||
const cloneLanguageOptions = useMemo(
|
||||
() => getPlatformLanguageOptions(platform, market),
|
||||
[market, platform],
|
||||
);
|
||||
const detailLanguageOptions = useMemo(
|
||||
() => getPlatformLanguageOptions(detailPlatform, detailMarket),
|
||||
[detailMarket, detailPlatform],
|
||||
);
|
||||
const ecommerceMentionImages: MentionImageOption[] = [
|
||||
...productImages.map((image, index) => ({ ...image, label: `商品图 ${index + 1}` })),
|
||||
...cloneReferenceImages.map((image, index) => ({ ...image, label: `参考图 ${index + 1}` })),
|
||||
];
|
||||
|
||||
const ecommerceVideoImageDataUrls = useMemo(
|
||||
() => productImages.map((img) => img.src),
|
||||
[productImages],
|
||||
);
|
||||
const ecommerceVideoImageFiles = useMemo(
|
||||
() => productImages.map((img) => img.file),
|
||||
[productImages],
|
||||
);
|
||||
|
||||
const selectedProductSetOutput =
|
||||
productSetOutputOptions.find((option) => option.key === productSetOutput) ?? productSetOutputOptions[0]!;
|
||||
const selectedCloneOutput = cloneOutputOptions.find((option) => option.key === cloneOutput) ?? cloneOutputOptions[1]!;
|
||||
const productSetPreviewReady = productSetStatus === "done";
|
||||
const cloneSetTotal = Object.values(cloneSetCounts).reduce((sum, value) => sum + value, 0);
|
||||
const cloneSetTotal = useMemo(
|
||||
() => Object.values(cloneSetCounts).reduce((sum, value) => sum + value, 0),
|
||||
[cloneSetCounts],
|
||||
);
|
||||
const canGenerateSet = setImages.length > 0 && productSetStatus !== "generating";
|
||||
const canGenerate = (cloneOutput === "video-outfit"
|
||||
? Boolean(videoOutfitVideoFile && videoOutfitRefFile)
|
||||
@@ -965,9 +1001,12 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
const canGenerateDetail = detailProductImages.length > 0 && detailStatus !== "generating";
|
||||
const cloneVideoDurationProgress =
|
||||
((cloneVideoDuration - cloneVideoDurationMin) / (cloneVideoDurationMax - cloneVideoDurationMin)) * 100;
|
||||
const cloneVideoDurationStyle: CSSProperties = {
|
||||
"--clone-video-duration-progress": `${cloneVideoDurationProgress}%`,
|
||||
} as CSSProperties;
|
||||
const cloneVideoDurationStyle: CSSProperties = useMemo(
|
||||
() => ({
|
||||
"--clone-video-duration-progress": `${cloneVideoDurationProgress}%`,
|
||||
}) as CSSProperties,
|
||||
[cloneVideoDurationProgress],
|
||||
);
|
||||
|
||||
const trackEcommerceTask = (taskId: string) => {
|
||||
activeEcommerceTaskIdsRef.current.add(taskId);
|
||||
@@ -1162,6 +1201,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
};
|
||||
|
||||
const clearCloneSetCountHold = () => {
|
||||
window.removeEventListener("pointerup", clearCloneSetCountHold);
|
||||
window.removeEventListener("pointercancel", clearCloneSetCountHold);
|
||||
if (countHoldTimeoutRef.current !== null) {
|
||||
window.clearTimeout(countHoldTimeoutRef.current);
|
||||
countHoldTimeoutRef.current = null;
|
||||
@@ -1276,6 +1317,34 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
requirement,
|
||||
});
|
||||
|
||||
const latestCloneSettingSnapshot = useMemo(
|
||||
() => createCloneSettingSnapshot(cloneSettingName, "clone-setting-latest"),
|
||||
[
|
||||
cloneOutput,
|
||||
platform,
|
||||
market,
|
||||
language,
|
||||
ratio,
|
||||
cloneSetCounts,
|
||||
selectedCloneDetailModules,
|
||||
cloneModelPanelTab,
|
||||
selectedCloneModelScenes,
|
||||
cloneModelCustomScene,
|
||||
cloneModelGender,
|
||||
cloneModelAge,
|
||||
cloneModelEthnicity,
|
||||
cloneModelBody,
|
||||
cloneModelAppearance,
|
||||
cloneVideoQuality,
|
||||
cloneVideoDuration,
|
||||
cloneVideoSmart,
|
||||
cloneReferenceMode,
|
||||
cloneReplicateLevel,
|
||||
requirement,
|
||||
cloneSettingName,
|
||||
],
|
||||
);
|
||||
|
||||
const persistLatestCloneSetting = () => {
|
||||
const snapshot = createCloneSettingSnapshot(cloneSettingName, "clone-setting-latest");
|
||||
latestCloneSettingRef.current = snapshot;
|
||||
@@ -1323,8 +1392,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
latestCloneSettingRef.current = createCloneSettingSnapshot(cloneSettingName, "clone-setting-latest");
|
||||
});
|
||||
latestCloneSettingRef.current = latestCloneSettingSnapshot;
|
||||
}, [latestCloneSettingSnapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
const latestSetting = readCloneLatestSetting();
|
||||
@@ -1631,7 +1700,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
const generatedUrls: string[] = [];
|
||||
const stamp = Date.now();
|
||||
|
||||
for (const countKey of cloneSetCountOptions.map((o) => o.key)) {
|
||||
for (const countKey of cloneSetCountKeys) {
|
||||
if (imageAbortRef.current.current) break;
|
||||
const count = counts[countKey];
|
||||
for (let i = 0; i < count; i++) {
|
||||
@@ -1908,7 +1977,13 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
platform, ratio, language, market,
|
||||
{ gender: modelGender, age: modelAge, ethnicity: modelEthnicity, body: modelBody, appearance, scenes: selectedScenes, smartScene },
|
||||
(s) => setTryOnStatus(s as TryOnStatus),
|
||||
(res) => setTryOnResultImages(res.map((r) => r.src).filter(Boolean)),
|
||||
(res) => {
|
||||
const urls: string[] = [];
|
||||
for (const item of res) {
|
||||
if (item.src) urls.push(item.src);
|
||||
}
|
||||
setTryOnResultImages(urls);
|
||||
},
|
||||
);
|
||||
lastFailedActionRef.current = () => handleTryOnGenerate();
|
||||
};
|
||||
@@ -2028,7 +2103,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
productImages.length === 0 ? "请先上传商品原图" : status === "generating" ? "生成中..." : `生成${selectedCloneOutput.label}`;
|
||||
const setPreviewCards: CloneResult[] = [];
|
||||
let setIndex = 0;
|
||||
for (const countKey of cloneSetCountOptions.map((o) => o.key)) {
|
||||
for (const countKey of cloneSetCountKeys) {
|
||||
const count = cloneSetCounts[countKey];
|
||||
const info = setCountLabels[countKey];
|
||||
for (let i = 0; i < count; i++) {
|
||||
@@ -2043,7 +2118,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
|
||||
const clonePreviewCards: CloneResult[] = [];
|
||||
let cloneIndex = 0;
|
||||
for (const countKey of cloneSetCountOptions.map((o) => o.key)) {
|
||||
for (const countKey of cloneSetCountKeys) {
|
||||
const count = cloneSetCounts[countKey];
|
||||
const info = setCountLabels[countKey];
|
||||
for (let i = 0; i < count; i++) {
|
||||
@@ -2055,6 +2130,12 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
cloneIndex++;
|
||||
}
|
||||
}
|
||||
const detailSourcePreviewImages = detailProductImages.length
|
||||
? detailProductImages.reduce<string[]>((urls, item) => {
|
||||
urls.push(item.src);
|
||||
return urls;
|
||||
}, [])
|
||||
: detailProductSamples;
|
||||
const cloneBasicSelects: Array<{
|
||||
key: CloneBasicSelectKey;
|
||||
label: string;
|
||||
@@ -2564,7 +2645,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
|
||||
<section className="product-detail-demo-board">
|
||||
<div className="product-detail-source-stack">
|
||||
{(detailProductImages.length ? detailProductImages.map((item) => item.src) : detailProductSamples).map((src, index) => (
|
||||
{detailSourcePreviewImages.map((src, index) => (
|
||||
<figure key={`${src}-${index}`}>
|
||||
<img src={src} alt={`商品原图 ${index + 1}`} />
|
||||
</figure>
|
||||
@@ -2692,8 +2773,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
<main className="product-clone-preview product-clone-preview--video" style={{ padding: 0 }}>
|
||||
<EcommerceVideoWorkspace
|
||||
isAuthenticated={Boolean((_props as Record<string, unknown>).isAuthenticated)}
|
||||
productImageDataUrls={productImages.map((img) => img.src)}
|
||||
productImageFiles={productImages.map((img) => img.file)}
|
||||
productImageDataUrls={ecommerceVideoImageDataUrls}
|
||||
productImageFiles={ecommerceVideoImageFiles}
|
||||
requirement={requirement}
|
||||
platform={platform}
|
||||
aspectRatio={ratio.includes("9:16") || ratio.includes("9:16") ? "9:16" : ratio.includes("16:9") || ratio.includes("16:9") ? "16:9" : ratio.includes("3:4") || ratio.includes("3:4") ? "3:4" : "9:16"}
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
TagsOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||
import "../../styles/pages/more-tools.css";
|
||||
import "../../styles/pages/image-workbench.css";
|
||||
import "../../styles/pages/ecommerce.css";
|
||||
import "../../styles/pages/local-theme-parity.css";
|
||||
import type { WebProjectSummary } from "../../types";
|
||||
import { useDebounce } from "../../hooks/useDebounce";
|
||||
import { templateCarouselCases, templateCases, templateCategories, type TemplateCase } from "./ecommerceTemplates";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import "../../styles/pages/ecommerce-video.css";
|
||||
import {
|
||||
CloseOutlined,
|
||||
CopyOutlined,
|
||||
@@ -122,6 +123,7 @@ export default function EcommerceVideoWorkspace({
|
||||
const [flowZoom, setFlowZoom] = useState(1);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const renderAbortRef = useRef({ current: false });
|
||||
const actionNoticeTimerRef = useRef<number | null>(null);
|
||||
const setView = useAppStore((s) => s.setView);
|
||||
const keepaliveRestoredFingerprintRef = useRef<string | null>(null);
|
||||
const keepalivePollingStartedRef = useRef(false);
|
||||
@@ -277,9 +279,23 @@ export default function EcommerceVideoWorkspace({
|
||||
// Note: keep-alive is NOT cleared on completion — results persist across page switches.
|
||||
// Only cleared when user explicitly starts a new plan via handlePlan.
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (actionNoticeTimerRef.current !== null) {
|
||||
window.clearTimeout(actionNoticeTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const showNotice = (msg: string) => {
|
||||
setActionNotice(msg);
|
||||
setTimeout(() => setActionNotice(null), 3000);
|
||||
if (actionNoticeTimerRef.current !== null) {
|
||||
window.clearTimeout(actionNoticeTimerRef.current);
|
||||
}
|
||||
actionNoticeTimerRef.current = window.setTimeout(() => {
|
||||
actionNoticeTimerRef.current = null;
|
||||
setActionNotice(null);
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const handleDownload = async (url: string) => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type AdVideoUserConfig,
|
||||
} from "../../api/adVideoPlanClient";
|
||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||
import { serverRequest } from "../../api/serverConnection";
|
||||
import { waitForTask } from "../../api/taskSubscription";
|
||||
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
|
||||
import { normalizeEcommerceImageMime } from "./ecommerceImageValidation";
|
||||
@@ -430,15 +431,6 @@ export interface VideoHistoryListResponse {
|
||||
offset: number;
|
||||
}
|
||||
|
||||
import { getStoredToken } from "../../api/serverConnection";
|
||||
|
||||
const API_BASE = "/api/ai/ecommerce/video-history";
|
||||
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
const token = getStoredToken();
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
export async function buildDurableVideoHistoryPayload(payload: SaveVideoHistoryPayload): Promise<SaveVideoHistoryPayload> {
|
||||
const uploadAssetByUrl = payload.uploadAssetByUrl;
|
||||
const scenes = await Promise.all(
|
||||
@@ -486,13 +478,12 @@ export async function buildDurableVideoHistoryPayload(payload: SaveVideoHistoryP
|
||||
|
||||
export async function saveVideoHistory(payload: SaveVideoHistoryPayload): Promise<{ id: number; createdAt: string }> {
|
||||
const { uploadAssetByUrl: _uploadAssetByUrl, ...historyPayload } = await buildDurableVideoHistoryPayload(payload);
|
||||
const res = await fetch(API_BASE, {
|
||||
return serverRequest<{ id: number; createdAt: string }>("ai/ecommerce/video-history", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...getAuthHeaders() },
|
||||
body: JSON.stringify(historyPayload),
|
||||
body: historyPayload,
|
||||
maxRetries: 0,
|
||||
fallbackMessage: "Failed to save video history",
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to save video history");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function removeTemporaryHistoryUrls(item: VideoHistoryItem): VideoHistoryItem {
|
||||
@@ -511,12 +502,10 @@ export async function fetchVideoHistory(
|
||||
limit = 20,
|
||||
offset = 0,
|
||||
): Promise<VideoHistoryListResponse> {
|
||||
const res = await fetch(
|
||||
`${API_BASE}?limit=${limit}&offset=${offset}`,
|
||||
{ headers: getAuthHeaders() },
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed to fetch video history");
|
||||
const history = (await res.json()) as VideoHistoryListResponse;
|
||||
const search = new URLSearchParams({ limit: String(limit), offset: String(offset) });
|
||||
const history = await serverRequest<VideoHistoryListResponse>(`ai/ecommerce/video-history?${search}`, {
|
||||
fallbackMessage: "Failed to fetch video history",
|
||||
});
|
||||
return {
|
||||
...history,
|
||||
items: history.items.map(removeTemporaryHistoryUrls),
|
||||
@@ -524,9 +513,9 @@ export async function fetchVideoHistory(
|
||||
}
|
||||
|
||||
export async function deleteVideoHistory(id: number): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/${id}`, {
|
||||
await serverRequest<void>(`ai/ecommerce/video-history/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: getAuthHeaders(),
|
||||
maxRetries: 0,
|
||||
fallbackMessage: "Failed to delete video history",
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to delete video history");
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type CSSPr
|
||||
import type { WebViewKey, WebImageWorkbenchTool } from "../../types";
|
||||
import { useScrollEntrance } from "../../hooks/useScrollEntrance";
|
||||
import { ossAssets } from "../../data/ossAssets";
|
||||
import "../../styles/pages/home.css";
|
||||
import WelcomeSplash from "./WelcomeSplash";
|
||||
import ToolboxSection from "./ToolboxSection";
|
||||
import ScriptReviewShowcase from "./ScriptReviewShowcase";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import "../../styles/pages/model-generation-showcase.css";
|
||||
|
||||
type ShowMode = "agent" | "image" | "video";
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import "../../styles/pages/script-review-showcase.css";
|
||||
|
||||
const DIMS = [
|
||||
{ name: "钩子设计", score: 16, max: 20, hue: 145, desc: "吸引力·悬念·黄金三秒", isPerfect: false, isLow: false },
|
||||
@@ -50,6 +51,12 @@ function ScriptReviewShowcase() {
|
||||
const scoreRef = useRef<HTMLSpanElement>(null);
|
||||
const barRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const scoreValRefs = useRef<(HTMLSpanElement | null)[]>([]);
|
||||
const animationTimersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
|
||||
const clearAnimationTimers = () => {
|
||||
animationTimersRef.current.forEach((timer) => clearTimeout(timer));
|
||||
animationTimersRef.current = [];
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const el = document.getElementById("script-review-showcase");
|
||||
@@ -69,18 +76,23 @@ function ScriptReviewShowcase() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!animated) return;
|
||||
const timer = setTimeout(() => {
|
||||
clearAnimationTimers();
|
||||
const scheduleAnimation = (callback: () => void, delay: number) => {
|
||||
const timer = setTimeout(callback, delay);
|
||||
animationTimersRef.current.push(timer);
|
||||
};
|
||||
scheduleAnimation(() => {
|
||||
animateNumber(scoreRef.current, 77, 1400);
|
||||
barRefs.current.forEach((bar, i) => {
|
||||
if (!bar) return;
|
||||
const pct = parseFloat(bar.dataset.pct ?? "0");
|
||||
setTimeout(() => { bar.style.height = `${pct}%`; }, i * 100 + 400);
|
||||
scheduleAnimation(() => { bar.style.height = `${pct}%`; }, i * 100 + 400);
|
||||
});
|
||||
scoreValRefs.current.forEach((el, i) => {
|
||||
setTimeout(() => animateNumber(el, parseInt(el?.dataset.target ?? "0"), 800), i * 100 + 400);
|
||||
scheduleAnimation(() => animateNumber(el, parseInt(el?.dataset.target ?? "0"), 800), i * 100 + 400);
|
||||
});
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
return clearAnimationTimers;
|
||||
}, [animated]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import "../../styles/pages/script-review-visual.css";
|
||||
|
||||
const DIMS = [
|
||||
{ name: "钩子设计", score: 19, max: 20, hue: 145 },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ToolOutlined } from "@ant-design/icons";
|
||||
import type { WebViewKey, WebImageWorkbenchTool } from "../../types";
|
||||
import { ossAssets } from "../../data/ossAssets";
|
||||
import "../../styles/pages/toolbox.css";
|
||||
|
||||
const {
|
||||
imageBefore: toolImageBefore,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import "../../styles/pages/welcome-splash.css";
|
||||
|
||||
interface WelcomeSplashProps {
|
||||
onEnter: () => void;
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
ThunderboltOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useCallback, useEffect, useRef, useState, type DragEvent } from "react";
|
||||
import "../../styles/pages/more-tools.css";
|
||||
import "../../styles/pages/image-workbench.css";
|
||||
import type { WebImageWorkbenchTool, WebViewKey } from "../../types";
|
||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||
import { waitForTask } from "../../api/taskSubscription";
|
||||
@@ -37,6 +39,9 @@ type WorkMode = "single" | "blend";
|
||||
type OutputSize = "9:16" | "16:9" | "4:3" | "3:4" | "1:1";
|
||||
type OutputCount = 1 | 2 | 3 | 4;
|
||||
|
||||
const OUTPUT_SIZE_OPTIONS: OutputSize[] = ["9:16", "16:9", "4:3", "3:4", "1:1"];
|
||||
const OUTPUT_COUNT_OPTIONS: OutputCount[] = [1, 2, 3, 4];
|
||||
|
||||
const SIZE_TO_RATIO: Record<OutputSize, string> = {
|
||||
"9:16": "9:16",
|
||||
"16:9": "16:9",
|
||||
@@ -80,6 +85,20 @@ const CAMERA_EFFECT_PRESETS = [
|
||||
{ key: "hdr", label: "HDR", prompt: "HDR高动态范围,明暗细节丰富,色彩饱和" },
|
||||
] as const;
|
||||
|
||||
const CAMERA_EFFECT_PROMPT_BY_KEY = new Map<string, string>(
|
||||
CAMERA_EFFECT_PRESETS.map((effect) => [effect.key, effect.prompt]),
|
||||
);
|
||||
|
||||
function getCameraEffectsPrompt(effectKeys: Set<string>): string {
|
||||
if (effectKeys.size === 0) return "";
|
||||
const prompts: string[] = [];
|
||||
for (const key of effectKeys) {
|
||||
const prompt = CAMERA_EFFECT_PROMPT_BY_KEY.get(key);
|
||||
if (prompt) prompts.push(prompt);
|
||||
}
|
||||
return prompts.join(",");
|
||||
}
|
||||
|
||||
function shotScaleToZoom(shotScale: number): number {
|
||||
const map: Record<number, number> = { 1: 24, 2: 28, 3: 32, 4: 35, 5: 40, 6: 50, 7: 60, 8: 75, 9: 85, 10: 100 };
|
||||
return map[Math.round(Math.max(1, Math.min(10, shotScale)))] || 40;
|
||||
@@ -154,6 +173,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
|
||||
abortRef.current = false;
|
||||
taskIdRef.current = saved.taskId;
|
||||
void waitForTask(saved.taskId, {
|
||||
kind: "image",
|
||||
onProgress: (e) => {
|
||||
setStatus(`${e.status} / ${e.progress}%`);
|
||||
if (e.status === "completed" && e.resultUrl) {
|
||||
@@ -296,9 +316,9 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
|
||||
|
||||
const [isInpaintDragging, setIsInpaintDragging] = useState(false);
|
||||
|
||||
const handleInpaintDragOver = (e: DragEvent<HTMLDivElement>) => { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer.types.includes("Files")) setIsInpaintDragging(true); };
|
||||
const handleInpaintDragLeave = (e: DragEvent<HTMLDivElement>) => { e.preventDefault(); e.stopPropagation(); if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) setIsInpaintDragging(false); };
|
||||
const handleInpaintDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||
const handleInpaintDragOver = (e: DragEvent<HTMLElement>) => { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer.types.includes("Files")) setIsInpaintDragging(true); };
|
||||
const handleInpaintDragLeave = (e: DragEvent<HTMLElement>) => { e.preventDefault(); e.stopPropagation(); if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) setIsInpaintDragging(false); };
|
||||
const handleInpaintDrop = (e: DragEvent<HTMLElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsInpaintDragging(false);
|
||||
@@ -464,9 +484,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
|
||||
const refUrls = await uploadReferenceImages([cameraImage]);
|
||||
const model = "wan2.7-image-pro";
|
||||
const cameraDesc = `镜头预设: ${cameraPreset}, 方向: ${cameraDirection}, 水平: ${cameraHorizontal}°, 垂直: ${cameraVertical}°, 倾斜: ${cameraRoll}°, 焦距: ${cameraZoom}mm`;
|
||||
const effectsDesc = cameraEffects.size > 0
|
||||
? Array.from(cameraEffects).map((key) => CAMERA_EFFECT_PRESETS.find((e) => e.key === key)?.prompt).filter(Boolean).join(",")
|
||||
: "";
|
||||
const effectsDesc = getCameraEffectsPrompt(cameraEffects);
|
||||
const fullPrompt = cameraPromptEnabled && cameraPrompt.trim()
|
||||
? `${cameraDesc}${effectsDesc ? `。视觉效果: ${effectsDesc}` : ""}。${cameraPrompt}`
|
||||
: `${cameraDesc}${effectsDesc ? `。视觉效果: ${effectsDesc}` : ""}。保持人物和场景一致,按照镜头参数重新构图。`;
|
||||
@@ -512,6 +530,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
|
||||
|
||||
const pollTaskUntilDone = useCallback(async (taskId: string): Promise<string | null> => {
|
||||
return waitForTask(taskId, {
|
||||
kind: "image",
|
||||
abortRef,
|
||||
onProgress: (e) => setGenerationProgress(e.progress || 0),
|
||||
});
|
||||
@@ -625,7 +644,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
|
||||
referenceUrls: refUrls,
|
||||
});
|
||||
taskIdRef.current = taskId;
|
||||
saveToolTaskState("imagewb", { taskId, status: "running", progress: 0 });
|
||||
saveToolTaskState("imagewb", { taskId, status: "running", progress: 0 });
|
||||
|
||||
const tempUrl = await pollTaskUntilDone(taskId);
|
||||
if (tempUrl) {
|
||||
@@ -818,7 +837,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
|
||||
<h3>参数</h3>
|
||||
<span className="image-workbench-field-label">输出分辨率</span>
|
||||
<div className="image-workbench-segmented">
|
||||
{(["9:16", "16:9", "4:3", "3:4", "1:1"] as OutputSize[]).map((s) => (
|
||||
{OUTPUT_SIZE_OPTIONS.map((s) => (
|
||||
<button key={s} type="button" className={outputSize === s ? "is-active" : ""} onClick={() => setOutputSize(s)}>
|
||||
{s}
|
||||
</button>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "@ant-design/icons";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import "../../styles/pages/more.css";
|
||||
import type { WebImageWorkbenchTool, WebViewKey } from "../../types";
|
||||
|
||||
interface MorePageProps {
|
||||
|
||||
@@ -18,8 +18,9 @@ import {
|
||||
ShareAltOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useEffect, useRef, useState, type ChangeEvent, type FormEvent, type KeyboardEvent } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type FormEvent, type KeyboardEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import "../../styles/pages/profile.css";
|
||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||
import { assetClient } from "../../api/assetClient";
|
||||
import { communityClient, type ServerCommunityCase } from "../../api/communityClient";
|
||||
@@ -268,8 +269,14 @@ function ProfilePage({
|
||||
const [isDeletingDetail, setIsDeletingDetail] = useState(false);
|
||||
const [isDownloadingDetail, setIsDownloadingDetail] = useState(false);
|
||||
|
||||
const completedTasks = tasks.filter((task) => task.status === "completed");
|
||||
const visibleWorks = completedTasks.length ? completedTasks : tasks.slice(0, 6);
|
||||
const completedTasks = useMemo(
|
||||
() => tasks.filter((task) => task.status === "completed"),
|
||||
[tasks],
|
||||
);
|
||||
const visibleWorks = useMemo(
|
||||
() => (completedTasks.length ? completedTasks : tasks.slice(0, 6)),
|
||||
[completedTasks, tasks],
|
||||
);
|
||||
const totalBalance = usage.balanceCents + (session?.user.enterpriseBalanceCents || 0);
|
||||
const packageLabel = session?.user.activePackages?.[0]?.name || "按量积分";
|
||||
const avatarUrl = session?.user.avatarUrl || localAvatarUrl || null;
|
||||
@@ -697,17 +704,21 @@ function ProfilePage({
|
||||
const handleDownloadSelectedDetail = async () => {
|
||||
if (!detailSelection || isDownloadingDetail) return;
|
||||
|
||||
const isWork = detailSelection.kind === "work";
|
||||
const item = detailSelection.item;
|
||||
const url = isWork ? item.outputUrl : item.imageUrl || item.url || "";
|
||||
const url =
|
||||
detailSelection.kind === "work"
|
||||
? detailSelection.item.outputUrl
|
||||
: detailSelection.item.imageUrl || detailSelection.item.url || "";
|
||||
if (!url) {
|
||||
setDetailNotice("暂无可下载的媒体文件");
|
||||
return;
|
||||
}
|
||||
|
||||
const isVideo = isWork ? item.type === "video" : item.type === "video" || /\.(mp4|webm|mov|m4v)(\?|#|$)/i.test(url);
|
||||
const taskId = isWork ? item.id : item.sourceTaskId || undefined;
|
||||
const name = isWork ? item.title : item.name;
|
||||
const isVideo =
|
||||
detailSelection.kind === "work"
|
||||
? detailSelection.item.type === "video"
|
||||
: detailSelection.item.type === "video" || /\.(mp4|webm|mov|m4v)(\?|#|$)/i.test(url);
|
||||
const taskId = detailSelection.kind === "work" ? detailSelection.item.id : detailSelection.item.sourceTaskId || undefined;
|
||||
const name = detailSelection.kind === "work" ? detailSelection.item.title : detailSelection.item.name;
|
||||
|
||||
setIsDownloadingDetail(true);
|
||||
setDetailNotice("正在准备下载...");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import "../../styles/pages/provider-health.css";
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
@@ -164,4 +165,4 @@ export default function ProviderHealthPage({ session, onOpenLogin }: ProviderHea
|
||||
</div>
|
||||
</WorkspacePageShell>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
VideoCameraOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useCallback, useEffect, useRef, useState, type DragEvent } from "react";
|
||||
import "../../styles/pages/more-tools.css";
|
||||
import "../../styles/pages/image-workbench.css";
|
||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||
import { waitForTask } from "../../api/taskSubscription";
|
||||
import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive";
|
||||
|
||||
@@ -10,8 +10,11 @@ import {
|
||||
UploadOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useEffect, useRef, useState, type ChangeEvent, type DragEvent, type KeyboardEvent } from "react";
|
||||
import "../../styles/pages/script-tokens-v5.css";
|
||||
import "../../styles/pages/script-tokens.css";
|
||||
import { evaluateScript } from "../../api/scriptEvalClient";
|
||||
import { buildApiUrl, getStoredToken } from "../../api/serverConnection";
|
||||
import { ShellIcon } from "../../components/ShellIcon";
|
||||
import { useSessionStore } from "../../stores";
|
||||
|
||||
interface ScoreDimension {
|
||||
@@ -244,9 +247,21 @@ function getDimensionSubScores(result: EvalResult, dim: ScoreDimension): Array<[
|
||||
.slice(0, 5);
|
||||
}
|
||||
|
||||
function normalizeEvidenceItems(evidence: unknown[] | undefined, limit: number): string[] {
|
||||
if (!Array.isArray(evidence)) return [];
|
||||
const items: string[] = [];
|
||||
for (const item of evidence) {
|
||||
const value = String(item).trim();
|
||||
if (!value) continue;
|
||||
items.push(value);
|
||||
if (items.length >= limit) break;
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function getDimensionEvidence(result: EvalResult, dim: ScoreDimension): string[] {
|
||||
const evidence = result.evidence?.[dim.key] ?? (dim.key === "logic" ? result.evidence?.dialogue : undefined);
|
||||
return Array.isArray(evidence) ? evidence.map(String).map((item) => item.trim()).filter(Boolean).slice(0, 3) : [];
|
||||
return normalizeEvidenceItems(evidence, 3);
|
||||
}
|
||||
|
||||
function formatReportMarkdown(result: EvalResult, script: string): string {
|
||||
@@ -538,10 +553,10 @@ function ScriptTokensPage() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="script-eval-v5-upload-icon"><UploadOutlined /></div>
|
||||
<div className="script-eval-v5-upload-icon"><ShellIcon name="upload" /></div>
|
||||
<div className="script-eval-v5-upload-text">拖拽或点击上传</div>
|
||||
<button type="button" className="script-eval-v5-upload-btn" onClick={(e) => { e.stopPropagation(); fileInputRef.current?.click(); }}>
|
||||
<UploadOutlined /> 选择剧本
|
||||
<ShellIcon name="upload" /> 选择剧本
|
||||
</button>
|
||||
<div className="script-eval-v5-upload-hint">{TEXT_FILE_HINT}</div>
|
||||
</>
|
||||
@@ -614,11 +629,11 @@ function ScriptTokensPage() {
|
||||
disabled={loading || !hasContent}
|
||||
onClick={() => void handleEvaluate()}
|
||||
>
|
||||
{loading ? <LoadingOutlined /> : <ThunderboltOutlined />}
|
||||
{loading ? <ShellIcon name="loading" /> : <ShellIcon name="thunderbolt" />}
|
||||
<span>{loading ? "评测中..." : "开始评测"}</span>
|
||||
</button>
|
||||
<button type="button" className="script-eval-v5-export-btn" disabled={!result} onClick={handleExportMarkdown}>
|
||||
<DownloadOutlined />
|
||||
<ShellIcon name="download" />
|
||||
<span>导出评测报告</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -636,10 +651,10 @@ function ScriptTokensPage() {
|
||||
{result && (
|
||||
<>
|
||||
<button type="button" className="script-eval-v5-action-btn" onClick={() => void handleCopyReport()}>
|
||||
<CopyOutlined />{copied ? "已复制" : "复制"}
|
||||
<ShellIcon name="copy" />{copied ? "已复制" : "复制"}
|
||||
</button>
|
||||
<button type="button" className="script-eval-v5-action-btn" onClick={handleExportMarkdown}>
|
||||
<DownloadOutlined />导出
|
||||
<ShellIcon name="download" />导出
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -673,7 +688,7 @@ function ScriptTokensPage() {
|
||||
onKeyDown={uploadKeyDown}
|
||||
>
|
||||
<div className="script-eval-v5-upload-card-icon">
|
||||
<FileTextOutlined />
|
||||
<ShellIcon name="file-text" />
|
||||
</div>
|
||||
<div className="script-eval-v5-upload-card-title">
|
||||
{uploadedFile ? "剧本已导入" : "上传剧本文件"}
|
||||
@@ -777,7 +792,7 @@ function ScriptTokensPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="script-eval-report__chart-note">
|
||||
<BarChartOutlined />
|
||||
<ShellIcon name="bar-chart" />
|
||||
<span>
|
||||
{activeDim === null
|
||||
? "悬停维度可查看当前分项表现,优先从低分项制定改稿计划。"
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
BarChartOutlined,
|
||||
CheckCircleOutlined,
|
||||
LeftOutlined,
|
||||
LineChartOutlined,
|
||||
ReloadOutlined,
|
||||
RightOutlined,
|
||||
TeamOutlined,
|
||||
UserOutlined,
|
||||
WarningOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ShellIcon } from "../../components/ShellIcon";
|
||||
import "../../styles/pages/more-tools.css";
|
||||
import "../../styles/pages/script-tokens-v5.css";
|
||||
import "../../styles/pages/script-tokens.css";
|
||||
import type {
|
||||
WebEnterpriseUsageMember,
|
||||
WebEnterpriseUsageRecord,
|
||||
@@ -243,7 +235,7 @@ function TokenUsagePage({
|
||||
<header className="management-center-toolbar" aria-label="管理中心操作">
|
||||
<div className="management-center-toolbar__title">
|
||||
<button type="button" className="management-center-toolbar__back" aria-label="返回工具盒" onClick={onOpenMore}>
|
||||
<ArrowLeftOutlined />
|
||||
<ShellIcon name="arrow-left" />
|
||||
</button>
|
||||
<span>
|
||||
<strong>管理中心</strong>
|
||||
@@ -254,18 +246,18 @@ function TokenUsagePage({
|
||||
{enterpriseUsageLoading ? "正在同步企业用量" : enterpriseUsageError || "服务器已连接"}
|
||||
</span>
|
||||
<button type="button" onClick={refreshEnterpriseUsage} disabled={enterpriseUsageLoading}>
|
||||
<ReloadOutlined />
|
||||
<ShellIcon name="reload" />
|
||||
刷新数据
|
||||
</button>
|
||||
<button type="button" className="is-muted-action">
|
||||
<UserOutlined />
|
||||
<ShellIcon name="user" />
|
||||
成员管理
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{isLowBalance ? (
|
||||
<div className="management-balance-alert" role="alert">
|
||||
<WarningOutlined />
|
||||
<ShellIcon name="warning" />
|
||||
<span>当前余额 {formatCredits(availableBalanceCents)},可能不足以完成下一次生成,请及时充值。</span>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -284,7 +276,7 @@ function TokenUsagePage({
|
||||
<article className="management-card management-card--chart">
|
||||
<div className="management-card__head">
|
||||
<h2>
|
||||
<BarChartOutlined />
|
||||
<ShellIcon name="bar-chart" />
|
||||
模型消耗分布
|
||||
</h2>
|
||||
<span>{enterpriseUsageLoading ? "SYNC" : modelBreakdown.length ? `${modelBreakdown.length} 个模型` : "LIVE"}</span>
|
||||
@@ -306,7 +298,7 @@ function TokenUsagePage({
|
||||
</div>
|
||||
) : (
|
||||
<div className="management-empty-chart">
|
||||
<BarChartOutlined />
|
||||
<ShellIcon name="bar-chart" />
|
||||
<span>暂无模型用量数据</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -315,7 +307,7 @@ function TokenUsagePage({
|
||||
<article className="management-card management-status-card">
|
||||
<div className="management-card__head">
|
||||
<h2>
|
||||
<LineChartOutlined />
|
||||
<ShellIcon name="line-chart" />
|
||||
系统状态
|
||||
</h2>
|
||||
</div>
|
||||
@@ -344,7 +336,7 @@ function TokenUsagePage({
|
||||
<section className="management-card management-members">
|
||||
<div className="management-card__head">
|
||||
<h2>
|
||||
<TeamOutlined />
|
||||
<ShellIcon name="team" />
|
||||
团队成员 ({members.length})
|
||||
</h2>
|
||||
<button type="button">{isEnterpriseAdmin ? "企业管理员" : "当前账号"}</button>
|
||||
@@ -363,7 +355,7 @@ function TokenUsagePage({
|
||||
<b>{member.taskCount} 调用</b>
|
||||
<b>{formatDateTime(member.lastUsedAt)}</b>
|
||||
</span>
|
||||
<CheckCircleOutlined />
|
||||
<ShellIcon name="check-circle" />
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
@@ -372,7 +364,7 @@ function TokenUsagePage({
|
||||
<section className="management-card management-records">
|
||||
<div className="management-card__head">
|
||||
<h2>
|
||||
<BarChartOutlined />
|
||||
<ShellIcon name="bar-chart" />
|
||||
调用记录
|
||||
</h2>
|
||||
<span>{records.length} 条记录</span>
|
||||
@@ -408,11 +400,11 @@ function TokenUsagePage({
|
||||
{records.length > pageSize && (
|
||||
<div className="management-record-pagination">
|
||||
<button type="button" disabled={recordPage === 0} onClick={() => setRecordPage((p) => p - 1)}>
|
||||
<LeftOutlined />
|
||||
<ShellIcon name="chevron-left" />
|
||||
</button>
|
||||
<span>{recordPage + 1} / {totalPages}</span>
|
||||
<button type="button" disabled={recordPage >= totalPages - 1} onClick={() => setRecordPage((p) => p + 1)}>
|
||||
<RightOutlined />
|
||||
<ShellIcon name="chevron-right" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent } from "react";
|
||||
import type { WebViewKey } from "../../types";
|
||||
import ImageMentionMenu, { getImageMentionQuery, insertImageMentionValue, type MentionImageOption } from "../ecommerce/ImageMentionMenu";
|
||||
import "../../styles/pages/ecommerce.css";
|
||||
import "../../styles/pages/size-template.css";
|
||||
import "../../styles/pages/local-theme-parity.css";
|
||||
|
||||
interface SizeTemplatePageProps {
|
||||
isAuthenticated?: boolean;
|
||||
|
||||
@@ -13,6 +13,9 @@ import {
|
||||
VideoCameraOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useCallback, useEffect, useRef, useState, type CSSProperties, type DragEvent } from "react";
|
||||
import "../../styles/pages/more-tools.css";
|
||||
import "../../styles/pages/image-workbench.css";
|
||||
import "../../styles/pages/subtitle-removal.css";
|
||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||
import { waitForTask } from "../../api/taskSubscription";
|
||||
import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive";
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
SwapOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import "../../styles/pages/more-tools.css";
|
||||
import "../../styles/pages/image-workbench.css";
|
||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||
import { waitForTask } from "../../api/taskSubscription";
|
||||
import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive";
|
||||
|
||||
@@ -34,13 +34,14 @@ import {
|
||||
type ReactNode,
|
||||
type SyntheticEvent,
|
||||
} from "react";
|
||||
import "../../styles/pages/workbench.css";
|
||||
import type { WebGenerationPreviewTask, WebUserSession } from "../../types";
|
||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||
import { claimGenerationSlot, getActiveGenerationTaskCount, getGenerationUserKey, releaseGenerationSlot } from "../../api/generationConcurrency";
|
||||
import { preUploadReference, resolvePreUploadedUrl } from "../../api/referenceUploadService";
|
||||
import { assetClient } from "../../api/assetClient";
|
||||
import { communityClient } from "../../api/communityClient";
|
||||
import { RechargeModal } from "../../components/RechargeModal/RechargeModal";
|
||||
import { loadRechargeModal, type RechargeModalComponent } from "../../components/RechargeModal/loadRechargeModal";
|
||||
import { useGenerationTasks } from "../../hooks/useGenerationTasks";
|
||||
|
||||
import { conversationClient, type ConversationSummary } from "../../api/conversationClient";
|
||||
@@ -270,6 +271,7 @@ function WorkbenchPage({
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [generationStatus, setGenerationStatus] = useState("准备就绪");
|
||||
const [showRechargeModal, setShowRechargeModal] = useState(false);
|
||||
const [RechargeModal, setRechargeModal] = useState<RechargeModalComponent | null>(null);
|
||||
const [savedAssetMentionItems, setSavedAssetMentionItems] = useState<
|
||||
Pick<ReferenceItem, "id" | "kind" | "name" | "previewUrl" | "remoteUrl" | "token">[]
|
||||
>([]);
|
||||
@@ -293,6 +295,21 @@ function WorkbenchPage({
|
||||
activeConversationIdRef.current = activeConversationId;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showRechargeModal || RechargeModal) return;
|
||||
|
||||
let cancelled = false;
|
||||
void loadRechargeModal().then((component) => {
|
||||
if (!cancelled) {
|
||||
setRechargeModal(() => component);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [RechargeModal, showRechargeModal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
let cancelled = false;
|
||||
@@ -376,7 +393,7 @@ function WorkbenchPage({
|
||||
.get()
|
||||
.then((capabilities) => {
|
||||
if (cancelled) return;
|
||||
const nextVideoModels = VIDEO_MODEL_OPTIONS;
|
||||
const nextVideoModels = capabilities.videoModels.length ? capabilities.videoModels : VIDEO_MODEL_OPTIONS;
|
||||
|
||||
applyImageModels(capabilities.imageModels);
|
||||
setVideoModelOptions(nextVideoModels);
|
||||
@@ -3237,7 +3254,9 @@ function WorkbenchPage({
|
||||
|
||||
{renderMessagePreviewOverlay()}
|
||||
{renderDeleteDialog()}
|
||||
<RechargeModal open={showRechargeModal} onClose={() => setShowRechargeModal(false)} currentBalance={session?.user?.balanceCents} />
|
||||
{showRechargeModal && RechargeModal ? (
|
||||
<RechargeModal open={showRechargeModal} onClose={() => setShowRechargeModal(false)} currentBalance={session?.user?.balanceCents} />
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* Persists task state to localStorage so in-progress tasks survive page switches.
|
||||
*/
|
||||
|
||||
import { waitForTask } from "../../api/taskSubscription";
|
||||
|
||||
const KEEPALIVE_PREFIX = "omniai:tool-task:";
|
||||
|
||||
interface ToolTaskKeepalive {
|
||||
@@ -59,38 +61,19 @@ export function clearToolTaskState(key: string): void {
|
||||
try { window.localStorage.removeItem(KEEPALIVE_PREFIX + key); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const TASK_POLL_INTERVAL = 3000;
|
||||
const TASK_POLL_TIMEOUT = 30 * 60 * 1000;
|
||||
|
||||
export async function pollTaskUntilDone(
|
||||
taskId: string,
|
||||
onProgress?: (progress: number) => void,
|
||||
abortRef?: { current: boolean },
|
||||
kind: "image" | "video" = "video",
|
||||
): Promise<string | null> {
|
||||
const startTime = Date.now();
|
||||
const { aiGenerationClient } = await import("../../api/aiGenerationClient");
|
||||
|
||||
while (true) {
|
||||
if (abortRef?.current) return null;
|
||||
if (Date.now() - startTime > TASK_POLL_TIMEOUT) return null;
|
||||
|
||||
try {
|
||||
const task = await aiGenerationClient.getTaskStatus(taskId);
|
||||
if (!task) return null;
|
||||
|
||||
const progress = Math.min(99, task.progress || 0);
|
||||
onProgress?.(progress);
|
||||
|
||||
if (task.status === "completed") {
|
||||
return task.resultUrl || null;
|
||||
}
|
||||
if (task.status === "failed" || task.status === "cancelled") {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
// retry on next poll
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, TASK_POLL_INTERVAL));
|
||||
try {
|
||||
return await waitForTask(taskId, {
|
||||
kind,
|
||||
abortRef,
|
||||
onProgress: (event) => onProgress?.(Math.min(99, Number(event.progress || 0))),
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user