From e921deac741187971fab2ede4ebe2e72d1cdf296 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 19 Sep 2026 14:25:24 +0800 Subject: [PATCH] feat: add list mode for AI-powered batch table generation - Add text/list toggle in prompt panel - List mode: AI analyzes connected images + prompt, outputs structured JSON table - Batch table node renders AI-generated text columns as read-only content - Column names and row count determined by AI (3-6 columns, N rows) --- .../canvas/canvas-batch-table-node.tsx | 1167 +-- .../canvas/canvas-node-prompt-panel.tsx | 2250 +++--- web/src/pages/canvas/list-mode-generator.ts | 204 + web/src/pages/canvas/project.tsx | 6461 +++++++++-------- web/src/types/canvas.ts | 1176 +-- 5 files changed, 5762 insertions(+), 5496 deletions(-) create mode 100644 web/src/pages/canvas/list-mode-generator.ts diff --git a/web/src/components/canvas/canvas-batch-table-node.tsx b/web/src/components/canvas/canvas-batch-table-node.tsx index e5e55cc97..baaa29ce3 100644 --- a/web/src/components/canvas/canvas-batch-table-node.tsx +++ b/web/src/components/canvas/canvas-batch-table-node.tsx @@ -1,580 +1,587 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; -import { Button, Input, Select, Switch, Tooltip } from "antd"; -import { GripVertical, Image as ImageIcon, LoaderCircle, Play, Plus, RefreshCw, Rows3, Trash2, Upload } from "lucide-react"; - -import { CachedResourceImage } from "@/components/cached-resource-image"; -import { BATCH_REFERENCE_HANDLE_GAP, BATCH_REFERENCE_HANDLE_TOP, MAX_BATCH_REFERENCE_COLUMNS, MAX_BATCH_TEXT_COLUMNS, batchGridTemplateColumns, batchPromptForRow, batchReferenceColumns, batchReferenceHandleId, batchTextColumns, batchTextHandleId, batchTextHandleTop } from "@/lib/canvas/canvas-batch-table"; -import type { CanvasTheme } from "@/lib/canvas-theme"; -import type { CanvasBatchOperation, CanvasBatchRow, CanvasBatchTableData, CanvasConnection, CanvasGenerationBatch, CanvasGenerationBatchItem, CanvasNodeData } from "@/types/canvas"; - -type Props = { - node: CanvasNodeData; - nodes: CanvasNodeData[]; - connections: CanvasConnection[]; - batch?: CanvasGenerationBatch; - theme: CanvasTheme; - onPatchTable: (patch: Partial) => void; - onAddRow: () => void; - onRemoveRow: (rowId: string) => void; - onUpdateRow: (rowId: string, patch: Partial) => void; - onFillRows: () => void; - onGenerate: (rowIds?: string[]) => void; - onRetryItem: (batchId: string, itemId: string) => void; - onAddReferenceColumn: () => void; - onAddTextColumn: () => void; - onReorderReferenceColumns: (fromColumnId: string, toColumnId: string) => void; - onMoveReferenceCell: (sourceRowId: string, sourceColumnIndex: number, targetRowId: string, targetColumnIndex: number) => void; - onReplaceReference: (node: CanvasNodeData) => void; - onUploadReference: (rowId: string, columnIndex: number, file: File) => void; - onConnectStart: (event: ReactPointerEvent, handleId: string) => void; - onConnectDrop?: (event: ReactPointerEvent, handleId: string) => void; - readOnly?: boolean; -}; - -const OPERATION_OPTIONS = [ - { value: "try_on", label: "批量换装" }, - { value: "creative", label: "创意生图" }, -] satisfies Array<{ value: CanvasBatchOperation; label: string }>; - -type ReferenceCell = { rowId: string; columnIndex: number }; - -export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, theme, onPatchTable, onAddRow, onRemoveRow, onUpdateRow, onFillRows, onGenerate, onRetryItem, onAddReferenceColumn, onAddTextColumn, onReorderReferenceColumns, onMoveReferenceCell, onReplaceReference, onUploadReference, onConnectStart, onConnectDrop, readOnly = false }: Props) { - const table = node.metadata?.batchTable || { operation: "try_on" as const, concurrency: 10, rows: [] }; - const referenceColumns = batchReferenceColumns(table); - const textColumns = batchTextColumns(table); - const globalPrompt = table.globalPrompt || ""; - const hasGlobalPrompt = Boolean(globalPrompt.trim()); - const nodeById = useMemo(() => new Map(nodes.map((item) => [item.id, item])), [nodes]); - const batchItemByRowId = useMemo(() => new Map((batch?.items || []).map((item) => [item.rowId, item])), [batch?.items]); - const connectedImageCount = useMemo(() => new Set(connections.filter((connection) => connection.toNodeId === node.id).map((connection) => connection.fromNodeId)).size, [connections, node.id]); - const completed = table.rows.filter((row) => Boolean(row.outputNodeId && nodeById.get(row.outputNodeId)?.metadata?.content)).length; - const gridTemplateColumns = batchGridTemplateColumns(referenceColumns.length, textColumns.length); - const [draggingColumnId, setDraggingColumnId] = useState(null); - const [draggingCell, setDraggingCell] = useState(null); - const [dropCell, setDropCell] = useState(null); - const draggingCellRef = useRef(null); - const dropCellRef = useRef(null); - const dragStartRef = useRef<{ x: number; y: number; pointerId: number; cell: ReferenceCell } | null>(null); - const lastPointerRef = useRef({ x: 0, y: 0 }); - const suppressClickRef = useRef(false); - const uploadTargetRef = useRef<{ rowId: string; columnIndex: number } | null>(null); - const uploadInputRef = useRef(null); - - const clearReferenceDrag = useCallback(() => { - dragStartRef.current = null; - draggingCellRef.current = null; - dropCellRef.current = null; - setDraggingCell(null); - setDropCell(null); - }, []); - - const findReferenceCellAtPoint = useCallback((clientX: number, clientY: number) => { - const hit = document.elementsFromPoint(clientX, clientY) - .map((element) => element.closest("[data-batch-reference-cell]")) - .find((element): element is HTMLElement => Boolean(element)); - if (!hit) return null; - const rowId = hit.dataset.batchRowId; - const columnIndex = Number(hit.dataset.batchColumnIndex); - return rowId && Number.isInteger(columnIndex) ? { rowId, columnIndex } : null; - }, []); - - const updateReferenceDrag = useCallback((clientX: number, clientY: number) => { - const start = dragStartRef.current; - if (!start || readOnly) return; - const distance = Math.hypot(clientX - start.x, clientY - start.y); - if (!draggingCellRef.current && distance < 5) return; - if (!draggingCellRef.current) { - draggingCellRef.current = start.cell; - setDraggingCell(start.cell); - suppressClickRef.current = true; - } - const nextDropCell = findReferenceCellAtPoint(clientX, clientY); - dropCellRef.current = nextDropCell; - setDropCell(nextDropCell); - }, [findReferenceCellAtPoint, readOnly]); - - const finishReferenceDrag = useCallback((clientX: number, clientY: number, cancel = false) => { - const start = dragStartRef.current; - if (!start) return; - const sourceCell = draggingCellRef.current; - const targetCell = cancel ? null : dropCellRef.current || findReferenceCellAtPoint(clientX, clientY); - if (sourceCell && targetCell) { - onMoveReferenceCell(sourceCell.rowId, sourceCell.columnIndex, targetCell.rowId, targetCell.columnIndex); - } - clearReferenceDrag(); - window.setTimeout(() => { suppressClickRef.current = false; }, 0); - }, [clearReferenceDrag, findReferenceCellAtPoint, onMoveReferenceCell]); - - const cancelReferenceDrag = useCallback(() => { - if (!dragStartRef.current) return; - clearReferenceDrag(); - window.setTimeout(() => { suppressClickRef.current = false; }, 0); - }, [clearReferenceDrag]); - - useEffect(() => { - const handleWindowPointerMove = (event: PointerEvent) => { - const start = dragStartRef.current; - if (!start || event.pointerId !== start.pointerId) return; - const distance = Math.hypot(event.clientX - start.x, event.clientY - start.y); - if (distance >= 5) event.preventDefault(); - updateReferenceDrag(event.clientX, event.clientY); - }; - const handleWindowPointerUp = (event: PointerEvent) => { - const start = dragStartRef.current; - if (!start || event.pointerId !== start.pointerId) return; - finishReferenceDrag(event.clientX, event.clientY); - }; - const handleWindowPointerCancel = (event: PointerEvent) => { - const start = dragStartRef.current; - if (!start || event.pointerId !== start.pointerId) return; - cancelReferenceDrag(); - }; - const handleWindowMouseUp = () => { - if (!dragStartRef.current) return; - finishReferenceDrag(lastPointerRef.current.x, lastPointerRef.current.y); - }; - const handleWindowBlur = () => cancelReferenceDrag(); - window.addEventListener("pointermove", handleWindowPointerMove, { passive: false }); - window.addEventListener("pointerup", handleWindowPointerUp, true); - window.addEventListener("pointercancel", handleWindowPointerCancel, true); - window.addEventListener("mouseup", handleWindowMouseUp, true); - window.addEventListener("blur", handleWindowBlur); - return () => { - window.removeEventListener("pointermove", handleWindowPointerMove); - window.removeEventListener("pointerup", handleWindowPointerUp, true); - window.removeEventListener("pointercancel", handleWindowPointerCancel, true); - window.removeEventListener("mouseup", handleWindowMouseUp, true); - window.removeEventListener("blur", handleWindowBlur); - }; - }, [cancelReferenceDrag, finishReferenceDrag, updateReferenceDrag]); - - const handleReferencePointerDown = useCallback((event: ReactPointerEvent, rowId: string, columnIndex: number, hasMaterial: boolean) => { - if (readOnly || !hasMaterial) return; - event.preventDefault(); - event.stopPropagation(); - event.currentTarget.setPointerCapture(event.pointerId); - lastPointerRef.current = { x: event.clientX, y: event.clientY }; - dragStartRef.current = { x: event.clientX, y: event.clientY, pointerId: event.pointerId, cell: { rowId, columnIndex } }; - suppressClickRef.current = false; - }, [readOnly]); - - const handleReferencePointerMove = useCallback((event: ReactPointerEvent) => { - event.stopPropagation(); - lastPointerRef.current = { x: event.clientX, y: event.clientY }; - updateReferenceDrag(event.clientX, event.clientY); - }, [updateReferenceDrag]); - - const handleReferencePointerUp = useCallback((event: ReactPointerEvent) => { - event.stopPropagation(); - if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); - finishReferenceDrag(event.clientX, event.clientY); - }, [finishReferenceDrag]); - - const handleReferencePointerCancel = useCallback((event: ReactPointerEvent) => { - event.stopPropagation(); - if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); - cancelReferenceDrag(); - }, [cancelReferenceDrag]); - - return ( -
event.stopPropagation()} - onMouseDown={(event) => { - // Only the top padding is a node drag surface; keep child controls isolated. - if (event.target !== event.currentTarget) event.stopPropagation(); - }} - > - { - const file = event.currentTarget.files?.[0]; - const target = uploadTargetRef.current; - uploadTargetRef.current = null; - event.currentTarget.value = ""; - if (file && target) onUploadReference(target.rowId, target.columnIndex, file); - }} - /> - {!readOnly ? : null} -
event.stopPropagation()} - > - ({ value, label: String(value) }))} - disabled={readOnly} - onChange={(concurrency) => onPatchTable({ concurrency })} - className="w-20" - aria-label="并发数" - /> - - {referenceColumns.length} 组参考 · 已连 {connectedImageCount} 张图 · 完成 {completed}/{table.rows.length} - - {!readOnly ? ( -
- 全局提示词 - onPatchTable({ globalPrompt: event.target.value })} - /> - {hasGlobalPrompt ? 已覆盖全部 : null} -
- ) : null} - {!readOnly ? ( -
- - - -
- ) : null} -
- -
event.stopPropagation()}> -
- # - 任务 - {referenceColumns.map((column) => ( - setDraggingColumnId(column.id)} onDragEnd={() => setDraggingColumnId(null)} onDrop={(targetColumnId) => { if (draggingColumnId) onReorderReferenceColumns(draggingColumnId, targetColumnId); setDraggingColumnId(null); }} /> - ))} - {textColumns.map((column, index) => ( - {column.label} - ))} - 任务提示词 - 生成结果 - -
- {table.rows.length ? ( - table.rows.map((row, index) => { - const output = row.outputNodeId ? nodeById.get(row.outputNodeId) : undefined; - const item = batchItemByRowId.get(row.id); - const status = rowStatus(item, output); - return ( -
- {index + 1} -
- onUpdateRow(row.id, { enabled })} aria-label={`任务 ${index + 1}`} /> - {row.enabled === false ? "关" : "开"} -
- {referenceColumns.map((column, columnIndex) => ( - { uploadTargetRef.current = { rowId: row.id, columnIndex }; uploadInputRef.current?.click(); }} - onPointerDown={handleReferencePointerDown} - onPointerMove={handleReferencePointerMove} - onPointerUp={handleReferencePointerUp} - onPointerCancel={handleReferencePointerCancel} - onLostPointerCapture={handleReferencePointerCancel} - /> - ))} - {textColumns.map((column, columnIndex) => ( -
- -
- ))} - onUpdateRow(row.id, { prompt: event.target.value })} /> -
- {output?.metadata?.content || output?.metadata?.storageKey ? ( - } - /> - ) : ( - - )} -
-
{status.label}
- {!readOnly && status.retryable && batch && item ? ( - - ) : null} -
- {status.loading ? : null} -
- {!readOnly ? ( - -
- ); - }) - ) : ( -
连接图片后会自动生成任务;点击缩略图可替换素材,拖动参考图列可调整顺序。
- )} -
-
- ); -} - -function ReferenceColumnHeader({ column, theme, readOnly, dragging, onDragStart, onDragEnd, onDrop }: { column: { id: string; label: string }; theme: CanvasTheme; readOnly: boolean; dragging: boolean; onDragStart: () => void; onDragEnd: () => void; onDrop: (columnId: string) => void }) { - return ( - { - event.dataTransfer.effectAllowed = "move"; - event.dataTransfer.setData("text/plain", column.id); - onDragStart(); - }} - onDragEnd={onDragEnd} - onDragOver={(event) => { if (!readOnly) { event.preventDefault(); event.dataTransfer.dropEffect = "move"; } }} - onDrop={(event) => { event.preventDefault(); onDrop(column.id); }} - > - {!readOnly ? : null} - {column.label} - - ); -} - -function BatchTextHandle(props: Omit, "column"> & { column: { id: string; label: string } }) { - return ; -} - -function BatchReferenceHandles({ - columns, - textColumns, - theme, - onAdd, - onAddText, - onConnectStart, - onConnectDrop, -}: { - columns: ReturnType; - textColumns: ReturnType; - theme: CanvasTheme; - onAdd: () => void; - onAddText: () => void; - onConnectStart: (event: ReactPointerEvent, handleId: string) => void; - onConnectDrop?: (event: ReactPointerEvent, handleId: string) => void; -}) { - const commonStyle = { left: 0, width: 44, height: 44, transform: "translate(-50%, -50%)", transformOrigin: "center" }; - const referenceAddTop = BATCH_REFERENCE_HANDLE_TOP + columns.length * BATCH_REFERENCE_HANDLE_GAP; - const textTop = batchTextHandleTop(columns.length); - return ( - <> - {columns.map((column, index) => ( - - ))} - {textColumns.map((column, index) => ( - - ))} - {columns.length < MAX_BATCH_REFERENCE_COLUMNS ? ( - - - - ) : null} - {textColumns.length < MAX_BATCH_TEXT_COLUMNS ? ( - - - - ) : null} - - ); -} - -function BatchReferenceHandle({ - column, - top, - badge, - theme, - commonStyle, - onConnectStart, - onConnectDrop, - handlePrefix = "reference", -}: { - column: { id: string; label: string }; - top: number; - badge: string; - theme: CanvasTheme; - commonStyle: { left: number; width: number; height: number; transform: string; transformOrigin: string }; - onConnectStart: (event: ReactPointerEvent, handleId: string) => void; - onConnectDrop?: (event: ReactPointerEvent, handleId: string) => void; - handlePrefix?: "reference" | "text"; -}) { - const [hovered, setHovered] = useState(false); - const [offset, setOffset] = useState({ x: 0, y: 0 }); - const handleId = handlePrefix === "text" ? batchTextHandleId(column.id) : batchReferenceHandleId(column.id); - const reset = useCallback(() => { - setHovered(false); - setOffset({ x: 0, y: 0 }); - }, []); - const update = useCallback((event: ReactPointerEvent) => { - const bounds = event.currentTarget.getBoundingClientRect(); - const dx = event.clientX - (bounds.left + bounds.width / 2); - const dy = event.clientY - (bounds.top + bounds.height / 2); - const limit = 16; - setOffset({ x: Math.max(-limit, Math.min(limit, dx)), y: Math.max(-limit, Math.min(limit, dy)) }); - }, []); - return ( - - - - ); -} -function TextNodeCell({ node, theme }: { node?: CanvasNodeData; theme: CanvasTheme }) { - const content = node?.metadata?.content || node?.metadata?.prompt; - return
{content || "未连接文字"}
; -} - -function ReferenceThumbnail({ node, theme, readOnly, rowId, columnIndex, columnId, draggingColumnId, isDraggingCell, isDropTarget, suppressClickRef, onReplace, onUpload, onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onLostPointerCapture }: { - node?: CanvasNodeData; - theme: CanvasTheme; - readOnly: boolean; - rowId: string; - columnIndex: number; - columnId: string; - draggingColumnId: string | null; - isDraggingCell: boolean; - isDropTarget: boolean; - suppressClickRef: MutableRefObject; - onReplace: (node: CanvasNodeData) => void; - onUpload: () => void; - onPointerDown: (event: ReactPointerEvent, rowId: string, columnIndex: number, hasMaterial: boolean) => void; - onPointerMove: (event: ReactPointerEvent) => void; - onPointerUp: (event: ReactPointerEvent) => void; - onPointerCancel: (event: ReactPointerEvent) => void; - onLostPointerCapture: (event: ReactPointerEvent) => void; -}) { - const cellData = { "data-batch-reference-cell": true, "data-batch-row-id": rowId, "data-batch-column-index": String(columnIndex) }; - const dropStyle = isDropTarget ? { boxShadow: `0 0 0 2px ${theme.accent.primary}, 0 0 0 5px ${theme.accent.primary}33` } : undefined; - if (!node || (!node.metadata?.content && !node.metadata?.storageKey)) { - return ( - - - - ); - } - const fallback = ; - return ( - - - - ); -} - -function EmptyThumbnail({ theme, sizeClass = "size-12" }: { theme: CanvasTheme; sizeClass?: string }): ReactNode { - return ( -
- -
- ); -} - -function rowStatus(item: CanvasGenerationBatchItem | undefined, output: CanvasNodeData | undefined) { - if (output?.metadata?.content) return { label: "生成完成", loading: false, retryable: false }; - if (item?.status === "failed") return { label: item.errorDetails || "生成失败", loading: false, retryable: true }; - if (item?.status === "cancelled") return { label: "已停止", loading: false, retryable: false }; - if (item && ["waiting", "submitting", "queued", "running"].includes(item.status)) - return { label: item.status === "waiting" ? "等待中" : item.status === "submitting" ? "正在提交" : item.status === "queued" ? "已排队" : "生成中", loading: true, retryable: false }; - if (output?.metadata?.status === "error") return { label: output.metadata.errorDetails || "生成失败", loading: false, retryable: false }; - return { label: "待生成", loading: false, retryable: false }; -} +import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; +import { Button, Input, Select, Switch, Tooltip } from "antd"; +import { GripVertical, Image as ImageIcon, LoaderCircle, Play, Plus, RefreshCw, Rows3, Trash2, Upload } from "lucide-react"; + +import { CachedResourceImage } from "@/components/cached-resource-image"; +import { BATCH_REFERENCE_HANDLE_GAP, BATCH_REFERENCE_HANDLE_TOP, MAX_BATCH_REFERENCE_COLUMNS, MAX_BATCH_TEXT_COLUMNS, batchGridTemplateColumns, batchPromptForRow, batchReferenceColumns, batchReferenceHandleId, batchTextColumns, batchTextHandleId, batchTextHandleTop } from "@/lib/canvas/canvas-batch-table"; +import type { CanvasTheme } from "@/lib/canvas-theme"; +import type { CanvasBatchOperation, CanvasBatchRow, CanvasBatchTableData, CanvasConnection, CanvasGenerationBatch, CanvasGenerationBatchItem, CanvasNodeData } from "@/types/canvas"; + +type Props = { + node: CanvasNodeData; + nodes: CanvasNodeData[]; + connections: CanvasConnection[]; + batch?: CanvasGenerationBatch; + theme: CanvasTheme; + onPatchTable: (patch: Partial) => void; + onAddRow: () => void; + onRemoveRow: (rowId: string) => void; + onUpdateRow: (rowId: string, patch: Partial) => void; + onFillRows: () => void; + onGenerate: (rowIds?: string[]) => void; + onRetryItem: (batchId: string, itemId: string) => void; + onAddReferenceColumn: () => void; + onAddTextColumn: () => void; + onReorderReferenceColumns: (fromColumnId: string, toColumnId: string) => void; + onMoveReferenceCell: (sourceRowId: string, sourceColumnIndex: number, targetRowId: string, targetColumnIndex: number) => void; + onReplaceReference: (node: CanvasNodeData) => void; + onUploadReference: (rowId: string, columnIndex: number, file: File) => void; + onConnectStart: (event: ReactPointerEvent, handleId: string) => void; + onConnectDrop?: (event: ReactPointerEvent, handleId: string) => void; + readOnly?: boolean; +}; + +const OPERATION_OPTIONS = [ + { value: "try_on", label: "批量换装" }, + { value: "creative", label: "创意生图" }, +] satisfies Array<{ value: CanvasBatchOperation; label: string }>; + +type ReferenceCell = { rowId: string; columnIndex: number }; + +export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, theme, onPatchTable, onAddRow, onRemoveRow, onUpdateRow, onFillRows, onGenerate, onRetryItem, onAddReferenceColumn, onAddTextColumn, onReorderReferenceColumns, onMoveReferenceCell, onReplaceReference, onUploadReference, onConnectStart, onConnectDrop, readOnly = false }: Props) { + const table = node.metadata?.batchTable || { operation: "try_on" as const, concurrency: 10, rows: [] }; + const referenceColumns = batchReferenceColumns(table); + const textColumns = batchTextColumns(table); + const globalPrompt = table.globalPrompt || ""; + const hasGlobalPrompt = Boolean(globalPrompt.trim()); + const nodeById = useMemo(() => new Map(nodes.map((item) => [item.id, item])), [nodes]); + const batchItemByRowId = useMemo(() => new Map((batch?.items || []).map((item) => [item.rowId, item])), [batch?.items]); + const connectedImageCount = useMemo(() => new Set(connections.filter((connection) => connection.toNodeId === node.id).map((connection) => connection.fromNodeId)).size, [connections, node.id]); + const completed = table.rows.filter((row) => Boolean(row.outputNodeId && nodeById.get(row.outputNodeId)?.metadata?.content)).length; + const isAiTable = Boolean(table.aiGenerated); + const gridTemplateColumns = batchGridTemplateColumns(referenceColumns.length, textColumns.length); + const [draggingColumnId, setDraggingColumnId] = useState(null); + const [draggingCell, setDraggingCell] = useState(null); + const [dropCell, setDropCell] = useState(null); + const draggingCellRef = useRef(null); + const dropCellRef = useRef(null); + const dragStartRef = useRef<{ x: number; y: number; pointerId: number; cell: ReferenceCell } | null>(null); + const lastPointerRef = useRef({ x: 0, y: 0 }); + const suppressClickRef = useRef(false); + const uploadTargetRef = useRef<{ rowId: string; columnIndex: number } | null>(null); + const uploadInputRef = useRef(null); + + const clearReferenceDrag = useCallback(() => { + dragStartRef.current = null; + draggingCellRef.current = null; + dropCellRef.current = null; + setDraggingCell(null); + setDropCell(null); + }, []); + + const findReferenceCellAtPoint = useCallback((clientX: number, clientY: number) => { + const hit = document.elementsFromPoint(clientX, clientY) + .map((element) => element.closest("[data-batch-reference-cell]")) + .find((element): element is HTMLElement => Boolean(element)); + if (!hit) return null; + const rowId = hit.dataset.batchRowId; + const columnIndex = Number(hit.dataset.batchColumnIndex); + return rowId && Number.isInteger(columnIndex) ? { rowId, columnIndex } : null; + }, []); + + const updateReferenceDrag = useCallback((clientX: number, clientY: number) => { + const start = dragStartRef.current; + if (!start || readOnly) return; + const distance = Math.hypot(clientX - start.x, clientY - start.y); + if (!draggingCellRef.current && distance < 5) return; + if (!draggingCellRef.current) { + draggingCellRef.current = start.cell; + setDraggingCell(start.cell); + suppressClickRef.current = true; + } + const nextDropCell = findReferenceCellAtPoint(clientX, clientY); + dropCellRef.current = nextDropCell; + setDropCell(nextDropCell); + }, [findReferenceCellAtPoint, readOnly]); + + const finishReferenceDrag = useCallback((clientX: number, clientY: number, cancel = false) => { + const start = dragStartRef.current; + if (!start) return; + const sourceCell = draggingCellRef.current; + const targetCell = cancel ? null : dropCellRef.current || findReferenceCellAtPoint(clientX, clientY); + if (sourceCell && targetCell) { + onMoveReferenceCell(sourceCell.rowId, sourceCell.columnIndex, targetCell.rowId, targetCell.columnIndex); + } + clearReferenceDrag(); + window.setTimeout(() => { suppressClickRef.current = false; }, 0); + }, [clearReferenceDrag, findReferenceCellAtPoint, onMoveReferenceCell]); + + const cancelReferenceDrag = useCallback(() => { + if (!dragStartRef.current) return; + clearReferenceDrag(); + window.setTimeout(() => { suppressClickRef.current = false; }, 0); + }, [clearReferenceDrag]); + + useEffect(() => { + const handleWindowPointerMove = (event: PointerEvent) => { + const start = dragStartRef.current; + if (!start || event.pointerId !== start.pointerId) return; + const distance = Math.hypot(event.clientX - start.x, event.clientY - start.y); + if (distance >= 5) event.preventDefault(); + updateReferenceDrag(event.clientX, event.clientY); + }; + const handleWindowPointerUp = (event: PointerEvent) => { + const start = dragStartRef.current; + if (!start || event.pointerId !== start.pointerId) return; + finishReferenceDrag(event.clientX, event.clientY); + }; + const handleWindowPointerCancel = (event: PointerEvent) => { + const start = dragStartRef.current; + if (!start || event.pointerId !== start.pointerId) return; + cancelReferenceDrag(); + }; + const handleWindowMouseUp = () => { + if (!dragStartRef.current) return; + finishReferenceDrag(lastPointerRef.current.x, lastPointerRef.current.y); + }; + const handleWindowBlur = () => cancelReferenceDrag(); + window.addEventListener("pointermove", handleWindowPointerMove, { passive: false }); + window.addEventListener("pointerup", handleWindowPointerUp, true); + window.addEventListener("pointercancel", handleWindowPointerCancel, true); + window.addEventListener("mouseup", handleWindowMouseUp, true); + window.addEventListener("blur", handleWindowBlur); + return () => { + window.removeEventListener("pointermove", handleWindowPointerMove); + window.removeEventListener("pointerup", handleWindowPointerUp, true); + window.removeEventListener("pointercancel", handleWindowPointerCancel, true); + window.removeEventListener("mouseup", handleWindowMouseUp, true); + window.removeEventListener("blur", handleWindowBlur); + }; + }, [cancelReferenceDrag, finishReferenceDrag, updateReferenceDrag]); + + const handleReferencePointerDown = useCallback((event: ReactPointerEvent, rowId: string, columnIndex: number, hasMaterial: boolean) => { + if (readOnly || !hasMaterial) return; + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + lastPointerRef.current = { x: event.clientX, y: event.clientY }; + dragStartRef.current = { x: event.clientX, y: event.clientY, pointerId: event.pointerId, cell: { rowId, columnIndex } }; + suppressClickRef.current = false; + }, [readOnly]); + + const handleReferencePointerMove = useCallback((event: ReactPointerEvent) => { + event.stopPropagation(); + lastPointerRef.current = { x: event.clientX, y: event.clientY }; + updateReferenceDrag(event.clientX, event.clientY); + }, [updateReferenceDrag]); + + const handleReferencePointerUp = useCallback((event: ReactPointerEvent) => { + event.stopPropagation(); + if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); + finishReferenceDrag(event.clientX, event.clientY); + }, [finishReferenceDrag]); + + const handleReferencePointerCancel = useCallback((event: ReactPointerEvent) => { + event.stopPropagation(); + if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); + cancelReferenceDrag(); + }, [cancelReferenceDrag]); + + return ( +
event.stopPropagation()} + onMouseDown={(event) => { + // Only the top padding is a node drag surface; keep child controls isolated. + if (event.target !== event.currentTarget) event.stopPropagation(); + }} + > + { + const file = event.currentTarget.files?.[0]; + const target = uploadTargetRef.current; + uploadTargetRef.current = null; + event.currentTarget.value = ""; + if (file && target) onUploadReference(target.rowId, target.columnIndex, file); + }} + /> + {!readOnly ? : null} +
event.stopPropagation()} + > + ({ value, label: String(value) }))} + disabled={readOnly} + onChange={(concurrency) => onPatchTable({ concurrency })} + className="w-20" + aria-label="并发数" + /> + + {referenceColumns.length} 组参考 · 已连 {connectedImageCount} 张图 · 完成 {completed}/{table.rows.length} + + {!readOnly ? ( +
+ 全局提示词 + onPatchTable({ globalPrompt: event.target.value })} + /> + {hasGlobalPrompt ? 已覆盖全部 : null} +
+ ) : null} + {!readOnly ? ( +
+ + + +
+ ) : null} +
+ +
event.stopPropagation()}> +
+ # + 任务 + {referenceColumns.map((column) => ( + setDraggingColumnId(column.id)} onDragEnd={() => setDraggingColumnId(null)} onDrop={(targetColumnId) => { if (draggingColumnId) onReorderReferenceColumns(draggingColumnId, targetColumnId); setDraggingColumnId(null); }} /> + ))} + {textColumns.map((column, index) => ( + {column.label} + ))} + 任务提示词 + 生成结果 + +
+ {table.rows.length ? ( + table.rows.map((row, index) => { + const output = row.outputNodeId ? nodeById.get(row.outputNodeId) : undefined; + const item = batchItemByRowId.get(row.id); + const status = rowStatus(item, output); + return ( +
+ {index + 1} +
+ onUpdateRow(row.id, { enabled })} aria-label={`任务 ${index + 1}`} /> + {row.enabled === false ? "关" : "开"} +
+ {referenceColumns.map((column, columnIndex) => ( + { uploadTargetRef.current = { rowId: row.id, columnIndex }; uploadInputRef.current?.click(); }} + onPointerDown={handleReferencePointerDown} + onPointerMove={handleReferencePointerMove} + onPointerUp={handleReferencePointerUp} + onPointerCancel={handleReferencePointerCancel} + onLostPointerCapture={handleReferencePointerCancel} + /> + ))} + {textColumns.map((column, columnIndex) => ( +
+ {table.aiGenerated ? ( +
+ {row.cells?.[column.id] || ""} +
+ ) : ( + + )} +
+ ))} + onUpdateRow(row.id, { prompt: event.target.value })} /> +
+ {output?.metadata?.content || output?.metadata?.storageKey ? ( + } + /> + ) : ( + + )} +
+
{status.label}
+ {!readOnly && status.retryable && batch && item ? ( + + ) : null} +
+ {status.loading ? : null} +
+ {!readOnly ? ( + +
+ ); + }) + ) : ( +
连接图片后会自动生成任务;点击缩略图可替换素材,拖动参考图列可调整顺序。
+ )} +
+
+ ); +} + +function ReferenceColumnHeader({ column, theme, readOnly, dragging, onDragStart, onDragEnd, onDrop }: { column: { id: string; label: string }; theme: CanvasTheme; readOnly: boolean; dragging: boolean; onDragStart: () => void; onDragEnd: () => void; onDrop: (columnId: string) => void }) { + return ( + { + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", column.id); + onDragStart(); + }} + onDragEnd={onDragEnd} + onDragOver={(event) => { if (!readOnly) { event.preventDefault(); event.dataTransfer.dropEffect = "move"; } }} + onDrop={(event) => { event.preventDefault(); onDrop(column.id); }} + > + {!readOnly ? : null} + {column.label} + + ); +} + +function BatchTextHandle(props: Omit, "column"> & { column: { id: string; label: string } }) { + return ; +} + +function BatchReferenceHandles({ + columns, + textColumns, + theme, + onAdd, + onAddText, + onConnectStart, + onConnectDrop, +}: { + columns: ReturnType; + textColumns: ReturnType; + theme: CanvasTheme; + onAdd: () => void; + onAddText: () => void; + onConnectStart: (event: ReactPointerEvent, handleId: string) => void; + onConnectDrop?: (event: ReactPointerEvent, handleId: string) => void; +}) { + const commonStyle = { left: 0, width: 44, height: 44, transform: "translate(-50%, -50%)", transformOrigin: "center" }; + const referenceAddTop = BATCH_REFERENCE_HANDLE_TOP + columns.length * BATCH_REFERENCE_HANDLE_GAP; + const textTop = batchTextHandleTop(columns.length); + return ( + <> + {columns.map((column, index) => ( + + ))} + {textColumns.map((column, index) => ( + + ))} + {columns.length < MAX_BATCH_REFERENCE_COLUMNS ? ( + + + + ) : null} + {textColumns.length < MAX_BATCH_TEXT_COLUMNS ? ( + + + + ) : null} + + ); +} + +function BatchReferenceHandle({ + column, + top, + badge, + theme, + commonStyle, + onConnectStart, + onConnectDrop, + handlePrefix = "reference", +}: { + column: { id: string; label: string }; + top: number; + badge: string; + theme: CanvasTheme; + commonStyle: { left: number; width: number; height: number; transform: string; transformOrigin: string }; + onConnectStart: (event: ReactPointerEvent, handleId: string) => void; + onConnectDrop?: (event: ReactPointerEvent, handleId: string) => void; + handlePrefix?: "reference" | "text"; +}) { + const [hovered, setHovered] = useState(false); + const [offset, setOffset] = useState({ x: 0, y: 0 }); + const handleId = handlePrefix === "text" ? batchTextHandleId(column.id) : batchReferenceHandleId(column.id); + const reset = useCallback(() => { + setHovered(false); + setOffset({ x: 0, y: 0 }); + }, []); + const update = useCallback((event: ReactPointerEvent) => { + const bounds = event.currentTarget.getBoundingClientRect(); + const dx = event.clientX - (bounds.left + bounds.width / 2); + const dy = event.clientY - (bounds.top + bounds.height / 2); + const limit = 16; + setOffset({ x: Math.max(-limit, Math.min(limit, dx)), y: Math.max(-limit, Math.min(limit, dy)) }); + }, []); + return ( + + + + ); +} +function TextNodeCell({ node, theme }: { node?: CanvasNodeData; theme: CanvasTheme }) { + const content = node?.metadata?.content || node?.metadata?.prompt; + return
{content || "未连接文字"}
; +} + +function ReferenceThumbnail({ node, theme, readOnly, rowId, columnIndex, columnId, draggingColumnId, isDraggingCell, isDropTarget, suppressClickRef, onReplace, onUpload, onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onLostPointerCapture }: { + node?: CanvasNodeData; + theme: CanvasTheme; + readOnly: boolean; + rowId: string; + columnIndex: number; + columnId: string; + draggingColumnId: string | null; + isDraggingCell: boolean; + isDropTarget: boolean; + suppressClickRef: MutableRefObject; + onReplace: (node: CanvasNodeData) => void; + onUpload: () => void; + onPointerDown: (event: ReactPointerEvent, rowId: string, columnIndex: number, hasMaterial: boolean) => void; + onPointerMove: (event: ReactPointerEvent) => void; + onPointerUp: (event: ReactPointerEvent) => void; + onPointerCancel: (event: ReactPointerEvent) => void; + onLostPointerCapture: (event: ReactPointerEvent) => void; +}) { + const cellData = { "data-batch-reference-cell": true, "data-batch-row-id": rowId, "data-batch-column-index": String(columnIndex) }; + const dropStyle = isDropTarget ? { boxShadow: `0 0 0 2px ${theme.accent.primary}, 0 0 0 5px ${theme.accent.primary}33` } : undefined; + if (!node || (!node.metadata?.content && !node.metadata?.storageKey)) { + return ( + + + + ); + } + const fallback = ; + return ( + + + + ); +} + +function EmptyThumbnail({ theme, sizeClass = "size-12" }: { theme: CanvasTheme; sizeClass?: string }): ReactNode { + return ( +
+ +
+ ); +} + +function rowStatus(item: CanvasGenerationBatchItem | undefined, output: CanvasNodeData | undefined) { + if (output?.metadata?.content) return { label: "生成完成", loading: false, retryable: false }; + if (item?.status === "failed") return { label: item.errorDetails || "生成失败", loading: false, retryable: true }; + if (item?.status === "cancelled") return { label: "已停止", loading: false, retryable: false }; + if (item && ["waiting", "submitting", "queued", "running"].includes(item.status)) + return { label: item.status === "waiting" ? "等待中" : item.status === "submitting" ? "正在提交" : item.status === "queued" ? "已排队" : "生成中", loading: true, retryable: false }; + if (output?.metadata?.status === "error") return { label: output.metadata.errorDetails || "生成失败", loading: false, retryable: false }; + return { label: "待生成", loading: false, retryable: false }; +} diff --git a/web/src/components/canvas/canvas-node-prompt-panel.tsx b/web/src/components/canvas/canvas-node-prompt-panel.tsx index 03a01ff0d..285e8c9e6 100644 --- a/web/src/components/canvas/canvas-node-prompt-panel.tsx +++ b/web/src/components/canvas/canvas-node-prompt-panel.tsx @@ -1,1109 +1,1141 @@ -import { Button, Image as AntImage, InputNumber, Modal, Popover } from "antd"; -import { Tooltip } from "@/components/ui/base/tooltip"; -import { useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; -import { ArrowLeftRight, ArrowUp, AtSign, Boxes, Camera, ChevronDown, FileText, GripVertical, ImageIcon, ImagePlus, Link2, LoaderCircle, Maximize2, Music2, Pencil, SlidersHorizontal, UserRound, Video, WandSparkles, X } from "lucide-react"; - -import { ModelPicker } from "@/components/model-picker"; -import { defaultConfig, modelOptionName, resolveModelChannel, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; -import { resolveCanvasGenerationModel } from "@/lib/canvas/canvas-project-generation"; -import { clampPromptEditorModalSize, PROMPT_EDITOR_VIEWPORT_MARGIN } from "@/lib/canvas/canvas-prompt-editor-size"; -import { CreditSymbol, requestCreditCost } from "@/constant/credits"; -import { canvasThemes } from "@/lib/canvas-theme"; -import { modelQuoteRequest } from "@/lib/model-pricing"; -import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options"; -import { modelRequestOptions, resolveCompatibleModel, resolveModelGenerationDefaults, defaultImageParamsForModel, type ModelRequirements } from "@/lib/model-selection"; -import { navigateToSettings } from "@/lib/settings-navigation"; -import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store"; -import { useUserStore } from "@/stores/use-user-store"; -import { CanvasCameraControlPopover } from "./canvas-camera-control-popover"; -import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover"; -import { CanvasAudioSettingsPopover, type CanvasAudioSettingKey } from "./canvas-audio-settings-popover"; -import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea"; -import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover"; -import { CanvasVideoPromptTools } from "./canvas-video-prompt-tools"; -import { CanvasPresetPicker, type CanvasPromptPreset } from "./canvas-preset-picker"; -import { CanvasPortraitTexturePopover } from "./canvas-portrait-texture-popover"; -import { CanvasPromptOptimizerDrawer } from "./canvas-prompt-optimizer-drawer"; -import { CanvasNodeType, type CanvasGenerationMode, type CanvasNodeData, type CanvasNodeMetadata, type CanvasWorkspaceMode } from "@/types/canvas"; -import { autoMentionCanvasResourceReferences, canvasResourceMentionToken, normalizeCanvasNodeMentionTokens, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; -import { promptOptimizerPlugin, PROMPT_OPTIMIZER_PLUGIN_ID } from "@/lib/plugins/builtin/prompt-optimizer"; -import { createPluginHostContext } from "@/services/plugin-host"; -import { usePluginStore } from "@/stores/use-plugin-store"; -import { useResolvedCanvasResourceReferences } from "./use-resolved-canvas-resource-references"; -import { quoteLogicalModel } from "@/services/api/logical-models"; - -export type CanvasNodeGenerationMode = CanvasGenerationMode; - -type CanvasNodePromptPanelProps = { - projectId: string; - node: CanvasNodeData; - isRunning: boolean; - onPromptChange: (nodeId: string, prompt: string) => void; - onConfigChange: (nodeId: string, patch: Partial) => void; - onGenerate: (nodeId: string, mode: CanvasNodeGenerationMode, prompt: string) => void; - mentionReferences?: CanvasResourceReference[]; - onAddReference?: (nodeId: string, reference: CanvasResourceReference) => CanvasResourceReference | undefined; - onRemoveReference?: (nodeId: string, reference: CanvasResourceReference) => void; - onReorderReferences?: (nodeId: string, orderedNodeIds: string[]) => void; - onReplaceReference?: (nodeId: string, oldReference: CanvasResourceReference, sourceNodeId: string) => void; - onReplaceReferenceFiles?: (nodeId: string, oldReference: CanvasResourceReference, files: File[]) => void; - onClose?: () => void; - onNodeMouseDown?: (event: ReactPointerEvent, nodeId: string) => void; - onImageSettingsOpenChange?: (open: boolean) => void; - workspaceMode?: CanvasWorkspaceMode; -}; - -type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes]; - -const PROMPT_REFERENCE_SHELF_HEIGHT = 58; -// Keep the compact editor readable at rest: three 20px lines plus 12px vertical padding. -const PROMPT_EDITOR_MIN_HEIGHT = 72; -const PROMPT_EDITOR_EXPANDED_MIN_HEIGHT = 200; -const PROMPT_EDITOR_LINE_HEIGHT = 20; -const PROMPT_EDITOR_EXPANDED_LINE_HEIGHT = 24; -const PROMPT_EDITOR_VERTICAL_PADDING = 12; -const PROMPT_EDITOR_EXPANDED_VERTICAL_PADDING = 20; -const PROMPT_EDITOR_MAX_LINES = 8; -const PROMPT_EDITOR_EXPANDED_MAX_LINES = 14; -const PROMPT_EDITOR_MODAL_WIDTH = "min(1200px, 92vw)"; -const PROMPT_EDITOR_MODAL_DEFAULT_WIDTH = 1200; -const PROMPT_EDITOR_MODAL_DEFAULT_HEIGHT = 420; - -export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChange, onConfigChange, onGenerate, mentionReferences = [], onAddReference, onRemoveReference, onReorderReferences, onReplaceReference, onReplaceReferenceFiles, onClose, onNodeMouseDown, onImageSettingsOpenChange, workspaceMode = "professional" }: CanvasNodePromptPanelProps) { - const globalConfig = useEffectiveConfig(); - const themeName = useActiveTheme(); - const theme = canvasThemes[themeName]; - const creditsEnabled = useUserStore((state) => state.features.creditsEnabled); - const promptOptimizerInstallation = usePluginStore((state) => state.installations.find((item) => item.manifest.id === PROMPT_OPTIMIZER_PLUGIN_ID)); - const promptOptimizerEnabled = usePluginStore((state) => state.pluginStates[PROMPT_OPTIMIZER_PLUGIN_ID]?.effectiveEnabled ?? Boolean(state.installations.find((item) => item.manifest.id === PROMPT_OPTIMIZER_PLUGIN_ID)?.enabled)); - const simpleMode = workspaceMode === "simple"; - const mode = defaultMode(node.type); - const hasTextContent = node.type === CanvasNodeType.Text && Boolean(node.metadata?.content?.trim()); - const hasImageContent = node.type === CanvasNodeType.Image && Boolean(node.metadata?.content); - const savedPrompt = node.metadata?.composerContent ?? node.metadata?.prompt ?? ""; - const [prompt, setPrompt] = useState(savedPrompt); - const [presetOpen, setPresetOpen] = useState(false); - const [expandedPresetOpen, setExpandedPresetOpen] = useState(false); - const [expandedPromptOpen, setExpandedPromptOpen] = useState(false); - const [expandedModalSize, setExpandedModalSize] = useState<{ width: number; height: number } | null>(null); - const expandedModalRef = useRef(null); - const [promptContentHeight, setPromptContentHeight] = useState(() => estimatePromptContentHeight(savedPrompt, false)); - const [expandedPromptContentHeight, setExpandedPromptContentHeight] = useState(() => estimatePromptContentHeight(savedPrompt, true)); - const [manualPromptHeight, setManualPromptHeight] = useState(null); - const [paramsExpanded, setParamsExpanded] = useState(false); // #98 决策2:B区参数区折叠状态(手风琴) - const [promptOptimizerOpen, setPromptOptimizerOpen] = useState(false); - const [autoLinkEnabled, setAutoLinkEnabled] = useState(true); - const resolvedMentionReferences = useResolvedCanvasResourceReferences(mentionReferences, { projectId }); - const normalizedSavedPrompt = useMemo(() => normalizeCanvasNodeMentionTokens(savedPrompt, mentionReferences), [mentionReferences, savedPrompt]); - const activeReferences = resolvedMentionReferences.filter((item) => item.active && item.kind !== "skill"); - const requirements: ModelRequirements = { - capability: mode, - input: { - textCount: (prompt.trim() ? 1 : 0) + activeReferences.filter((item) => item.kind === "text").length, - imageCount: activeReferences.filter((item) => item.kind === "image").length, - videoCount: activeReferences.filter((item) => item.kind === "video").length, - audioCount: activeReferences.filter((item) => item.kind === "audio").length, - characterCount: activeReferences.filter((item) => item.kind === "character").length, - }, - videoOperation: node.metadata?.videoEditOperation, - videoSeconds: mode === "video" ? node.metadata?.seconds || globalConfig.videoSeconds : undefined, - options: modelRequestOptions({ - ...globalConfig, - size: node.metadata?.size || globalConfig.size, - quality: node.metadata?.quality || globalConfig.quality, - count: String(node.metadata?.count || globalConfig.count), - transparentBackground: node.metadata?.transparentBackground || globalConfig.transparentBackground, - videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds, - vquality: node.metadata?.vquality || globalConfig.vquality, - videoGenerateAudio: node.metadata?.generateAudio || globalConfig.videoGenerateAudio, - videoWatermark: node.metadata?.watermark || globalConfig.videoWatermark, - audioVoice: node.metadata?.audioVoice || globalConfig.audioVoice, - audioFormat: node.metadata?.audioFormat || globalConfig.audioFormat, - audioSpeed: node.metadata?.audioSpeed || globalConfig.audioSpeed, - }, mode), - }; - const config = buildNodeConfig(globalConfig, node, mode, requirements); - const resolvedRequirements: ModelRequirements = { - ...requirements, - options: modelRequestOptions(config, mode), - videoSeconds: mode === "video" ? config.videoSeconds : undefined, - }; - const promptOptimizerProvider = useMemo(() => { - if (!promptOptimizerEnabled || !promptOptimizerInstallation || !promptOptimizerPlugin.createPromptOptimizer) return null; - return promptOptimizerPlugin.createPromptOptimizer(createPluginHostContext(promptOptimizerPlugin, promptOptimizerInstallation, globalConfig)); - }, [globalConfig, promptOptimizerEnabled, promptOptimizerInstallation]); - const generationCount = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1))); - const priceChannel = resolveModelChannel(config, config.model); - const configuredCredits = requestCreditCost({ - channelMode: priceChannel.scope === "system" ? "remote" : "local", - modelCosts: priceChannel.modelCosts, - model: modelOptionName(config.model), - count: mode === "image" ? generationCount : 1, - seconds: mode === "video" ? config.videoSeconds : 1, - capability: mode, - config, - requirements: resolvedRequirements, - }); - const quoteRequest = modelQuoteRequest(config, config.model, mode, resolvedRequirements); - const quoteRequestKey = JSON.stringify(quoteRequest || null); - const [quotedCredits, setQuotedCredits] = useState(null); - const credits = quotedCredits ?? configuredCredits; - const activeReferenceCount = activeReferences.length; - const videoFrameOptions = resolvedMentionReferences.filter((item) => item.active && item.kind === "image").map((item) => ({ nodeId: item.nodeId, label: item.label, title: item.title, previewUrl: item.previewUrl })); - const hasVideoPromptTools = mode === "video" && !simpleMode && videoFrameOptions.length > 0; - const monochromeAccent = theme.node.activeStroke; - const composerTokens = { - "--canvas-composer-surface": theme.node.panel, - "--canvas-composer-control-surface": theme.toolbar.itemHover, - "--canvas-composer-control-hover": theme.toolbar.activeBg, - "--canvas-composer-shadow": theme.node.shadow, - "--cn-text": theme.node.text, - } as CSSProperties; - const composerSurfaceStyle = { - ...composerTokens, - background: theme.node.panel, - color: theme.node.text, - boxShadow: theme.node.shadow, - } as CSSProperties; - const controlSurface = "var(--canvas-composer-control-surface)"; - const promptBounds = promptEditorBounds(false, activeReferenceCount > 0); - const expandedPromptBounds = promptEditorBounds(true, activeReferenceCount > 0); - const composerHeight = clampPromptHeight(manualPromptHeight ?? promptContentHeight + (activeReferenceCount ? PROMPT_REFERENCE_SHELF_HEIGHT : 0), promptBounds); - const expandedComposerHeight = clampPromptHeight(expandedPromptContentHeight + (activeReferenceCount ? PROMPT_REFERENCE_SHELF_HEIGHT : 0), expandedPromptBounds); - const measureExpandedModalSize = () => { - const rect = expandedModalRef.current?.getBoundingClientRect(); - if (!rect?.width || !rect.height) return { width: PROMPT_EDITOR_MODAL_DEFAULT_WIDTH, height: PROMPT_EDITOR_MODAL_DEFAULT_HEIGHT }; - return { width: Math.round(rect.width), height: Math.round(rect.height) }; - }; - const isSubmitDisabled = !isRunning && !prompt.trim(); - const canExpandPrompt = mode === "image" || mode === "video"; - const canOptimizePrompt = Boolean(promptOptimizerProvider) && canExpandPrompt; - const isPortraitTexture = mode === "image" && Boolean(node.metadata?.portraitTexture); - const autoMentionedPrompt = useMemo(() => autoMentionCanvasResourceReferences(prompt, resolvedMentionReferences), [prompt, resolvedMentionReferences]); - const canAutoMention = autoMentionedPrompt !== prompt; - - useEffect(() => { - setPrompt(normalizedSavedPrompt); - if (normalizedSavedPrompt !== savedPrompt) onPromptChange(node.id, normalizedSavedPrompt); - }, [node.id, normalizedSavedPrompt, onPromptChange, savedPrompt]); - - useEffect(() => { - setExpandedPromptOpen(false); - setExpandedPresetOpen(false); - setExpandedModalSize(null); - setPromptContentHeight(estimatePromptContentHeight(normalizedSavedPrompt, false)); - setExpandedPromptContentHeight(estimatePromptContentHeight(normalizedSavedPrompt, true)); - setManualPromptHeight(null); - }, [node.id]); - - useEffect(() => { - if (!expandedPromptOpen) return; - const constrainSize = () => setExpandedModalSize((size) => { - if (!size) return size; - const next = clampExpandedModalSize(size); - return next.width === size.width && next.height === size.height ? size : next; - }); - constrainSize(); - window.addEventListener("resize", constrainSize); - return () => window.removeEventListener("resize", constrainSize); - }, [expandedPromptOpen]); - - useEffect(() => { - if (!creditsEnabled || !quoteRequest) { - setQuotedCredits(null); - return; - } - const controller = new AbortController(); - setQuotedCredits(null); - quoteLogicalModel(quoteRequest.logicalModelID, quoteRequest.intent, controller.signal) - .then(({ quote }) => setQuotedCredits(quote.amountMicrocredits / 1_000_000)) - .catch(() => { - if (!controller.signal.aborted) setQuotedCredits(null); - }); - return () => controller.abort(); - // quoteRequestKey captures the full normalized request without retriggering on object identity. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [creditsEnabled, quoteRequestKey]); - - const skillReferences = useMemo(() => resolvedMentionReferences.filter((item) => item.kind === "skill"), [resolvedMentionReferences]); - - const updatePrompt = (value: string) => { - setPrompt(value); - onPromptChange(node.id, value); - if (/(^|\s)\/[\p{L}\p{N}_-]*$/u.test(value)) { - if (expandedPromptOpen) setExpandedPresetOpen(true); - else setPresetOpen(true); - } - }; - - const applyPreset = (preset: CanvasPromptPreset) => { - const withoutSlash = prompt.replace(/(^|\s)\/[\p{L}\p{N}_-]*$/u, "$1").trimEnd(); - updatePrompt(withoutSlash ? `${withoutSlash}\n${preset.prompt}` : preset.prompt); - }; - - const insertPromptReference = (reference: CanvasResourceReference) => { - const insertText = `${canvasResourceMentionToken(reference)} `; - const pendingMentionMatch = /@[^\s@,。!?、,.!?;:]*\s*$/.exec(prompt); - if (pendingMentionMatch) { - const prefix = prompt.slice(0, pendingMentionMatch.index).replace(/\s*$/, ""); - updatePrompt(prefix ? `${prefix} ${insertText}` : insertText); - return; - } - const basePrompt = prompt.replace(/\s*$/, ""); - updatePrompt(basePrompt ? `${basePrompt} ${insertText}` : insertText); - }; - - const submit = () => { - const text = prompt.trim(); - if (!text || isRunning) return false; - onGenerate(node.id, mode, text); - return true; - }; - - const submitExpandedPrompt = () => { - if (submit()) { - setExpandedPresetOpen(false); - setExpandedPromptOpen(false); - } - }; - - const renderComposerHeader = (expanded: boolean) => ( -
{ - const target = event.target instanceof Element ? event.target : null; - if (!target?.closest("button, input, textarea, select, a, [contenteditable=\"true\"], [data-canvas-no-drag]")) onNodeMouseDown?.(event, node.id); - }} - > - {isPortraitTexture ? ( - <> - onConfigChange(node.id, { portraitTexture })} /> - {activeReferenceCount > 0 ? {referenceShelfHeading(activeReferences)} : null} - - ) : ( -
- - - - {modeDisplayName(mode)}生成 - {activeReferenceCount > 0 ? {referenceShelfHeading(activeReferences)} : null} -
- )} -
- {!simpleMode ? : null} - {canOptimizePrompt ? ( - - - - ) : null} - {!expanded && canExpandPrompt ? ( - - - - ) : null} - {!expanded && onClose ? ( - - - - ) : null} -
-
- ); - - const renderSubmitButton = (expanded: boolean) => { - const showCost = creditsEnabled && credits !== null; - const formattedCredits = credits?.toLocaleString("zh-CN", { maximumFractionDigits: 6 }); - const actionLabel = isRunning ? "生成中" : showCost ? `预计消耗 ${formattedCredits} 积分,生成` : "生成"; - return ( - - ); - }; - - const renderComposerControls = (expanded: boolean) => - simpleMode ? ( -
- - {activeReferenceCount ? `已连接 ${activeReferenceCount} 个素材` : "将使用默认模型与参数"} - - updatePrompt(autoMentionedPrompt)} - onAutoLinkEnabledChange={setAutoLinkEnabled} - accent={monochromeAccent} - compact - /> - {renderSubmitButton(expanded)} -
- ) : ( -
-
- onConfigChange(node.id, mode === "image" ? { model, ...defaultImageParamsForModel(config, model) } : { model })} - capability={mode} - requirements={resolvedRequirements} - onMissingConfig={() => navigateToSettings({ continueCreation: true })} - showSelectedPrice={false} - showOptionPrices={creditsEnabled} - variant="creation" - showConfiguredModelName - /> -
-
- updatePrompt(autoMentionedPrompt)} - onAutoLinkEnabledChange={setAutoLinkEnabled} - accent={monochromeAccent} - compact={!expanded} - /> - {mode === "text" ? ( - - onConfigChange(node.id, { textCount: Math.max(1, Math.min(15, Math.floor(Math.abs(Number(value)) || 1))) })} - aria-label="文本生成份数" - className="!w-14 !h-7 [&_.ant-input-number-input]:!text-[var(--fs-tiny)]" - /> - - ) : mode === "image" ? ( - // 图片模式下,显示相机配置与镜头配置 - <> - onConfigChange(node.id, { cameraControl: options })} - theme={theme} - compact={!expanded} - /> - onConfigChange(node.id, key === "count" ? { count: Number(value) || 1 } : { [key]: value })} - onMissingConfig={() => navigateToSettings({ continueCreation: true })} - onOpenChange={expanded ? undefined : onImageSettingsOpenChange} - /> - - ) : mode === "video" ? ( - onConfigChange(node.id, videoConfigPatch(key, value))} - /> - ) : mode === "audio" ? ( - onConfigChange(node.id, audioConfigPatch(key, value))} - /> - ) : null} - {renderSubmitButton(expanded)} -
-
- ); - - const renderPromptEditor = (expanded: boolean, fill = false) => { - const bounds = expanded ? expandedPromptBounds : promptBounds; - const height = expanded ? expandedComposerHeight : composerHeight; - return ( - <> -
- onRemoveReference?.(node.id, reference)} - onReorder={onReorderReferences ? (orderedNodeIds) => onReorderReferences(node.id, orderedNodeIds) : undefined} - onReplaceReference={onReplaceReference ? (oldReference, sourceNodeId) => onReplaceReference(node.id, oldReference, sourceNodeId) : undefined} - onReplaceReferenceFiles={onReplaceReferenceFiles ? (oldReference, files) => onReplaceReferenceFiles(node.id, oldReference, files) : undefined} - /> - onAddReference(node.id, reference) : undefined} - includeAssetLibrary - onChange={updatePrompt} - autoLinkEnabled={autoLinkEnabled} - onReferenceFilesDrop={onReplaceReferenceFiles ? (reference, files) => onReplaceReferenceFiles(node.id, reference, files) : undefined} - onContentSizeChange={expanded ? setExpandedPromptContentHeight : setPromptContentHeight} - containerClassName="min-h-0 flex-1" - className={expanded - ? "thin-scrollbar h-full w-full resize-none overflow-y-auto border-none bg-transparent px-3 py-2.5 text-[var(--fs-body-lg)] leading-6 !outline-none !ring-0 !shadow-none focus:!outline-none focus:!ring-0 focus:!shadow-none placeholder:text-current placeholder:opacity-35" - : "thin-scrollbar h-full w-full resize-none overflow-y-auto border-none bg-transparent px-2.5 py-1.5 text-[var(--fs-body)] leading-5 !outline-none !ring-0 !shadow-none focus:!outline-none focus:!ring-0 focus:!shadow-none placeholder:text-current placeholder:opacity-35"} - style={{ color: theme.node.text, outline: "none", boxShadow: "none" }} - placeholder={promptPlaceholder(mode, hasImageContent, hasTextContent)} - aria-label={`${modeDisplayName(mode)}提示词`} - /> -
- {!expanded && } - - ); - }; - - return ( - item.model === modelOptionName(config.model))?.protocol || priceChannel.interfaceType} - config={globalConfig} - optimizerModel={globalConfig.textModel} - references={activeReferences} - provider={promptOptimizerProvider} - onClose={() => setPromptOptimizerOpen(false)} - onApply={(nextPrompt) => updatePrompt(nextPrompt)} - > -
event.stopPropagation()} - onPointerDown={(event) => event.stopPropagation()} - onWheel={(event) => event.stopPropagation()} - > - {renderComposerHeader(false)} - - {renderPromptEditor(false)} - - {/* B区 参数区(对应 #98 决策2:默认折叠,手风琴展开)*/} - {hasVideoPromptTools ? ( -
- - {paramsExpanded ? ( -
- onConfigChange(node.id, patch)} /> -
- ) : null} -
- ) : null} - - {renderComposerControls(false)} - - { - setExpandedPresetOpen(false); - setExpandedPromptOpen(false); - }} - styles={{ - container: { border: 0, borderRadius: "var(--canvas-composer-radius)", padding: 0, overflow: "hidden", background: theme.node.panel, boxShadow: theme.node.shadow }, - body: { minHeight: 0, padding: 0 }, - }} - > -
-
-
{renderComposerHeader(true)}
- {renderPromptEditor(true, Boolean(expandedModalSize))} - {hasVideoPromptTools ? ( -
- onConfigChange(node.id, patch)} /> -
- ) : null} -
{renderComposerControls(true)}
-
- -
-
- -
-
- ); -} - -function ReferenceToolsPopover({ canAutoMention, autoLinkEnabled, onAutoMention, onAutoLinkEnabledChange, accent, compact }: { canAutoMention: boolean; autoLinkEnabled: boolean; onAutoMention: () => void; onAutoLinkEnabledChange: (enabled: boolean) => void; accent: string; compact: boolean }) { - return ( - -
-
智能引用
-
输入素材序号或名称后按 Tab,可快速引用
-
-
-
AutoLink
- -
- - - } - > - -
- ); -} - -function GenerationModeIcon({ mode }: { mode: CanvasNodeGenerationMode }) { - if (mode === "image") return ; - if (mode === "video") return