feat(cloud-agent): 云端 Agent - 完善运行时与资源持久化

This commit is contained in:
ddcat666
2026-09-26 17:05:36 +08:00
parent 48e6a705ef
commit 993580a7bd
40 changed files with 1739 additions and 440 deletions

View File

@@ -6,7 +6,7 @@ import { creationAttachmentKind, type CreationAttachment } from "./creation-asse
import type { CreationConversation, CreationMessage, CreationShotRailEntry } from "./creation-types";
type CreationRuntime = typeof import("./creation-runtime");
type PersistedCreationTask = GenerationTask & { creationResultUrls?: string[]; creationError?: string };
type PersistedCreationTask = GenerationTask & { creationResultUrls?: string[]; creationResultStorageKeys?: string[]; creationError?: string };
export function newConversation(): CreationConversation {
return { id: createClientId(), title: "新创作", updatedAt: new Date().toISOString(), messages: [] };
@@ -76,7 +76,8 @@ export async function materializeCreationTaskResults(runtime: CreationRuntime, t
try {
const materialized = await runtime.runGenerationConsumer(signal, (managedSignal: AbortSignal) => runtime.materializeGenerationTaskAssets(task, managedSignal));
const creationResultUrls = runtime.generationTaskMaterializedUrls(materialized);
return creationResultUrls.length ? { ...materialized, creationResultUrls } : materialized;
const creationResultStorageKeys = runtime.generationTaskMaterializedStorageKeys(materialized);
return creationResultUrls.length || creationResultStorageKeys.length ? { ...materialized, ...(creationResultUrls.length ? { creationResultUrls } : {}), ...(creationResultStorageKeys.length ? { creationResultStorageKeys } : {}) } : materialized;
} catch (error) {
return { ...task, creationError: error instanceof Error ? error.message : "生成结果资源化失败" };
}
@@ -105,16 +106,18 @@ export function reconcileCreationTaskMessages(runtime: CreationRuntime, conversa
const expectedTaskCount = Math.max(0, ...matches.map((task) => task.clientContext?.batchCount || 0));
if (!matches.length || (expectedTaskCount > 0 && matches.length < expectedTaskCount) || matches.some((task) => task.status === "queued" || task.status === "running")) return message;
const resultUrls = Array.from(new Set(matches.filter((task) => task.status === "succeeded").flatMap(creationTaskResultUrls)));
const succeeded = matches.filter((task) => task.status === "succeeded");
const resultUrls = Array.from(new Set(succeeded.flatMap(creationTaskResultUrls)));
const resultStorageKeys = Array.from(new Set(succeeded.flatMap(creationTaskResultStorageKeys)));
const failedCount = matches.filter((task) => task.status !== "succeeded" || Boolean(task.creationError)).length;
const nextTaskIds = Array.from(new Set([...(message.taskIds || []), ...matches.map((task) => task.id)]));
completedAt = matches.reduce((latest, task) => conversationTimestamp(task.updatedAt) > conversationTimestamp(latest) ? task.updatedAt : latest, completedAt);
conversationChanged = true;
changed = true;
if (resultUrls.length) {
const content = message.mode === "video" ? "视频已生成" : failedCount ? `${resultUrls.length} 张图片已生成,${failedCount} 张失败` : "图片已生成";
return { ...message, status: "done" as const, content, resultUrls, error: undefined, taskIds: nextTaskIds };
if (resultUrls.length || resultStorageKeys.length) {
const content = message.mode === "video" ? "视频已生成" : failedCount ? `${resultStorageKeys.length || resultUrls.length} 张图片已生成,${failedCount} 张失败` : "图片已生成";
return { ...message, status: "done" as const, content, ...(resultUrls.length ? { resultUrls } : {}), ...(resultStorageKeys.length ? { resultStorageKeys } : {}), error: undefined, taskIds: nextTaskIds };
}
if (matches.every((task) => task.status === "cancelled")) {
return { ...message, status: "cancelled" as const, content: "已停止", error: undefined, taskIds: nextTaskIds };
@@ -132,6 +135,11 @@ function creationTaskResultUrls(task: PersistedCreationTask) {
return [];
}
function creationTaskResultStorageKeys(task: PersistedCreationTask) {
if (task.creationResultStorageKeys?.length) return task.creationResultStorageKeys;
return [];
}
export function conversationTimestamp(value: string) {
const timestamp = new Date(value).getTime();
return Number.isFinite(timestamp) ? timestamp : 0;

View File

@@ -3,7 +3,7 @@ export { isGenerationTaskCancelled, runBackendGenerationTask, runBackendGenerati
export { subscribeGenerationTasks } from "@/services/api/task-center";
export { uploadMediaFile } from "@/services/file-storage";
export { uploadImage } from "@/services/image-storage";
export { consumeGenerationTaskMessage, generationTaskMaterializedUrls, materializeGenerationTaskAssets, projectGenerationTaskResult } from "@/services/project-asset-sync";
export { consumeGenerationTaskMessage, generationTaskMaterializedStorageKeys, generationTaskMaterializedUrls, materializeGenerationTaskAssets, projectGenerationTaskResult } from "@/services/project-asset-sync";
export { applyGenerationConsumerEffect } from "@/services/generation-consumer-dedupe";
export { beginGenerationConsumer, runGenerationConsumer } from "@/services/generation-consumer-lifecycle";
export { recoverCreationTextTask } from "@/services/creation-text-task-recovery";

View File

@@ -18,7 +18,10 @@ export type CreationMessage = {
createdAt: string;
status?: CreationStatus;
model?: string;
/** 当前会话内的临时访问地址;长期身份使用 resultStorageKeys。 */
resultUrls?: string[];
/** 生成结果对应的稳定素材定位符,不随 OSS 签名 URL 过期。 */
resultStorageKeys?: string[];
error?: string;
generationErrorCode?: string;
generationOperation?: string;

View File

@@ -14,7 +14,7 @@ import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown";
import { GenerationToolCard, type GenerationToolStatus } from "@/components/ai/generation-tool-card";
import { WorkingDots, WorkingGlow } from "@/components/ai/working-indicator";
import { MessageReasoning } from "@/components/ai/message-reasoning";
import { creationResultAssetIds } from "@/lib/canvas/canvas-asset-handoff";
import { creationResultAssetIds, creationResultStorageKeys } from "@/lib/canvas/canvas-asset-handoff";
import { generationErrorMessage } from "@/lib/generation-error";
import { formatVideoResolutionLabel as videoResolutionLabel } from "@/lib/video-generation-options";
import { useAssetStore } from "@/stores/use-asset-store";
@@ -36,7 +36,9 @@ import { mergedImageCapabilityConfig, type ModelRequirements } from "@/lib/model
import { modelQuoteDescription, modelQuoteRequest } from "@/lib/model-pricing";
import type { Skill } from "@/services/api/skills";
import { quoteModel, type LogicalModelQuote } from "@/services/api/logical-models";
import { resolveResourceUrl } from "@/services/api/resources";
import { getResourceAccess, resolveResourceAccessURL, resolveResourceUrl, resourceIdFromStorageKey } from "@/services/api/resources";
import { resolveMediaUrl } from "@/services/file-storage";
import { resolveImageUrl } from "@/services/image-storage";
import { modelOptionName, resolveModelChannel, type AiConfig } from "@/stores/use-config-store";
import { useAppearanceStore } from "@/stores/use-appearance-store";
import { useUserStore } from "@/stores/use-user-store";
@@ -253,16 +255,76 @@ function MediaResult({ item, onRetryFailure, onCreateVariant, onContinueCanvas,
const [previewUrl, setPreviewUrl] = useState("");
const [previewType, setPreviewType] = useState<"image" | "video">("image");
const assets = useAssetStore((state) => state.assets);
const resultUrls = item.resultUrls || [];
const resultAssetIds = resultUrls.length ? creationResultAssetIds(assets, { messageId: item.id, taskIds: item.taskIds || [], resultUrls }) : [];
const canContinueWithResults = resultUrls.length > 0 && resultAssetIds.length === resultUrls.length;
const storedResultUrls = item.resultUrls || [];
const resultStorageKeys = item.resultStorageKeys?.length
? item.resultStorageKeys
: creationResultStorageKeys(assets, { messageId: item.id, taskIds: item.taskIds || [], resultUrls: storedResultUrls });
const [resolvedResultUrls, setResolvedResultUrls] = useState(storedResultUrls);
useEffect(() => {
let active = true;
const timers: ReturnType<typeof setTimeout>[] = [];
const updateResultUrl = (index: number, url: string) => {
if (!active) return;
setResolvedResultUrls((current) => {
const next = [...current];
next[index] = url;
return next;
});
};
const refreshRemote = async (index: number, storageKey: string): Promise<void> => {
try {
const access = await getResourceAccess(storageKey, "display");
if (!active) return;
updateResultUrl(index, resolveResourceAccessURL(access.url));
const refreshAt = access.refreshAt ? new Date(access.refreshAt).getTime() : Number.NaN;
const expiresAt = access.expiresAt ? new Date(access.expiresAt).getTime() : Number.NaN;
const nextRefreshAt = Number.isFinite(refreshAt) ? refreshAt : expiresAt;
const delay = Number.isFinite(nextRefreshAt) ? Math.max(10_000, nextRefreshAt - Date.now()) : 4 * 60_000;
timers.push(setTimeout(() => void refreshRemote(index, storageKey), delay));
} catch {
if (active) timers.push(setTimeout(() => void refreshRemote(index, storageKey), 60_000));
}
};
if (!resultStorageKeys.length) {
setResolvedResultUrls(storedResultUrls);
return () => {
active = false;
};
}
// 历史记录只把 URL 当作短期 hint;真正恢复依赖稳定 storageKey,展示 URL 由访问缓存按需续签。
setResolvedResultUrls(storedResultUrls);
const resolver = item.mode === "video" ? resolveMediaUrl : resolveImageUrl;
void Promise.all(
resultStorageKeys.map(async (storageKey, index) => {
if (resourceIdFromStorageKey(storageKey)) {
await refreshRemote(index, storageKey);
return;
}
const url = await resolver(storageKey, storedResultUrls[index] || "");
updateResultUrl(index, url);
}),
).catch(() => {
// 保留当前页面已有的 URL hint;若已过期,页面会走明确的媒体空态而不是回退到平台正文代理。
});
return () => {
active = false;
timers.forEach((timer) => clearTimeout(timer));
};
}, [item.id, item.mode, resultStorageKeys.join("|"), storedResultUrls.join("|")]);
const alignedResultUrls = resultStorageKeys.length ? resolvedResultUrls : storedResultUrls;
const displayResultUrls = alignedResultUrls.filter(Boolean);
const resultAssetIds = alignedResultUrls.length || resultStorageKeys.length ? creationResultAssetIds(assets, { messageId: item.id, taskIds: item.taskIds || [], resultUrls: alignedResultUrls, resultStorageKeys }) : [];
const expectedResultCount = resultStorageKeys.length || alignedResultUrls.length;
const canContinueWithResults = expectedResultCount > 0 && resultAssetIds.length === expectedResultCount;
if (item.status === "pending") return <CreationMediaPending mode={item.mode || "image"} ratio={item.settings?.ratio} />;
if ((item.status === "error" || item.status === "cancelled") && !resultUrls.length) return <div className="creation-media-error"><span>{item.status === "cancelled" ? item.content || "已停止" : generationErrorMessage(item.error || "生成失败")}</span><button type="button" onClick={onRetryFailure}><RefreshCw />重新生成</button></div>;
if (!resultUrls.length) return <div className="creation-media-empty">没有返回可预览结果 <button type="button" onClick={onRetryFailure}>重试</button></div>;
if ((item.status === "error" || item.status === "cancelled") && !expectedResultCount) return <div className="creation-media-error"><span>{item.status === "cancelled" ? item.content || "已停止" : generationErrorMessage(item.error || "生成失败")}</span><button type="button" onClick={onRetryFailure}><RefreshCw />重新生成</button></div>;
if (!expectedResultCount || !displayResultUrls.length) return <div className="creation-media-empty">没有返回可预览结果 <button type="button" onClick={onRetryFailure}>重试</button></div>;
const isVideo = item.mode === "video";
return <div className="creation-media-result">
{isVideo ? <button type="button" className="creation-video-result" onClick={() => { setPreviewType("video"); setPreviewUrl(resultUrls[0]); }} aria-label="预览生成视频"><video muted preload="metadata" src={resultUrls[0]} /><span><Maximize2 />预览视频</span></button> : <div className="creation-image-result-grid">{resultUrls.map((url) => <button key={url} type="button" className="creation-image-result" onClick={() => { setPreviewType("image"); setPreviewUrl(url); }} aria-label="预览生成图片"><img src={url} alt="生成结果" /><span><Maximize2 /></span></button>)}</div>}
<div className="creation-media-actions"><span>{isVideo ? "视频结果" : `${resultUrls.length} 张图片`}</span><Button type="link" size="small" loading={openingCanvas} disabled={!canContinueWithResults} title={canContinueWithResults ? undefined : "素材保存完成后才能转入画布"} onClick={() => onContinueCanvas(resultAssetIds)}>添加到画布</Button>{resultUrls.map((url, index) => <a key={`${url}-download`} href={url} download>{resultUrls.length > 1 ? `下载 ${index + 1}` : <><Download />下载</>}</a>)}</div>
{isVideo ? <button type="button" className="creation-video-result" onClick={() => { setPreviewType("video"); setPreviewUrl(displayResultUrls[0]); }} aria-label="预览生成视频"><video muted preload="metadata" src={displayResultUrls[0]} /><span><Maximize2 />预览视频</span></button> : <div className="creation-image-result-grid">{displayResultUrls.map((url) => <button key={url} type="button" className="creation-image-result" onClick={() => { setPreviewType("image"); setPreviewUrl(url); }} aria-label="预览生成图片"><img src={url} alt="生成结果" /><span><Maximize2 /></span></button>)}</div>}
<div className="creation-media-actions"><span>{isVideo ? "视频结果" : `${displayResultUrls.length} 张图片`}</span><Button type="link" size="small" loading={openingCanvas} disabled={!canContinueWithResults} title={canContinueWithResults ? undefined : "素材保存完成后才能转入画布"} onClick={() => onContinueCanvas(resultAssetIds)}>添加到画布</Button>{displayResultUrls.map((url, index) => <a key={`${url}-download`} href={url} download>{displayResultUrls.length > 1 ? `下载 ${index + 1}` : <><Download />下载</>}</a>)}</div>
<CreationMediaPreviewModal url={previewUrl} type={previewType} onClose={() => setPreviewUrl("")} />
</div>;
}

View File

@@ -295,15 +295,15 @@ export default function CreatePage() {
const persistedTasks = await materializeCreationTaskResults(runtime, contextual, observationController.signal);
if (cancelled) return;
taskSyncWarningRef.current = false;
const attachable = persistedTasks.filter((task) => task.status === "succeeded" && Boolean(task.clientContext?.messageId) && Boolean(task.creationResultUrls?.length));
const attachable = persistedTasks.filter((task) => task.status === "succeeded" && Boolean(task.clientContext?.messageId) && Boolean(task.creationResultUrls?.length || task.creationResultStorageKeys?.length));
for (const task of attachable) {
try {
await runtime.consumeGenerationTaskMessage(task, task.clientContext!.messageId!, async ({ effectKey, resultUrls }) => {
await runtime.consumeGenerationTaskMessage(task, task.clientContext!.messageId!, async ({ effectKey, resultUrls, resultStorageKeys }) => {
if (cancelled) return;
await updateConversationMessage(task.clientContext!.conversationId!, task.clientContext!.messageId!, (item) =>
runtime.applyGenerationConsumerEffect(item, effectKey, (current) => ({ ...current, status: "done" as const, resultUrls: Array.from(new Set([...(current.resultUrls || []), ...resultUrls])) })).value,
runtime.applyGenerationConsumerEffect(item, effectKey, (current) => ({ ...current, status: "done" as const, ...(resultUrls.length ? { resultUrls: Array.from(new Set([...(current.resultUrls || []), ...resultUrls])) } : {}), ...(resultStorageKeys.length ? { resultStorageKeys: Array.from(new Set([...(current.resultStorageKeys || []), ...resultStorageKeys])) } : {}) })).value,
);
}, { signal: observationController.signal, materialize: async () => task, materializedUrls: runtime.generationTaskMaterializedUrls });
}, { signal: observationController.signal, materialize: async () => task, materializedUrls: runtime.generationTaskMaterializedUrls, materializedStorageKeys: runtime.generationTaskMaterializedStorageKeys });
} catch (error) {
if (cancelled || observationController.signal.aborted) return;
console.warn("创作任务结果挂载失败,将使用已物化结果收敛消息状态", error);
@@ -719,8 +719,8 @@ export default function CreatePage() {
const storedImages = await Promise.allSettled(generatedImages.map(async ({ image, taskId, batchIndex }) => {
if (!taskId) throw new Error("生成任务缺少稳定任务标识");
const task = completedCreationGenerationTask(runtime, { taskId, task: boundTasks.get(taskId), mode: "image", prompt: expandedPrompt, result: { mode: "image", images: [image] }, conversationId: activeConversation.id, messageId: assistantMessage.id, batchIndex, batchCount: taskCount });
const materialized = await runtime.consumeGenerationTaskMessage(task, assistantMessage.id, async ({ resultUrls, effectKey }) => {
await updateOriginAssistant((item) => runtime.applyGenerationConsumerEffect(item, effectKey, (current) => ({ ...current, status: "done" as const, content: "图片已生成", resultUrls: Array.from(new Set([...(current.resultUrls || []), ...resultUrls])) })).value);
const materialized = await runtime.consumeGenerationTaskMessage(task, assistantMessage.id, async ({ resultUrls, resultStorageKeys, effectKey }) => {
await updateOriginAssistant((item) => runtime.applyGenerationConsumerEffect(item, effectKey, (current) => ({ ...current, status: "done" as const, content: "图片已生成", ...(resultUrls.length ? { resultUrls: Array.from(new Set([...(current.resultUrls || []), ...resultUrls])) } : {}), ...(resultStorageKeys.length ? { resultStorageKeys: Array.from(new Set([...(current.resultStorageKeys || []), ...resultStorageKeys])) } : {}) })).value);
}, { signal: requestLifecycle.signal });
const url = runtime.generationTaskMaterializedUrls(materialized)[0];
if (!url) throw new Error("图片结果资源不可用");
@@ -752,8 +752,8 @@ export default function CreatePage() {
const taskId = Array.from(boundTaskIds)[0];
if (!taskId) throw new Error("生成任务缺少稳定任务标识");
const task = completedCreationGenerationTask(runtime, { taskId, task: boundTasks.get(taskId), mode: "video", prompt: expandedPrompt, result, conversationId: activeConversation.id, messageId: assistantMessage.id });
const materialized = await runtime.consumeGenerationTaskMessage(task, assistantMessage.id, async ({ resultUrls, effectKey }) => {
await updateOriginAssistant((item) => runtime.applyGenerationConsumerEffect(item, effectKey, (current) => ({ ...current, status: "done" as const, content: "视频已生成", resultUrls })).value);
const materialized = await runtime.consumeGenerationTaskMessage(task, assistantMessage.id, async ({ resultUrls, resultStorageKeys, effectKey }) => {
await updateOriginAssistant((item) => runtime.applyGenerationConsumerEffect(item, effectKey, (current) => ({ ...current, status: "done" as const, content: "视频已生成", resultUrls, ...(resultStorageKeys.length ? { resultStorageKeys } : {}) })).value);
}, { signal: requestLifecycle.signal });
if (!runtime.generationTaskMaterializedUrls(materialized)[0]) throw new Error("视频结果资源不可用");
}
@@ -805,9 +805,11 @@ export default function CreatePage() {
try {
const assets = useAssetStore.getState().assets;
const generatedAssetIds = selectedAssetIds || source.messages.flatMap((item) => {
if (!item.resultUrls?.length) return [];
const ids = creationResultAssetIds(assets, { messageId: item.id, taskIds: item.taskIds || [], resultUrls: item.resultUrls });
if (ids.length !== item.resultUrls.length) throw new Error("部分生成素材还未保存完成,请稍后转入画布。");
const resultStorageKeys = item.resultStorageKeys || [];
const resultUrls = item.resultUrls || [];
if (!resultStorageKeys.length && !resultUrls.length) return [];
const ids = creationResultAssetIds(assets, { messageId: item.id, taskIds: item.taskIds || [], resultUrls, resultStorageKeys });
if (ids.length !== Math.max(resultStorageKeys.length, resultUrls.length)) throw new Error("部分生成素材还未保存完成,请稍后转入画布。");
return ids;
});
const referenceKeys = new Set(source.messages.flatMap((item) => (item.attachments || []).map((attachment) => attachment.storageKey).filter(Boolean)));