From 78e6959ca460051399c62d78d5f01341c492232c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 28 Jul 2026 15:22:49 +0800 Subject: [PATCH] =?UTF-8?q?=E7=94=BB=E5=B8=83agent=E4=BA=A4=E4=BA=92?= =?UTF-8?q?=E4=BC=98=E5=8C=96=20(#105)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 输入框尺寸根据输入内容调整高度,达到最大高度后再出现翻页条。 before: ![shotmd-1784699696.jpg](/attachments/20491333-efe9-44f3-ba6c-1d84579473b2) after: ![shotmd-1784699381.jpg](/attachments/6018e4e1-6ccc-4de7-92de-5d3f5fb43846) ![shotmd-1784699431.jpg](/attachments/8e91351e-29a5-4220-a7e2-e09554b00935) 调整对话切换角标样式 before: ![shotmd-1784699673.jpg](/attachments/7c3470ab-6f7d-4aa8-9660-4db8e942bd28) after: ![shotmd-1784699465.jpg](/attachments/e8342741-c1a4-4669-9ad4-c8b34cfeaf30) 新建对话按钮禁用 当已在一个新对话中 Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/105 Co-authored-by: 王德宇 Co-committed-by: 王德宇 --- .../【编辑器】画布Agent对话面板-2026-07-03.md | 7 +- .../EditorAgentConversationPanelView.test.tsx | 273 +++++++++++++++++- .../EditorAgentConversationPanelView.tsx | 133 +++++---- .../EditorAgentDraftTextarea.test.tsx | 132 +++++++++ .../EditorAgentDraftTextarea.tsx | 113 ++++++++ .../useEditorAgentConversation.test.tsx | 262 +++++++++++++++++ .../useEditorAgentConversation.ts | 73 ++++- src/index.css | 25 ++ 8 files changed, 951 insertions(+), 67 deletions(-) create mode 100644 src/components/image-editor/EditorAgentConversation/EditorAgentDraftTextarea.test.tsx create mode 100644 src/components/image-editor/EditorAgentConversation/EditorAgentDraftTextarea.tsx diff --git a/docs/【编辑器】画布Agent对话面板-2026-07-03.md b/docs/【编辑器】画布Agent对话面板-2026-07-03.md index 9c9a55663..8a2c4a2b0 100644 --- a/docs/【编辑器】画布Agent对话面板-2026-07-03.md +++ b/docs/【编辑器】画布Agent对话面板-2026-07-03.md @@ -59,8 +59,13 @@ - 对话框与既有任务侧栏(`ImageCanvasTaskSidebarView`)**互斥展开**:展开一个自动收起另一个;各自收起后保留入口按钮。 - 对话框与左侧素材 / 图层侧栏**不互斥**,允许同时展开,便于在对话中选取和核对画布素材;左侧栏切换不改变 Agent 面板开关状态。 - 桌面端对话框固定宽约 360–400px;移动端抽屉式全宽覆盖;收起态为胶囊/圆形入口按钮。 +- 底部消息输入框随输入内容从单行高度自动增长,最大高度为 + 128px;输入框及其 Enter 提交、原生自适应和兼容降级统一封装在独立 `EditorAgentDraftTextarea` 组件中。支持 `field-sizing: content` 的浏览器使用原生内容尺寸自适应,不支持该属性的旧 Safari / iOS WebView 使用前端测量降级,并在宽度变化时重新计算换行高度。内容超过最大高度后停止增长并启用内部纵向滚动,内容缩短或清空后同步收缩。内部滚动条使用浅灰窄滑块和透明轨道,上下留白不得溢出输入框圆角边界;输入框在窄屏下允许收缩且不产生横向滚动。 +- Enter 发送必须同时排除 `isComposing` 和旧 Safari / WebKit 候选词确认事件的 `keyCode === 229`,避免输入法选词时误发送。 - 会话管理入口在对话框头部:当前会话标题 + 历史会话下拉(按更新时间倒序)+ 新建对话按钮,全部包在对话框内。 -- 快速切换会话或会话轮询刷新产生并发详情请求时,前端只允许最后发起的请求更新当前会话、消息、错误和加载态;旧响应不得覆盖用户最新选择。 +- 当前会话没有任何已发送消息时,新建对话按钮置灰且不可点击;输入框草稿和未发送附件不算会话内容。当前会话已有消息时可新建,新建成功后只切换到返回的空白会话,输入文字、附件及附件选择状态与切换历史会话时一样原样保留,旧会话继续保留在历史会话下拉中;创建失败同样不修改草稿。 +- 新会话创建请求 pending 时禁用历史会话下拉和发送动作,但输入框与附件仍可编辑;会话列表或历史消息加载期间同样禁用发送。表单提交处理器必须复用相同门禁,不能先清空草稿再由 hook 静默跳过发送。 +- 快速切换会话或会话轮询刷新产生并发详情请求时,每个请求必须获得唯一且单调递增的请求序号;前端只允许最后发起且有权生效的请求更新当前会话、消息、错误和加载态。被正在进行的会话切换压制的旧会话 refresh 不得提前结束新切换的加载态,旧响应也不得覆盖用户最新选择。 - 普通 JSON 消息请求的回包必须绑定发送时的会话:用户在等待期间切换到其他会话后,只更新原会话的列表摘要,不得把原会话的 `deltaMessages` 、错误或画布刷新副作用应用到当前面板。 - 收起对话框只是隐藏面板,不卸载当前会话 hook;普通 JSON 消息请求的等待态和外部生成任务状态必须在收起 / 重新打开之间保持一致。 diff --git a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx index edafe0bab..63f0fa034 100644 --- a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx @@ -11,6 +11,7 @@ import { import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { + EditorAgentConversationDetail, EditorAgentMessage, EditorAgentMessageResponse, } from '@/packages/shared/src/contracts'; @@ -264,11 +265,13 @@ describe('EditorAgentConversationPanelView', () => { expect(screen.getByText('已经看到画布内容')).toBeTruthy(); }); - fireEvent.click(screen.getByRole('button', { name: '新建对话' })); - await waitFor(() => { - expect(client.createConversation).toHaveBeenCalledWith('project-1', {}); + const newConversationButton = screen.getByRole('button', { + name: '新建对话', + }) as HTMLButtonElement; + expect(newConversationButton.disabled).toBe(false); + fireEvent.change(screen.getByLabelText('发送给画布 Agent'), { + target: { value: '应保留到新会话的草稿' }, }); - fireEvent.click(screen.getByRole('button', { name: '添加附件' })); const attachmentDialog = screen.getByRole('dialog', { name: '选择图片附件', @@ -292,6 +295,32 @@ describe('EditorAgentConversationPanelView', () => { ); expect(screen.getByText('角色图层')).toBeTruthy(); + fireEvent.click(newConversationButton); + await waitFor(() => { + expect(client.createConversation).toHaveBeenCalledWith('project-1', {}); + }); + await waitFor(() => { + expect( + (screen.getByLabelText('当前对话') as HTMLSelectElement).value, + ).toBe('conversation-2'); + expect(newConversationButton.disabled).toBe(true); + }); + expect( + (screen.getByLabelText('发送给画布 Agent') as HTMLTextAreaElement).value, + ).toBe('应保留到新会话的草稿'); + expect(screen.getByText('角色图层')).toBeTruthy(); + expect( + screen.getByRole('option', { name: '角色参考' }), + ).toBeTruthy(); + expect(screen.getByRole('option', { name: '新对话' })).toBeTruthy(); + + fireEvent.change(screen.getByLabelText('发送给画布 Agent'), { + target: { value: '尚未发送的草稿' }, + }); + expect(newConversationButton.disabled).toBe(true); + fireEvent.click(newConversationButton); + expect(client.createConversation).toHaveBeenCalledTimes(1); + fireEvent.change(screen.getByLabelText('发送给画布 Agent'), { target: { value: '参考附件做像素风' }, }); @@ -412,6 +441,242 @@ describe('EditorAgentConversationPanelView', () => { }); }); + it('keeps new conversation disabled when the project has no conversation history', async () => { + const client = createClient(); + vi.mocked(client.listConversations).mockResolvedValueOnce([]); + + render( + , + ); + + await waitFor(() => { + expect(client.listConversations).toHaveBeenCalledWith('project-1'); + expect(screen.getByText('暂无消息')).toBeTruthy(); + }); + const newConversationButton = screen.getByRole('button', { + name: '新建对话', + }) as HTMLButtonElement; + expect(newConversationButton.disabled).toBe(true); + + fireEvent.change(screen.getByLabelText('发送给画布 Agent'), { + target: { value: '未发送内容' }, + }); + expect(newConversationButton.disabled).toBe(true); + fireEvent.click(newConversationButton); + expect(client.createConversation).not.toHaveBeenCalled(); + }); + + it('preserves the current conversation draft when creating a conversation fails', async () => { + const client = createClient(); + vi.mocked(client.createConversation).mockRejectedValueOnce( + new Error('创建新会话失败'), + ); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText('已经看到画布内容')).toBeTruthy(); + }); + fireEvent.change(screen.getByLabelText('发送给画布 Agent'), { + target: { value: '需要保留的草稿' }, + }); + fireEvent.click(screen.getByRole('button', { name: '新建对话' })); + + await waitFor(() => { + expect(screen.getByText('创建新会话失败')).toBeTruthy(); + }); + expect( + (screen.getByLabelText('发送给画布 Agent') as HTMLTextAreaElement).value, + ).toBe('需要保留的草稿'); + expect(screen.getByText('已经看到画布内容')).toBeTruthy(); + expect( + (screen.getByLabelText('当前对话') as HTMLSelectElement).value, + ).toBe('conversation-1'); + }); + + it('preserves newer draft edits while creating a conversation', async () => { + const client = createClient(); + let resolveCreate!: (detail: EditorAgentConversationDetail) => void; + vi.mocked(client.createConversation).mockImplementationOnce( + () => + new Promise((resolve) => { + resolveCreate = resolve; + }), + ); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText('已经看到画布内容')).toBeTruthy(); + }); + const draftInput = screen.getByLabelText( + '发送给画布 Agent', + ) as HTMLTextAreaElement; + fireEvent.change(draftInput, { + target: { value: '旧项目草稿' }, + }); + fireEvent.click(screen.getByRole('button', { name: '新建对话' })); + + await waitFor(() => { + expect(client.createConversation).toHaveBeenCalledWith('project-1', {}); + expect(draftInput.disabled).toBe(false); + }); + const conversationSelect = screen.getByLabelText( + '当前对话', + ) as HTMLSelectElement; + const sendButton = screen.getByRole('button', { + name: '发送', + }) as HTMLButtonElement; + expect(conversationSelect.disabled).toBe(true); + expect(sendButton.disabled).toBe(true); + expect( + (screen.getByRole('button', { name: '添加附件' }) as HTMLButtonElement) + .disabled, + ).toBe(false); + fireEvent.change(draftInput, { + target: { value: '创建期间更新的草稿' }, + }); + fireEvent.submit(draftInput.closest('form') as HTMLFormElement); + expect(client.sendMessage).not.toHaveBeenCalled(); + expect(draftInput.value).toBe('创建期间更新的草稿'); + + await act(async () => { + resolveCreate({ + conversationId: 'conversation-2', + projectId: 'project-1', + title: '新对话', + messages: [], + createdAt: '2026-07-03T00:01:00.000Z', + updatedAt: '2026-07-03T00:01:00.000Z', + }); + await Promise.resolve(); + }); + expect(draftInput.value).toBe('创建期间更新的草稿'); + expect(conversationSelect.value).toBe('conversation-2'); + expect(conversationSelect.disabled).toBe(false); + expect(sendButton.disabled).toBe(false); + }); + + it('preserves the draft when submission is attempted during conversation loading', async () => { + const client = createClient(); + let resolveConversationLoad!: ( + detail: EditorAgentConversationDetail, + ) => void; + vi.mocked(client.listConversations).mockResolvedValueOnce([ + { + conversationId: 'conversation-1', + projectId: 'project-1', + title: '角色参考', + updatedAt: '2026-07-03T00:01:00.000Z', + }, + { + conversationId: 'conversation-2', + projectId: 'project-1', + title: '背景参考', + updatedAt: '2026-07-03T00:00:00.000Z', + }, + ]); + vi.mocked(client.getConversation) + .mockResolvedValueOnce({ + conversationId: 'conversation-1', + projectId: 'project-1', + title: '角色参考', + messages: [ + { + id: 0, + role: 'assistant', + text: '已经看到画布内容', + attachments: [], + toolCall: null, + createdAt: '2026-07-03T00:00:10.000Z', + }, + ], + createdAt: '2026-07-03T00:00:00.000Z', + updatedAt: '2026-07-03T00:00:10.000Z', + }) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveConversationLoad = resolve; + }), + ); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText('已经看到画布内容')).toBeTruthy(); + }); + const draftInput = screen.getByLabelText( + '发送给画布 Agent', + ) as HTMLTextAreaElement; + const conversationSelect = screen.getByLabelText( + '当前对话', + ) as HTMLSelectElement; + fireEvent.change(draftInput, { + target: { value: '切换期间必须保留的草稿' }, + }); + fireEvent.change(conversationSelect, { + target: { value: 'conversation-2' }, + }); + + const sendButton = screen.getByRole('button', { + name: '发送', + }) as HTMLButtonElement; + await waitFor(() => { + expect(client.getConversation).toHaveBeenCalledWith('conversation-2'); + expect(sendButton.disabled).toBe(true); + }); + fireEvent.submit(draftInput.closest('form') as HTMLFormElement); + expect(client.sendMessage).not.toHaveBeenCalled(); + expect(draftInput.value).toBe('切换期间必须保留的草稿'); + + await act(async () => { + resolveConversationLoad({ + conversationId: 'conversation-2', + projectId: 'project-1', + title: '背景参考', + messages: [ + { + id: 1, + role: 'assistant', + text: '背景会话内容', + attachments: [], + toolCall: null, + createdAt: '2026-07-03T00:02:00.000Z', + }, + ], + createdAt: '2026-07-03T00:00:00.000Z', + updatedAt: '2026-07-03T00:02:00.000Z', + }); + await Promise.resolve(); + }); + expect(await screen.findByText('背景会话内容')).toBeTruthy(); + expect(draftInput.value).toBe('切换期间必须保留的草稿'); + expect(sendButton.disabled).toBe(false); + }); + it('disables sending while a message request is pending without showing stop', async () => { const client = createClient(); let resolveSend!: (response: EditorAgentMessageResponse) => void; diff --git a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx index 17a43090c..8d52c8c51 100644 --- a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx +++ b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx @@ -1,5 +1,6 @@ import { Bot, + ChevronDown, Loader2, MessageCircle, Paperclip, @@ -19,6 +20,7 @@ import { PlatformDangerConfirmDialog } from '@/src/components/common/PlatformDan import AttachmentChip from '@/src/components/image-editor/EditorAgentConversation/AttachmentChip.tsx'; import { AttachmentPicker } from '@/src/components/image-editor/EditorAgentConversation/AttachmentPicker.tsx'; import { attachmentKey } from '@/src/components/image-editor/EditorAgentConversation/common.ts'; +import { EditorAgentDraftTextarea } from '@/src/components/image-editor/EditorAgentConversation/EditorAgentDraftTextarea.tsx'; import { MessageBubble, ThinkingBubble, @@ -69,6 +71,7 @@ export function EditorAgentConversationPanelView({ const effectiveProjectId = hasConversationMounted ? projectId : null; const { conversations, + activeConversation, activeConversationId, messages, isLoadingConversations, @@ -118,13 +121,29 @@ export function EditorAgentConversationPanelView({ const hasProject = Boolean(projectId?.trim()); const isConversationBusy = isWaiting || isToolCallActionPending; + const hasCurrentConversationContent = messages.length > 0; + const isConversationSelectDisabled = + !conversations.length || + isLoadingConversations || + isCreatingConversation || + isConversationBusy; + const isMessageSubmissionBlocked = + isCreatingConversation || + isLoadingConversations || + isLoadingMessages || + isWaiting || + isToolCallActionPending || + isPastingAttachment || + !hasProject; + const currentConversationTitle = activeConversation?.title ?? '新对话'; + + const handleCreateConversation = () => { + void createConversation().catch(() => undefined); + }; const submitMessage = (event: FormEvent) => { event.preventDefault(); - if (isWaiting) { - return; - } - if (isPastingAttachment) { + if (isMessageSubmissionBlocked) { return; } const text = draftText.trim(); @@ -164,43 +183,70 @@ export function EditorAgentConversationPanelView({ >