Improve agent input handling and tool summary grouping

This commit is contained in:
jxxghp
2026-08-07 13:06:01 +08:00
parent 871a9a072b
commit 1dfb51f3c4
5 changed files with 151 additions and 5 deletions

View File

@@ -214,6 +214,7 @@ const messages = ref<AgentChatMessage[]>([])
const historySessions = ref<AgentSessionHistoryItem[]>([])
const sessionId = ref('')
const sending = ref(false)
const isComposing = ref(false)
const streamError = ref('')
const historyMenuOpen = ref(false)
const messageListRef = ref<HTMLElement | null>(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"
/>
<IconBtn
class="agent-assistant-record agent-assistant-surface-btn"

View File

@@ -246,6 +246,39 @@ describe('AgentAssistantPanel stream recovery', () => {
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: '<button><slot /></button>' },
PerfectScrollbar: { template: '<div><slot /></div>' },
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: '<button><slot /></button>' },
PerfectScrollbar: { template: '<div><slot /></div>' },
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' },

View File

@@ -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',

View File

@@ -771,7 +771,7 @@ export default {
emptyTitle: '今天想处理什么?',
emptySubtitle: '站点、订阅、下载、整理任务,都可以直接问我。',
placeholder: '询问 MoviePilot输入 / 使用命令',
processingPlaceholder: '智能体正在处理,请稍候...',
processingPlaceholder: '处理中...',
commandLoading: '正在加载命令...',
commandLoadFailed: '命令列表加载失败',
stop: '停止生成',

View File

@@ -771,7 +771,7 @@ export default {
emptyTitle: '今天想處理什麼?',
emptySubtitle: '站點、訂閱、下載、整理任務,都可以直接問我。',
placeholder: '詢問 MoviePilot輸入 / 使用命令',
processingPlaceholder: '智能體正在處理,請稍候...',
processingPlaceholder: '處理中...',
commandLoading: '正在載入命令...',
commandLoadFailed: '命令列表載入失敗',
stop: '停止生成',