From 0945115e531962eb44b27272a8e8d9ea40661f65 Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Thu, 10 Sep 2026 13:48:31 +0800 Subject: [PATCH] =?UTF-8?q?chore(release):=20=E7=89=88=E6=9C=AC=E5=8F=91?= =?UTF-8?q?=E5=B8=83=20-=20=E5=8F=91=E5=B8=83=20v1.2.8.rc3=20=E5=80=99?= =?UTF-8?q?=E9=80=89=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复生产前端 Rolldown 分包循环依赖白屏,补齐画布引用排序与素材 payload 合同,并同步工作流生成素材字段。 Co-authored-by: Cursor --- CHANGELOG.md | 8 ++ VERSION | 2 +- backend/internal/service/asset_library.go | 4 +- backend/internal/service/project_workflow.go | 30 ++++- .../service/user_asset_client_payload.go | 108 ++++++++++++++++++ .../service/user_asset_client_payload_test.go | 62 ++++++++++ backend/internal/service/user_data.go | 6 +- backend/internal/service/user_data_page.go | 4 +- docs/content/docs/progress/pending-test.mdx | 12 ++ .../canvas/canvas-assistant-online-tools.ts | 10 +- .../canvas/canvas-node-prompt-panel.tsx | 77 ++++++++++++- ...use-resolved-canvas-resource-references.ts | 41 +++++-- web/src/components/image-settings-panel.tsx | 7 +- .../lib/canvas/canvas-resource-references.ts | 31 +++++ web/src/pages/canvas/project.tsx | 20 +++- web/src/services/api/generation-task.ts | 9 +- web/src/styles/globals.css | 32 +++++- web/test/canvas-resource-references.test.ts | 46 +++++++- web/test/generation-task-local.test.ts | 54 ++++++++- web/test/image-size-presets.test.tsx | 7 ++ web/vite.config.ts | 2 +- 21 files changed, 535 insertions(+), 37 deletions(-) create mode 100644 backend/internal/service/user_asset_client_payload.go create mode 100644 backend/internal/service/user_asset_client_payload_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index ea3b1193d..d9eac0ea1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +## v1.2.8.rc3 + +- 发布 v1.2.8 第三个候选版本;镜像标签为 `1.2.8.rc3`,GitHub Release 标记为预发布,不覆盖已发布的 `latest`。 +- 修复生产环境前端启动白屏:Rolldown 分包循环依赖导致 `application` 初始化失败,改为递归包含分包依赖。 +- 画布节点引用区支持拖拽排序,并同步连线顺序与 @ 提及编号;绘图节点预览可在创作面板正确解析。 +- 在线助手引用图片改为使用已同步的 `storageKey`,未上传完成的引用会给出明确错误提示。 +- 后端补齐工作流生成素材的 `coverUrl`、`tags`、时间戳与尺寸字段,并在读取用户素材时归一化客户端 payload 合同。 + ## v1.2.8.rc1 - 发布 v1.2.8 首个候选版本;镜像标签为 `1.2.8.rc1`,GitHub Release 标记为预发布,不覆盖已发布的 `latest`。 diff --git a/VERSION b/VERSION index 1fc5941a0..71a57e3f7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.2.8.rc1 +v1.2.8.rc3 diff --git a/backend/internal/service/asset_library.go b/backend/internal/service/asset_library.go index 571af36aa..d00451db6 100644 --- a/backend/internal/service/asset_library.go +++ b/backend/internal/service/asset_library.go @@ -58,8 +58,8 @@ func (s *Service) UserAssetsPage(userID string, page int, pageSize int, filter U } rawAssets := make([]json.RawMessage, 0, len(assets)) for _, asset := range assets { - if strings.TrimSpace(asset.PayloadJSON) != "" { - rawAssets = append(rawAssets, json.RawMessage(asset.PayloadJSON)) + if payload := clientAssetPayload(asset); len(payload) > 0 { + rawAssets = append(rawAssets, payload) } } kindRows, categoryRows, folderRows, err := s.repo.UserAssetFacets(userID, filter.Status) diff --git a/backend/internal/service/project_workflow.go b/backend/internal/service/project_workflow.go index ac1adce65..c5d0a1832 100644 --- a/backend/internal/service/project_workflow.go +++ b/backend/internal/service/project_workflow.go @@ -597,11 +597,35 @@ func (s *Service) ensureGeneratedProjectAsset(task model.Task, projectID string, title = "镜头产物" } title += " · " + label + width := resource.Width + height := resource.Height + if width <= 0 { + width = 1 + } + if height <= 0 { + height = 1 + } + resourceURL := "/api/resources/" + resourceID + "/file" + data := map[string]any{ + "storageKey": "resource:" + resourceID, + "mimeType": resource.MimeType, + "bytes": resource.Size, + "width": width, + "height": height, + } + if mediaType == "image" { + data["dataUrl"] = resourceURL + } else { + data["url"] = resourceURL + if resource.DurationMs > 0 { + data["durationMs"] = resource.DurationMs + } + } payload, _ := json.Marshal(map[string]any{ "id": assetID, "kind": mediaType, "category": model.AssetCategoryMaterial, "status": model.AssetVersionStatusConfirmed, - "primaryVersionId": versionID, "title": title, - "data": map[string]any{"storageKey": "resource:" + resourceID, "url": "/api/resources/" + resourceID + "/file", "mimeType": resource.MimeType, "bytes": resource.Size}, - "metadata": map[string]any{"source": "short-drama-workflow", "taskId": task.ID, "shotId": shot.ID, "projectIds": []string{projectID}}, + "primaryVersionId": versionID, "title": title, "coverUrl": resourceURL, "tags": []string{}, + "createdAt": now.UTC().Format(time.RFC3339Nano), "updatedAt": now.UTC().Format(time.RFC3339Nano), + "data": data, "metadata": map[string]any{"source": "short-drama-workflow", "taskId": task.ID, "shotId": shot.ID, "projectIds": []string{projectID}}, }) asset := &model.Asset{ID: assetID, UserID: task.UserID, Kind: mediaType, Category: model.AssetCategoryMaterial, Status: model.AssetVersionStatusConfirmed, PrimaryVersionID: versionID, Title: title, PayloadJSON: string(payload), CreatedAt: now, UpdatedAt: now} version := &model.AssetVersion{ID: versionID, AssetID: assetID, Version: 1, Status: model.AssetVersionStatusConfirmed, DefinitionJSON: "{}", Prompt: task.Prompt, Note: "工作流生成产物", CreatedAt: now, UpdatedAt: now} diff --git a/backend/internal/service/user_asset_client_payload.go b/backend/internal/service/user_asset_client_payload.go new file mode 100644 index 000000000..1bfce56b6 --- /dev/null +++ b/backend/internal/service/user_asset_client_payload.go @@ -0,0 +1,108 @@ +package service + +import ( + "encoding/json" + "strings" + "time" + + "infinite-canvas/backend/internal/model" +) + +// clientAssetPayload 把数据库里的素材记录补齐为前端素材合同可消费的 JSON。 +// 工作流等内部写入路径可能绕过 UpsertUserAsset,导致 payload 缺少 coverUrl、tags 或时间戳。 +func clientAssetPayload(asset model.Asset) json.RawMessage { + raw := strings.TrimSpace(asset.PayloadJSON) + if raw == "" { + return nil + } + var payload map[string]any + if err := json.Unmarshal([]byte(raw), &payload); err != nil || payload == nil { + return json.RawMessage(raw) + } + if _, ok := payload["coverUrl"]; !ok { + payload["coverUrl"] = deriveAssetCoverURL(payload) + } + if _, ok := payload["tags"]; !ok { + payload["tags"] = []string{} + } + if _, ok := payload["createdAt"]; !ok { + payload["createdAt"] = formatClientAssetTime(asset.CreatedAt) + } + if _, ok := payload["updatedAt"]; !ok { + payload["updatedAt"] = formatClientAssetTime(asset.UpdatedAt) + } + if data, ok := payload["data"].(map[string]any); ok { + kind, _ := payload["kind"].(string) + switch kind { + case "image", "video": + ensurePositiveAssetDimension(data, "width") + ensurePositiveAssetDimension(data, "height") + } + } + encoded, err := json.Marshal(payload) + if err != nil { + return json.RawMessage(raw) + } + return encoded +} + +func deriveAssetCoverURL(payload map[string]any) string { + if value, ok := payload["coverUrl"].(string); ok && strings.TrimSpace(value) != "" { + return value + } + data, ok := payload["data"].(map[string]any) + if !ok { + return "" + } + for _, key := range []string{"dataUrl", "url"} { + if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" { + return value + } + } + if storageKey, ok := data["storageKey"].(string); ok { + return resourceURLFromStorageKey(storageKey) + } + return "" +} + +func resourceURLFromStorageKey(storageKey string) string { + storageKey = strings.TrimSpace(storageKey) + if !strings.HasPrefix(storageKey, "resource:") { + return "" + } + resourceID := strings.TrimSpace(strings.TrimPrefix(storageKey, "resource:")) + if resourceID == "" { + return "" + } + return "/api/resources/" + resourceID + "/file" +} + +func ensurePositiveAssetDimension(data map[string]any, key string) { + value, ok := jsonNumberValue(data[key]) + if !ok || value <= 0 { + data[key] = 1 + } +} + +func jsonNumberValue(value any) (float64, bool) { + switch item := value.(type) { + case float64: + return item, true + case int: + return float64(item), true + case int64: + return float64(item), true + case json.Number: + parsed, err := item.Float64() + return parsed, err == nil + default: + return 0, false + } +} + +func formatClientAssetTime(value time.Time) string { + if value.IsZero() { + return time.Now().UTC().Format(time.RFC3339Nano) + } + return value.UTC().Format(time.RFC3339Nano) +} diff --git a/backend/internal/service/user_asset_client_payload_test.go b/backend/internal/service/user_asset_client_payload_test.go new file mode 100644 index 000000000..447505ea7 --- /dev/null +++ b/backend/internal/service/user_asset_client_payload_test.go @@ -0,0 +1,62 @@ +package service + +import ( + "encoding/json" + "testing" + "time" + + "infinite-canvas/backend/internal/model" +) + +func TestClientAssetPayloadRepairsWorkflowAssetDocument(t *testing.T) { + createdAt := time.Date(2026, 9, 9, 12, 0, 0, 0, time.UTC) + asset := model.Asset{ + ID: "workflow-asset-test", + PayloadJSON: `{ + "id":"workflow-asset-test", + "kind":"video", + "title":"镜头01 · 视频", + "category":"material", + "status":"confirmed", + "tags":[], + "data":{"url":"/api/resources/res-1/file","storageKey":"resource:res-1","width":0,"height":0,"bytes":1,"mimeType":"video/mp4"} + }`, + CreatedAt: createdAt, + UpdatedAt: createdAt, + } + + raw := clientAssetPayload(asset) + var payload map[string]any + if err := json.Unmarshal(raw, &payload); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + if payload["coverUrl"] != "/api/resources/res-1/file" { + t.Fatalf("coverUrl = %#v", payload["coverUrl"]) + } + if payload["createdAt"] == nil || payload["updatedAt"] == nil { + t.Fatalf("timestamps missing: %#v", payload) + } + data := payload["data"].(map[string]any) + if data["width"].(float64) != 1 || data["height"].(float64) != 1 { + t.Fatalf("dimensions = %#v", data) + } +} + +func TestClientAssetPayloadPreservesCompleteDocument(t *testing.T) { + rawInput := `{"id":"asset-1","kind":"image","title":"完整","coverUrl":"https://example.com/a.png","tags":["角色"],"createdAt":"2026-08-29T00:00:00.000Z","updatedAt":"2026-08-29T00:00:00.000Z","data":{"dataUrl":"https://example.com/a.png","width":2,"height":3,"bytes":1,"mimeType":"image/png"}}` + asset := model.Asset{PayloadJSON: rawInput} + raw := clientAssetPayload(asset) + var before map[string]any + var after map[string]any + if err := json.Unmarshal([]byte(rawInput), &before); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(raw, &after); err != nil { + t.Fatal(err) + } + beforeJSON, _ := json.Marshal(before) + afterJSON, _ := json.Marshal(after) + if string(beforeJSON) != string(afterJSON) { + t.Fatalf("payload changed: %s", raw) + } +} diff --git a/backend/internal/service/user_data.go b/backend/internal/service/user_data.go index aaa02c579..d01e5e3c6 100644 --- a/backend/internal/service/user_data.go +++ b/backend/internal/service/user_data.go @@ -65,7 +65,7 @@ func (s *Service) UserAsset(userID string, id string) (json.RawMessage, error) { if err != nil { return nil, err } - return json.RawMessage(asset.PayloadJSON), nil + return clientAssetPayload(*asset), nil } func (s *Service) UpsertUserAsset(userID string, raw json.RawMessage) (UserDataSummary, error) { @@ -129,8 +129,8 @@ func (s *Service) UserAssets(userID string) ([]json.RawMessage, error) { } result := make([]json.RawMessage, 0, len(assets)) for _, asset := range assets { - if strings.TrimSpace(asset.PayloadJSON) != "" { - result = append(result, json.RawMessage(asset.PayloadJSON)) + if payload := clientAssetPayload(asset); len(payload) > 0 { + result = append(result, payload) } } return result, nil diff --git a/backend/internal/service/user_data_page.go b/backend/internal/service/user_data_page.go index 252fe2160..1759263bf 100644 --- a/backend/internal/service/user_data_page.go +++ b/backend/internal/service/user_data_page.go @@ -28,7 +28,9 @@ func (s *Service) UserAssetsByIDs(userID string, ids []string) ([]json.RawMessag } result := make([]json.RawMessage, 0, len(assets)) for _, asset := range assets { - result = append(result, json.RawMessage(asset.PayloadJSON)) + if payload := clientAssetPayload(asset); len(payload) > 0 { + result = append(result, payload) + } } return result, nil } diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 133cebed3..8ed3ec633 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -1562,3 +1562,15 @@ v2(用户反馈首版难看后重做):菜单改用 antd Dropdown(placeme - [ ] iOS Safari 真机地址栏变化、长时间浏览帧率与设备发热。 - [ ] Wing It! 部分低分辨率色彩分镜后续升级为高清成片镜头。 - [ ] 是否将品牌首页设为根路径入口,由项目维护者另行决定。 + +## 画布生成重试与引用交互修复(2026-09-10) + +已修复五处画布契约断点:在线节点重试的任务提交保留重试身份;比例型图片协议切换分辨率时同步实际质量档位;在线 Agent 多图只提交 `resource:` 引用、不再嵌入 Data URL;绘图引用从项目本地绘图仓库恢复缩略图;已连接参考素材支持拖动及左右方向键排序,并同步更新 `@图片N` 等编号。专项测试与前端构建通过,需浏览器验收: + +- [ ] 对一个失败的图片、视频或音频节点点「重新生成」,任务中心出现新的排队/运行任务,节点最终随任务进入成功或明确失败状态,不长期停在「正在连接上游」。 +- [ ] 在比例型图片模型中直接点 4K 后立即生成,无需二次点击;摘要与实际请求均为 4K,切换 1K/2K 同理。 +- [ ] 在线 Agent 同时引用多张已上传图片时,请求可进入上游,不再出现 `http: request body too large`;尚未同步的本地图片会在提交前给出明确提示。 +- [ ] 多个绘图节点连接到生成节点后,参考栏显示各自最新缩略图;编辑并保存绘图后重新打开面板,缩略图随 revision 更新。 +- [ ] 用拖动手柄或聚焦后按左右方向键调整参考顺序,标签编号和提示词中的 `@图片N` 同步更新;刷新画布后顺序保持,配置节点承接引用时同样有效。 +- [ ] 明暗主题下拖动手柄、缩略图预览、删除按钮和横向滚动无错位;拖动引用不触发画布节点拖拽。 + diff --git a/web/src/components/canvas/canvas-assistant-online-tools.ts b/web/src/components/canvas/canvas-assistant-online-tools.ts index d3f0e739f..bc3e1cf33 100644 --- a/web/src/components/canvas/canvas-assistant-online-tools.ts +++ b/web/src/components/canvas/canvas-assistant-online-tools.ts @@ -1,7 +1,7 @@ import { nanoid } from "nanoid"; import { type ResponseInputMessage, type ResponseToolCall } from "@/services/api/image"; import { runBackendToolGenerationTask } from "@/services/api/generation-task"; -import { imageToDataUrl } from "@/services/image-storage"; +import { resourceIdFromStorageKey } from "@/services/api/resources"; import { normalizeModelOptionValue, resolveModelRequestConfig, selectableModelsByCapability, type AiConfig } from "@/stores/use-config-store"; import { NODE_DEFAULT_SIZE } from "@/constant/canvas"; import { CanvasNodeType, type CanvasAssistantMessage, type CanvasAssistantReference, type CanvasAssistantSession, type CanvasNodeData } from "@/types/canvas"; @@ -476,6 +476,12 @@ export function buildAssistantReferences(nodes: CanvasNodeData[], selectedNodeId export async function buildToolAgentMessages(snapshot: CanvasAgentSnapshot, history: CanvasAssistantMessage[], userMessage: CanvasAssistantMessage, skills: Skill[] = []): Promise { const refs = userMessage.references || []; + const imageRefs = refs.filter((item) => item.type === CanvasNodeType.Image); + const unavailableImageRefs = imageRefs.filter((item) => !resourceIdFromStorageKey(item.storageKey)); + if (unavailableImageRefs.length) { + const names = unavailableImageRefs.map((item) => item.title || item.id).join("、"); + throw new Error(`引用图片尚未同步到服务器:${names}。请等待上传完成后重试`); + } const skillCatalog = skills .filter((skill) => skill.isAdded) .slice(0, 40) @@ -493,7 +499,7 @@ export async function buildToolAgentMessages(snapshot: CanvasAgentSnapshot, hist content: [ ...refs.flatMap((item) => (item.text ? [{ type: "text" as const, text: `选中节点 ${item.title}:${item.text}` }] : [])), { type: "text", text: `当前画布:${JSON.stringify(compactSnapshot(snapshot))}\n\n用户需求:${userMessage.text}` }, - ...(await Promise.all(refs.filter((item) => item.dataUrl).map(async (item) => ({ type: "image_url" as const, image_url: { url: await imageToDataUrl(item) } })))), + ...imageRefs.map((item) => ({ type: "image_url" as const, image_url: { url: item.storageKey! } })), ], }, ]; diff --git a/web/src/components/canvas/canvas-node-prompt-panel.tsx b/web/src/components/canvas/canvas-node-prompt-panel.tsx index 92ac442d8..6a82a4fb4 100644 --- a/web/src/components/canvas/canvas-node-prompt-panel.tsx +++ b/web/src/components/canvas/canvas-node-prompt-panel.tsx @@ -1,7 +1,7 @@ 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 { ArrowUp, AtSign, Boxes, Camera, ChevronDown, FileText, ImageIcon, ImagePlus, Link2, LoaderCircle, Maximize2, Music2, Pencil, SlidersHorizontal, UserRound, Video, WandSparkles, X } from "lucide-react"; +import { 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"; @@ -36,6 +36,7 @@ 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; @@ -43,6 +44,7 @@ type CanvasNodePromptPanelProps = { onGenerate: (nodeId: string, mode: CanvasNodeGenerationMode, prompt: string) => void; mentionReferences?: CanvasResourceReference[]; onRemoveReference?: (nodeId: string, reference: CanvasResourceReference) => void; + onReorderReferences?: (nodeId: string, orderedNodeIds: string[]) => void; onClose?: () => void; onNodeMouseDown?: (event: ReactPointerEvent, nodeId: string) => void; onImageSettingsOpenChange?: (open: boolean) => void; @@ -60,7 +62,7 @@ const PROMPT_EDITOR_VERTICAL_PADDING = 12; const PROMPT_EDITOR_EXPANDED_VERTICAL_PADDING = 20; const PROMPT_EDITOR_MAX_LINES = 8; -export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate, mentionReferences = [], onRemoveReference, onClose, onNodeMouseDown, onImageSettingsOpenChange, workspaceMode = "professional" }: CanvasNodePromptPanelProps) { +export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChange, onConfigChange, onGenerate, mentionReferences = [], onRemoveReference, onReorderReferences, onClose, onNodeMouseDown, onImageSettingsOpenChange, workspaceMode = "professional" }: CanvasNodePromptPanelProps) { const globalConfig = useEffectiveConfig(); const themeName = useThemeStore((state) => state.theme); const theme = canvasThemes[themeName]; @@ -83,7 +85,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi const [paramsExpanded, setParamsExpanded] = useState(false); // #98 决策2:B区参数区折叠状态(手风琴) const [promptOptimizerOpen, setPromptOptimizerOpen] = useState(false); const [autoLinkEnabled, setAutoLinkEnabled] = useState(true); - const resolvedMentionReferences = useResolvedCanvasResourceReferences(mentionReferences); + 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 = { @@ -436,7 +438,13 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi return ( <>
- onRemoveReference?.(node.id, reference)} /> + onRemoveReference?.(node.id, reference)} + onReorder={onReorderReferences ? (orderedNodeIds) => onReorderReferences(node.id, orderedNodeIds) : undefined} + /> void; onRemove?: (reference: CanvasResourceReference) => void }) { +function ConnectedReferenceShelf({ references, theme, onInsert, onRemove, onReorder }: { references: CanvasResourceReference[]; theme: CanvasTheme; onInsert: (reference: CanvasResourceReference) => void; onRemove?: (reference: CanvasResourceReference) => void; onReorder?: (orderedNodeIds: string[]) => void }) { const activeReferences = references.filter((item) => item.active && item.kind !== "skill"); const [imagePreview, setImagePreview] = useState(null); + const [draggedReferenceId, setDraggedReferenceId] = useState(null); if (!activeReferences.length) return null; + const moveReference = (sourceId: string, targetId: string) => { + if (!onReorder || sourceId === targetId) return; + const sourceIndex = activeReferences.findIndex((reference) => reference.nodeId === sourceId); + const targetIndex = activeReferences.findIndex((reference) => reference.nodeId === targetId); + if (sourceIndex < 0 || targetIndex < 0) return; + const ordered = [...activeReferences]; + const [moved] = ordered.splice(sourceIndex, 1); + ordered.splice(targetIndex, 0, moved); + onReorder(ordered.map((reference) => reference.nodeId)); + }; + + const moveReferenceByOffset = (sourceId: string, offset: -1 | 1) => { + const sourceIndex = activeReferences.findIndex((reference) => reference.nodeId === sourceId); + const target = activeReferences[sourceIndex + offset]; + if (!target) return; + moveReference(sourceId, target.nodeId); + }; + return ( <>
@@ -667,7 +694,45 @@ function ConnectedReferenceShelf({ references, theme, onInsert, onRemove }: { re {activeReferences.map((reference, index) => { const canPreview = Boolean(reference.previewUrl) && (reference.kind === "image" || reference.kind === "character" || reference.kind === "video"); return ( - + { + if (!onReorder || !draggedReferenceId) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + }} + onDrop={(event) => { + event.preventDefault(); + const sourceId = draggedReferenceId || event.dataTransfer.getData("text/plain"); + setDraggedReferenceId(null); + moveReference(sourceId, reference.nodeId); + }} + > + {onReorder ? ( + + ) : null} {index + 1}
: null} - {showSize ? onConfigChange("size", size)} /> : null} + {showSize ? applyImageSizeSelection(onConfigChange, size, nextQuality)} /> : null} {showCount && effectiveMaxCount > 1 ? (
生成张数 @@ -124,6 +124,11 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle = ); } +export function applyImageSizeSelection(onConfigChange: ImageSettingsPanelProps["onConfigChange"], size: string, quality?: string) { + onConfigChange("size", size); + if (quality) onConfigChange("quality", quality); +} + export function ImageSettingsTheme({ theme, children }: { theme: CanvasTheme; children: ReactNode }) { return ( Boolean(node && isResourceNode(node))); } +/** + * 调整目标节点的素材输入边顺序。连接数组是引用编号的唯一顺序源;只替换相关 + * 输入边所在槽位,避免改变主链和其他节点的连线顺序。 + */ +export function reorderCanvasResourceConnections(targetNodeId: string, orderedNodeIds: string[], nodes: CanvasNodeData[], connections: CanvasConnection[]) { + const uniqueOrder = [...new Set(orderedNodeIds.filter(Boolean))]; + if (uniqueOrder.length < 2) return connections; + const configNodeId = connections.find((connection) => connection.fromNodeId === targetNodeId && nodes.find((node) => node.id === connection.toNodeId)?.type === CanvasNodeType.Config)?.toNodeId; + const configInputs = configNodeId ? getContextResourceNodes(configNodeId, nodes, connections).filter((node) => node.id !== targetNodeId) : []; + const receiverId = configInputs.length ? configNodeId! : targetNodeId; + const orderSet = new Set(uniqueOrder); + const slots = connections + .map((connection, index) => ({ connection, index })) + .filter(({ connection }) => connection.toNodeId === receiverId && orderSet.has(connection.fromNodeId)); + if (slots.length !== uniqueOrder.length) return connections; + const connectionBySource = new Map(slots.map(({ connection }) => [connection.fromNodeId, connection])); + if (uniqueOrder.some((nodeId) => !connectionBySource.has(nodeId))) return connections; + const reordered = uniqueOrder.map((nodeId) => connectionBySource.get(nodeId)!); + if (slots.every(({ connection }, index) => connection === reordered[index])) return connections; + const next = [...connections]; + slots.forEach(({ index }, orderIndex) => { + next[index] = reordered[orderIndex]; + }); + return next; +} + function getConnectedConfigResourceNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) { const configConnection = connections.find((connection) => connection.fromNodeId === nodeId && nodes.find((node) => node.id === connection.toNodeId)?.type === CanvasNodeType.Config); if (!configConnection) return []; @@ -409,6 +438,8 @@ function labelResourceNodes(nodes: CanvasNodeData[], active: boolean) { : node.metadata?.previewContent || node.metadata?.content, storageKey: node.metadata?.storageKey, previewStorageKey: node.type === CanvasNodeType.Video ? node.metadata?.videoPreview?.storageKey : undefined, + drawingId: node.type === CanvasNodeType.Drawing ? node.metadata?.drawingId : undefined, + drawingRevision: node.type === CanvasNodeType.Drawing ? node.metadata?.drawingRevision : undefined, text: node.metadata?.workflowKind === "character" ? node.metadata.characterPrompt : node.type === CanvasNodeType.Text ? node.metadata?.content || node.metadata?.prompt : node.type === CanvasNodeType.Skill ? skillResourceText(node) : undefined, active, sourceType: node.type, diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index f5b80ea95..f4280d4c1 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -72,7 +72,7 @@ import { CanvasVersionCompareModal } from "@/components/canvas/canvas-version-co import { CanvasLocalAgentPanel } from "@/components/canvas/canvas-local-agent-panel"; import { useFocusMode } from "@/hooks/use-focus-mode"; import { useCanvasAgentStore } from "@/stores/canvas/use-canvas-agent-store"; -import { applyCanvasConnectionPromptSync, getContextResourceNodes, normalizeCanvasNodeMentionTokens, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; +import { applyCanvasConnectionPromptSync, getContextResourceNodes, normalizeCanvasNodeMentionTokens, reorderCanvasResourceConnections, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; import { CanvasConnectionCreateMenu, CanvasNodePanelOverlay, type PendingConnectionCreate } from "@/components/canvas/canvas-workspace-overlays"; import { CanvasOverlayLayerContainer, CanvasOverlayLayerProvider } from "@/components/canvas/canvas-overlay-layer"; import { CanvasLeaferGraphicsLayer } from "@/components/canvas/canvas-leafer-graphics-layer"; @@ -1162,6 +1162,20 @@ function InfiniteCanvasPage() { setSelectedConnectionId((current) => current && removedConnectionIds.has(current) ? null : current); }, [connectionsRef, nodesRef, setConnections, setNodes, setSelectedConnectionId]); + const handleReorderNodeReferences = useCallback((targetNodeId: string, orderedNodeIds: string[]) => { + const previousNodes = nodesRef.current; + const previousConnections = connectionsRef.current; + const nextConnections = reorderCanvasResourceConnections(targetNodeId, orderedNodeIds, previousNodes, previousConnections); + if (nextConnections === previousConnections) return; + const nextNodes = applyCanvasConnectionPromptSync(previousNodes, previousConnections, previousNodes, nextConnections); + if (nextNodes !== previousNodes) { + nodesRef.current = nextNodes; + setNodes(nextNodes); + } + connectionsRef.current = nextConnections; + setConnections(nextConnections); + }, [connectionsRef, nodesRef, setConnections, setNodes]); + const handleProjectFolderInsert = useCallback((folderId: string) => { const folder = linkedProjectQuery.data?.assetFolders.find((item) => item.id === folderId); if (!folder || !linkedProjectId) throw new Error("素材文件夹已不存在,请刷新后重试"); @@ -1879,6 +1893,7 @@ function InfiniteCanvasPage() { /> ) : ( setDialogNodeId(null)} onNodeMouseDown={handleNodeMouseDown} workspaceMode={workspaceMode} @@ -1896,7 +1912,7 @@ function InfiniteCanvasPage() { /> ); }, - [configInputsById, handleConfigNodeChange, handleGenerateNode, handleNodePromptChange, handleRemoveNodeReference, mentionReferencesByNodeId, runningNodeId, skillMentionReferences, workspaceMode], + [configInputsById, handleConfigNodeChange, handleGenerateNode, handleNodePromptChange, handleRemoveNodeReference, handleReorderNodeReferences, mentionReferencesByNodeId, projectId, runningNodeId, skillMentionReferences, workspaceMode], ); const renderCanvasNodeContent = useCallback( diff --git a/web/src/services/api/generation-task.ts b/web/src/services/api/generation-task.ts index a6e438ea0..badc50b1a 100644 --- a/web/src/services/api/generation-task.ts +++ b/web/src/services/api/generation-task.ts @@ -117,7 +117,7 @@ export async function runBackendGenerationTask( assertBackendRuntimeConfigured(config, mode); const prepared = await prepareGenerationReferences({ referenceImages, referenceVideos, referenceAudios, mask }); throwIfAborted(signal); - return createAndWaitGenerationTask({ projectId, mode, prompt, config, referenceImages, referenceVideos, referenceAudios, textHistory, signal, metadata, onTaskUpdate, onTextDelta, streamText, enableThinking }, prepared, dependencies); + return createAndWaitGenerationTask({ projectId, mode, prompt, config, referenceImages, referenceVideos, referenceAudios, textHistory, signal, metadata, onTaskUpdate, onTextDelta, streamText, enableThinking, clientOperationId, retryOf, attemptGroupId }, prepared, dependencies); } // 分镜等后台生产流程只需要可靠提交任务;任务状态与产物由项目工作区轮询和 @@ -492,7 +492,12 @@ function backendGenerationTaskInput(options: BackendGenerationTaskOptions, prepa referenceVideos: prepared.referenceVideos, referenceAudios: prepared.referenceAudios, mask: prepared.mask, - metadata: generationMetadata(config, metadata), + metadata: generationMetadata(config, { + ...metadata, + ...(options.clientOperationId ? { clientOperationId: options.clientOperationId } : {}), + ...(options.retryOf ? { retryOf: options.retryOf } : {}), + ...(options.attemptGroupId ? { attemptGroupId: options.attemptGroupId } : {}), + }), }, }; } diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index ff57f38c8..fdd7dddb6 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -9370,11 +9370,39 @@ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--foreground) 10%, transparent); } + .canvas-node-reference-chip[data-dragging="true"] { + opacity: 0.58; + } + + .canvas-node-reference-drag-handle { + display: grid; + width: 18px; + flex: 0 0 18px; + place-items: center; + border: 0; + border-right: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent); + border-radius: var(--r-md) 0 0 var(--r-md); + background: transparent; + padding: 0; + color: color-mix(in srgb, var(--foreground) 42%, transparent); + cursor: grab; + } + + .canvas-node-reference-drag-handle:hover, + .canvas-node-reference-drag-handle:focus-visible { + background: var(--canvas-composer-control-hover); + color: var(--foreground); + } + + .canvas-node-reference-drag-handle:active { + cursor: grabbing; + } + .canvas-node-reference-order { position: absolute; z-index: 1; top: 2px; - left: 2px; + left: 20px; display: grid; width: 16px; height: 16px; @@ -9395,7 +9423,7 @@ flex: 0 0 var(--canvas-reference-chip-size, 44px); overflow: hidden; border: 0; - border-radius: var(--r-md) 0 0 var(--r-md); + border-radius: 0; background: transparent; padding: 0; cursor: zoom-in; diff --git a/web/test/canvas-resource-references.test.ts b/web/test/canvas-resource-references.test.ts index e6f36aadf..514b73699 100644 --- a/web/test/canvas-resource-references.test.ts +++ b/web/test/canvas-resource-references.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { autoMentionCanvasResourceReferences, findCanvasResourceAutoLinkMatch, type CanvasResourceReference, applyCanvasConnectionPromptSync, buildAssetMentionReferences, buildCanvasNodeMentionReferenceMap, buildNodeMentionReferences, buildOrderedCanvasResourceReferences, canvasResourceMentionToken, collectUpstreamVideoNodes, imageGenerationReferenceConnections } from "../src/lib/canvas/canvas-resource-references"; +import { autoMentionCanvasResourceReferences, findCanvasResourceAutoLinkMatch, type CanvasResourceReference, applyCanvasConnectionPromptSync, buildAssetMentionReferences, buildCanvasNodeMentionReferenceMap, buildNodeMentionReferences, buildOrderedCanvasResourceReferences, canvasResourceMentionToken, collectUpstreamVideoNodes, imageGenerationReferenceConnections, reorderCanvasResourceConnections } from "../src/lib/canvas/canvas-resource-references"; import { canvasNodeToAsset } from "../src/lib/canvas/canvas-node-asset"; import { buildNodeGenerationInputs } from "../src/components/canvas/canvas-node-generation"; import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../src/types/canvas"; @@ -227,6 +227,21 @@ describe("canvas resource mention slots", () => { expect(uploadedReference?.storageKey).toBe("video:user:source"); }); + test("绘图引用保留本地预览定位信息", () => { + const drawing: CanvasNodeData = { + id: "drawing-a", + type: CanvasNodeType.Drawing, + title: "草图 A", + position: { x: 0, y: 0 }, + width: 100, + height: 100, + metadata: { drawingId: "drawing-document-a", drawingRevision: 3 }, + }; + const target = videoNode("target"); + const [reference] = buildNodeMentionReferences(target, [drawing, target], [connection(drawing.id, target.id)]); + expect(reference).toMatchObject({ kind: "image", label: "绘图1", drawingId: "drawing-document-a", drawingRevision: 3 }); + }); + test("画布节点引用只保存类型位置,不保存节点 ID", () => { const target = videoNode("target"); const image = imageNode("image-a"); @@ -339,3 +354,32 @@ describe("image generation reference connections", () => { expect(copied.every((item) => item.toNodeId === "result")).toBe(true); }); }); + +describe("reorder canvas resource connections", () => { + test("直接引用按指定顺序换位并同步编号提示词", () => { + const imageA = imageNode("image-a"); + const imageB = imageNode("image-b"); + const target = { ...videoNode("target"), metadata: { composerContent: "比较 @图片1 和 @图片2" } }; + const nodes = [imageA, imageB, target]; + const previousConnections = [connection(imageA.id, target.id), connection(imageB.id, target.id)]; + const nextConnections = reorderCanvasResourceConnections(target.id, [imageB.id, imageA.id], nodes, previousConnections); + expect(nextConnections.map((item) => item.fromNodeId)).toEqual([imageB.id, imageA.id]); + const nextNodes = applyCanvasConnectionPromptSync(nodes, previousConnections, nodes, nextConnections); + expect(nextNodes.find((node) => node.id === target.id)?.metadata?.composerContent).toBe("比较 @图片2 和 @图片1"); + }); + + test("配置节点承接的引用可排序且不改变主链和无关连线", () => { + const imageA = imageNode("image-a"); + const imageB = imageNode("image-b"); + const target = videoNode("target"); + const config: CanvasNodeData = { id: "config", type: CanvasNodeType.Config, title: "config", position: { x: 0, y: 0 }, width: 100, height: 100, metadata: {} }; + const other = videoNode("other"); + const main = connection(target.id, config.id); + const unrelated = connection(imageA.id, other.id); + const previousConnections = [main, connection(imageA.id, config.id), unrelated, connection(imageB.id, config.id)]; + const nextConnections = reorderCanvasResourceConnections(target.id, [imageB.id, imageA.id], [imageA, imageB, target, config, other], previousConnections); + expect(nextConnections).toEqual([main, connection(imageB.id, config.id), unrelated, connection(imageA.id, config.id)]); + expect(nextConnections[0]).toBe(main); + expect(nextConnections[2]).toBe(unrelated); + }); +}); diff --git a/web/test/generation-task-local.test.ts b/web/test/generation-task-local.test.ts index e3ecbcc69..e00ed596f 100644 --- a/web/test/generation-task-local.test.ts +++ b/web/test/generation-task-local.test.ts @@ -8,7 +8,7 @@ import { LocalDreaminaGenerationClientError, runLocalDreaminaGenerationTask, typ import { createGenerationBatchRetryContexts, createGenerationRetryContext, generationTaskMetadata, runBackendCanvasGenerationTask, runCanvasGenerationTaskToConsumer } from "../src/lib/canvas/canvas-project-generation"; import { runCanvasAgentGenerationOps } from "../src/pages/canvas/use-canvas-agent-operations"; import { CanvasNodeType, type CanvasNodeData } from "../src/types/canvas"; -import { onlineToolToOps } from "../src/components/canvas/canvas-assistant-online-tools"; +import { buildToolAgentMessages, onlineToolToOps } from "../src/components/canvas/canvas-assistant-online-tools"; import { generationTaskShowsProgress, generationTaskStageLabel, generationTaskStatusLabel } from "../src/lib/generation-task-display"; import { generationErrorMessage } from "../src/lib/generation-error"; import { generationTaskNodeId, syncGenerationTaskToCanvasStore } from "../src/lib/canvas/canvas-generation-task-sync"; @@ -339,6 +339,58 @@ test("backend text generation forwards streaming callbacks and thinking options" expect(result).toEqual({ mode: "text", text: "完整正文", reasoning: "思考摘要" }); }); +test("online node retry creates a backend task and preserves retry identity in task metadata", async () => { + let createdInput: Parameters[1]>["createTask"]>[0] | undefined; + const running: GenerationTask = { id: "retry-task-new", type: "canvas_image", status: "running", prompt: "retry", attempts: 1, createdAt: "", updatedAt: "" }; + await runBackendGenerationTask( + { + projectId: "canvas-retry", + mode: "image", + prompt: "retry", + config: backendModelConfig("retry-image-model"), + clientOperationId: "retry:operation-0000000000000001", + retryOf: "retry-task-old", + attemptGroupId: "retry-group", + }, + { + createTask: async (input) => { + createdInput = input; + return running; + }, + waitTask: async () => ({ ...running, status: "succeeded", resultJson: JSON.stringify({ mode: "image", images: [] }) }), + runLocal: async () => { throw new Error("must not use local Runtime"); }, + createId: () => "unused-retry-id", + now: () => "2026-09-10T00:00:00.000Z", + }, + ); + expect(createdInput?.input?.metadata).toMatchObject({ + clientOperationId: "retry:operation-0000000000000001", + retryOf: "retry-task-old", + attemptGroupId: "retry-group", + }); +}); + +test("online canvas Agent sends resource references instead of inline image bodies", async () => { + const snapshot = { projectId: "canvas-agent", title: "Agent", nodes: [], connections: [], selectedNodeIds: [], viewport: { x: 0, y: 0, zoom: 1 } }; + const messages = await buildToolAgentMessages(snapshot, [], { + id: "message", + role: "user", + text: "分析图片", + references: [{ id: "image", type: CanvasNodeType.Image, title: "参考图", dataUrl: `data:image/png;base64,${"A".repeat(1024)}`, storageKey: "resource:image-remote" }], + }); + const serialized = JSON.stringify(messages); + expect(serialized).toContain("resource:image-remote"); + expect(serialized).not.toContain("data:image/png"); + expect(serialized.length).toBeLessThan(20_000); + + await expect(buildToolAgentMessages(snapshot, [], { + id: "local-message", + role: "user", + text: "分析本地图片", + references: [{ id: "local-image", type: CanvasNodeType.Image, title: "未上传图片", dataUrl: "data:image/png;base64,AAAA" }], + })).rejects.toThrow("引用图片尚未同步到服务器"); +}); + test("the shared local generation entry projects pre-receipt work as submitting without fake progress", async () => { const updates: GenerationTask[] = []; let release!: () => void; diff --git a/web/test/image-size-presets.test.tsx b/web/test/image-size-presets.test.tsx index dd3a188d0..14b9e984c 100644 --- a/web/test/image-size-presets.test.tsx +++ b/web/test/image-size-presets.test.tsx @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { renderToStaticMarkup } from "react-dom/server"; import { ImageSizePicker } from "../src/components/image-size-picker"; +import { applyImageSizeSelection } from "../src/components/image-settings-panel"; import { ImageSizePresetsEditor } from "../src/components/image-size-presets-editor"; import { ModelCapabilityEditor } from "../src/components/model-capability-editor"; import { defaultImageCapabilityConfig, normalizeModelCapabilityConfig } from "../src/lib/model-capabilities"; @@ -10,6 +11,12 @@ import { resolveImageRequestSize, validateImageSize } from "../src/services/api/ import { buildGeminiImageGenerationConfig } from "../src/lib/gemini-image"; describe("统一图片分辨率与宽高比", () => { + test("比例协议切换 4K 时同时提交尺寸和真实质量档位", () => { + const changes: Array<[string, string]> = []; + applyImageSizeSelection((key, value) => changes.push([key, value]), "16:9", "high"); + expect(changes).toEqual([["size", "16:9"], ["quality", "high"]]); + }); + test("像素协议从旧比例配置切换 4K 后发送真实像素,不回退到 1K", () => { const profile = defaultImageCapabilityConfig(); profile.size = { parameter: "size", values: ["16:9"], default: "16:9", allowCustom: true }; diff --git a/web/vite.config.ts b/web/vite.config.ts index 3197058af..2b8777311 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -40,7 +40,7 @@ export default defineConfig({ output: { strictExecutionOrder: true, codeSplitting: { - includeDependenciesRecursively: false, + includeDependenciesRecursively: true, minSize: 20 * 1024, groups: [ {