feat(web): 角色与音频工作台 - 完善素材选择、生成参数与同步交互

This commit is contained in:
ddcat666
2026-10-01 09:43:02 +08:00
parent 5650da6d31
commit 22e7331d04
77 changed files with 3641 additions and 721 deletions

View File

@@ -27,7 +27,7 @@ export type AgentProfileView = {
layers: AgentProfileLayer[];
};
export type AgentApprovalPreviewOperation = "add_node" | "update_node" | "connect_nodes" | "arrange_nodes" | "generate_media" | "create_storyboard" | "edit_storyboard" | "plan_step";
export type AgentApprovalPreviewOperation = "add_node" | "update_node" | "connect_nodes" | "arrange_nodes" | "generate_media" | "create_storyboard" | "edit_storyboard" | "create_character" | "plan_step";
export type AgentApprovalPreviewItem = {
operation: AgentApprovalPreviewOperation;

View File

@@ -608,7 +608,7 @@ export function updateAdminDrawingEngineSetting(input: Pick<CanvasDrawingEngineS
return http.patch<{ setting: CanvasDrawingEngineSetting }>("/admin/settings/drawing-engine", input);
}
export type AdminApiLogParams = AdminListParams & { recordType?: "request" | "download" | "all" };
export type AdminApiLogParams = AdminListParams & { recordType?: "request" | "download" | "all"; capability?: "text" | "image" | "video" | "audio" };
export function listAdminApiLogs(params: AdminApiLogParams = {}) {
return http.get<{ logs: ApiCallLog[]; total: number; page: number; pageSize: number }>("/admin/api-logs", { params });
@@ -618,8 +618,16 @@ export function getAdminApiLog(id: string) {
return http.get<{ log: ApiCallLog }>(`/admin/api-logs/${encodeURIComponent(id)}`);
}
export function queryAdminApiLogTask(id: string) {
return http.post<AdminProviderTaskQueryResult>(`/admin/api-logs/${encodeURIComponent(id)}/query-task`);
export function queryAdminApiLogTask(id: string, providerRequestId?: string) {
return http.post<AdminProviderTaskQueryResult>(`/admin/api-logs/${encodeURIComponent(id)}/query-task`, providerRequestId ? { providerRequestId } : undefined, { timeout: 120_000 });
}
export function recoverAdminApiLogVideoByURL(id: string, url: string, providerRequestId?: string) {
return http.post<AdminProviderTaskQueryResult>(`/admin/api-logs/${encodeURIComponent(id)}/recover-url`, { url, providerRequestId }, { timeout: 120_000 });
}
export function batchRecoverAdminApiLogs(ids: string[]) {
return http.post<{ items: Array<{ logId: string; recovered: boolean; providerStatus?: string; error?: string }>; success: number; failed: number; skipped: number }>("/admin/api-logs/recover-batch", { ids }, { timeout: 180_000 });
}
export async function exportAdminApiLogs(params: AdminApiLogParams & { ids?: string[] } = {}) {

View File

@@ -1,3 +1,4 @@
import { normalizeAudioFormatForConfig, normalizeAudioVoiceForConfig } from "@/lib/audio-generation";
import { getMediaBlob } from "@/services/file-storage";
import { getImageBlob } from "@/services/image-storage";
import { resourceIdFromStorageKey, resourceStorageKey, uploadResourceFile } from "@/services/api/resources";
@@ -306,7 +307,7 @@ function backendGenerationTaskInput(options: BackendGenerationTaskOptions, prepa
referenceVideos: prepared.referenceVideos,
referenceAudios: prepared.referenceAudios,
mask: prepared.mask,
metadata: generationMetadata(config, mode, {
metadata: generationMetadata(config, {
...metadata,
...(options.clientOperationId ? { clientOperationId: options.clientOperationId } : {}),
...(options.retryOf ? { retryOf: options.retryOf } : {}),
@@ -316,7 +317,7 @@ function backendGenerationTaskInput(options: BackendGenerationTaskOptions, prepa
};
}
function generationMetadata(config: AiConfig, mode: BackendGenerationMode, metadata?: Record<string, unknown>) {
function generationMetadata(config: AiConfig, metadata?: Record<string, unknown>) {
const channel = resolveModelChannel(config, config.model);
const model = modelOptionName(config.model);
const modelCost = channel.modelCosts?.find((item) => item.model === model);
@@ -329,20 +330,8 @@ function generationMetadata(config: AiConfig, mode: BackendGenerationMode, metad
? existing[protocol] as Record<string, unknown>
: {};
const defaults = modelCost?.defaultOptions && typeof modelCost.defaultOptions === "object" ? modelCost.defaultOptions : {};
const audioOptions = mode === "audio" ? {
emo_control_method: config.audioEmotionControlMethod || "与音色参考音频相同",
emo_random: config.audioEmotionRandom === "true",
emo_happy: Number(config.audioEmotionHappy || 0),
emo_angry: Number(config.audioEmotionAngry || 0),
emo_sad: Number(config.audioEmotionSad || 0),
emo_afraid: Number(config.audioEmotionAfraid || 0),
emo_disgusted: Number(config.audioEmotionDisgusted || 0),
emo_melancholic: Number(config.audioEmotionMelancholic || 0),
emo_surprised: Number(config.audioEmotionSurprised || 0),
emo_calm: Number(config.audioEmotionCalm || 0),
} : {};
if (!Object.keys(defaults).length && !Object.keys(audioOptions).length && !Object.keys(namespace).length) return metadata;
return { ...metadata, providerOptions: { ...existing, [protocol]: { ...defaults, ...audioOptions, ...namespace } } };
if (!Object.keys(defaults).length && !Object.keys(namespace).length) return metadata;
return { ...metadata, providerOptions: { ...existing, [protocol]: { ...defaults, ...namespace } } };
}
async function prepareBackendMediaReference(media: ReferenceVideo | ReferenceAudio) {
@@ -420,9 +409,11 @@ export function backendProviderConfig(config: AiConfig, mode: BackendGenerationM
videoGenerateAudio: config.videoGenerateAudio,
videoWatermark: config.videoWatermark,
videoArkPrivateAssetUpload: config.videoArkPrivateAssetUpload,
audioVoice: config.audioVoice,
audioFormat: config.audioFormat,
audioVoice: normalizeAudioVoiceForConfig(config, config.audioVoice),
audioFormat: normalizeAudioFormatForConfig(config, config.audioFormat),
audioSpeed: config.audioSpeed,
audioLanguage: config.audioLanguage,
audioDialect: config.audioDialect,
audioInstructions: config.audioInstructions,
systemPrompt: config.systemPrompt,
};
@@ -462,9 +453,11 @@ function workflowProviderConfig(config: AiConfig, requestConfig: ReturnType<type
videoGenerateAudio: config.videoGenerateAudio,
videoWatermark: config.videoWatermark,
videoArkPrivateAssetUpload: config.videoArkPrivateAssetUpload,
audioVoice: config.audioVoice,
audioFormat: config.audioFormat,
audioVoice: normalizeAudioVoiceForConfig(config, config.audioVoice),
audioFormat: normalizeAudioFormatForConfig(config, config.audioFormat),
audioSpeed: config.audioSpeed,
audioLanguage: config.audioLanguage,
audioDialect: config.audioDialect,
audioInstructions: config.audioInstructions,
workflowId: workflow.workflowId,
webappId: workflow.webappId,
@@ -497,7 +490,7 @@ function logicalCapabilityOptions(config: AiConfig, mode: BackendGenerationMode)
: mode === "video"
? { size: config.size, videoSeconds: Number(config.videoSeconds), vquality: config.vquality, videoGenerateAudio: config.videoGenerateAudio === "true", videoWatermark: config.videoWatermark === "true" }
: mode === "audio"
? { audioVoice: config.audioVoice, audioFormat: config.audioFormat, audioSpeed: Number(config.audioSpeed) }
? { audioVoice: config.audioVoice, audioFormat: config.audioFormat, audioSpeed: Number(config.audioSpeed), audioLanguage: config.audioLanguage, audioDialect: config.audioDialect }
: {};
const filtered = Object.fromEntries(Object.entries(candidates).filter(([key]) => Boolean(spec?.options?.[key])));
// 只把前台模型声明过的参数送进能力匹配。未声明的 quality 不能因为画布选了 4K 档位

View File

@@ -1,4 +1,4 @@
import { http } from "@/services/api/request";
import { compactApiParams, http } from "@/services/api/request";
import { normalizeAssetCategory, type AssetCategory } from "@/lib/asset-category";
import type { GenerationTask } from "@/services/api/task-center";
@@ -519,6 +519,49 @@ export function listVoiceProfiles() {
return http.get<{ profiles: VoiceProfile[] }>("/voice-profiles");
}
export type CharacterListPage = {
characters: ProjectCharacterDetail[];
page: number;
pageSize: number;
total: number;
hasMore: boolean;
};
export function listCharacters(options: { page?: number; pageSize?: number; query?: string; ids?: string[] } = {}) {
return http.get<CharacterListPage>("/characters", {
params: compactApiParams({
page: options.page,
pageSize: options.pageSize,
q: options.query,
ids: options.ids?.length ? options.ids.join(",") : undefined,
}),
});
}
export function getCharacter(assetId: string) {
return http.get<ProjectCharacterDetail>(`/characters/${encodeURIComponent(assetId)}`);
}
export function createCharacter(input: { name: string; definition?: Record<string, unknown>; imageResourceId?: string; audioResourceId?: string; voiceName?: string; instructions?: string }) {
return http.post<ProjectCharacterDetail>("/characters", input);
}
export function updateCharacter(assetId: string, input: { name: string; definition: Record<string, unknown> }) {
return http.patch<ProjectCharacterDetail>(`/characters/${encodeURIComponent(assetId)}`, input);
}
export function replaceCharacterRepresentations(assetId: string, representations: Array<{ role: string; resourceId: string; metadata?: Record<string, unknown> }>) {
return http.put<ProjectCharacterDetail>(`/characters/${encodeURIComponent(assetId)}/representations`, { representations });
}
export function bindCharacterVoice(assetId: string, input: { sampleResourceId?: string; voiceName?: string; instructions?: string }) {
return http.put<ProjectCharacterDetail>(`/characters/${encodeURIComponent(assetId)}/voice`, input);
}
export function unbindCharacterVoice(assetId: string) {
return http.delete<ProjectCharacterDetail>(`/characters/${encodeURIComponent(assetId)}/voice`);
}
export function createProjectCharacter(projectId: string, input: { name: string; definition?: Record<string, unknown> }) {
return http.post<ProjectCharacterDetail>(`/projects/${encodeURIComponent(projectId)}/characters`, input);
}

View File

@@ -1,4 +1,15 @@
import { deleteRemoteAssets, deleteRemoteCanvasProject, getRemoteAsset, getRemoteAssetsByIds, getRemoteCanvasProject, getRemoteUserDataSnapshot, listRemoteAssetsPage, restoreRemoteCanvasHistory, upsertRemoteAsset, upsertRemoteCanvasProject } from "@/services/api/user-data";
import {
deleteRemoteAssets,
deleteRemoteCanvasProject,
getRemoteAsset,
getRemoteAssetsByIds,
getRemoteCanvasProject,
getRemoteUserDataSnapshot,
listRemoteAssetsPage,
restoreRemoteCanvasHistory,
upsertRemoteAsset,
upsertRemoteCanvasProject,
} from "@/services/api/user-data";
import { ApiError } from "@/services/api/request";
import { canvasContentHash, sameCanvasContent } from "@/lib/canvas/canvas-content";
import { getActiveUserScope } from "@/lib/user-scope";
@@ -14,7 +25,7 @@ import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
import { useCanvasHistoryStore } from "@/stores/canvas/use-canvas-history-store";
import { repairMissingCanvasAssets, repairMissingCanvasVideoPreviews, collectCanvasMediaAssetIds, rebindInconsistentCanvasAssets, type CanvasAssetRebindResult } from "@/services/canvas-asset-repair";
import { canvasNodeToAsset } from "@/lib/canvas/canvas-node-asset";
import { applyAgentCanvasPatch, type AgentCanvasPatch } from "@/lib/canvas/agent-canvas-patch";
import { applyAgentCanvasPatch, mergeAgentCanvasEditor, type AgentCanvasPatch } from "@/lib/canvas/agent-canvas-patch";
import { collectLocalMediaKeys, ensureRemoteResourceReferences } from "./user-data-sync-media";
export { numberValue } from "./user-data-sync-media";
@@ -105,7 +116,7 @@ export async function loadCanvasProjectForEditing(id: string, options: { latest?
const hash = await canvasContentHash(remote);
if (epoch !== sessionEpoch || getActiveUserScope() !== scope) throw new Error("账号已切换,请重新打开画布");
const local = useCanvasStore.getState().openProject(id);
const cachedClean = local?.remoteContentHash && local.remoteContentHash === await canvasContentHash(local);
const cachedClean = local?.remoteContentHash && local.remoteContentHash === (await canvasContentHash(local));
const dirty = local && (!verifiedProjects.has(id) && incrementalSession ? !cachedClean : !sameCanvasContent(acknowledgedProjects.get(id), local));
if (dirty && !sameCanvasContent(local, remote)) {
const draftCount = await preserveCanvasSyncDraft(local, scope);
@@ -135,13 +146,15 @@ export async function loadCanvasProjectForEditing(id: string, options: { latest?
options.onLoad?.(project);
acknowledgedProjects.set(id, project);
verifiedProjects.add(id);
useCanvasStore.setState((state) => ({ projects: local ? state.projects.map((item) => item.id === id ? project : item) : [...state.projects, project] }));
useCanvasStore.setState((state) => ({ projects: local ? state.projects.map((item) => (item.id === id ? project : item)) : [...state.projects, project] }));
await flushCanvasStorePersistence();
useSyncProgressStore.getState().setProjectProgress(id, { phase: "done", message: "已加载云端最新版本" });
return project;
});
remoteProjectLoadPromises.set(id, request);
const clearPending = () => { if (remoteProjectLoadPromises.get(id) === request) remoteProjectLoadPromises.delete(id); };
const clearPending = () => {
if (remoteProjectLoadPromises.get(id) === request) remoteProjectLoadPromises.delete(id);
};
void request.then(clearPending, clearPending);
return request;
}
@@ -152,7 +165,9 @@ const agentCanvasListeners = new Set<(project: CanvasProject, previous: CanvasPr
export function subscribeAgentCanvasRefresh(listener: (project: CanvasProject, previous: CanvasProject | undefined) => void) {
agentCanvasListeners.add(listener);
return () => { agentCanvasListeners.delete(listener); };
return () => {
agentCanvasListeners.delete(listener);
};
}
export async function refreshCanvasAfterAgent(id: string) {
@@ -164,20 +179,31 @@ export async function refreshCanvasAfterAgent(id: string) {
if (epoch !== sessionEpoch) throw new Error("账号已切换");
const current = useCanvasStore.getState().projects.find((candidate) => candidate.id === id);
const baseline = acknowledgedProjects.get(id);
const cachedDirty = current && incrementalSession && !verifiedProjects.has(id) && current.remoteContentHash !== await canvasContentHash(current);
const cachedDirty = current && incrementalSession && !verifiedProjects.has(id) && current.remoteContentHash !== (await canvasContentHash(current));
const baselineClean = baseline && baseline.remoteContentHash === (await canvasContentHash(baseline));
if (epoch !== sessionEpoch) throw new Error("账号已切换");
let projected = project;
if (current && (cachedDirty || !sameCanvasContent(baseline, current)) && !sameCanvasContent(current, project)) {
// Replayed events can request a snapshot while local edits await saving.
// An unchanged, verified ancestor is not a concurrent cloud edit.
// A missing/out-of-order Agent delta is recoverable when local and remote
// edits touch different fields. Rebase the cloud snapshot onto the live
// editor before escalating to a user-visible conflict.
if (!cachedDirty && current.revision === project.revision && baseline?.revision === project.revision && sameCanvasContent(baseline, project)) {
useSyncProgressStore.getState().setProjectProgress(id, { phase: "pending", message: "本地修改等待保存到云端" });
scheduleRemoteUserDataSync();
return current;
}
await preserveAgentConflict(current);
throw new Error("Agent 已更新服务端画布,但本地存在未同步编辑。已保留本地草稿,请加载云端最新版本。");
try {
if (!baseline || (cachedDirty && !baselineClean)) throw new Error("缺少画布合并基线");
projected = mergeAgentCanvasEditor(baseline, project, current.nodes, current.connections, current);
} catch (error) {
await preserveAgentConflict(current);
throw error instanceof Error ? error : new Error("Agent 更新与本地编辑存在不可自动合并的冲突");
}
}
const projected = { ...project, viewport: current?.viewport || project.viewport, remoteContentHash: await canvasContentHash(project) };
const hash = await canvasContentHash(project);
projected = { ...projected, viewport: current?.viewport || project.viewport, remoteContentHash: hash };
if (epoch !== sessionEpoch || useCanvasStore.getState().openProject(id) !== (current || null)) throw new Error("画布仍在更新,请重新同步 Agent 结果");
try {
if (!sameCanvasContent(current, projected)) {
@@ -187,11 +213,13 @@ export async function refreshCanvasAfterAgent(id: string) {
if (current) await preserveAgentConflict(current);
throw error;
}
acknowledgedProjects.set(id, projected);
acknowledgedProjects.set(id, { ...project, viewport: projected.viewport, remoteContentHash: hash });
verifiedProjects.add(id);
useCanvasStore.setState((state) => ({ projects: [...state.projects.filter((candidate) => candidate.id !== id), projected] }));
await flushCanvasStorePersistence();
useSyncProgressStore.getState().setProjectProgress(id, { phase: "done", message: "已同步 Agent 画布结果" });
const pending = !sameCanvasContent(project, projected);
useSyncProgressStore.getState().setProjectProgress(id, { phase: pending ? "pending" : "done", message: pending ? "Agent 结果已合并,本地修改等待保存" : "已同步 Agent 画布结果" });
if (pending) scheduleRemoteUserDataSync();
return projected;
});
}
@@ -207,10 +235,17 @@ export async function applyAgentCanvasPatches(id: string, patches: AgentCanvasPa
let projected = current;
// A rejected delta requests a snapshot through createAgentCanvasSync.
// Only that reconciliation can distinguish stale replay from a conflict.
if (incrementalSession && !verifiedProjects.has(id) && baseline.remoteContentHash !== await canvasContentHash(baseline)) throw new Error("本地缓存尚未核对,请加载云端最新版本");
if (incrementalSession && !verifiedProjects.has(id) && baseline.remoteContentHash !== (await canvasContentHash(baseline))) throw new Error("本地缓存尚未核对,请加载云端最新版本");
if (current.revision !== baseline.revision || !Number.isSafeInteger(baseline.revision)) throw new Error("画布同步基线已变化");
for (const patch of patches) {
const orderedPatches: AgentCanvasPatch[] = [];
const seenRevisions = new Set<number>();
for (const patch of [...patches].sort((left, right) => (left.revision ?? 0) - (right.revision ?? 0))) {
if (!Number.isSafeInteger(patch.revision) || !Number.isSafeInteger(patch.baseRevision)) throw new Error("Agent 增量缺少版本,请重新读取画布");
if (seenRevisions.has(patch.revision!)) continue;
seenRevisions.add(patch.revision!);
orderedPatches.push(patch);
}
for (const patch of orderedPatches) {
if (patch.revision! <= remote.revision!) continue;
if (patch.baseRevision !== remote.revision || patch.revision !== patch.baseRevision! + 1) throw new Error("Agent 画布增量版本不连续,请重新读取画布");
remote = { ...applyAgentCanvasPatch(remote, patch), revision: patch.revision };
@@ -227,7 +262,7 @@ export async function applyAgentCanvasPatches(id: string, patches: AgentCanvasPa
acknowledgedProjects.set(id, remote);
verifiedProjects.add(id);
if (projected === current) return current;
useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => project.id === id ? projected : project) }));
useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => (project.id === id ? projected : project)) }));
await flushCanvasStorePersistence();
useSyncProgressStore.getState().setProjectProgress(id, { phase: sameCanvasContent(remote, projected) ? "done" : "pending", message: "已同步 Agent 画布结果" });
return projected;
@@ -296,8 +331,8 @@ export async function loadAssetsForUse(ids: Iterable<string>) {
}
export async function syncRemoteUserData(userId?: string | null) {
// 登录/切换账号时,服务端快照建立新的远端基线;本地 IndexedDB 只负责首屏缓存,
// 不能把服务端已经删除或当前用户无权访问的实体重新补回去。后续增量保存必须基于这份基线做冲突校验。
// 登录/切换账号时,服务端快照建立新的远端基线;本地 IndexedDB 只负责首屏缓存,
// 不能把服务端已经删除或当前用户无权访问的实体重新补回去。后续增量保存必须基于这份基线做冲突校验。
await withRemoteUserDataSyncExclusive(async () => {
incrementalSession = false;
activeRemoteUserId = userId || "";
@@ -316,15 +351,17 @@ export async function syncRemoteUserData(userId?: string | null) {
const remoteById = new Map(snapshot.projects.map((project) => [project.id, project]));
// Archive unsynced work before replacing the active cache, including deleted remote canvases.
for (const local of localProjects) {
const clean = local.remoteContentHash && local.remoteContentHash === await canvasContentHash(local);
const clean = local.remoteContentHash && local.remoteContentHash === (await canvasContentHash(local));
if (!clean && !sameCanvasContent(local, remoteById.get(local.id))) await preserveCanvasSyncDraft(local);
}
const projects = await Promise.all(snapshot.projects.map(async (project) => {
const local = localProjects.find((item) => item.id === project.id);
const draftCount = (await readCanvasSyncDrafts(project.id)).length;
useSyncProgressStore.getState().setProjectProgress(project.id, { phase: "done", draftCount, message: "已保存到云端" });
return { ...project, viewport: local?.viewport || project.viewport || { x: 0, y: 0, k: 1 }, remoteContentHash: await canvasContentHash(project) };
}));
const projects = await Promise.all(
snapshot.projects.map(async (project) => {
const local = localProjects.find((item) => item.id === project.id);
const draftCount = (await readCanvasSyncDrafts(project.id)).length;
useSyncProgressStore.getState().setProjectProgress(project.id, { phase: "done", draftCount, message: "已保存到云端" });
return { ...project, viewport: local?.viewport || project.viewport || { x: 0, y: 0, k: 1 }, remoteContentHash: await canvasContentHash(project) };
}),
);
if (useCanvasStore.getState().projects !== localProjects) throw new Error("本地画布仍在更新,已保留本地内容,请重新同步");
useCanvasStore.getState().replaceProjects(projects);
useAssetStore.getState().replaceAssets(parseAssetRecordList(snapshot.assets));
@@ -391,7 +428,12 @@ export function hasRemoteUserDataSyncSession() {
* 结果仍原样返回,由调用方决定如何提示或重试,避免同步层把写入失败伪装成成功。
*/
export function withRemoteUserDataSyncExclusive<T>(operation: () => Promise<T>): Promise<T> {
const pending = remoteOperationTail.then(() => undefined, () => undefined).then(operation);
const pending = remoteOperationTail
.then(
() => undefined,
() => undefined,
)
.then(operation);
remoteOperationTail = pending.then(
() => undefined,
() => undefined,
@@ -448,10 +490,7 @@ function scheduleRemoteUserDataRetry(error: unknown) {
stopRetryProgress();
return false;
}
const delayMs = Math.max(
REMOTE_SYNC_RETRY_DELAYS_MS[syncRetryAttempt],
error instanceof ApiError ? error.retryAfterMs || 0 : 0,
);
const delayMs = Math.max(REMOTE_SYNC_RETRY_DELAYS_MS[syncRetryAttempt], error instanceof ApiError ? error.retryAfterMs || 0 : 0);
syncRetryAttempt += 1;
if (syncTimer) window.clearTimeout(syncTimer);
updateRetryProgress(delayMs);
@@ -525,10 +564,7 @@ export async function deleteAssetsWithRemoteSync(ids: string[]) {
if (epoch !== sessionEpoch) throw new Error("账号已切换,请刷新原账号素材库确认删除结果");
// 列表查询也会进入同步队列,必须在退出删除队列后触发,不能在队列内等待刷新。
// 刷新慢或失败不应阻塞确认弹窗关闭,也不能把已完成的删除报告为失败。
void Promise.all([
appQueryClient.invalidateQueries({ queryKey: ["asset-library"] }, { throwOnError: true }),
appQueryClient.invalidateQueries({ queryKey: ["asset-picker"] }, { throwOnError: true }),
]).catch((error) => {
void Promise.all([appQueryClient.invalidateQueries({ queryKey: ["asset-library"] }, { throwOnError: true }), appQueryClient.invalidateQueries({ queryKey: ["asset-picker"] }, { throwOnError: true })]).catch((error) => {
console.warn("素材删除后列表刷新失败", error);
});
}
@@ -649,14 +685,13 @@ export async function deleteCanvasProjectsWithRemoteSync(ids: string[]) {
}
export async function saveRemoteUserDataNow(input?: string | readonly string[] | { force?: boolean; repairMissingResources?: boolean }) {
const projectId = typeof input === "string" || Array.isArray(input) ? input as string | readonly string[] : undefined;
const options = input && typeof input === "object" && !Array.isArray(input) ? input as { force?: boolean; repairMissingResources?: boolean } : {};
const projectId = typeof input === "string" || Array.isArray(input) ? (input as string | readonly string[]) : undefined;
const options = input && typeof input === "object" && !Array.isArray(input) ? (input as { force?: boolean; repairMissingResources?: boolean }) : {};
const epoch = sessionEpoch;
if (!activeRemoteUserId) throw new Error("尚未建立云端同步会话,本地内容尚未保存到云端");
requireRemoteUserDataBaseline();
const assertNoConflict = () => {
const ids = projectId === undefined ? useCanvasStore.getState().projects.map((project) => project.id)
: typeof projectId === "string" ? [projectId] : projectId;
const ids = projectId === undefined ? useCanvasStore.getState().projects.map((project) => project.id) : typeof projectId === "string" ? [projectId] : projectId;
if (ids.some((id) => useSyncProgressStore.getState().syncingProjects[id]?.phase === "conflict")) {
throw new ApiError("云端画布已有更新,请保留本地草稿并加载最新版本", { status: 409 });
}
@@ -742,7 +777,12 @@ async function drainRemoteUserDataChanges(options: { force?: boolean; repairMiss
async function saveRemoteUserDataBatch(uploaded: Map<string, string>, options: { force?: boolean; repairMissingResources?: boolean } = {}) {
// 中央兜底:任何调用方只要把持久媒体写进画布,提交前都会先补齐素材记录与 assetId。
// 页面级入口仍主动入库,以便立即反馈;这里负责阻止遗漏入口形成远端幽灵资源。
const changedProjectIds = new Set(useCanvasStore.getState().projects.filter((project) => !sameCanvasContent(acknowledgedProjects.get(project.id), project)).map((project) => project.id));
const changedProjectIds = new Set(
useCanvasStore
.getState()
.projects.filter((project) => !sameCanvasContent(acknowledgedProjects.get(project.id), project))
.map((project) => project.id),
);
repairMissingCanvasAssets(changedProjectIds, incrementalSession);
const currentProjects = useCanvasStore.getState().projects;
const currentAssets = useAssetStore.getState().assets;
@@ -779,73 +819,103 @@ async function saveRemoteUserDataBatch(uploaded: Map<string, string>, options: {
const keysToUpload = collectLocalMediaKeys(source);
const total = keysToUpload.length;
useSyncProgressStore.getState().setProjectProgress(source.id, {
projectId: source.id,
total,
completed: 0,
phase: total > 0 ? "uploading" : "saving",
message: total > 0 ? "正在同步媒体至云端" : "正在保存画布",
});
projectId: source.id,
total,
completed: 0,
phase: total > 0 ? "uploading" : "saving",
message: total > 0 ? "正在同步媒体至云端" : "正在保存画布",
});
const onMediaUploaded = () => {
if (total > 0) {
useSyncProgressStore.getState().incrementProjectCompleted(source.id);
}
};
try {
if (!Number.isSafeInteger(source.revision) || source.revision! < 0) {
throw new ApiError("缺少画布版本,请保留草稿并加载云端最新版本", { status: 428 });
}
const hash = await canvasContentHash(source);
const remotePayload = await ensureRemoteResourceReferences(source, uploaded, onMediaUploaded);
if (total > 0) {
useSyncProgressStore.getState().setProjectProgress(source.id, {
phase: "saving",
message: "正在保存画布结构",
});
}
const { project: saved } = await upsertRemoteCanvasProject(sanitizeCanvasProjectForRemoteSync(remotePayload), { repairMissingResources: options.repairMissingResources === true });
if (!Number.isSafeInteger(saved.revision) || saved.revision !== source.revision! + 1) {
throw new ApiError("服务端未返回有效画布版本,请加载云端最新版本", { status: 409 });
}
const current = useCanvasStore.getState().openProject(source.id);
if (current && current.revision !== source.revision) {
throw new ApiError("画布基线已变化,请加载云端最新版本", { status: 409 });
}
const acknowledged = { ...source, revision: saved.revision, remoteContentHash: hash };
acknowledgedProjects.set(source.id, acknowledged);
verifiedProjects.add(source.id);
if (current) {
useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => project === current
? { ...project, revision: saved.revision, remoteContentHash: hash } : project) }));
}
await flushCanvasStorePersistence();
const pending = !sameCanvasContent(source, useCanvasStore.getState().openProject(source.id) || undefined);
useSyncProgressStore.getState().setProjectProgress(source.id, { phase: pending ? "pending" : "done", message: pending ? "有新修改等待保存" : "已保存到云端" });
if (pending) syncQueued = true;
} catch (error) {
const conflict = error instanceof ApiError && error.reason !== "canvas_history_resources_missing" && (error.status === 409 || error.status === 428);
if (conflict) {
let candidate = source;
let saved = false;
let lastError: unknown;
for (let attempt = 0; attempt < 3 && !saved; attempt += 1) {
if (!Number.isSafeInteger(candidate.revision) || candidate.revision! < 0) {
throw new ApiError("缺少画布版本,请保留草稿并加载云端最新版本", { status: 428 });
}
const hash = await canvasContentHash(candidate);
const remotePayload = await ensureRemoteResourceReferences(candidate, uploaded, onMediaUploaded);
if (total > 0) {
useSyncProgressStore.getState().setProjectProgress(source.id, {
phase: attempt > 0 ? "reconciling" : "saving",
message: attempt > 0 ? "正在自动合并云端与本地修改" : "正在保存画布结构",
});
}
try {
const { project: remote } = await getRemoteCanvasProject(source.id);
const { project: savedProject } = await upsertRemoteCanvasProject(sanitizeCanvasProjectForRemoteSync(remotePayload), { repairMissingResources: options.repairMissingResources === true });
if (!Number.isSafeInteger(savedProject.revision) || savedProject.revision !== candidate.revision! + 1) {
throw new ApiError("服务端未返回有效画布版本,请加载云端最新版本", { status: 409 });
}
const current = useCanvasStore.getState().openProject(source.id);
if (current && current === useCanvasStore.getState().openProject(source.id) && sameCanvasContent(current, remote)) {
const hash = await canvasContentHash(remote);
if (current === useCanvasStore.getState().openProject(source.id)) {
const reconciled = { ...remote, viewport: current.viewport, remoteContentHash: hash };
if (current && current.revision !== candidate.revision) {
throw new ApiError("画布基线已变化,请加载云端最新版本", { status: 409 });
}
const acknowledged = { ...candidate, revision: savedProject.revision, remoteContentHash: hash };
acknowledgedProjects.set(source.id, acknowledged);
verifiedProjects.add(source.id);
if (current) {
useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => (project === current ? { ...project, revision: savedProject.revision, remoteContentHash: hash } : project)) }));
}
await flushCanvasStorePersistence();
const pending = !sameCanvasContent(candidate, useCanvasStore.getState().openProject(source.id) || undefined);
useSyncProgressStore.getState().setProjectProgress(source.id, { phase: pending ? "pending" : "done", message: pending ? "有新修改等待保存" : "已保存到云端" });
if (pending) syncQueued = true;
saved = true;
continue;
} catch (error) {
lastError = error;
const conflict = error instanceof ApiError && error.reason !== "canvas_history_resources_missing" && (error.status === 409 || error.status === 428);
if (!conflict || attempt >= 2) throw error;
try {
const { project: remote } = await getRemoteCanvasProject(source.id);
const current = useCanvasStore.getState().openProject(source.id);
if (!current || current !== useCanvasStore.getState().openProject(source.id)) throw error;
if (sameCanvasContent(current, remote)) {
const remoteHash = await canvasContentHash(remote);
const reconciled = { ...remote, viewport: current.viewport, remoteContentHash: remoteHash };
acknowledgedProjects.set(source.id, reconciled);
verifiedProjects.add(source.id);
useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => project.id === source.id ? reconciled : project) }));
useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => (project.id === source.id ? reconciled : project)) }));
await flushCanvasStorePersistence();
useSyncProgressStore.getState().setProjectProgress(source.id, { phase: "done", message: "云端内容一致,已自动校准版本" });
saved = true;
continue;
}
const baseline = acknowledgedProjects.get(source.id);
if (!baseline) throw error;
let rebased: CanvasProject;
try {
rebased = mergeAgentCanvasEditor(baseline, remote, current.nodes, current.connections, current);
} catch (mergeError) {
throw new ApiError(mergeError instanceof Error ? mergeError.message : "本地与云端存在同一字段修改", { status: 409 });
}
const remoteHash = await canvasContentHash(remote);
if (useCanvasStore.getState().openProject(source.id) !== current) {
throw new ApiError("画布在自动合并期间发生新修改,请重试", { status: 409 });
}
candidate = { ...rebased, viewport: current.viewport, remoteContentHash: remoteHash };
acknowledgedProjects.set(source.id, { ...remote, viewport: current.viewport, remoteContentHash: remoteHash });
verifiedProjects.add(source.id);
useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => (project.id === source.id ? candidate : project)) }));
await flushCanvasStorePersistence();
useSyncProgressStore.getState().setProjectProgress(source.id, { phase: "reconciling", message: "检测到云端更新,正在自动合并本地修改" });
} catch (rebaseError) {
lastError = rebaseError;
throw rebaseError;
}
} catch {
// Keep the original conflict and preserve the local draft below.
}
}
if (!saved && lastError) throw lastError;
} catch (error) {
const conflict = error instanceof ApiError && error.reason !== "canvas_history_resources_missing" && (error.status === 409 || error.status === 428);
useSyncProgressStore.getState().setProjectProgress(source.id, {
phase: conflict ? "conflict" : "error",
message: error instanceof Error ? error.message : "云端同步失败,等待重试",
message: conflict ? "本地与云端存在同一字段修改,请选择保留的版本" : error instanceof Error ? error.message : "云端同步失败,等待重试",
});
if (conflict) {
try {