Files
Genarrative/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts
T
k88936 98daad90c0 实现画布 Agent 请求停止交互
为每轮消息请求注入 AbortController 并停止当前 HTTP 请求

停止后在 isAborting 生命周期内静默刷新并阻止重复发送

补充 AbortError、客户端 signal 与面板状态回归测试
2026-07-22 17:37:54 +08:00

708 lines
22 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type {
CreateEditorAgentConversationRequest,
EditorAgentAttachmentRef,
EditorAgentConversationDetail,
EditorAgentConversationSummary,
EditorAgentMessage,
EditorAgentMessageRequest,
EditorAgentMessageResponse,
} from '@/packages/shared/src/contracts';
import {
cancelEditorAgentToolCall,
confirmEditorAgentToolCall,
createEditorAgentConversation,
deleteEditorAgentConversation,
getEditorAgentConversation,
listEditorAgentConversations,
sendEditorAgentMessage,
type SendEditorAgentMessageOptions,
} from '../../../services/image-editor/editorAgentClient.ts';
import { isAbortError } from '../../../services/apiClient.ts';
export type EditorAgentConversationClient = {
listConversations: (
projectId: string,
) => Promise<EditorAgentConversationSummary[]>;
createConversation: (
projectId: string,
input?: CreateEditorAgentConversationRequest,
) => Promise<EditorAgentConversationDetail>;
getConversation: (
conversationId: string,
) => Promise<EditorAgentConversationDetail>;
deleteConversation: (
conversationId: string,
) => Promise<EditorAgentConversationSummary>;
sendMessage: (
conversationId: string,
payload: EditorAgentMessageRequest,
options: SendEditorAgentMessageOptions,
) => Promise<EditorAgentMessageResponse>;
confirmToolCall: (conversationId: string, messageId: number) => Promise<void>;
cancelToolCall: (conversationId: string, messageId: number) => Promise<void>;
};
type UseEditorAgentConversationOptions = {
projectId?: string | null;
client?: EditorAgentConversationClient;
onCanvasRefreshRequested?: () => void;
onConfirmSent?: () => void;
};
export type EditorAgentToolCallAction = 'confirm' | 'cancel';
export type EditorAgentToolCallActionState = {
messageId: number;
action: EditorAgentToolCallAction;
} | null;
type ActiveEditorAgentSend = {
requestId: number;
conversationId: string;
clientMessageId: string;
controller: AbortController;
};
const defaultEditorAgentConversationClient: EditorAgentConversationClient = {
listConversations: listEditorAgentConversations,
createConversation: createEditorAgentConversation,
getConversation: getEditorAgentConversation,
deleteConversation: deleteEditorAgentConversation,
sendMessage: sendEditorAgentMessage,
confirmToolCall: confirmEditorAgentToolCall,
cancelToolCall: cancelEditorAgentToolCall,
};
export const EDITOR_AGENT_PATIENCE_NOTICE_DELAY_MS = 120_000;
function createEditorAgentClientMessageId() {
const randomId =
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
return `editor-agent-${randomId}`;
}
function createLocalUserMessage(params: {
id: number;
clientMessageId: string;
text: string;
attachments: EditorAgentAttachmentRef[];
}): EditorAgentMessage {
return {
id: params.id,
clientMessageId: params.clientMessageId,
role: 'user',
text: params.text,
attachments: params.attachments,
toolCall: null,
createdAt: new Date().toISOString(),
};
}
function sortConversationsByUpdatedAt(
conversations: EditorAgentConversationSummary[],
) {
return [...conversations].sort(
(left, right) =>
Date.parse(right.updatedAt) - Date.parse(left.updatedAt) ||
left.conversationId.localeCompare(right.conversationId),
);
}
function summaryFromDetail(
detail: EditorAgentConversationDetail,
): EditorAgentConversationSummary {
return {
conversationId: detail.conversationId,
projectId: detail.projectId,
title: detail.title,
updatedAt: detail.updatedAt,
};
}
function upsertConversationSummary(
conversations: EditorAgentConversationSummary[],
summary: EditorAgentConversationSummary,
) {
const nextConversations = conversations.some(
(conversation) => conversation.conversationId === summary.conversationId,
)
? conversations.map((conversation) =>
conversation.conversationId === summary.conversationId
? summary
: conversation,
)
: [summary, ...conversations];
return sortConversationsByUpdatedAt(nextConversations);
}
export function useEditorAgentConversation({
projectId,
client = defaultEditorAgentConversationClient,
onCanvasRefreshRequested,
onConfirmSent,
}: UseEditorAgentConversationOptions) {
const normalizedProjectId = projectId?.trim() ?? '';
const [conversations, setConversations] = useState<
EditorAgentConversationSummary[]
>([]);
const [activeConversationId, setActiveConversationId] = useState<
string | null
>(null);
const [messages, setMessages] = useState<EditorAgentMessage[]>([]);
const [isLoadingConversations, setIsLoadingConversations] = useState(false);
const [isLoadingMessages, setIsLoadingMessages] = useState(false);
const [isCreatingConversation, setIsCreatingConversation] = useState(false);
const [isDeletingConversation, setIsDeletingConversation] = useState(false);
const [isWaiting, setIsWaiting] = useState(false);
const [isAborting, setIsAborting] = useState(false);
const [patienceNoticeConversationId, setPatienceNoticeConversationId] =
useState<string | null>(null);
const [toolCallAction, setToolCallAction] =
useState<EditorAgentToolCallActionState>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const normalizedProjectIdRef = useRef(normalizedProjectId);
const activeConversationIdRef = useRef<string | null>(null);
const activeToolCallActionRef = useRef<EditorAgentToolCallActionState>(null);
const conversationLoadRequestIdRef = useRef(0);
const createConversationRequestIdRef = useRef(0);
const isWaitingRef = useRef(false);
const pendingSendRequestIdRef = useRef(0);
const activeSendRef = useRef<ActiveEditorAgentSend | null>(null);
const stoppedSendRequestIdRef = useRef<number | null>(null);
const patienceNoticeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
useEffect(() => {
activeConversationIdRef.current = activeConversationId;
}, [activeConversationId]);
normalizedProjectIdRef.current = normalizedProjectId;
useEffect(() => {
return () => {
pendingSendRequestIdRef.current += 1;
createConversationRequestIdRef.current += 1;
isWaitingRef.current = false;
activeSendRef.current = null;
stoppedSendRequestIdRef.current = null;
if (patienceNoticeTimerRef.current !== null) {
clearTimeout(patienceNoticeTimerRef.current);
patienceNoticeTimerRef.current = null;
}
};
}, []);
useEffect(() => {
pendingSendRequestIdRef.current += 1;
createConversationRequestIdRef.current += 1;
isWaitingRef.current = false;
activeSendRef.current = null;
stoppedSendRequestIdRef.current = null;
if (patienceNoticeTimerRef.current !== null) {
clearTimeout(patienceNoticeTimerRef.current);
patienceNoticeTimerRef.current = null;
}
setIsWaiting(false);
setIsAborting(false);
setIsCreatingConversation(false);
setPatienceNoticeConversationId(null);
}, [normalizedProjectId]);
const activeConversation = useMemo(
() =>
activeConversationId
? (conversations.find(
(conversation) =>
conversation.conversationId === activeConversationId,
) ?? null)
: null,
[activeConversationId, conversations],
);
const applyConversationDetail = useCallback(
(detail: EditorAgentConversationDetail) => {
activeConversationIdRef.current = detail.conversationId;
setConversations((currentConversations) =>
upsertConversationSummary(
currentConversations,
summaryFromDetail(detail),
),
);
setActiveConversationId(detail.conversationId);
setMessages(detail.messages);
},
[],
);
const loadConversation = useCallback(
async (
conversationId: string,
options: { showLoading?: boolean; reportError?: boolean } = {},
) => {
const requestId = conversationLoadRequestIdRef.current + 1;
conversationLoadRequestIdRef.current = requestId;
const showLoading = options.showLoading ?? true;
const reportError = options.reportError ?? true;
if (showLoading) {
setIsLoadingMessages(true);
}
if (reportError) {
setErrorMessage(null);
}
try {
const detail = await client.getConversation(conversationId);
if (conversationLoadRequestIdRef.current === requestId) {
applyConversationDetail(detail);
}
return detail;
} catch (error) {
if (
reportError &&
conversationLoadRequestIdRef.current === requestId
) {
setErrorMessage(
error instanceof Error ? error.message : '读取画布 Agent 会话失败',
);
}
throw error;
} finally {
if (
showLoading &&
conversationLoadRequestIdRef.current === requestId
) {
setIsLoadingMessages(false);
}
}
},
[applyConversationDetail, client],
);
useEffect(() => {
conversationLoadRequestIdRef.current += 1;
setIsLoadingMessages(false);
if (!normalizedProjectId) {
setConversations([]);
activeConversationIdRef.current = null;
setActiveConversationId(null);
setMessages([]);
setErrorMessage(null);
return undefined;
}
let disposed = false;
setIsLoadingConversations(true);
setErrorMessage(null);
Promise.resolve(client.listConversations(normalizedProjectId) ?? [])
.then(async (nextConversations) => {
if (disposed) {
return;
}
const sortedConversations =
sortConversationsByUpdatedAt(nextConversations);
setConversations(sortedConversations);
const firstConversation = sortedConversations[0] ?? null;
if (!firstConversation) {
activeConversationIdRef.current = null;
setActiveConversationId(null);
setMessages([]);
return;
}
const detail = await client.getConversation(
firstConversation.conversationId,
);
if (!disposed) {
applyConversationDetail(detail);
}
})
.catch((error: unknown) => {
if (!disposed) {
setErrorMessage(
error instanceof Error
? error.message
: '读取画布 Agent 会话列表失败',
);
}
})
.finally(() => {
if (!disposed) {
setIsLoadingConversations(false);
}
});
return () => {
disposed = true;
};
}, [applyConversationDetail, client, normalizedProjectId]);
const createConversation = useCallback(async () => {
if (!normalizedProjectId) {
throw new Error('缺少画布项目 ID');
}
const requestedProjectId = normalizedProjectId;
const requestId = createConversationRequestIdRef.current + 1;
createConversationRequestIdRef.current = requestId;
setIsCreatingConversation(true);
setErrorMessage(null);
try {
const detail = await client.createConversation(requestedProjectId, {});
if (
createConversationRequestIdRef.current === requestId &&
normalizedProjectIdRef.current === requestedProjectId
) {
applyConversationDetail(detail);
}
return detail;
} catch (error) {
if (
createConversationRequestIdRef.current === requestId &&
normalizedProjectIdRef.current === requestedProjectId
) {
setErrorMessage(
error instanceof Error ? error.message : '创建画布 Agent 会话失败',
);
}
throw error;
} finally {
if (
createConversationRequestIdRef.current === requestId &&
normalizedProjectIdRef.current === requestedProjectId
) {
setIsCreatingConversation(false);
}
}
}, [applyConversationDetail, client, normalizedProjectId]);
const selectConversation = useCallback(
async (conversationId: string) => {
if (conversationId === activeConversationId) {
return null;
}
return loadConversation(conversationId);
},
[activeConversationId, loadConversation],
);
const refreshActiveConversation = useCallback(async () => {
const conversationId = activeConversationIdRef.current;
if (!conversationId) {
return null;
}
return loadConversation(conversationId, { showLoading: false });
}, [loadConversation]);
const requestCanvasRefreshForMessages = useCallback(
(nextMessages: EditorAgentMessage[]) => {
if (
nextMessages.some((message) => {
const toolCall = message.toolCall;
return Boolean(
toolCall?.externalJobId &&
(toolCall.images.length > 0 ||
(toolCall.videos?.length ?? 0) > 0 ||
(toolCall.audios?.length ?? 0) > 0),
);
})
) {
onCanvasRefreshRequested?.();
}
},
[onCanvasRefreshRequested],
);
const applyDeltaMessages = useCallback(
(deltaMessages: EditorAgentMessage[]) => {
setMessages((currentMessages) => {
return [...currentMessages, ...deltaMessages];
});
requestCanvasRefreshForMessages(deltaMessages);
},
[requestCanvasRefreshForMessages],
);
const ensureConversationForSend = useCallback(async () => {
if (activeConversationId) {
return activeConversationId;
}
const detail = await createConversation();
return detail.conversationId;
}, [activeConversationId, createConversation]);
const sendMessage = useCallback(
async (rawText: string, attachments: EditorAgentAttachmentRef[] = []) => {
const text = rawText.trim();
if (
(!text && !attachments.length) ||
isWaitingRef.current ||
activeToolCallActionRef.current !== null ||
isLoadingConversations ||
isLoadingMessages
) {
return;
}
isWaitingRef.current = true;
const requestedProjectId = normalizedProjectId;
const requestId = pendingSendRequestIdRef.current + 1;
pendingSendRequestIdRef.current = requestId;
setErrorMessage(null);
setIsWaiting(true);
setPatienceNoticeConversationId(null);
let conversationId: string | null = null;
let optimisticMessage: EditorAgentMessage | null = null;
try {
conversationId = await ensureConversationForSend();
if (
pendingSendRequestIdRef.current !== requestId ||
normalizedProjectIdRef.current !== requestedProjectId
) {
return;
}
const clientMessageId = createEditorAgentClientMessageId();
const controller = new AbortController();
const nextOptimisticMessage = createLocalUserMessage({
id: -1,
clientMessageId,
text,
attachments,
});
optimisticMessage = nextOptimisticMessage;
activeSendRef.current = {
requestId,
conversationId,
clientMessageId,
controller,
};
setMessages((currentMessages) => [
...currentMessages,
nextOptimisticMessage,
]);
const pendingConversationId = conversationId;
patienceNoticeTimerRef.current = setTimeout(() => {
if (pendingSendRequestIdRef.current === requestId) {
setPatienceNoticeConversationId(pendingConversationId);
}
}, EDITOR_AGENT_PATIENCE_NOTICE_DELAY_MS);
const response = await client.sendMessage(
conversationId,
{
clientMessageId,
text,
attachments,
},
{ signal: controller.signal },
);
if (pendingSendRequestIdRef.current !== requestId) {
return;
}
setConversations((currentConversations) =>
upsertConversationSummary(
currentConversations,
response.conversation,
),
);
if (activeConversationIdRef.current !== conversationId) {
return;
}
if (response.errorMessage) {
setErrorMessage(response.errorMessage);
} else {
applyDeltaMessages(response.deltaMessages);
}
} catch (error) {
const wasStoppedByUser =
stoppedSendRequestIdRef.current === requestId && isAbortError(error);
if (wasStoppedByUser) {
if (
conversationId &&
activeConversationIdRef.current === conversationId
) {
await loadConversation(conversationId, {
showLoading: false,
reportError: false,
}).catch(() => undefined);
}
return;
}
const message =
error instanceof Error ? error.message : '发送画布 Agent 消息失败';
const shouldReportError =
pendingSendRequestIdRef.current === requestId &&
(!conversationId ||
activeConversationIdRef.current === conversationId);
if (shouldReportError) {
setErrorMessage(message);
if (optimisticMessage) {
setMessages((currentMessages) =>
currentMessages.filter(
(message) => message !== optimisticMessage,
),
);
}
throw error;
}
} finally {
if (pendingSendRequestIdRef.current === requestId) {
if (patienceNoticeTimerRef.current !== null) {
clearTimeout(patienceNoticeTimerRef.current);
patienceNoticeTimerRef.current = null;
}
isWaitingRef.current = false;
if (activeSendRef.current?.requestId === requestId) {
activeSendRef.current = null;
}
if (stoppedSendRequestIdRef.current === requestId) {
stoppedSendRequestIdRef.current = null;
}
setIsWaiting(false);
setIsAborting(false);
setPatienceNoticeConversationId(null);
}
}
},
[
client,
ensureConversationForSend,
applyDeltaMessages,
isLoadingConversations,
isLoadingMessages,
loadConversation,
normalizedProjectId,
],
);
const stopCurrentTurn = useCallback(() => {
const activeSend = activeSendRef.current;
if (!activeSend || activeSend.controller.signal.aborted) {
return;
}
stoppedSendRequestIdRef.current = activeSend.requestId;
setIsAborting(true);
activeSend.controller.abort();
}, []);
const resolveToolCall = useCallback(
async (messageId: number, action: EditorAgentToolCallAction) => {
const conversationId = activeConversationIdRef.current;
if (!conversationId || activeToolCallActionRef.current) {
return;
}
const nextAction = { messageId, action } as const;
activeToolCallActionRef.current = nextAction;
setToolCallAction(nextAction);
setErrorMessage(null);
try {
await (action === 'confirm'
? client.confirmToolCall(conversationId, messageId)
: client.cancelToolCall(conversationId, messageId));
if (activeConversationIdRef.current !== conversationId) {
return;
}
const detail = await loadConversation(conversationId, {
showLoading: false,
});
const updatedMessage = detail.messages.find(
(message) => message.id === messageId,
);
if (action === 'confirm' && updatedMessage?.toolCall?.externalJobId) {
onConfirmSent?.();
}
} catch (error) {
if (activeConversationIdRef.current === conversationId) {
setErrorMessage(
error instanceof Error
? error.message
: action === 'confirm'
? '确认画布 Agent 操作失败'
: '取消画布 Agent 操作失败',
);
}
throw error;
} finally {
if (activeToolCallActionRef.current === nextAction) {
activeToolCallActionRef.current = null;
setToolCallAction(null);
}
}
},
[client, loadConversation, onConfirmSent],
);
const confirmToolCall = useCallback(
(messageId: number) => resolveToolCall(messageId, 'confirm'),
[resolveToolCall],
);
const cancelToolCall = useCallback(
(messageId: number) => resolveToolCall(messageId, 'cancel'),
[resolveToolCall],
);
const deleteActiveConversation = useCallback(async () => {
if (!activeConversationId) {
return;
}
const deletingConversationId = activeConversationId;
setIsDeletingConversation(true);
setErrorMessage(null);
try {
await client.deleteConversation(deletingConversationId);
const remainingConversations = conversations.filter(
(conversation) =>
conversation.conversationId !== deletingConversationId,
);
setConversations(remainingConversations);
const nextConversation = remainingConversations[0] ?? null;
if (nextConversation) {
await loadConversation(nextConversation.conversationId);
return;
}
activeConversationIdRef.current = null;
setActiveConversationId(null);
setMessages([]);
} catch (error) {
setErrorMessage(
error instanceof Error ? error.message : '删除画布 Agent 会话失败',
);
throw error;
} finally {
setIsDeletingConversation(false);
}
}, [activeConversationId, client, conversations, loadConversation]);
return {
conversations,
activeConversation,
activeConversationId,
messages,
isLoadingConversations,
isLoadingMessages,
isCreatingConversation,
isDeletingConversation,
isWaiting,
isAborting,
isPatienceNoticeVisible:
isWaiting && patienceNoticeConversationId === activeConversationId,
toolCallAction,
isToolCallActionPending: toolCallAction !== null,
errorMessage,
createConversation,
selectConversation,
refreshActiveConversation,
sendMessage,
stopCurrentTurn,
confirmToolCall,
cancelToolCall,
deleteActiveConversation,
};
}