514 lines
15 KiB
TypeScript
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,
|
|
};
|
|
}
|