diff --git a/CHANGELOG.md b/CHANGELOG.md index ad0b7b813..42ac40a70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # CHANGELOG +## v1.6.6 + +- 画布云端写入改为可幂等重放的 operations 事务,支持字段级并发合并、冲突保护和删除后迟到更新拦截;完善预演场景保存、版本同步与 Agent 变更撤销。 +- Agent 视频审批可在确认前调整模型与生成规格;完善分镜校验、生成资源访问与任务结果恢复。 +- 优化大画布节点渲染预算和性能模式,统一工作区与管理后台侧栏体验。 +- 修复 PostgreSQL 连接池 keepalive 与闲置事务限制、短剧关闭时误隐藏角色库相关接口、灵感页刷新重定向和模型图片分辨率价格档识别。 +- 升级注意:数据库 schema 升至 v50,新增画布操作幂等回执表,启动时自动迁移。 + ## v1.6.5 - Agent 重复读取同一结果或同一张图时改为软收敛:运行保持进行中,下一步暂时收起只读工具,仍可回复、提问或执行写操作。 diff --git a/VERSION b/VERSION index ef0519020..0591f3366 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.6.5 +v1.6.6 diff --git a/backend/internal/app/channel_model_pricing_test.go b/backend/internal/app/channel_model_pricing_test.go index aa5e61ee2..81058b5af 100644 --- a/backend/internal/app/channel_model_pricing_test.go +++ b/backend/internal/app/channel_model_pricing_test.go @@ -219,17 +219,7 @@ func TestRepriceTierFieldValidation(t *testing.T) { } } -func TestNormalizeChannelModelTierSelectorAcceptsResolutionTypo(t *testing.T) { - selector, resolution, videoSeconds, err := normalizeChannelModelTierSelector("image", ChannelModelPriceTierRequest{ - Selector: map[string]string{"resoluton": "2K"}, - }) - if err != nil { - t.Fatal(err) - } - if selector["resolution"] != "2k" || selector["resoluton"] != "" || resolution != "*" || videoSeconds != 0 { - t.Fatalf("selector=%v resolution=%q videoSeconds=%d", selector, resolution, videoSeconds) - } -} +func TestBatchRepricingRollsBackOnConcurrentChange(t *testing.T) { for _, target := range []string{"model", "tier"} { t.Run(target, func(t *testing.T) { svc, db, _ := seedRepricingModels(t) @@ -265,3 +255,15 @@ func TestNormalizeChannelModelTierSelectorAcceptsResolutionTypo(t *testing.T) { }) } } + +func TestNormalizeChannelModelTierSelectorAcceptsResolutionTypo(t *testing.T) { + selector, resolution, videoSeconds, err := normalizeChannelModelTierSelector("image", ChannelModelPriceTierRequest{ + Selector: map[string]string{"resoluton": "2K"}, + }) + if err != nil { + t.Fatal(err) + } + if selector["resolution"] != "2k" || selector["resoluton"] != "" || resolution != "*" || videoSeconds != 0 { + t.Fatalf("selector=%v resolution=%q videoSeconds=%d", selector, resolution, videoSeconds) + } +} diff --git a/backend/internal/app/channel_presentation_test.go b/backend/internal/app/channel_presentation_test.go index ca87d7587..7955a9340 100644 --- a/backend/internal/app/channel_presentation_test.go +++ b/backend/internal/app/channel_presentation_test.go @@ -49,7 +49,7 @@ func TestChannelPresentationUsesModelLabelsAndPublicOrder(t *testing.T) { if err != nil { t.Fatal(err) } - if len(catalog) != 2 || catalog[0].ID != "second" || catalog[0].Name != "渠道second" || catalog[0].DisplayName != "渠道second" { + if len(catalog) != 2 || catalog[0].ID != "second" || catalog[0].Name != "" || catalog[0].DisplayName != "" { t.Fatalf("public catalog: %#v", catalog) } legacy, err := svc.PublicSystemChannels() @@ -74,7 +74,7 @@ func TestChannelPresentationUsesModelLabelsAndPublicOrder(t *testing.T) { t.Fatal(err) } catalog, err = svc.publicSystemChannelCatalog(nil) - if err != nil || catalog[0].Name != "渠道second" { + if err != nil || catalog[0].Name != "" || catalog[0].DisplayName != "" { t.Fatalf("channel name: %v %v", catalog, err) } for _, invalid := range []int{-1, 1000000} { diff --git a/backend/internal/app/model_catalog.go b/backend/internal/app/model_catalog.go index e5466b27e..d4c2ef6ac 100644 --- a/backend/internal/app/model_catalog.go +++ b/backend/internal/app/model_catalog.go @@ -169,10 +169,12 @@ func (s *Service) publicSystemChannelCatalog(intent *ModelRequestIntent) ([]Publ } if len(publicModels) > 0 { + // 系统渠道的真实名称属于内部部署信息(供应商、中转站、内部命名等), + // 不得通过普通用户可访问的创作目录泄漏。前端只依赖 ID 做分组,名称留空后由客户端统一展示为“系统模型”。 result = append(result, PublicChannelCatalog{ ID: channel.ID, - Name: channel.Name, - DisplayName: channel.Name, + Name: "", + DisplayName: "", SortOrder: channel.SortOrder, Models: publicModels, }) diff --git a/backend/internal/app/resource_generated.go b/backend/internal/app/resource_generated.go index db3e082d9..cc611f560 100644 --- a/backend/internal/app/resource_generated.go +++ b/backend/internal/app/resource_generated.go @@ -6,8 +6,10 @@ package app import ( "bytes" + "context" "encoding/base64" "encoding/json" + "errors" "fmt" "image" "io" @@ -182,22 +184,30 @@ func downloadRemoteResource(rawURL string, maxBytes int64) (remoteResourcePayloa if err != nil { return remoteResourcePayload{}, err } - client := OutboundHTTPClient(90 * time.Second) + client := OutboundHTTPClient(3 * time.Minute) req, err := http.NewRequest(http.MethodGet, parsed.String(), nil) if err != nil { return remoteResourcePayload{}, err } resp, err := client.Do(req) if err != nil { - return remoteResourcePayload{}, err + // 把下载层网络错误统一映射为可公开的 502/504 文案,避免在恢复接口上被 failService + // 折叠成通用的“系统处理失败”。同时保留真实原因在 Cause 里供诊断日志追踪。 + if errors.Is(err, context.DeadlineExceeded) { + return remoteResourcePayload{}, WrapAppError(http.StatusGatewayTimeout, "远程资源下载超时,请检查地址是否可访问或稍后重试", err) + } + return remoteResourcePayload{}, WrapAppError(http.StatusBadGateway, "远程资源下载失败,请检查地址是否可访问", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return remoteResourcePayload{}, fmt.Errorf("远程资源下载失败:%s", resp.Status) + return remoteResourcePayload{}, WrapAppError(http.StatusBadGateway, fmt.Sprintf("远程资源下载失败:%s", resp.Status), nil) } data, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes)) if err != nil { - return remoteResourcePayload{}, err + if errors.Is(err, context.DeadlineExceeded) { + return remoteResourcePayload{}, WrapAppError(http.StatusGatewayTimeout, "远程资源下载超时,请检查地址是否可访问或稍后重试", err) + } + return remoteResourcePayload{}, WrapAppError(http.StatusBadGateway, "远程资源读取失败,请检查地址是否可访问", err) } if int64(len(data)) >= maxBytes { return remoteResourcePayload{}, BadAuthRequest(fmt.Sprintf("远程资源必须小于 %s", formatStorageLimit(maxBytes))) diff --git a/web/src/components/canvas/canvas-agent-video-approval-settings.tsx b/web/src/components/canvas/canvas-agent-video-approval-settings.tsx index da83aa349..38debac75 100644 --- a/web/src/components/canvas/canvas-agent-video-approval-settings.tsx +++ b/web/src/components/canvas/canvas-agent-video-approval-settings.tsx @@ -8,13 +8,7 @@ import { resolveCompatibleModel, type ModelRequirements } from "@/lib/model-sele import type { AgentMediaSettings } from "@/services/api/agent"; import { PUBLIC_MODEL_CATALOG_ID, useEffectiveConfig } from "@/stores/use-config-store"; -export function CanvasAgentVideoApprovalSettings({ initial, value, theme, disabled, onChange }: { - initial: AgentVideoApproval; - value?: AgentMediaSettings; - theme: CanvasTheme; - disabled: boolean; - onChange: (value: AgentMediaSettings) => void; -}) { +export function CanvasAgentVideoApprovalSettings({ initial, value, theme, disabled, onChange }: { initial: AgentVideoApproval; value?: AgentMediaSettings; theme: CanvasTheme; disabled: boolean; onChange: (value: AgentMediaSettings) => void }) { const effective = useEffectiveConfig(); const config = useMemo(() => ({ ...effective, channels: effective.channels.filter((channel) => channel.scope === "system" || channel.id === PUBLIC_MODEL_CATALOG_ID) }), [effective]); const settings = value || initial; @@ -42,7 +36,15 @@ export function CanvasAgentVideoApprovalSettings({ initial, value, theme, disabl const profile = modelCapabilityConfigFor(config, next).video; if (!profile) return; const normalized = normalizeVideoValue(profile, { seconds: duration, ratio: settings.size, resolution: settings.quality }); - onChange({ ...agentApprovalModelSelection(config, next), size: normalized.ratio, quality: normalized.resolution, durationSeconds: Number(normalized.seconds), videoGenerateAudio: settings.videoGenerateAudio, videoWatermark: settings.videoWatermark, videoArkPrivateAssetUpload: settings.videoArkPrivateAssetUpload }); + onChange({ + ...agentApprovalModelSelection(config, next), + size: normalized.ratio, + quality: normalized.resolution, + durationSeconds: Number(normalized.seconds), + videoGenerateAudio: settings.videoGenerateAudio, + videoWatermark: settings.videoWatermark, + videoArkPrivateAssetUpload: settings.videoArkPrivateAssetUpload, + }); }; const changeOption = (key: "vquality" | "size" | "videoSeconds" | "videoGenerateAudio" | "videoWatermark" | "videoArkPrivateAssetUpload", next: string) => { @@ -75,12 +77,32 @@ export function CanvasAgentVideoApprovalSettings({ initial, value, theme, disabl return (
-
生成模型
+
+ 生成模型 +
- {model ? :

当前模型不在可选目录中,可重新选择;提交时将重新校验模型与规格。

} -

本次规格:{settings.size}{settings.quality ? ` · ${settings.quality}` : ""}{settings.durationSeconds ? ` · ${settings.durationSeconds} 秒` : ""}

-

修改仅用于本次生成,费用按最终模型和规格计算。

+ {model ? ( + + ) : ( +

+ 当前模型不在可选目录中,可重新选择;提交时将重新校验模型与规格。 +

+ )} +

+ 本次规格:{settings.size} + {settings.quality ? ` · ${settings.quality}` : ""} + {settings.durationSeconds ? ` · ${settings.durationSeconds} 秒` : ""} +

+

+ 修改仅用于本次生成,费用按最终模型和规格计算。 +

); } diff --git a/web/src/components/canvas/canvas-cloud-agent-panel-parts.tsx b/web/src/components/canvas/canvas-cloud-agent-panel-parts.tsx index 85cf58c70..1a978fd25 100644 --- a/web/src/components/canvas/canvas-cloud-agent-panel-parts.tsx +++ b/web/src/components/canvas/canvas-cloud-agent-panel-parts.tsx @@ -571,7 +571,11 @@ export function ApprovalCard({ 无法确认具体目标,继续前请重新读取画布。 )} - {imageApproval ? : videoApproval ? : null} + {imageApproval ? ( + + ) : videoApproval ? ( + + ) : null} diff --git a/web/src/components/canvas/previs/canvas-previs-workbench.tsx b/web/src/components/canvas/previs/canvas-previs-workbench.tsx index 6312fb126..8ac6b80c1 100644 --- a/web/src/components/canvas/previs/canvas-previs-workbench.tsx +++ b/web/src/components/canvas/previs/canvas-previs-workbench.tsx @@ -79,7 +79,7 @@ export function CanvasPrevisWorkbench({ onDeleteImageNode: (nodeId: string) => void; onFlush?: () => void | Promise; }) { - const { message } = App.useApp(); + const { message, modal } = App.useApp(); const theme = canvasThemes[useActiveTheme()]; const effectiveCanvasId = useMemo(() => { const explicit = canvasId?.trim(); @@ -241,6 +241,7 @@ export function CanvasPrevisWorkbench({ resetWorkbench(); }, [open, resetWorkbench, scene, writeDraft]); + // 打开会话时检查合法本地恢复候选 // 父级项目收到 Agent 的预演台语义补丁后,scene.id 不变但 updatedAt 会变化; // 不能只按 scene.id 忽略,否则打开着的工作台永远看不到 Agent 的修改。 useEffect(() => { @@ -257,7 +258,7 @@ export function CanvasPrevisWorkbench({ setSelectedBone(null); setPlayhead(0); message.info("Agent 已更新预演场景"); - }, [message, open, scene, setPlayhead, setSelectedBone, setSelectedLightId, setSelectedObjectId, writeDraft]); + }, [message, modal, open, scene, writeDraft]); const activeShot = draft?.shots?.find((item) => item.id === draft.activeShotId) || draft?.shots?.[0] || null; const activeCamera = draft?.cameras?.find((item) => item.id === activeShot?.cameraId) || draft?.cameras?.[0] || null; @@ -355,6 +356,7 @@ export function CanvasPrevisWorkbench({ [writeAndPublish], ); + // 打开会话时检查合法本地恢复候选之后,挂载暂存手势生命周期。 // 暂存手势的终止生命周期:常驻安装,非活跃时 end 为空操作。 useEffect( () => @@ -466,7 +468,14 @@ export function CanvasPrevisWorkbench({ return; } recordPrevisDiagnostic("PREVIS_CLOSE_BLOCKED", { sceneId: draftRef.current?.id, saveOutcome: "stay", revision: saveController.progress.revision }); - message.error(decision.message); + modal.confirm({ + title: "保存未完成", + content: decision.message, + okText: "放弃修改并关闭", + cancelText: "留在预演台", + okButtonProps: { danger: true }, + onOk: onClose, + }); closingRef.current = false; })(); }; @@ -1246,6 +1255,12 @@ export function CanvasPrevisWorkbench({ {saveIndicator.label} + {saveController.progress.status === "error" ? ( + + ) : null} {onboardingScope ? (