mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-12 00:54:42 +08:00
Improve agent input handling and tool summary grouping
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -771,7 +771,7 @@ export default {
|
||||
emptyTitle: '今天想处理什么?',
|
||||
emptySubtitle: '站点、订阅、下载、整理任务,都可以直接问我。',
|
||||
placeholder: '询问 MoviePilot,输入 / 使用命令',
|
||||
processingPlaceholder: '智能体正在处理,请稍候...',
|
||||
processingPlaceholder: '处理中...',
|
||||
commandLoading: '正在加载命令...',
|
||||
commandLoadFailed: '命令列表加载失败',
|
||||
stop: '停止生成',
|
||||
|
||||
@@ -771,7 +771,7 @@ export default {
|
||||
emptyTitle: '今天想處理什麼?',
|
||||
emptySubtitle: '站點、訂閱、下載、整理任務,都可以直接問我。',
|
||||
placeholder: '詢問 MoviePilot,輸入 / 使用命令',
|
||||
processingPlaceholder: '智能體正在處理,請稍候...',
|
||||
processingPlaceholder: '處理中...',
|
||||
commandLoading: '正在載入命令...',
|
||||
commandLoadFailed: '命令列表載入失敗',
|
||||
stop: '停止生成',
|
||||
|
||||
Reference in New Issue
Block a user