diff --git a/src/components/agent/AgentAssistantPanel.vue b/src/components/agent/AgentAssistantPanel.vue index c2a7f9b9..9ee51b42 100644 --- a/src/components/agent/AgentAssistantPanel.vue +++ b/src/components/agent/AgentAssistantPanel.vue @@ -214,6 +214,7 @@ const messages = ref([]) const historySessions = ref([]) const sessionId = ref('') const sending = ref(false) +const isComposing = ref(false) const streamError = ref('') const historyMenuOpen = ref(false) const messageListRef = ref(null) @@ -1269,6 +1270,45 @@ function normalizeToolMessage(message: string) { return message.replace(/^=>\s*/, '').trim() } +// 解析非啰嗦模式的工具汇总,供相邻工具状态按类别累计次数。 +function parseToolSummary(message: string) { + const summaryMatch = message.match(/^((.+))$/) + if (!summaryMatch) return null + + const parts = summaryMatch[1].split(',').map(part => { + const countMatch = part.trim().match(/^(.*?\D)(\d+)(\D.*)$/) + if (!countMatch) return null + return { + prefix: countMatch[1], + count: Number(countMatch[2]), + suffix: countMatch[3], + } + }) + return parts.every(Boolean) ? (parts as Array<{ prefix: string; count: number; suffix: string }>) : null +} + +// 仅合并相邻的非啰嗦工具汇总;正文或具体工具提示会自然终止当前聚合组。 +function mergeToolSummaries(currentMessage: string, nextMessage: string) { + const currentParts = parseToolSummary(currentMessage) + const nextParts = parseToolSummary(nextMessage) + if (!currentParts || !nextParts) return null + + const mergedParts = currentParts.map(part => ({ ...part })) + const partIndexes = new Map(mergedParts.map((part, index) => [`${part.prefix}\u0000${part.suffix}`, index])) + nextParts.forEach(part => { + const key = `${part.prefix}\u0000${part.suffix}` + const existingIndex = partIndexes.get(key) + if (existingIndex === undefined) { + partIndexes.set(key, mergedParts.length) + mergedParts.push({ ...part }) + return + } + mergedParts[existingIndex].count += part.count + }) + + return `(${mergedParts.map(part => `${part.prefix}${part.count}${part.suffix}`).join(',')})` +} + // 将当前消息里的运行中工具标记为完成。 function markToolsDone(message: AgentChatMessage) { message.tools.forEach(tool => { @@ -1305,7 +1345,20 @@ function getRenderableMessageSegments(message: AgentChatMessage): AgentRenderabl } const tool = message.tools[segment.toolIndex] - if (tool) renderableSegments.push({ type: 'tool', key: `tool-${tool.id}`, tool }) + if (tool) { + const previousSegment = renderableSegments.at(-1) + const mergedMessage = + previousSegment?.type === 'tool' ? mergeToolSummaries(previousSegment.tool.message, tool.message) : null + if (previousSegment?.type === 'tool' && mergedMessage) { + previousSegment.tool = { + ...previousSegment.tool, + message: mergedMessage, + status: previousSegment.tool.status === 'running' || tool.status === 'running' ? 'running' : 'done', + } + } else { + renderableSegments.push({ type: 'tool', key: `tool-${tool.id}`, tool }) + } + } return renderableSegments }, []) } @@ -2180,11 +2233,19 @@ function handlePageShow() { // 处理输入框回车发送。 function handleInputKeydown(event: KeyboardEvent) { - if (event.key !== 'Enter' || event.shiftKey) return + if (event.key !== 'Enter' || event.shiftKey || isComposing.value || event.isComposing || event.keyCode === 229) return event.preventDefault() sendMessage() } +function handleCompositionStart() { + isComposing.value = true +} + +function handleCompositionEnd() { + isComposing.value = false +} + watch(isOpen, syncAgentAssistantOpenState, { immediate: true }) watch(drawerWidth, () => { if (isOpen.value) syncAgentAssistantOpenState(true) @@ -2595,6 +2656,8 @@ onScopeDispose(() => { :placeholder="inputPlaceholder" @input="handleInputChange" @keydown="handleInputKeydown" + @compositionstart="handleCompositionStart" + @compositionend="handleCompositionEnd" /> { wrapper.unmount() }) + it('does not send when Enter confirms an IME composition', async () => { + const fetchMock = vi.fn(async () => createAgentResponse([])) + vi.stubGlobal('fetch', fetchMock) + + const wrapper = shallowMount(AgentAssistantPanel, { + props: { modelValue: true }, + global: { + stubs: { + AgentMarkdownContent: agentMarkdownContentStub, + IconBtn: { template: '' }, + PerfectScrollbar: { template: '
' }, + VIcon: true, + }, + }, + }) + const textarea = wrapper.find('textarea') + await flushPromises() + fetchMock.mockClear() + await textarea.setValue('搜索电影') + await textarea.trigger('compositionstart') + await textarea.trigger('keydown', { key: 'Enter', isComposing: true }) + await textarea.trigger('compositionend') + await flushPromises() + + expect(fetchMock).not.toHaveBeenCalled() + + await textarea.trigger('keydown', { key: 'Enter' }) + await flushPromises() + expect(fetchMock).toHaveBeenCalled() + + wrapper.unmount() + }) + it('renders interleaved assistant text and tool events in their SSE order', async () => { const serverSessionId = 'web-agent:ordered-segments' const streamEvents = [ @@ -354,6 +387,56 @@ describe('AgentAssistantPanel stream recovery', () => { wrapper.unmount() }) + it('aggregates adjacent non-verbose tools and starts a new group after text', async () => { + const streamEvents = [ + { type: 'start', session_id: 'web-agent:tool-groups' }, + { type: 'tool', message: '(查询了 1 次数据)' }, + { type: 'tool', message: '(查看了 1 个目录)' }, + { type: 'tool', message: '(查询了 1 次数据)' }, + { type: 'delta', content: '继续分析。' }, + { type: 'tool', message: '(读取了 1 个文件)' }, + { type: 'tool', message: '(读取了 1 个文件)' }, + { type: 'done' }, + ] + const streamBody = streamEvents.map(event => `data: ${JSON.stringify(event)}\n\n`).join('') + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') { + return new Response(streamBody, { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }) + } + return createAgentResponse([]) + }), + ) + + const wrapper = shallowMount(AgentAssistantPanel, { + props: { modelValue: true }, + global: { + stubs: { + AgentMarkdownContent: agentMarkdownContentStub, + IconBtn: { template: '' }, + PerfectScrollbar: { template: '
' }, + VIcon: true, + }, + }, + }) + await wrapper.find('textarea').setValue('分析插件') + await wrapper.find('textarea').trigger('keydown', { key: 'Enter' }) + await flushPromises() + + const renderedSegments = wrapper.findAll('.agent-assistant-segments > *') + expect(renderedSegments).toHaveLength(3) + expect(renderedSegments[0].text()).toContain('查询了 2 次数据,查看了 1 个目录') + expect(renderedSegments[1].classes()).toContain('agent-assistant-message__bubble') + expect(renderedSegments[1].text()).toBe('继续分析。') + expect(renderedSegments[2].text()).toContain('读取了 2 个文件') + + wrapper.unmount() + }) + it('coalesces consecutive text deltas into one UI update before a terminal event', async () => { const streamEvents = [ { type: 'start', session_id: 'web-agent:coalesced' }, diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index 8fd12ce2..7e59cca7 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -782,7 +782,7 @@ export default { emptyTitle: 'What should we handle today?', emptySubtitle: 'Ask about sites, subscriptions, downloads, or organization tasks.', placeholder: 'Ask MoviePilot, Type / for commands', - processingPlaceholder: 'MoviePilot is working, please wait...', + processingPlaceholder: 'Processing...', commandLoading: 'Loading commands...', commandLoadFailed: 'Failed to load commands', stop: 'Stop generating', diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index 5406bc0a..27107631 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -771,7 +771,7 @@ export default { emptyTitle: '今天想处理什么?', emptySubtitle: '站点、订阅、下载、整理任务,都可以直接问我。', placeholder: '询问 MoviePilot,输入 / 使用命令', - processingPlaceholder: '智能体正在处理,请稍候...', + processingPlaceholder: '处理中...', commandLoading: '正在加载命令...', commandLoadFailed: '命令列表加载失败', stop: '停止生成', diff --git a/src/locales/zh-TW.ts b/src/locales/zh-TW.ts index 7a5261c5..b6a994bb 100644 --- a/src/locales/zh-TW.ts +++ b/src/locales/zh-TW.ts @@ -771,7 +771,7 @@ export default { emptyTitle: '今天想處理什麼?', emptySubtitle: '站點、訂閱、下載、整理任務,都可以直接問我。', placeholder: '詢問 MoviePilot,輸入 / 使用命令', - processingPlaceholder: '智能體正在處理,請稍候...', + processingPlaceholder: '處理中...', commandLoading: '正在載入命令...', commandLoadFailed: '命令列表載入失敗', stop: '停止生成',