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