Files
Genarrative/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts
T
2026-07-10 11:51:13 +08:00

514 lines
15 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type {
CreateEditorAgentConversationRequest,
EditorAgentAttachmentRef,
EditorAgentConversationDetail,
EditorAgentConversationSummary,
EditorAgentMessage,
EditorAgentMessageRequest,
EditorAgentMessageResponse,
} from '../../../../packages/shared/src/contracts/editorAgent.ts';
import {
cancelEditorAgentToolCall,
confirmEditorAgentToolCall,
createEditorAgentConversation,
deleteEditorAgentConversation,
getEditorAgentConversation,
listEditorAgentConversations,
sendEditorAgentMessage,
type SendEditorAgentMessageOptions,
} from '../../../services/image-editor/editorAgentClient.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<EditorAgentMessage>;
cancelToolCall: (
conversationId: string,
messageId: number,
) => Promise<EditorAgentMessage>;
};
type UseEditorAgentConversationOptions = {
projectId?: string | null;
client?: EditorAgentConversationClient;
onCanvasRefreshRequested?: () => void;
};
export type EditorAgentToolCallAction = 'confirm' | 'cancel';
export type EditorAgentToolCallActionState = {
messageId: number;
action: EditorAgentToolCallAction;
} | null;
const defaultEditorAgentConversationClient: EditorAgentConversationClient = {
listConversations: listEditorAgentConversations,
createConversation: createEditorAgentConversation,
getConversation: getEditorAgentConversation,
deleteConversation: deleteEditorAgentConversation,
sendMessage: sendEditorAgentMessage,
confirmToolCall: confirmEditorAgentToolCall,
cancelToolCall: cancelEditorAgentToolCall,
};
function isAbortError(error: unknown) {
return (
error instanceof Error &&
(error.name === 'AbortError' ||
(typeof DOMException !== 'undefined' &&
error instanceof DOMException &&
error.name === 'AbortError'))
);
}
function createLocalUserMessage(params: {
id: number;
text: string;
attachments: EditorAgentAttachmentRef[];
}): EditorAgentMessage {
return {
id: params.id,
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,
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,
}: 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 [toolCallAction, setToolCallAction] =
useState<EditorAgentToolCallActionState>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const activeRequestAbortControllerRef = useRef<AbortController | null>(null);
const activeConversationIdRef = useRef<string | null>(null);
const activeToolCallActionRef = useRef<EditorAgentToolCallActionState>(null);
const nextLocalMessageIdRef = useRef(-1);
useEffect(() => {
activeConversationIdRef.current = activeConversationId;
}, [activeConversationId]);
const activeConversation = useMemo(
() =>
activeConversationId
? (conversations.find(
(conversation) =>
conversation.conversationId === activeConversationId,
) ?? null)
: null,
[activeConversationId, conversations],
);
const applyConversationDetail = useCallback(
(detail: EditorAgentConversationDetail) => {
setConversations((currentConversations) =>
upsertConversationSummary(
currentConversations,
summaryFromDetail(detail),
),
);
setActiveConversationId(detail.conversationId);
setMessages(detail.messages);
},
[],
);
const loadConversation = useCallback(
async (conversationId: string) => {
setIsLoadingMessages(true);
setErrorMessage(null);
try {
const detail = await client.getConversation(conversationId);
applyConversationDetail(detail);
return detail;
} catch (error) {
setErrorMessage(
error instanceof Error ? error.message : '读取画布 Agent 会话失败',
);
throw error;
} finally {
setIsLoadingMessages(false);
}
},
[applyConversationDetail, client],
);
useEffect(() => {
if (!normalizedProjectId) {
setConversations([]);
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) {
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');
}
setIsCreatingConversation(true);
setErrorMessage(null);
try {
const detail = await client.createConversation(normalizedProjectId, {});
applyConversationDetail(detail);
return detail;
} catch (error) {
setErrorMessage(
error instanceof Error ? error.message : '创建画布 Agent 会话失败',
);
throw error;
} finally {
setIsCreatingConversation(false);
}
}, [applyConversationDetail, client, normalizedProjectId]);
const selectConversation = useCallback(
async (conversationId: string) => {
if (conversationId === activeConversationId) {
return null;
}
return loadConversation(conversationId);
},
[activeConversationId, loadConversation],
);
const requestCanvasRefreshForMessages = useCallback(
(nextMessages: EditorAgentMessage[]) => {
if (
nextMessages.some(
(message) =>
message.toolCall?.status === 'completed' &&
message.toolCall.images.length > 0,
)
) {
onCanvasRefreshRequested?.();
}
},
[onCanvasRefreshRequested],
);
const applyDeltaMessages = useCallback(
(deltaMessages: EditorAgentMessage[]) => {
setMessages((currentMessages) => [...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) ||
isWaiting ||
activeToolCallActionRef.current !== null ||
isLoadingConversations ||
isLoadingMessages
) {
return;
}
const conversationId = await ensureConversationForSend();
const abortController = new AbortController();
activeRequestAbortControllerRef.current = abortController;
setErrorMessage(null);
setIsWaiting(true);
const localMessageId = nextLocalMessageIdRef.current;
nextLocalMessageIdRef.current -= 1;
setMessages((currentMessages) => [
...currentMessages,
createLocalUserMessage({
id: localMessageId,
text,
attachments,
}),
]);
try {
const response = await client.sendMessage(
conversationId,
{
text,
attachments,
},
{
signal: abortController.signal,
},
);
if (response.errorMessage) {
setErrorMessage(response.errorMessage);
} else {
applyDeltaMessages(response.deltaMessages);
}
} catch (error) {
if (isAbortError(error) || abortController.signal.aborted) {
return;
}
const message =
error instanceof Error ? error.message : '发送画布 Agent 消息失败';
setErrorMessage(message);
} finally {
if (activeRequestAbortControllerRef.current === abortController) {
activeRequestAbortControllerRef.current = null;
}
setIsWaiting(false);
}
},
[
client,
ensureConversationForSend,
applyDeltaMessages,
isLoadingConversations,
isLoadingMessages,
isWaiting,
],
);
const stopCurrentTurn = useCallback(() => {
activeRequestAbortControllerRef.current?.abort();
activeRequestAbortControllerRef.current = null;
setIsWaiting(false);
}, []);
const resolveToolCall = useCallback(
async (messageId: number, action: EditorAgentToolCallAction) => {
const conversationId = activeConversationIdRef.current;
if (!conversationId || activeToolCallActionRef.current) {
return null;
}
const nextAction = { messageId, action } as const;
activeToolCallActionRef.current = nextAction;
setToolCallAction(nextAction);
setErrorMessage(null);
try {
const updatedMessage = await (action === 'confirm'
? client.confirmToolCall(conversationId, messageId)
: client.cancelToolCall(conversationId, messageId));
if (activeConversationIdRef.current !== conversationId) {
return updatedMessage;
}
setMessages((currentMessages) =>
currentMessages.map((message) =>
message.id === updatedMessage.id ? updatedMessage : message,
),
);
if (action === 'confirm') {
requestCanvasRefreshForMessages([updatedMessage]);
}
return updatedMessage;
} catch (error) {
if (activeConversationIdRef.current === conversationId) {
setErrorMessage(
error instanceof Error
? error.message
: action === 'confirm'
? '确认画布 Agent 操作失败'
: '取消画布 Agent 操作失败',
);
}
return null;
} finally {
if (activeToolCallActionRef.current === nextAction) {
activeToolCallActionRef.current = null;
setToolCallAction(null);
}
}
},
[client, requestCanvasRefreshForMessages],
);
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;
}
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,
toolCallAction,
isToolCallActionPending: toolCallAction !== null,
errorMessage,
createConversation,
selectConversation,
sendMessage,
stopCurrentTurn,
confirmToolCall,
cancelToolCall,
deleteActiveConversation,
};
}