b8e6e9f3ef
新增 VITE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR 控制画布 Agent 入口显示。 默认 .env 关闭、本地 .env.local 开启,并补充示例和类型声明。 禁用时隐藏 dock 按钮和收起态 Agent 面板,开启时保留右侧任务列表互斥逻辑。 修正右侧 Agent 和任务列表切换不影响左侧资源栏。 补充画布 Agent 开关、面板独立性和 dock 隐藏测试。
666 lines
19 KiB
TypeScript
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,
|
|
};
|
|
}
|