统一画布代理文本与附件输入约束

要求用户消息包含非空文本,附件仅作为消息上下文。
同步后端领域校验、接口错误和请求测试。
禁用前端纯附件发送并更新交互回归测试。
保留发送失败时的草稿与附件恢复行为。
This commit is contained in:
2026-07-31 11:30:48 +08:00
parent b09a98db48
commit 2fa05cd605
8 changed files with 44 additions and 51 deletions
@@ -24,6 +24,7 @@ import { EditorAgentConversationPanelView } from './EditorAgentConversationPanel
const createEditorProjectResourceMock = vi.hoisted(() => vi.fn());
const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn());
const probeImageFileDimensionsMock = vi.hoisted(() => vi.fn());
const ATTACHMENT_PROMPT = '请参考附件';
vi.mock('@/src/services/image-editor/editorProjectClient.ts', async () => {
const actual = await vi.importActual<
@@ -116,6 +117,12 @@ function createClient(): EditorAgentConversationClient {
};
}
function enterAttachmentPrompt() {
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
target: { value: ATTACHMENT_PROMPT },
});
}
afterEach(() => {
vi.useRealTimers();
});
@@ -425,13 +432,14 @@ describe('EditorAgentConversationPanelView', () => {
fireEvent.click(screen.getByRole('menuitem', { name: '引用' }));
expect(await screen.findByText('Agent生成图片-1')).toBeTruthy();
enterAttachmentPrompt();
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => {
expect(client.sendMessage).toHaveBeenCalledWith(
'conversation-1',
expect.objectContaining({
text: '',
text: ATTACHMENT_PROMPT,
attachments: [
expect.objectContaining({
source: 'canvas_resource',
@@ -792,13 +800,14 @@ describe('EditorAgentConversationPanelView', () => {
expect(screen.getByText('粘贴图片')).toBeTruthy();
});
enterAttachmentPrompt();
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => {
expect(client.sendMessage).toHaveBeenCalledWith(
'conversation-1',
expect.objectContaining({
text: '',
text: ATTACHMENT_PROMPT,
attachments: [
expect.objectContaining({
source: 'canvas_resource',
@@ -864,6 +873,7 @@ describe('EditorAgentConversationPanelView', () => {
expect(screen.getByText('历史粘贴图')).toBeTruthy();
});
enterAttachmentPrompt();
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => {
expect(client.sendMessage).toHaveBeenCalledWith(
@@ -950,6 +960,7 @@ describe('EditorAgentConversationPanelView', () => {
});
expect(screen.queryByText('最新附件')).toBeNull();
enterAttachmentPrompt();
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => {
expect(client.sendMessage).toHaveBeenCalledWith(
@@ -1080,6 +1091,7 @@ describe('EditorAgentConversationPanelView', () => {
});
expect(await screen.findByText('粘贴图片')).toBeTruthy();
enterAttachmentPrompt();
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => {
expect(client.sendMessage).toHaveBeenCalledWith(
@@ -1222,6 +1234,7 @@ describe('EditorAgentConversationPanelView', () => {
expect(await screen.findByText('最多 9 张')).toBeTruthy();
expect(screen.queryByText('粘贴图片')).toBeNull();
enterAttachmentPrompt();
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => {
const request = vi.mocked(client.sendMessage).mock.calls[0]?.[1];
@@ -1239,7 +1252,7 @@ describe('EditorAgentConversationPanelView', () => {
});
});
it('sends selected attachments even when the text input is empty', async () => {
it('rejects selected attachments when the text input is empty', async () => {
const client = createClient();
render(
@@ -1282,23 +1295,15 @@ describe('EditorAgentConversationPanelView', () => {
fireEvent.click(
within(attachmentDialog).getByRole('button', { name: '应用' }),
);
fireEvent.click(screen.getByRole('button', { name: '发送' }));
const sendButton = screen.getByRole('button', {
name: '发送',
}) as HTMLButtonElement;
expect(sendButton.disabled).toBe(true);
await waitFor(() => {
expect(client.sendMessage).toHaveBeenCalledWith(
'conversation-1',
expect.objectContaining({
text: '',
attachments: [
expect.objectContaining({
source: 'canvas_resource',
referenceId: 'resource-1',
}),
],
}),
expect.any(Object),
);
});
fireEvent.submit(sendButton.closest('form')!);
expect(client.sendMessage).not.toHaveBeenCalled();
expect(screen.getByText('角色图层')).toBeTruthy();
});
it('restores the draft and selected attachments when sending fails', async () => {
@@ -1464,6 +1469,7 @@ describe('EditorAgentConversationPanelView', () => {
fireEvent.click(
within(attachmentDialog).getByRole('button', { name: '应用' }),
);
enterAttachmentPrompt();
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => {
@@ -1548,6 +1554,7 @@ describe('EditorAgentConversationPanelView', () => {
fireEvent.click(
within(attachmentDialog).getByRole('button', { name: '应用' }),
);
enterAttachmentPrompt();
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => {
@@ -1659,6 +1666,7 @@ describe('EditorAgentConversationPanelView', () => {
fireEvent.click(
within(attachmentDialog).getByRole('button', { name: '应用' }),
);
enterAttachmentPrompt();
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => {
@@ -134,6 +134,7 @@ export function EditorAgentConversationPanelView({
isWaiting ||
isToolCallActionPending ||
isPastingAttachment ||
!draftText.trim() ||
!hasProject;
const currentConversationTitle = activeConversation?.title ?? '新对话';
@@ -147,9 +148,6 @@ export function EditorAgentConversationPanelView({
return;
}
const text = draftText.trim();
if (!text && !attachments.length) {
return;
}
setDraftText('');
const nextAttachments = consumeAttachments();
void sendMessage(text, nextAttachments).catch(() => {
@@ -366,10 +364,7 @@ export function EditorAgentConversationPanelView({
<button
type="submit"
className="inline-flex h-10 min-w-16 shrink-0 items-center justify-center gap-1.5 rounded-full bg-slate-900 px-3 text-sm font-semibold text-white disabled:opacity-45"
disabled={
isMessageSubmissionBlocked ||
(!draftText.trim() && !attachments.length)
}
disabled={isMessageSubmissionBlocked}
>
<Send className="h-3.5 w-3.5" aria-hidden="true" />
@@ -794,7 +794,7 @@ describe('useEditorAgentConversation', () => {
expect(result.current.isPatienceNoticeVisible).toBe(false);
});
it('allows sending an attachment-only message', async () => {
it('rejects an attachment-only message before calling the client', async () => {
const client = createClient();
const { result } = renderHook(() =>
useEditorAgentConversation({ projectId: 'project-1', client }),
@@ -818,20 +818,7 @@ describe('useEditorAgentConversation', () => {
]);
});
expect(client.sendMessage).toHaveBeenCalledWith(
'conversation-1',
expect.objectContaining({
text: '',
attachments: [
expect.objectContaining({
source: 'canvas_resource',
referenceId: 'resource-1',
label: '一二三四五六七八九十甲乙丙丁戊己庚辛壬癸子丑寅卯',
}),
],
}),
expect.any(Object),
);
expect(client.sendMessage).not.toHaveBeenCalled();
});
it('applies persisted backend planning errors as system messages', async () => {
@@ -452,7 +452,7 @@ export function useEditorAgentConversation({
async (rawText: string, attachments: EditorAgentAttachmentRef[] = []) => {
const text = rawText.trim();
if (
(!text && !attachments.length) ||
!text ||
isWaitingRef.current ||
activeToolCallActionRef.current !== null ||
isLoadingConversations ||