优化美术 Agent 长等待提示与超时处理

将 120 秒硬超时改为请求存活时的耐心等待提示。
收口 provider 安全上限、有限重试和明确失败错误。
补齐等待互斥、计时清理、前后端测试及契约文档。
This commit is contained in:
2026-07-21 16:05:52 +08:00
parent 8bdc728fd3
commit 48c9ee2fae
12 changed files with 430 additions and 82 deletions
@@ -8,13 +8,14 @@ import {
waitFor,
within,
} from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type {
EditorAgentMessage,
EditorAgentMessageResponse,
} from '@/packages/shared/src/contracts';
import type { EditorAgentConversationClient } from '@/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts';
import { EDITOR_AGENT_PATIENCE_NOTICE_DELAY_MS } from '@/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts';
import { useImageCanvasContextStore } from '@/src/components/image-editor/useImageCanvasContextStore.ts';
import { EditorAgentConversationPanelView } from './EditorAgentConversationPanelView.tsx';
@@ -114,6 +115,10 @@ function createClient(): EditorAgentConversationClient {
};
}
afterEach(() => {
vi.useRealTimers();
});
function createPendingToolCallMessage(): EditorAgentMessage {
return {
id: 2,
@@ -331,18 +336,26 @@ describe('EditorAgentConversationPanelView', () => {
await waitFor(() => {
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
});
vi.useFakeTimers();
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
target: { value: '继续规划' },
});
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => {
expect(
screen.getByRole('button', { name: '发送' }).hasAttribute('disabled'),
).toBe(true);
await act(async () => {
await Promise.resolve();
});
expect(
screen.getByRole('button', { name: '发送' }).hasAttribute('disabled'),
).toBe(true);
expect(screen.queryByRole('button', { name: '停止' })).toBeNull();
expect(screen.queryByText('仍在处理中,请耐心等待')).toBeNull();
act(() => {
vi.advanceTimersByTime(EDITOR_AGENT_PATIENCE_NOTICE_DELAY_MS);
});
expect(screen.getByText('仍在处理中,请耐心等待')).toBeTruthy();
await act(async () => {
resolveSend({
@@ -355,7 +368,9 @@ describe('EditorAgentConversationPanelView', () => {
deltaMessages: [],
errorMessage: null,
});
await Promise.resolve();
});
expect(screen.queryByText('仍在处理中,请耐心等待')).toBeNull();
});
it('uploads pasted images as canvas attachments before sending', async () => {
@@ -431,9 +446,7 @@ describe('EditorAgentConversationPanelView', () => {
);
});
expect(screen.getByRole('option', { name: '角色参考' })).toBeTruthy();
expect(
screen.queryByRole('option', { name: 'conversation-1' }),
).toBeNull();
expect(screen.queryByRole('option', { name: 'conversation-1' })).toBeNull();
});
it('sends selected attachments even when the text input is empty', async () => {
@@ -555,9 +568,9 @@ describe('EditorAgentConversationPanelView', () => {
).toBe('失败后恢复这条草稿');
expect(screen.getByText('角色图层')).toBeTruthy();
expect(
within(screen.getByRole('log', { name: '画布 Agent 消息流' })).queryByText(
'失败后恢复这条草稿',
),
within(
screen.getByRole('log', { name: '画布 Agent 消息流' }),
).queryByText('失败后恢复这条草稿'),
).toBeNull();
});
@@ -692,9 +705,9 @@ describe('EditorAgentConversationPanelView', () => {
});
expect(screen.getByRole('button', { name: '执行中' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '确认' })).toBeNull();
expect(screen.getByRole('button', { name: '取消' }).hasAttribute('disabled')).toBe(
true,
);
expect(
screen.getByRole('button', { name: '取消' }).hasAttribute('disabled'),
).toBe(true);
await act(async () => {
resolveConfirmation();
@@ -258,6 +258,7 @@ export function EditorAgentConversationPanelView({
isCreatingConversation,
isDeletingConversation,
isWaiting,
isPatienceNoticeVisible,
toolCallAction,
isToolCallActionPending,
errorMessage,
@@ -614,7 +615,9 @@ export function EditorAgentConversationPanelView({
}}
/>
))}
{isWaiting ? <ThinkingBubble /> : null}
{isWaiting ? (
<ThinkingBubble showPatienceNotice={isPatienceNoticeVisible} />
) : null}
</>
) : (
<div className="rounded-3xl border border-dashed border-slate-200 bg-white/70 px-4 py-8 text-center text-sm text-slate-400">
@@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest';
import type { EditorAgentMessage } from '@/packages/shared/src/contracts';
import { MessageBubble } from './MessageBubble.tsx';
import { MessageBubble, ThinkingBubble } from './MessageBubble.tsx';
function renderMessage(message: EditorAgentMessage) {
return render(
@@ -19,6 +19,18 @@ function renderMessage(message: EditorAgentMessage) {
}
describe('MessageBubble', () => {
it('shows a patience notice only for an extended pending request', () => {
const { rerender } = render(<ThinkingBubble />);
expect(screen.getByLabelText('Agent思考中')).toBeTruthy();
expect(screen.queryByText('仍在处理中,请耐心等待')).toBeNull();
rerender(<ThinkingBubble showPatienceNotice />);
expect(screen.getByLabelText('Agent仍在处理中')).toBeTruthy();
expect(screen.getByText('仍在处理中,请耐心等待')).toBeTruthy();
});
it('shows prefixed system errors as red Agent errors without the wire prefix', () => {
renderMessage({
id: 2,
@@ -14,9 +14,16 @@ function messageRoleLabel(role: EditorAgentMessage['role']) {
return 'Agent';
}
export function ThinkingBubble() {
export function ThinkingBubble({
showPatienceNotice = false,
}: {
showPatienceNotice?: boolean;
}) {
return (
<article className="flex justify-start" aria-label="Agent思考中">
<article
className="flex justify-start"
aria-label={showPatienceNotice ? 'Agent仍在处理中' : 'Agent思考中'}
>
<div className="max-w-[86%] rounded-3xl border border-slate-200 bg-white px-3.5 py-3 text-sm leading-6 shadow-sm">
<div className="flex items-center gap-1.5">
<span className="flex gap-0.5">
@@ -33,6 +40,9 @@ export function ThinkingBubble() {
style={{ animationDelay: '300ms' }}
/>
</span>
{showPatienceNotice ? (
<span className="text-slate-600"></span>
) : null}
</div>
</div>
</article>
@@ -61,7 +71,11 @@ export function MessageBubble({
? message.text.slice(EDITOR_AGENT_ERROR_MESSAGE_PREFIX.length)
: null;
if (message.role === 'system' && !message.toolCall && systemErrorText === null) {
if (
message.role === 'system' &&
!message.toolCall &&
systemErrorText === null
) {
return null;
}
if (
@@ -1,7 +1,7 @@
/* @vitest-environment jsdom */
import { act, renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type {
EditorAgentConversationDetail,
@@ -9,6 +9,7 @@ import type {
EditorAgentMessageResponse,
} from '../../../../packages/shared/src/contracts/editorAgent.ts';
import {
EDITOR_AGENT_PATIENCE_NOTICE_DELAY_MS,
type EditorAgentConversationClient,
useEditorAgentConversation,
} from './useEditorAgentConversation.ts';
@@ -116,6 +117,10 @@ describe('useEditorAgentConversation', () => {
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers();
});
it('loads conversations and applies delta messages', async () => {
const client = createClient();
const onCanvasRefreshRequested = vi.fn();
@@ -445,6 +450,87 @@ describe('useEditorAgentConversation', () => {
);
});
it('does not send or apply a stale conversation created after switching projects', async () => {
const client = createClient();
let resolveCreate!: (detail: EditorAgentConversationDetail) => void;
vi.mocked(client.listConversations)
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{
conversationId: 'conversation-project-2',
projectId: 'project-2',
title: '项目二会话',
updatedAt: '2026-07-03T00:02:00.000Z',
},
]);
vi.mocked(client.createConversation).mockImplementationOnce(
() =>
new Promise<EditorAgentConversationDetail>((resolve) => {
resolveCreate = resolve;
}),
);
vi.mocked(client.getConversation).mockResolvedValueOnce({
conversationId: 'conversation-project-2',
projectId: 'project-2',
title: '项目二会话',
messages: [
{
id: 20,
role: 'assistant',
text: '项目二消息',
attachments: [],
toolCall: null,
createdAt: '2026-07-03T00:02:00.000Z',
},
],
createdAt: '2026-07-03T00:02:00.000Z',
updatedAt: '2026-07-03T00:02:00.000Z',
});
const { result, rerender } = renderHook(
({ projectId }) => useEditorAgentConversation({ projectId, client }),
{ initialProps: { projectId: 'project-1' } },
);
await waitFor(() => {
expect(result.current.isLoadingConversations).toBe(false);
});
let sendPromise!: Promise<void>;
act(() => {
sendPromise = result.current.sendMessage('旧项目消息');
});
await waitFor(() => {
expect(client.createConversation).toHaveBeenCalledWith('project-1', {});
});
rerender({ projectId: 'project-2' });
await waitFor(() => {
expect(result.current.activeConversationId).toBe(
'conversation-project-2',
);
});
await act(async () => {
resolveCreate({
conversationId: 'conversation-project-1',
projectId: 'project-1',
title: '旧项目新会话',
messages: [],
createdAt: '2026-07-03T00:01:00.000Z',
updatedAt: '2026-07-03T00:01:00.000Z',
});
await sendPromise;
});
expect(client.sendMessage).not.toHaveBeenCalled();
expect(result.current.activeConversationId).toBe('conversation-project-2');
expect(result.current.messages.map((message) => message.text)).toEqual([
'项目二消息',
]);
expect(result.current.isWaiting).toBe(false);
expect(result.current.isPatienceNoticeVisible).toBe(false);
});
it('allows sending an attachment-only message', async () => {
const client = createClient();
const { result } = renderHook(() =>
@@ -769,7 +855,9 @@ describe('useEditorAgentConversation', () => {
);
await waitFor(() => {
expect(result.current.messages[0]?.toolCall?.status).toBe('not_completed');
expect(result.current.messages[0]?.toolCall?.status).toBe(
'not_completed',
);
});
const getConversationCallsBeforeCancel = vi.mocked(client.getConversation)
.mock.calls.length;
@@ -809,6 +897,78 @@ describe('useEditorAgentConversation', () => {
expect(result.current.messages).toHaveLength(0);
});
it('replaces the extended patience notice with the actual request failure', async () => {
const client = createClient();
let rejectSend!: (error: Error) => void;
vi.mocked(client.sendMessage).mockImplementation(
() =>
new Promise<EditorAgentMessageResponse>((_resolve, reject) => {
rejectSend = reject;
}),
);
const { result } = renderHook(() =>
useEditorAgentConversation({ projectId: 'project-1', client }),
);
await waitFor(() => {
expect(result.current.activeConversation?.conversationId).toBe(
'conversation-1',
);
});
vi.useFakeTimers();
let sendPromise!: Promise<void>;
act(() => {
sendPromise = result.current.sendMessage('请继续');
});
await act(async () => {
await Promise.resolve();
});
act(() => {
vi.advanceTimersByTime(EDITOR_AGENT_PATIENCE_NOTICE_DELAY_MS);
});
expect(result.current.isPatienceNoticeVisible).toBe(true);
await act(async () => {
rejectSend(new Error('LLM 连接已断开'));
await sendPromise.catch(() => undefined);
});
expect(result.current.isPatienceNoticeVisible).toBe(false);
expect(result.current.isWaiting).toBe(false);
expect(result.current.errorMessage).toBe('LLM 连接已断开');
expect(result.current.messages).toHaveLength(0);
});
it('cleans the patience timer when the hook unmounts', async () => {
const client = createClient();
vi.mocked(client.sendMessage).mockImplementation(
() => new Promise<EditorAgentMessageResponse>(() => undefined),
);
const { result, unmount } = renderHook(() =>
useEditorAgentConversation({ projectId: 'project-1', client }),
);
await waitFor(() => {
expect(result.current.activeConversation?.conversationId).toBe(
'conversation-1',
);
});
vi.useFakeTimers();
act(() => {
void result.current.sendMessage('请继续');
});
await act(async () => {
await Promise.resolve();
});
expect(vi.getTimerCount()).toBe(1);
unmount();
expect(vi.getTimerCount()).toBe(0);
});
it('keeps the active request pending without exposing a stop action', async () => {
const client = createClient();
let capturedSignal: AbortSignal | null = null;
@@ -830,16 +990,27 @@ describe('useEditorAgentConversation', () => {
);
});
void act(() => {
void result.current.sendMessage('请继续');
vi.useFakeTimers();
let sendPromise!: Promise<void>;
act(() => {
sendPromise = result.current.sendMessage('请继续');
void result.current.sendMessage('不要重复发送');
});
await waitFor(() => {
expect(result.current.isWaiting).toBe(true);
await act(async () => {
await Promise.resolve();
});
expect(result.current.isWaiting).toBe(true);
expect(result.current.isPatienceNoticeVisible).toBe(false);
expect(client.sendMessage).toHaveBeenCalledTimes(1);
expect(capturedSignal).toBeNull();
expect('stopCurrentTurn' in result.current).toBe(false);
act(() => {
vi.advanceTimersByTime(EDITOR_AGENT_PATIENCE_NOTICE_DELAY_MS);
});
expect(result.current.isPatienceNoticeVisible).toBe(true);
await act(async () => {
resolveSend({
conversation: {
@@ -851,10 +1022,10 @@ describe('useEditorAgentConversation', () => {
deltaMessages: [],
errorMessage: null,
});
await sendPromise;
});
await waitFor(() => {
expect(result.current.isWaiting).toBe(false);
});
expect(result.current.isWaiting).toBe(false);
expect(result.current.isPatienceNoticeVisible).toBe(false);
});
});
@@ -40,14 +40,8 @@ export type EditorAgentConversationClient = {
payload: EditorAgentMessageRequest,
options: SendEditorAgentMessageOptions,
) => Promise<EditorAgentMessageResponse>;
confirmToolCall: (
conversationId: string,
messageId: number,
) => Promise<void>;
cancelToolCall: (
conversationId: string,
messageId: number,
) => Promise<void>;
confirmToolCall: (conversationId: string, messageId: number) => Promise<void>;
cancelToolCall: (conversationId: string, messageId: number) => Promise<void>;
};
type UseEditorAgentConversationOptions = {
@@ -74,6 +68,8 @@ const defaultEditorAgentConversationClient: EditorAgentConversationClient = {
cancelToolCall: cancelEditorAgentToolCall,
};
export const EDITOR_AGENT_PATIENCE_NOTICE_DELAY_MS = 120_000;
function createEditorAgentClientMessageId() {
const randomId =
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
@@ -155,16 +151,51 @@ export function useEditorAgentConversation({
const [isCreatingConversation, setIsCreatingConversation] = useState(false);
const [isDeletingConversation, setIsDeletingConversation] = useState(false);
const [isWaiting, setIsWaiting] = 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 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;
if (patienceNoticeTimerRef.current !== null) {
clearTimeout(patienceNoticeTimerRef.current);
patienceNoticeTimerRef.current = null;
}
};
}, []);
useEffect(() => {
pendingSendRequestIdRef.current += 1;
createConversationRequestIdRef.current += 1;
isWaitingRef.current = false;
if (patienceNoticeTimerRef.current !== null) {
clearTimeout(patienceNoticeTimerRef.current);
patienceNoticeTimerRef.current = null;
}
setIsWaiting(false);
setIsCreatingConversation(false);
setPatienceNoticeConversationId(null);
}, [normalizedProjectId]);
const activeConversation = useMemo(
() =>
@@ -193,10 +224,7 @@ export function useEditorAgentConversation({
);
const loadConversation = useCallback(
async (
conversationId: string,
options: { showLoading?: boolean } = {},
) => {
async (conversationId: string, options: { showLoading?: boolean } = {}) => {
const requestId = conversationLoadRequestIdRef.current + 1;
conversationLoadRequestIdRef.current = requestId;
const showLoading = options.showLoading ?? true;
@@ -287,19 +315,37 @@ export function useEditorAgentConversation({
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(normalizedProjectId, {});
applyConversationDetail(detail);
const detail = await client.createConversation(requestedProjectId, {});
if (
createConversationRequestIdRef.current === requestId &&
normalizedProjectIdRef.current === requestedProjectId
) {
applyConversationDetail(detail);
}
return detail;
} catch (error) {
setErrorMessage(
error instanceof Error ? error.message : '创建画布 Agent 会话失败',
);
if (
createConversationRequestIdRef.current === requestId &&
normalizedProjectIdRef.current === requestedProjectId
) {
setErrorMessage(
error instanceof Error ? error.message : '创建画布 Agent 会话失败',
);
}
throw error;
} finally {
setIsCreatingConversation(false);
if (
createConversationRequestIdRef.current === requestId &&
normalizedProjectIdRef.current === requestedProjectId
) {
setIsCreatingConversation(false);
}
}
}, [applyConversationDetail, client, normalizedProjectId]);
@@ -328,9 +374,9 @@ export function useEditorAgentConversation({
const toolCall = message.toolCall;
return Boolean(
toolCall?.externalJobId &&
(toolCall.images.length > 0 ||
(toolCall.videos?.length ?? 0) > 0 ||
(toolCall.audios?.length ?? 0) > 0),
(toolCall.images.length > 0 ||
(toolCall.videos?.length ?? 0) > 0 ||
(toolCall.audios?.length ?? 0) > 0),
);
})
) {
@@ -363,29 +409,49 @@ export function useEditorAgentConversation({
const text = rawText.trim();
if (
(!text && !attachments.length) ||
isWaiting ||
isWaitingRef.current ||
activeToolCallActionRef.current !== null ||
isLoadingConversations ||
isLoadingMessages
) {
return;
}
const conversationId = await ensureConversationForSend();
const clientMessageId = createEditorAgentClientMessageId();
isWaitingRef.current = true;
const requestedProjectId = normalizedProjectId;
const requestId = pendingSendRequestIdRef.current + 1;
pendingSendRequestIdRef.current = requestId;
setErrorMessage(null);
setIsWaiting(true);
const optimisticMessage = createLocalUserMessage({
id: -1,
clientMessageId,
text,
attachments,
});
setMessages((currentMessages) => [
...currentMessages,
optimisticMessage,
]);
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 nextOptimisticMessage = createLocalUserMessage({
id: -1,
clientMessageId,
text,
attachments,
});
optimisticMessage = nextOptimisticMessage;
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,
{
@@ -396,6 +462,9 @@ export function useEditorAgentConversation({
{},
);
if (pendingSendRequestIdRef.current !== requestId) {
return;
}
setConversations((currentConversations) =>
upsertConversationSummary(
currentConversations,
@@ -413,15 +482,31 @@ export function useEditorAgentConversation({
} catch (error) {
const message =
error instanceof Error ? error.message : '发送画布 Agent 消息失败';
if (activeConversationIdRef.current === conversationId) {
const shouldReportError =
pendingSendRequestIdRef.current === requestId &&
(!conversationId ||
activeConversationIdRef.current === conversationId);
if (shouldReportError) {
setErrorMessage(message);
setMessages((currentMessages) =>
currentMessages.filter((message) => message !== optimisticMessage),
);
if (optimisticMessage) {
setMessages((currentMessages) =>
currentMessages.filter(
(message) => message !== optimisticMessage,
),
);
}
throw error;
}
throw error;
} finally {
setIsWaiting(false);
if (pendingSendRequestIdRef.current === requestId) {
if (patienceNoticeTimerRef.current !== null) {
clearTimeout(patienceNoticeTimerRef.current);
patienceNoticeTimerRef.current = null;
}
isWaitingRef.current = false;
setIsWaiting(false);
setPatienceNoticeConversationId(null);
}
}
},
[
@@ -430,7 +515,7 @@ export function useEditorAgentConversation({
applyDeltaMessages,
isLoadingConversations,
isLoadingMessages,
isWaiting,
normalizedProjectId,
],
);
@@ -537,6 +622,8 @@ export function useEditorAgentConversation({
isCreatingConversation,
isDeletingConversation,
isWaiting,
isPatienceNoticeVisible:
isWaiting && patienceNoticeConversationId === activeConversationId,
toolCallAction,
isToolCallActionPending: toolCallAction !== null,
errorMessage,