feat(cloud-agent): 云端 Agent - 完善运行时与资源持久化
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
|
||||
Reference in New Issue
Block a user