383 lines
12 KiB
TypeScript
383 lines
12 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
||
|
||
import type {
|
||
CustomWorldAgentOperationRecord,
|
||
CustomWorldAgentSessionSnapshot,
|
||
} from '../../../packages/shared/src/contracts/customWorldAgent';
|
||
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
|
||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||
import { normalizeCustomWorldProfileRecord } from '../../data/customWorldLibrary';
|
||
import {
|
||
executeRpgCreationAction,
|
||
getRpgCreationOperation,
|
||
upsertRpgWorldProfile,
|
||
} from '../../services/rpg-creation';
|
||
import type { CustomWorldProfile } from '../../types';
|
||
import {
|
||
resolveRpgCreationErrorMessage,
|
||
stringifyAgentBackedProfile,
|
||
} from './rpgEntryShared';
|
||
import type {
|
||
CustomWorldAutoSaveState,
|
||
SelectionStage,
|
||
SyncedAgentDraftResult,
|
||
} from './rpgEntryTypes';
|
||
|
||
type UseRpgCreationResultAutosaveParams = {
|
||
selectionStage: SelectionStage;
|
||
activeAgentSessionId: string | null;
|
||
generatedCustomWorldProfile: CustomWorldProfile | null;
|
||
isAgentDraftResultView: boolean;
|
||
userId: string | null | undefined;
|
||
setGeneratedCustomWorldProfile: (profile: CustomWorldProfile | null) => void;
|
||
setAgentOperation: (
|
||
operation: CustomWorldAgentOperationRecord | null,
|
||
) => void;
|
||
setSavedCustomWorldEntries: (
|
||
entries: CustomWorldLibraryEntry<CustomWorldProfile>[],
|
||
) => void;
|
||
setSelectedDetailEntry: (
|
||
updater:
|
||
| CustomWorldLibraryEntry<CustomWorldProfile>
|
||
| null
|
||
| ((
|
||
current: CustomWorldLibraryEntry<CustomWorldProfile> | null,
|
||
) => CustomWorldLibraryEntry<CustomWorldProfile> | null),
|
||
) => void;
|
||
refreshCustomWorldWorks: () => Promise<unknown>;
|
||
persistAgentUiState: (
|
||
sessionId: string | null,
|
||
operationId: string | null,
|
||
generationSource?: 'agent-draft-foundation' | null,
|
||
) => void;
|
||
syncAgentSessionSnapshot: (
|
||
sessionId: string,
|
||
) => Promise<CustomWorldAgentSessionSnapshot | null>;
|
||
syncAgentCreationResultView: (
|
||
sessionId: string,
|
||
) => Promise<RpgCreationResultView | null>;
|
||
buildDraftResultProfile: (
|
||
view: RpgCreationResultView | null,
|
||
) => CustomWorldProfile | null;
|
||
};
|
||
|
||
/**
|
||
* 协调结果页自动保存与 Agent 草稿同步。
|
||
* 这里统一维护去重签名、延时保存和“先同步 session 再落作品库”的顺序。
|
||
*/
|
||
export function useRpgCreationResultAutosave(
|
||
params: UseRpgCreationResultAutosaveParams,
|
||
) {
|
||
const {
|
||
selectionStage,
|
||
activeAgentSessionId,
|
||
generatedCustomWorldProfile,
|
||
isAgentDraftResultView,
|
||
userId,
|
||
setGeneratedCustomWorldProfile,
|
||
setAgentOperation,
|
||
setSavedCustomWorldEntries,
|
||
setSelectedDetailEntry,
|
||
refreshCustomWorldWorks,
|
||
persistAgentUiState,
|
||
syncAgentSessionSnapshot,
|
||
syncAgentCreationResultView,
|
||
buildDraftResultProfile,
|
||
} = params;
|
||
|
||
const customWorldAutoSaveTimeoutRef = useRef<number | null>(null);
|
||
const lastAutoSavedProfileSignatureRef = useRef<string | null>(null);
|
||
const latestAutoSaveRequestIdRef = useRef(0);
|
||
const latestAgentResultSyncSignatureRef = useRef<string | null>(null);
|
||
const isCustomWorldAutoSaveBusyRef = useRef(false);
|
||
|
||
const [customWorldAutoSaveState, setCustomWorldAutoSaveState] =
|
||
useState<CustomWorldAutoSaveState>('idle');
|
||
const [customWorldAutoSaveError, setCustomWorldAutoSaveError] = useState<
|
||
string | null
|
||
>(null);
|
||
|
||
const resetAutoSaveTrackingToIdle = useCallback(() => {
|
||
if (customWorldAutoSaveTimeoutRef.current !== null) {
|
||
window.clearTimeout(customWorldAutoSaveTimeoutRef.current);
|
||
customWorldAutoSaveTimeoutRef.current = null;
|
||
}
|
||
lastAutoSavedProfileSignatureRef.current = null;
|
||
latestAgentResultSyncSignatureRef.current = null;
|
||
setCustomWorldAutoSaveState('idle');
|
||
setCustomWorldAutoSaveError(null);
|
||
}, []);
|
||
|
||
const markAutoSavedProfile = useCallback((profile: CustomWorldProfile) => {
|
||
lastAutoSavedProfileSignatureRef.current =
|
||
stringifyAgentBackedProfile(profile);
|
||
}, []);
|
||
|
||
const saveGeneratedCustomWorld = useCallback(
|
||
async (profile = generatedCustomWorldProfile) => {
|
||
if (!profile) {
|
||
return null;
|
||
}
|
||
|
||
const requestId = latestAutoSaveRequestIdRef.current + 1;
|
||
latestAutoSaveRequestIdRef.current = requestId;
|
||
setCustomWorldAutoSaveState('saving');
|
||
setCustomWorldAutoSaveError(null);
|
||
|
||
try {
|
||
const mutation = await upsertRpgWorldProfile(profile, {
|
||
sourceAgentSessionId:
|
||
isAgentDraftResultView && activeAgentSessionId
|
||
? activeAgentSessionId
|
||
: null,
|
||
});
|
||
if (latestAutoSaveRequestIdRef.current !== requestId) {
|
||
return mutation;
|
||
}
|
||
|
||
const canonicalProfile =
|
||
normalizeCustomWorldProfileRecord(mutation.entry.profile) ??
|
||
mutation.entry.profile;
|
||
// Agent 结果页的界面真相来自 result-view;作品库响应只用于列表与签名回写,
|
||
// 避免旧兼容响应缺字段时覆盖当前完整编辑态。
|
||
lastAutoSavedProfileSignatureRef.current = stringifyAgentBackedProfile(
|
||
isAgentDraftResultView ? profile : canonicalProfile,
|
||
);
|
||
if (!isAgentDraftResultView) {
|
||
setGeneratedCustomWorldProfile(canonicalProfile);
|
||
}
|
||
setSavedCustomWorldEntries(mutation.entries);
|
||
if (userId) {
|
||
void refreshCustomWorldWorks().catch(() => {});
|
||
}
|
||
setSelectedDetailEntry((current) => {
|
||
if (!current || current.profileId === mutation.entry.profileId) {
|
||
return mutation.entry;
|
||
}
|
||
|
||
return current;
|
||
});
|
||
setCustomWorldAutoSaveState('saved');
|
||
setCustomWorldAutoSaveError(null);
|
||
return mutation;
|
||
} catch (error) {
|
||
if (latestAutoSaveRequestIdRef.current !== requestId) {
|
||
return null;
|
||
}
|
||
|
||
setCustomWorldAutoSaveState('error');
|
||
setCustomWorldAutoSaveError(
|
||
resolveRpgCreationErrorMessage(error, '保存自定义世界失败。'),
|
||
);
|
||
return null;
|
||
}
|
||
},
|
||
[
|
||
activeAgentSessionId,
|
||
generatedCustomWorldProfile,
|
||
isAgentDraftResultView,
|
||
refreshCustomWorldWorks,
|
||
setSavedCustomWorldEntries,
|
||
setSelectedDetailEntry,
|
||
setGeneratedCustomWorldProfile,
|
||
userId,
|
||
],
|
||
);
|
||
|
||
const executeAgentActionAndWait = useCallback(
|
||
async (action: Parameters<typeof executeRpgCreationAction>[1]) => {
|
||
if (!activeAgentSessionId) {
|
||
throw new Error('当前世界草稿会话已失效,请返回创作页重新打开草稿。');
|
||
}
|
||
|
||
const { operation } = await executeRpgCreationAction(
|
||
activeAgentSessionId,
|
||
action,
|
||
);
|
||
setAgentOperation(operation);
|
||
persistAgentUiState(activeAgentSessionId, operation.operationId);
|
||
|
||
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||
const latestOperation = await getRpgCreationOperation(
|
||
activeAgentSessionId,
|
||
operation.operationId,
|
||
);
|
||
setAgentOperation(latestOperation);
|
||
|
||
if (latestOperation.status === 'failed') {
|
||
throw new Error(
|
||
latestOperation.error ||
|
||
latestOperation.phaseDetail ||
|
||
'执行共创操作失败。',
|
||
);
|
||
}
|
||
|
||
if (latestOperation.status === 'completed') {
|
||
persistAgentUiState(activeAgentSessionId, null);
|
||
return syncAgentSessionSnapshot(activeAgentSessionId);
|
||
}
|
||
|
||
await new Promise((resolve) => window.setTimeout(resolve, 200));
|
||
}
|
||
|
||
throw new Error('执行共创操作超时。');
|
||
},
|
||
[
|
||
activeAgentSessionId,
|
||
persistAgentUiState,
|
||
setAgentOperation,
|
||
syncAgentSessionSnapshot,
|
||
],
|
||
);
|
||
|
||
const syncAgentDraftResultProfile = useCallback(
|
||
async (profile: CustomWorldProfile) => {
|
||
if (!activeAgentSessionId) {
|
||
return {
|
||
session: null,
|
||
profile: null,
|
||
} satisfies SyncedAgentDraftResult;
|
||
}
|
||
|
||
const profileSignature = stringifyAgentBackedProfile(profile);
|
||
const currentView =
|
||
await syncAgentCreationResultView(activeAgentSessionId);
|
||
if (!currentView?.canSyncResultProfile) {
|
||
const latestProfile = buildDraftResultProfile(currentView) ?? profile;
|
||
if (latestProfile) {
|
||
setGeneratedCustomWorldProfile(latestProfile);
|
||
}
|
||
latestAgentResultSyncSignatureRef.current =
|
||
stringifyAgentBackedProfile(latestProfile);
|
||
|
||
return {
|
||
session: currentView?.session ?? null,
|
||
profile: latestProfile,
|
||
view: currentView,
|
||
} satisfies SyncedAgentDraftResult;
|
||
}
|
||
|
||
if (latestAgentResultSyncSignatureRef.current !== profileSignature) {
|
||
await executeAgentActionAndWait({
|
||
action: 'sync_result_profile',
|
||
profile: profile as unknown as Record<string, unknown>,
|
||
});
|
||
latestAgentResultSyncSignatureRef.current = profileSignature;
|
||
}
|
||
|
||
const latestView =
|
||
await syncAgentCreationResultView(activeAgentSessionId);
|
||
const latestProfile = buildDraftResultProfile(latestView) ?? profile;
|
||
if (latestProfile) {
|
||
setGeneratedCustomWorldProfile(latestProfile);
|
||
}
|
||
latestAgentResultSyncSignatureRef.current =
|
||
stringifyAgentBackedProfile(latestProfile);
|
||
|
||
return {
|
||
session: latestView?.session ?? null,
|
||
profile: latestProfile,
|
||
view: latestView,
|
||
} satisfies SyncedAgentDraftResult;
|
||
},
|
||
[
|
||
activeAgentSessionId,
|
||
buildDraftResultProfile,
|
||
executeAgentActionAndWait,
|
||
setGeneratedCustomWorldProfile,
|
||
syncAgentCreationResultView,
|
||
],
|
||
);
|
||
|
||
useEffect(
|
||
() => () => {
|
||
if (customWorldAutoSaveTimeoutRef.current !== null) {
|
||
window.clearTimeout(customWorldAutoSaveTimeoutRef.current);
|
||
}
|
||
},
|
||
[],
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (!generatedCustomWorldProfile) {
|
||
resetAutoSaveTrackingToIdle();
|
||
return;
|
||
}
|
||
|
||
if (selectionStage !== 'custom-world-result') {
|
||
return;
|
||
}
|
||
|
||
if (isCustomWorldAutoSaveBusyRef.current) {
|
||
return;
|
||
}
|
||
|
||
const nextSignature = stringifyAgentBackedProfile(
|
||
generatedCustomWorldProfile,
|
||
);
|
||
if (nextSignature === lastAutoSavedProfileSignatureRef.current) {
|
||
return;
|
||
}
|
||
|
||
setCustomWorldAutoSaveState('saving');
|
||
if (customWorldAutoSaveTimeoutRef.current !== null) {
|
||
window.clearTimeout(customWorldAutoSaveTimeoutRef.current);
|
||
}
|
||
|
||
const profileToSave = generatedCustomWorldProfile;
|
||
customWorldAutoSaveTimeoutRef.current = window.setTimeout(() => {
|
||
void (async () => {
|
||
isCustomWorldAutoSaveBusyRef.current = true;
|
||
try {
|
||
let latestProfileToSave = profileToSave;
|
||
if (isAgentDraftResultView) {
|
||
const syncedResult =
|
||
await syncAgentDraftResultProfile(profileToSave);
|
||
if (syncedResult.view && !syncedResult.view.canAutosaveLibrary) {
|
||
setCustomWorldAutoSaveState('idle');
|
||
return;
|
||
}
|
||
// 作品库自动保存优先落同步后 session 重编译出的结果,避免继续保存旧的前端内存态。
|
||
latestProfileToSave = syncedResult.profile ?? profileToSave;
|
||
}
|
||
await saveGeneratedCustomWorld(latestProfileToSave);
|
||
} catch (error) {
|
||
setCustomWorldAutoSaveState('error');
|
||
setCustomWorldAutoSaveError(
|
||
resolveRpgCreationErrorMessage(error, '保存自定义世界失败。'),
|
||
);
|
||
} finally {
|
||
isCustomWorldAutoSaveBusyRef.current = false;
|
||
}
|
||
})();
|
||
customWorldAutoSaveTimeoutRef.current = null;
|
||
}, 600);
|
||
|
||
return () => {
|
||
if (customWorldAutoSaveTimeoutRef.current !== null) {
|
||
window.clearTimeout(customWorldAutoSaveTimeoutRef.current);
|
||
customWorldAutoSaveTimeoutRef.current = null;
|
||
}
|
||
};
|
||
}, [
|
||
generatedCustomWorldProfile,
|
||
isAgentDraftResultView,
|
||
resetAutoSaveTrackingToIdle,
|
||
saveGeneratedCustomWorld,
|
||
selectionStage,
|
||
syncAgentDraftResultProfile,
|
||
]);
|
||
|
||
return {
|
||
customWorldAutoSaveState,
|
||
setCustomWorldAutoSaveState,
|
||
customWorldAutoSaveError,
|
||
setCustomWorldAutoSaveError,
|
||
resetAutoSaveTrackingToIdle,
|
||
markAutoSavedProfile,
|
||
saveGeneratedCustomWorld,
|
||
syncAgentDraftResultProfile,
|
||
executeAgentActionAndWait,
|
||
};
|
||
}
|