Files
Genarrative/src/components/image-editor/useEditorAgentConversation.ts
T
kdletters b8e6e9f3ef 控制画布 Agent 入口默认关闭
新增 VITE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR 控制画布 Agent 入口显示。

默认 .env 关闭、本地 .env.local 开启,并补充示例和类型声明。

禁用时隐藏 dock 按钮和收起态 Agent 面板,开启时保留右侧任务列表互斥逻辑。

修正右侧 Agent 和任务列表切换不影响左侧资源栏。

补充画布 Agent 开关、面板独立性和 dock 隐藏测试。
2026-07-06 18:51:11 +08:00

666 lines
19 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type {
CreateEditorAgentConversationRequest,
EditorAgentAttachmentRef,
EditorAgentConversationDetail,
EditorAgentConversationSummary,
EditorAgentGenerationResultEvent,
EditorAgentGenerationRecord,
EditorAgentMessage,
EditorAgentSseEvent,
EditorAgentStage,
StreamEditorAgentMessageRequest,
} from '../../../packages/shared/src/contracts/editorAgent';
import {
createEditorAgentConversation,
deleteEditorAgentConversation,
getEditorAgentConversation,
listEditorAgentConversations,
streamEditorAgentMessage,
type StreamEditorAgentMessageOptions,
} from '../../services/image-editor/editorAgentClient';
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>;
streamMessage: (
conversationId: string,
payload: StreamEditorAgentMessageRequest,
options: StreamEditorAgentMessageOptions,
) => Promise<void>;
};
type UseEditorAgentConversationOptions = {
projectId?: string | null;
client?: EditorAgentConversationClient;
onGenerationResult?: (event: EditorAgentGenerationResultEvent) => void;
};
const defaultEditorAgentConversationClient: EditorAgentConversationClient = {
listConversations: listEditorAgentConversations,
createConversation: createEditorAgentConversation,
getConversation: getEditorAgentConversation,
deleteConversation: deleteEditorAgentConversation,
streamMessage: streamEditorAgentMessage,
};
function createClientId(prefix: string) {
if (
typeof crypto !== 'undefined' &&
typeof crypto.randomUUID === 'function'
) {
return `${prefix}-${crypto.randomUUID()}`;
}
return `${prefix}-${Date.now().toString(36)}-${Math.random()
.toString(36)
.slice(2)}`;
}
function isAbortError(error: unknown) {
return (
error instanceof Error &&
(error.name === 'AbortError' ||
(typeof DOMException !== 'undefined' &&
error instanceof DOMException &&
error.name === 'AbortError'))
);
}
function createLocalUserMessage(params: {
id: string;
text: string;
attachments: EditorAgentAttachmentRef[];
}): EditorAgentMessage {
return {
id: params.id,
role: 'user',
kind: 'chat',
text: params.text,
attachments: params.attachments,
generations: [],
status: 'completed',
createdAt: new Date().toISOString(),
};
}
function createAssistantMessage(params: {
id: string;
role?: EditorAgentMessage['role'];
kind?: EditorAgentMessage['kind'];
text?: string;
status?: EditorAgentMessage['status'];
generations?: EditorAgentGenerationRecord[];
}): EditorAgentMessage {
return {
id: params.id,
role: params.role ?? 'assistant',
kind: params.kind ?? 'chat',
text: params.text ?? '',
attachments: [],
generations: params.generations ?? [],
status: params.status ?? 'streaming',
createdAt: new Date().toISOString(),
};
}
function upsertMessage(
messages: EditorAgentMessage[],
messageId: string,
createMessage: () => EditorAgentMessage,
updateMessage: (message: EditorAgentMessage) => EditorAgentMessage,
) {
let updated = false;
const nextMessages = messages.map((message) => {
if (message.id !== messageId) {
return message;
}
updated = true;
return updateMessage(message);
});
if (updated) {
return nextMessages;
}
return [...messages, updateMessage(createMessage())];
}
function upsertGenerationRecord(
records: EditorAgentGenerationRecord[],
nextRecord: EditorAgentGenerationRecord,
) {
const existingIndex = records.findIndex(
(record) => record.toolCallId === nextRecord.toolCallId,
);
if (existingIndex === -1) {
return [...records, nextRecord];
}
return records.map((record, index) =>
index === existingIndex
? {
...record,
...nextRecord,
summary: nextRecord.summary ?? record.summary,
taskId: nextRecord.taskId ?? record.taskId,
model: nextRecord.model ?? record.model,
images: nextRecord.images.length ? nextRecord.images : record.images,
}
: record,
);
}
function markStreamingMessages(
messages: EditorAgentMessage[],
status: EditorAgentMessage['status'],
) {
return messages.map((message) =>
message.status === 'streaming' || message.status === 'generating'
? { ...message, status }
: message,
);
}
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,
createdAt: detail.createdAt,
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,
onGenerationResult,
}: UseEditorAgentConversationOptions) {
const normalizedProjectId = projectId?.trim() ?? '';
const [conversations, setConversations] = useState<
EditorAgentConversationSummary[]
>([]);
const [activeConversationId, setActiveConversationId] = useState<
string | null
>(null);
const [messages, setMessages] = useState<EditorAgentMessage[]>([]);
const [stage, setStage] = useState<EditorAgentStage>('idle');
const [isLoadingConversations, setIsLoadingConversations] = useState(false);
const [isLoadingMessages, setIsLoadingMessages] = useState(false);
const [isCreatingConversation, setIsCreatingConversation] = useState(false);
const [isDeletingConversation, setIsDeletingConversation] = useState(false);
const [isStreaming, setIsStreaming] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const activeStreamAbortControllerRef = useRef<AbortController | null>(null);
const activeConversationIdRef = useRef<string | null>(null);
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);
setStage('idle');
},
[],
);
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([]);
setStage('idle');
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 handleSseEvent = useCallback((event: EditorAgentSseEvent) => {
const eventConversationId = event.data.conversationId;
if (
eventConversationId &&
eventConversationId !== activeConversationIdRef.current
) {
return;
}
if (event.event === 'stage') {
setStage(event.data.stage);
return;
}
if (event.event === 'message_delta') {
if (event.data.kind === 'error') {
const nextErrorMessage = event.data.textDelta.trim();
if (nextErrorMessage) {
setErrorMessage(nextErrorMessage);
}
}
setMessages((currentMessages) =>
upsertMessage(
currentMessages,
event.data.messageId,
() =>
createAssistantMessage({
id: event.data.messageId,
role: event.data.role,
kind: event.data.kind,
}),
(message) => ({
...message,
role: event.data.role,
kind: event.data.kind,
text: `${message.text}${event.data.textDelta}`,
status: event.data.kind === 'error' ? 'failed' : 'streaming',
}),
),
);
return;
}
if (event.event === 'tool_started' || event.event === 'tool_completed') {
const status =
event.event === 'tool_started'
? 'generating'
: (event.data.status ?? 'completed');
const nextRecord: EditorAgentGenerationRecord = {
toolCallId: event.data.toolCallId,
toolName: event.data.toolName,
summary: event.data.summary ?? null,
taskId: event.data.taskId ?? null,
status,
model: event.data.model ?? null,
images: [],
error: event.data.error ?? null,
};
setMessages((currentMessages) =>
upsertMessage(
currentMessages,
event.data.messageId,
() =>
createAssistantMessage({
id: event.data.messageId,
status,
generations: [nextRecord],
}),
(message) => ({
...message,
status,
generations: upsertGenerationRecord(
message.generations,
nextRecord,
),
}),
),
);
return;
}
if (event.event === 'generation_result') {
onGenerationResult?.(event.data);
const nextRecord: EditorAgentGenerationRecord = {
toolCallId: event.data.toolCallId,
toolName: event.data.toolName,
summary: null,
taskId: null,
status: 'completed',
model: event.data.model,
images: event.data.images,
};
setMessages((currentMessages) =>
upsertMessage(
currentMessages,
event.data.messageId,
() =>
createAssistantMessage({
id: event.data.messageId,
status: 'completed',
generations: [nextRecord],
}),
(message) => ({
...message,
status: 'completed',
generations: upsertGenerationRecord(
message.generations,
nextRecord,
),
}),
),
);
return;
}
if (event.event === 'error') {
const messageId = createClientId('agent-error');
setStage('failed');
setErrorMessage(event.data.message);
setMessages((currentMessages) => [
...currentMessages,
createAssistantMessage({
id: messageId,
kind: 'error',
text: event.data.message,
status: 'failed',
}),
]);
return;
}
if (event.event === 'done') {
setStage((currentStage) =>
currentStage === 'failed' ? 'failed' : 'completed',
);
setMessages((currentMessages) =>
markStreamingMessages(currentMessages, 'completed'),
);
const nextTitle = event.data.title?.trim();
if (nextTitle) {
setConversations((currentConversations) =>
currentConversations.map((conversation) =>
conversation.conversationId === event.data.conversationId
? { ...conversation, title: nextTitle }
: conversation,
),
);
}
}
}, [onGenerationResult]);
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) ||
isStreaming ||
isLoadingConversations ||
isLoadingMessages
) {
return;
}
const conversationId = await ensureConversationForSend();
const clientMessageId = createClientId('client-message');
const abortController = new AbortController();
activeStreamAbortControllerRef.current = abortController;
setErrorMessage(null);
setStage('thinking');
setIsStreaming(true);
setMessages((currentMessages) => [
...currentMessages,
createLocalUserMessage({
id: clientMessageId,
text,
attachments,
}),
]);
try {
await client.streamMessage(
conversationId,
{
clientMessageId,
text,
attachments,
},
{
signal: abortController.signal,
onEvent: handleSseEvent,
},
);
setMessages((currentMessages) =>
markStreamingMessages(currentMessages, 'completed'),
);
} catch (error) {
if (isAbortError(error) || abortController.signal.aborted) {
return;
}
const message =
error instanceof Error ? error.message : '发送画布 Agent 消息失败';
setErrorMessage(message);
setStage('failed');
setMessages((currentMessages) => [
...markStreamingMessages(currentMessages, 'failed'),
createAssistantMessage({
id: createClientId('agent-error'),
kind: 'error',
text: message,
status: 'failed',
}),
]);
throw error;
} finally {
if (activeStreamAbortControllerRef.current === abortController) {
activeStreamAbortControllerRef.current = null;
}
setIsStreaming(false);
}
},
[
client,
ensureConversationForSend,
handleSseEvent,
isLoadingConversations,
isLoadingMessages,
isStreaming,
],
);
const stopCurrentTurn = useCallback(() => {
activeStreamAbortControllerRef.current?.abort();
activeStreamAbortControllerRef.current = null;
setMessages((currentMessages) =>
markStreamingMessages(currentMessages, 'stopped'),
);
setStage('idle');
setIsStreaming(false);
}, []);
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([]);
setStage('idle');
} catch (error) {
setErrorMessage(
error instanceof Error ? error.message : '删除画布 Agent 会话失败',
);
throw error;
} finally {
setIsDeletingConversation(false);
}
}, [activeConversationId, client, conversations, loadConversation]);
return {
conversations,
activeConversation,
activeConversationId,
messages,
stage,
isLoadingConversations,
isLoadingMessages,
isCreatingConversation,
isDeletingConversation,
isStreaming,
errorMessage,
createConversation,
selectConversation,
sendMessage,
stopCurrentTurn,
deleteActiveConversation,
};
}