362 lines
9.7 KiB
TypeScript
362 lines
9.7 KiB
TypeScript
import { useCallback, useRef, useState } from 'react';
|
|
|
|
import type { TextStreamOptions } from '../../services/aiTypes';
|
|
import type { SelectionStage } from './platformEntryTypes';
|
|
|
|
type CreationAgentMessageLike = {
|
|
clientMessageId: string;
|
|
text: string;
|
|
};
|
|
|
|
type CreationAgentSessionLike = {
|
|
sessionId: string;
|
|
draft?: unknown;
|
|
messages: Array<{
|
|
id: string;
|
|
role: string;
|
|
kind?: string;
|
|
text: string;
|
|
createdAt?: string;
|
|
}>;
|
|
updatedAt?: string;
|
|
};
|
|
|
|
type CreationAgentClientAdapter<
|
|
TSession extends CreationAgentSessionLike,
|
|
TCreatePayload,
|
|
TCreateResponse,
|
|
TMessagePayload extends CreationAgentMessageLike,
|
|
TActionPayload,
|
|
TActionResponse,
|
|
> = {
|
|
createSession: (payload: TCreatePayload) => Promise<TCreateResponse>;
|
|
getSession: (sessionId: string) => Promise<TCreateResponse>;
|
|
streamMessage: (
|
|
sessionId: string,
|
|
payload: TMessagePayload,
|
|
options?: TextStreamOptions,
|
|
) => Promise<TSession>;
|
|
executeAction: (
|
|
sessionId: string,
|
|
payload: TActionPayload,
|
|
) => Promise<TActionResponse>;
|
|
selectSession: (response: TCreateResponse) => TSession;
|
|
};
|
|
|
|
type PlatformCreationAgentFlowControllerOptions<
|
|
TSession extends CreationAgentSessionLike,
|
|
TCreatePayload,
|
|
TCreateResponse,
|
|
TMessagePayload extends CreationAgentMessageLike,
|
|
TActionPayload,
|
|
TActionResponse,
|
|
> = {
|
|
client: CreationAgentClientAdapter<
|
|
TSession,
|
|
TCreatePayload,
|
|
TCreateResponse,
|
|
TMessagePayload,
|
|
TActionPayload,
|
|
TActionResponse
|
|
>;
|
|
createPayload: TCreatePayload;
|
|
workspaceStage: SelectionStage;
|
|
resultStage: SelectionStage;
|
|
platformStage: SelectionStage;
|
|
isCompileAction: (payload: TActionPayload) => boolean;
|
|
resolveErrorMessage: (error: unknown, fallback: string) => string;
|
|
errorMessages: {
|
|
open: string;
|
|
restoreMissingSession: string;
|
|
restore: string;
|
|
submit: string;
|
|
execute: string;
|
|
};
|
|
enterCreateTab: () => void;
|
|
setSelectionStage: (stage: SelectionStage) => void;
|
|
onSessionOpened?: () => void;
|
|
onOpenError?: (params: {
|
|
error: unknown;
|
|
errorMessage: string;
|
|
}) => void;
|
|
onActionComplete?: (params: {
|
|
payload: TActionPayload;
|
|
response: TActionResponse;
|
|
session: TSession;
|
|
setSession: (session: TSession) => void;
|
|
}) => Promise<void> | void;
|
|
beforeExecuteAction?: (params: {
|
|
payload: TActionPayload;
|
|
session: TSession;
|
|
}) => void;
|
|
onActionError?: (params: {
|
|
payload: TActionPayload;
|
|
error: unknown;
|
|
errorMessage: string;
|
|
}) => void;
|
|
};
|
|
|
|
function buildOptimisticMessage<TMessagePayload extends CreationAgentMessageLike>(
|
|
payload: TMessagePayload,
|
|
) {
|
|
return {
|
|
id: payload.clientMessageId,
|
|
role: 'user',
|
|
kind: 'chat',
|
|
text: payload.text.trim(),
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
function buildInterruptedAssistantMessage(text: string) {
|
|
return {
|
|
id: `assistant-interrupted-${Date.now().toString(36)}`,
|
|
role: 'assistant',
|
|
kind: 'warning',
|
|
text: text.trim(),
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 轻量作品 Agent 创作流程的通用前端控制器。
|
|
* 这里只处理跨玩法一致的会话、流式消息、忙碌态与草稿恢复,玩法结果页和运行态动作留给外层。
|
|
*/
|
|
export function usePlatformCreationAgentFlowController<
|
|
TSession extends CreationAgentSessionLike,
|
|
TCreatePayload,
|
|
TCreateResponse,
|
|
TMessagePayload extends CreationAgentMessageLike,
|
|
TActionPayload,
|
|
TActionResponse,
|
|
>(
|
|
options: PlatformCreationAgentFlowControllerOptions<
|
|
TSession,
|
|
TCreatePayload,
|
|
TCreateResponse,
|
|
TMessagePayload,
|
|
TActionPayload,
|
|
TActionResponse
|
|
>,
|
|
) {
|
|
const [session, setSession] = useState<TSession | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [isBusy, setIsBusy] = useState(false);
|
|
const [streamingReplyText, setStreamingReplyText] = useState('');
|
|
const [isStreamingReply, setIsStreamingReply] = useState(false);
|
|
const latestStreamingReplyTextRef = useRef('');
|
|
|
|
const updateStreamingReplyText = useCallback((text: string) => {
|
|
latestStreamingReplyTextRef.current = text;
|
|
setStreamingReplyText(text);
|
|
}, []);
|
|
|
|
const resetStreamingReply = useCallback(() => {
|
|
latestStreamingReplyTextRef.current = '';
|
|
setStreamingReplyText('');
|
|
setIsStreamingReply(false);
|
|
}, []);
|
|
|
|
const openWorkspace = useCallback(async (createPayload?: TCreatePayload) => {
|
|
if (isBusy) {
|
|
return null;
|
|
}
|
|
|
|
setIsBusy(true);
|
|
setError(null);
|
|
resetStreamingReply();
|
|
|
|
try {
|
|
const response = await options.client.createSession(
|
|
createPayload ?? options.createPayload,
|
|
);
|
|
const nextSession = options.client.selectSession(response);
|
|
setSession(nextSession);
|
|
options.enterCreateTab();
|
|
options.onSessionOpened?.();
|
|
options.setSelectionStage(options.workspaceStage);
|
|
return nextSession;
|
|
} catch (caughtError) {
|
|
const errorMessage = options.resolveErrorMessage(
|
|
caughtError,
|
|
options.errorMessages.open,
|
|
);
|
|
setError(errorMessage);
|
|
options.onOpenError?.({
|
|
error: caughtError,
|
|
errorMessage,
|
|
});
|
|
return null;
|
|
} finally {
|
|
setIsBusy(false);
|
|
}
|
|
}, [isBusy, options, resetStreamingReply]);
|
|
|
|
const restoreDraft = useCallback(
|
|
async (sessionId: string | null | undefined) => {
|
|
const normalizedSessionId = sessionId?.trim();
|
|
if (!normalizedSessionId) {
|
|
setError(options.errorMessages.restoreMissingSession);
|
|
return null;
|
|
}
|
|
|
|
setIsBusy(true);
|
|
setError(null);
|
|
resetStreamingReply();
|
|
|
|
try {
|
|
const response = await options.client.getSession(normalizedSessionId);
|
|
const nextSession = options.client.selectSession(response);
|
|
setSession(nextSession);
|
|
options.enterCreateTab();
|
|
options.setSelectionStage(
|
|
nextSession.draft ? options.resultStage : options.workspaceStage,
|
|
);
|
|
return nextSession;
|
|
} catch (caughtError) {
|
|
setError(
|
|
options.resolveErrorMessage(caughtError, options.errorMessages.restore),
|
|
);
|
|
options.enterCreateTab();
|
|
options.setSelectionStage(options.platformStage);
|
|
return null;
|
|
} finally {
|
|
setIsBusy(false);
|
|
}
|
|
},
|
|
[options, resetStreamingReply],
|
|
);
|
|
|
|
const submitMessage = useCallback(
|
|
async (payload: TMessagePayload) => {
|
|
if (!session || isStreamingReply) {
|
|
return;
|
|
}
|
|
|
|
const optimisticMessage = buildOptimisticMessage(payload);
|
|
|
|
setError(null);
|
|
updateStreamingReplyText('');
|
|
setIsStreamingReply(true);
|
|
setSession((current) =>
|
|
current
|
|
? {
|
|
...current,
|
|
messages: [...current.messages, optimisticMessage],
|
|
updatedAt: optimisticMessage.createdAt,
|
|
}
|
|
: current,
|
|
);
|
|
|
|
try {
|
|
const nextSession = await options.client.streamMessage(
|
|
session.sessionId,
|
|
payload,
|
|
{
|
|
onUpdate: updateStreamingReplyText,
|
|
},
|
|
);
|
|
setSession(nextSession);
|
|
updateStreamingReplyText('');
|
|
} catch (caughtError) {
|
|
const interruptedReplyText =
|
|
latestStreamingReplyTextRef.current.trim();
|
|
// 上游流可能在已经吐出可读回复后才失败;把这段回复落进本地消息列表,避免 UI 收尾时突然消失。
|
|
if (interruptedReplyText) {
|
|
const interruptedMessage =
|
|
buildInterruptedAssistantMessage(interruptedReplyText);
|
|
setSession((current) =>
|
|
current
|
|
? {
|
|
...current,
|
|
messages: [...current.messages, interruptedMessage],
|
|
updatedAt: interruptedMessage.createdAt,
|
|
}
|
|
: current,
|
|
);
|
|
}
|
|
setError(
|
|
options.resolveErrorMessage(caughtError, options.errorMessages.submit),
|
|
);
|
|
} finally {
|
|
setIsStreamingReply(false);
|
|
}
|
|
},
|
|
[isStreamingReply, options, session, updateStreamingReplyText],
|
|
);
|
|
|
|
const executeAction = useCallback(
|
|
async (payload: TActionPayload, sessionOverride?: TSession | null) => {
|
|
const targetSession = sessionOverride ?? session;
|
|
if (!targetSession || isBusy) {
|
|
return;
|
|
}
|
|
|
|
setIsBusy(true);
|
|
setError(null);
|
|
|
|
try {
|
|
options.beforeExecuteAction?.({ payload, session: targetSession });
|
|
const response = await options.client.executeAction(
|
|
targetSession.sessionId,
|
|
payload,
|
|
);
|
|
await options.onActionComplete?.({
|
|
payload,
|
|
response,
|
|
session: targetSession,
|
|
setSession,
|
|
});
|
|
if (options.isCompileAction(payload)) {
|
|
options.setSelectionStage(options.resultStage);
|
|
}
|
|
} catch (caughtError) {
|
|
const errorMessage = options.resolveErrorMessage(
|
|
caughtError,
|
|
options.errorMessages.execute,
|
|
);
|
|
setError(errorMessage);
|
|
options.onActionError?.({
|
|
payload,
|
|
error: caughtError,
|
|
errorMessage,
|
|
});
|
|
} finally {
|
|
setIsBusy(false);
|
|
}
|
|
},
|
|
[isBusy, options, session],
|
|
);
|
|
|
|
const leaveFlow = useCallback(() => {
|
|
setError(null);
|
|
resetStreamingReply();
|
|
options.enterCreateTab();
|
|
options.setSelectionStage(options.platformStage);
|
|
}, [options, resetStreamingReply]);
|
|
|
|
const resetTransientState = useCallback(() => {
|
|
setError(null);
|
|
resetStreamingReply();
|
|
}, [resetStreamingReply]);
|
|
|
|
return {
|
|
session,
|
|
setSession,
|
|
error,
|
|
setError,
|
|
isBusy,
|
|
setIsBusy,
|
|
streamingReplyText,
|
|
setStreamingReplyText,
|
|
isStreamingReply,
|
|
setIsStreamingReply,
|
|
openWorkspace,
|
|
restoreDraft,
|
|
submitMessage,
|
|
executeAction,
|
|
leaveFlow,
|
|
resetTransientState,
|
|
};
|
|
}
|