fix(*): 质量检查 - 修复预演保存、增量同步与缺失资源恢复

This commit is contained in:
ddcat666
2026-10-12 00:37:15 +08:00
parent 298bee1fa9
commit 27c8066e6d
20 changed files with 257 additions and 71 deletions

View File

@@ -1,5 +1,13 @@
# CHANGELOG
## v1.6.6
- 画布云端写入改为可幂等重放的 operations 事务,支持字段级并发合并、冲突保护和删除后迟到更新拦截;完善预演场景保存、版本同步与 Agent 变更撤销。
- Agent 视频审批可在确认前调整模型与生成规格;完善分镜校验、生成资源访问与任务结果恢复。
- 优化大画布节点渲染预算和性能模式,统一工作区与管理后台侧栏体验。
- 修复 PostgreSQL 连接池 keepalive 与闲置事务限制、短剧关闭时误隐藏角色库相关接口、灵感页刷新重定向和模型图片分辨率价格档识别。
- 升级注意:数据库 schema 升至 v50,新增画布操作幂等回执表,启动时自动迁移。
## v1.6.5
- Agent 重复读取同一结果或同一张图时改为软收敛:运行保持进行中,下一步暂时收起只读工具,仍可回复、提问或执行写操作。

View File

@@ -1 +1 @@
v1.6.5
v1.6.6

View File

@@ -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)
}
}

View File

@@ -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} {

View File

@@ -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,
})

View File

@@ -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)))

View File

@@ -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 (
<fieldset disabled={disabled} className="min-w-0 space-y-3 border-0 p-0" data-canvas-no-zoom data-canvas-wheel-scroll aria-label="视频生成设置">
<div className="space-y-1.5">
<div className="text-xs" style={{ color: theme.node.muted }}>生成模型</div>
<div className="text-xs" style={{ color: theme.node.muted }}>
生成模型
</div>
<ModelPicker config={config} capability="video" value={model} onChange={changeModel} requirements={requirements} variant="creation" fullWidth showOptionPrices placeholder="选择生成模型" popoverClassName="agent-model-picker-popover" />
</div>
{model ? <VideoSettingsPanel config={{ ...config, model, size: settings.size, videoSeconds: duration, vquality: settings.quality, videoGenerateAudio: audio, videoWatermark: watermark, videoArkPrivateAssetUpload: privateAssetUpload }} onConfigChange={changeOption} theme={theme} showTitle={false} className="min-w-0 space-y-3" /> : <p className="text-xs" style={{ color: theme.node.muted }}>当前模型不在可选目录中,可重新选择;提交时将重新校验模型与规格。</p>}
<p className="text-xs" style={{ color: theme.node.text }}>本次规格:{settings.size}{settings.quality ? ` · ${settings.quality}` : ""}{settings.durationSeconds ? ` · ${settings.durationSeconds} 秒` : ""}</p>
<p className="text-xs" style={{ color: theme.node.muted }}>修改仅用于本次生成,费用按最终模型和规格计算。</p>
{model ? (
<VideoSettingsPanel
config={{ ...config, model, size: settings.size, videoSeconds: duration, vquality: settings.quality, videoGenerateAudio: audio, videoWatermark: watermark, videoArkPrivateAssetUpload: privateAssetUpload }}
onConfigChange={changeOption}
theme={theme}
showTitle={false}
className="min-w-0 space-y-3"
/>
) : (
<p className="text-xs" style={{ color: theme.node.muted }}>
当前模型不在可选目录中,可重新选择;提交时将重新校验模型与规格。
</p>
)}
<p className="text-xs" style={{ color: theme.node.text }}>
本次规格:{settings.size}
{settings.quality ? ` · ${settings.quality}` : ""}
{settings.durationSeconds ? ` · ${settings.durationSeconds} 秒` : ""}
</p>
<p className="text-xs" style={{ color: theme.node.muted }}>
修改仅用于本次生成,费用按最终模型和规格计算。
</p>
</fieldset>
);
}

View File

@@ -571,7 +571,11 @@ export function ApprovalCard({
无法确认具体目标,继续前请重新读取画布。
</div>
)}
{imageApproval ? <CanvasAgentImageApprovalSettings initial={imageApproval} value={mediaSettings} onChange={setMediaSettings} theme={theme} disabled={submitting} /> : videoApproval ? <CanvasAgentVideoApprovalSettings initial={videoApproval} value={mediaSettings} onChange={setMediaSettings} theme={theme} disabled={submitting} /> : null}
{imageApproval ? (
<CanvasAgentImageApprovalSettings initial={imageApproval} value={mediaSettings} onChange={setMediaSettings} theme={theme} disabled={submitting} />
) : videoApproval ? (
<CanvasAgentVideoApprovalSettings initial={videoApproval} value={mediaSettings} onChange={setMediaSettings} theme={theme} disabled={submitting} />
) : null}
<button type="button" className="canvas-agent-approval-reason-toggle" aria-expanded={showReason} onClick={() => setShowReason((value) => !value)} disabled={submitting}>
{showReason ? "收起拒绝理由" : "填写拒绝理由(可选)"}
</button>

View File

@@ -79,7 +79,7 @@ export function CanvasPrevisWorkbench({
onDeleteImageNode: (nodeId: string) => void;
onFlush?: () => void | Promise<void>;
}) {
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({
<span className={`previs-desk-save ${saveStatusClass}`} aria-live="polite">
{saveIndicator.label}
</span>
{saveController.progress.status === "error" ? (
<button type="button" className="previs-desk-header-action" onClick={() => void saveController.retrySave()}>
<Save className="size-3.5" />
重试保存
</button>
) : null}
{onboardingScope ? (
<button type="button" className="previs-desk-icon-button" aria-label="重新开始引导" title="重新开始引导" onClick={() => setOnboardingRestartSignal((value) => value + 1)}>
<Lightbulb className="size-4" />

View File

@@ -8,6 +8,7 @@ import type { PrevisScene } from "@/types/previs";
export type PrevisSaveController = {
progress: PrevisSaveProgress;
commitScene: (scene: PrevisScene) => void;
retrySave: () => Promise<boolean>;
prepareClose: () => Promise<PrevisCloseDecision>;
handlePageHide: () => Promise<boolean>;
};
@@ -59,7 +60,10 @@ export function usePrevisSaveCoordinator(input: { sceneId: string | null; initia
coordinatorRef.current = null;
const pending = coordinator.getSnapshot();
if (pending.confirmedRevision < pending.revision) {
void coordinator.flushLatest().catch(() => undefined).finally(() => coordinator.dispose());
void coordinator
.flushLatest()
.catch(() => undefined)
.finally(() => coordinator.dispose());
return;
}
coordinator.dispose();
@@ -79,6 +83,14 @@ export function usePrevisSaveCoordinator(input: { sceneId: string | null; initia
[syncProgress],
);
const retrySave = useCallback(async () => {
const coordinator = coordinatorRef.current;
if (!coordinator) return false;
const success = await coordinator.flushLatest();
syncProgress();
return success;
}, [syncProgress]);
const prepareClose = useCallback(async () => {
const coordinator = coordinatorRef.current;
if (!coordinator) return "close" as PrevisCloseDecision;
@@ -107,5 +119,5 @@ export function usePrevisSaveCoordinator(input: { sceneId: string | null; initia
}
}, [syncProgress]);
return { progress, commitScene, prepareClose, handlePageHide };
return { progress, commitScene, retrySave, prepareClose, handlePageHide };
}

View File

@@ -45,8 +45,6 @@ export function commitCanvasGenerationResult(current: CanvasNodeData[], before:
const existingIds = new Set(current.map((node) => node.id));
return [
...committedWithAdditional,
...additionalNodes
.filter((node) => !existingIds.has(node.id))
.map((node) => ({ ...node, position: { x: node.position.x + merged.position.x - result.position.x, y: node.position.y + merged.position.y - result.position.y } })),
...additionalNodes.filter((node) => !existingIds.has(node.id)).map((node) => ({ ...node, position: { x: node.position.x + merged.position.x - result.position.x, y: node.position.y + merged.position.y - result.position.y } })),
];
}

View File

@@ -383,16 +383,16 @@ async function buildGenerationTaskNodeResults(node: CanvasNodeData, task: Genera
const id = `${node.id}:task:${task.id}:image:${index}`;
const existing = nodes.find((item) => item.id === id);
const child = await buildGenerationTaskNodeResult(
existing
? { ...node, ...existing, id, title: existing.title, position: existing.position, width: existing.width, height: existing.height }
: { ...node, id, title: `${node.title} · ${index + 1}`, parentId: undefined },
existing ? { ...node, ...existing, id, title: existing.title, position: existing.position, width: existing.width, height: existing.height } : { ...node, id, title: `${node.title} · ${index + 1}`, parentId: undefined },
task,
nodes,
index,
);
children.push({
...child,
...(existing ? { title: existing.title, position: existing.position, width: existing.metadata?.manualSize ? existing.width : child.width, height: existing.metadata?.manualSize ? existing.height : child.height } : { position: imageGenerationChildPosition(resultNode.position, resultNode.width, child, index) }),
...(existing
? { title: existing.title, position: existing.position, width: existing.metadata?.manualSize ? existing.width : child.width, height: existing.metadata?.manualSize ? existing.height : child.height }
: { position: imageGenerationChildPosition(resultNode.position, resultNode.width, child, index) }),
metadata: {
...child.metadata,
assetId: task.outputs?.find((output) => output.outputIndex === index)?.materializedAssetId,

View File

@@ -15,6 +15,7 @@ export { isDirectSystemModel } from "@/lib/model-selection";
export function modelChannelLabel(config: AiConfig, value: string) {
const channel = resolveModelChannel(config, value);
const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(value));
if (channel.scope === "system") return cost?.channelLabel?.trim() || "系统模型";
return cost?.channelLabel?.trim() || channel.name || "未命名渠道";
}
@@ -39,7 +40,7 @@ export function groupModelsForPicker(config: AiConfig, options: string[]): Model
const key = JSON.stringify(["channel", channel.id]);
groups.set(key, {
key,
label: channel.name || "未命名渠道",
label: channel.scope === "system" ? "系统模型" : channel.name || "未命名渠道",
icon: modelIcon(config, otherModels[0]),
scope: channel.id === PUBLIC_MODEL_CATALOG_ID ? "" : "我的模型",
kind: "channel",

View File

@@ -68,8 +68,8 @@ export function ModelDefaultGrid({ config, onChange }: { config: AiConfig; onCha
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-semibold">{modelDisplayName(config, model)}</span>
<span className="mt-1 flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 text-[var(--fs-tiny)] text-foreground/45">
<span className="max-w-full truncate">{channel.name || "未命名渠道"}</span>
<span className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 text-[var(--fs-tiny)] text-foreground/45">
<span className="max-w-full truncate">{channel.scope === "system" ? "系统模型" : channel.name || "未命名渠道"}</span>
<span className="model-default-option-scope">{channel.scope === "system" ? "系统" : "自定义"}</span>
{creditsEnabled && cost ? <span className="model-default-price">{formatPrice(cost.billingMode === "token" ? (cost.outputTokenPriceMicrocredits || 0) : cost.unitPriceMicrocredits)} /{cost.billingMode === "token" ? "百万 Token" : cost.billingMode === "per_second" ? "秒" : "次"}</span> : null}
</span>

View File

@@ -20,8 +20,11 @@ export function createAgentCanvasSync(options: Options) {
let timer: ReturnType<typeof setTimeout> | undefined;
const schedule = () => {
if (disposed || running || timer || (!patches.length && !needsRefresh)) return;
const wait = patches.length ? options.batchMs ?? 40 : Math.max(options.batchMs ?? 40, (options.refreshIntervalMs ?? 1000) - (Date.now() - lastRefresh));
timer = setTimeout(() => { timer = undefined; void flush(); }, wait);
const wait = patches.length ? (options.batchMs ?? 40) : Math.max(options.batchMs ?? 40, (options.refreshIntervalMs ?? 1000) - (Date.now() - lastRefresh));
timer = setTimeout(() => {
timer = undefined;
void flush();
}, wait);
};
const flush = async () => {
if (disposed || running) return;
@@ -30,8 +33,11 @@ export function createAgentCanvasSync(options: Options) {
patches = [];
try {
if (batch.length) {
try { await options.applyPatches(batch); }
catch { needsRefresh = true; }
try {
await options.applyPatches(batch);
} catch {
needsRefresh = true;
}
}
if (!disposed && needsRefresh && Date.now() - lastRefresh >= (options.refreshIntervalMs ?? 1000)) {
needsRefresh = false;
@@ -51,7 +57,15 @@ export function createAgentCanvasSync(options: Options) {
const patch = event.payload.canvasPatch as AgentCanvasPatch | undefined;
if (patch && patch.canvasId === options.canvasId) {
patches.push(patch);
} else if (event.type === "canvas_updated" || event.type === "canvas_undone" || event.type === "generation_task_created" || event.type === "tool_failed" || event.type === "run_failed" || (event.type === "tool_completed" && ["canvas_apply_ops", "generate_media"].includes(String(event.payload.toolName)))) {
} else if (
!patches.length &&
(event.type === "canvas_updated" ||
event.type === "canvas_undone" ||
event.type === "generation_task_created" ||
event.type === "tool_failed" ||
event.type === "run_failed" ||
(event.type === "tool_completed" && ["canvas_apply_ops", "generate_media"].includes(String(event.payload.toolName))))
) {
// A patch may be dropped even after earlier patches succeeded.
// Keep the snapshot fallback available for every meaningful task
// boundary; refreshes remain rate-limited below.
@@ -59,7 +73,16 @@ export function createAgentCanvasSync(options: Options) {
}
schedule();
},
reconcile() { if (!disposed) { needsRefresh = true; schedule(); } },
dispose() { disposed = true; if (timer) clearTimeout(timer); patches = []; },
reconcile() {
if (!disposed) {
needsRefresh = true;
schedule();
}
},
dispose() {
disposed = true;
if (timer) clearTimeout(timer);
patches = [];
},
};
}

View File

@@ -456,7 +456,10 @@ async function flushResourceAccessBatch() {
}
try {
const data = await http.post<{ items: ResourceAccessBatchItem[] }>("/resources/access", entries.map((entry) => entry.payload));
const data = await http.post<{ items: ResourceAccessBatchItem[] }>(
"/resources/access",
entries.map((entry) => entry.payload),
);
await Promise.all(
entries.map(async (entry, index) => {
try {

View File

@@ -0,0 +1,70 @@
type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
const RESOURCE_KEY = /^resource:([^/]+)$/;
const RESOURCE_URL = /\/api\/resources\/([^/]+)\/file(?:[/?#]|$)/;
const LOCATOR_KEY = /(?:storage|resource|preview|poster|thumbnail|content|url|locator|asset)/i;
function resourceId(value: string) {
const storageMatch = value.match(RESOURCE_KEY);
if (storageMatch) return storageMatch[1];
return value.match(RESOURCE_URL)?.[1] || "";
}
function isMediaLocatorKey(key: string) {
return LOCATOR_KEY.test(key);
}
function collect(value: unknown, ids: Set<string>, key = "") {
if (typeof value === "string") {
if (isMediaLocatorKey(key)) {
const id = resourceId(value);
if (id) ids.add(id);
}
return;
}
if (Array.isArray(value)) {
value.forEach((item) => collect(item, ids, key));
return;
}
if (!value || typeof value !== "object") return;
Object.entries(value).forEach(([childKey, childValue]) => collect(childValue, ids, childKey));
}
export function canvasResourceIds(value: unknown) {
const ids = new Set<string>();
collect(value, ids);
return ids;
}
function clear(value: JsonValue, missing: Set<string>, serverReported: boolean, key = ""): JsonValue | undefined {
if (Array.isArray(value)) return value.map((item) => clear(item, missing, serverReported, key)).filter((item): item is JsonValue => item !== undefined);
if (!value || typeof value !== "object") {
if (typeof value === "string" && isMediaLocatorKey(key) && missing.has(resourceId(value))) return undefined;
return value;
}
const entries = Object.entries(value);
const invalidMedia =
!serverReported &&
entries.some(([childKey, childValue]) => {
if (typeof childValue !== "string" || !isMediaLocatorKey(childKey)) return false;
return missing.has(resourceId(childValue));
});
const output: Record<string, JsonValue> = {};
for (const [childKey, childValue] of entries) {
if (childKey === "assetId" && invalidMedia && !serverReported) continue;
if (serverReported && childKey !== "assetId" && isMediaLocatorKey(childKey)) continue;
if (typeof childValue === "string" && isMediaLocatorKey(childKey) && missing.has(resourceId(childValue))) continue;
if (childKey === "status" && childValue === "success" && invalidMedia) {
output[childKey] = "idle";
continue;
}
const next = clear(childValue, missing, serverReported, childKey);
if (next !== undefined) output[childKey] = next;
}
return output;
}
export function clearMissingCanvasResources<T>(value: T, missingIds: Set<string>, _scope = "", serverReported = false): T {
return clear(value as unknown as JsonValue, missingIds, serverReported) as T;
}

View File

@@ -362,7 +362,7 @@ export const useConfigStore = create<ConfigStore>()(
createModelChannel({
...channel,
id: channel.id || `system-${index + 1}`,
name: channel.name || `系统渠道 ${index + 1}`,
name: "系统模型",
scope: "system",
apiKey: channel.apiKey || "system",
}),

View File

@@ -5,8 +5,19 @@ import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
function canvas(patch: Partial<CanvasProject> = {}): CanvasProject {
return {
id: "canvas", title: "自由画布 1", createdAt: "", updatedAt: "", nodes: [], connections: [], chatSessions: [], activeChatId: null,
backgroundMode: "dots" as CanvasProject["backgroundMode"], showImageInfo: false, viewport: { x: 0, y: 0, k: 1 }, previsScenes: [], ...patch,
id: "canvas",
title: "自由画布 1",
createdAt: "",
updatedAt: "",
nodes: [],
connections: [],
chatSessions: [],
activeChatId: null,
backgroundMode: "dots" as CanvasProject["backgroundMode"],
showImageInfo: false,
viewport: { x: 0, y: 0, k: 1 },
previsScenes: [],
...patch,
};
}
@@ -23,23 +34,28 @@ describe("画布 operations 生成", () => {
});
test("修复旧 journal:移除新画布中重复的 set_field,保留 mutationId", () => {
const legacy = JSON.parse(JSON.stringify({
mutationId: "m-1",
operations: [
{ type: "create_canvas", id: "canvas", patch: { title: "自由画布 1" } },
{ type: "set_field", field: "title", value: "自由画布 1", beforeValue: undefined },
],
}));
const legacy = JSON.parse(
JSON.stringify({
mutationId: "m-1",
operations: [
{ type: "create_canvas", id: "canvas", patch: { title: "自由画布 1" } },
{ type: "set_field", field: "title", value: "自由画布 1", beforeValue: undefined },
],
}),
);
const repaired = repairPendingCanvasMutationRequest(legacy, undefined, canvas());
expect(repaired.mutationId).toBe("m-1");
expect(repaired.operations).toEqual([{ type: "create_canvas", id: "canvas", patch: { title: "自由画布 1" } }]);
});
test("修复旧 journal:移除空 projectId 到缺省值的语义 no-op", () => {
const legacy = { mutationId: "m-3", operations: [
{ type: "create_node", node: { id: "node-1" } },
{ type: "set_field", field: "projectId", value: null, beforeValue: "" },
] } as never;
const legacy = {
mutationId: "m-3",
operations: [
{ type: "create_node", node: { id: "node-1" } },
{ type: "set_field", field: "projectId", value: null, beforeValue: "" },
],
} as never;
const repaired = repairPendingCanvasMutationRequest(legacy, canvas({ projectId: "" }), canvas({ projectId: undefined }));
expect(repaired.mutationId).toBe("m-3");
expect(repaired.operations).toEqual([{ type: "create_node", node: { id: "node-1" } }]);

View File

@@ -35,7 +35,7 @@ test("same model display name groups all channels and preserves their prices", (
expect(groups).toHaveLength(1);
expect(config.channels.map((channel) => channel.modelCosts![0].description)).toEqual(["", "优惠渠道-993的使用说明", "特惠渠道-730的使用说明"]);
expect(groups.map((group) => [group.label, group.kind, group.models.map((item) => [item.label, item.models])])).toEqual([
["Seedance 2.0", "product", [["正常渠道", ["a::seedance-2.0"]], ["优惠渠道-993", ["b::seedance-2.0"]], ["特惠渠道-730", ["c::seedance-2.0"]]]],
["Seedance 2.0", "product", [["系统模型", ["a::seedance-2.0"]], ["优惠渠道-993", ["b::seedance-2.0"]], ["特惠渠道-730", ["c::seedance-2.0"]]]],
]);
expect(config.channels.map((channel) => priceTierSummaryLabel(priceTiersForCurrentSelection(channel.modelCosts![0].logicalPriceTiers!, "video", config)))).toEqual(["0.3 积分/秒", "0.3 积分/秒", "0.2 积分/秒"]);
});