mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 23:56: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 historySessions = ref<AgentSessionHistoryItem[]>([])
|
||||||
const sessionId = ref('')
|
const sessionId = ref('')
|
||||||
const sending = ref(false)
|
const sending = ref(false)
|
||||||
|
const isComposing = ref(false)
|
||||||
const streamError = ref('')
|
const streamError = ref('')
|
||||||
const historyMenuOpen = ref(false)
|
const historyMenuOpen = ref(false)
|
||||||
const messageListRef = ref<HTMLElement | null>(null)
|
const messageListRef = ref<HTMLElement | null>(null)
|
||||||
@@ -1269,6 +1270,45 @@ function normalizeToolMessage(message: string) {
|
|||||||
return message.replace(/^=>\s*/, '').trim()
|
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) {
|
function markToolsDone(message: AgentChatMessage) {
|
||||||
message.tools.forEach(tool => {
|
message.tools.forEach(tool => {
|
||||||
@@ -1305,7 +1345,20 @@ function getRenderableMessageSegments(message: AgentChatMessage): AgentRenderabl
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tool = message.tools[segment.toolIndex]
|
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
|
return renderableSegments
|
||||||
}, [])
|
}, [])
|
||||||
}
|
}
|
||||||
@@ -2180,11 +2233,19 @@ function handlePageShow() {
|
|||||||
|
|
||||||
// 处理输入框回车发送。
|
// 处理输入框回车发送。
|
||||||
function handleInputKeydown(event: KeyboardEvent) {
|
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()
|
event.preventDefault()
|
||||||
sendMessage()
|
sendMessage()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleCompositionStart() {
|
||||||
|
isComposing.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCompositionEnd() {
|
||||||
|
isComposing.value = false
|
||||||
|
}
|
||||||
|
|
||||||
watch(isOpen, syncAgentAssistantOpenState, { immediate: true })
|
watch(isOpen, syncAgentAssistantOpenState, { immediate: true })
|
||||||
watch(drawerWidth, () => {
|
watch(drawerWidth, () => {
|
||||||
if (isOpen.value) syncAgentAssistantOpenState(true)
|
if (isOpen.value) syncAgentAssistantOpenState(true)
|
||||||
@@ -2595,6 +2656,8 @@ onScopeDispose(() => {
|
|||||||
:placeholder="inputPlaceholder"
|
:placeholder="inputPlaceholder"
|
||||||
@input="handleInputChange"
|
@input="handleInputChange"
|
||||||
@keydown="handleInputKeydown"
|
@keydown="handleInputKeydown"
|
||||||
|
@compositionstart="handleCompositionStart"
|
||||||
|
@compositionend="handleCompositionEnd"
|
||||||
/>
|
/>
|
||||||
<IconBtn
|
<IconBtn
|
||||||
class="agent-assistant-record agent-assistant-surface-btn"
|
class="agent-assistant-record agent-assistant-surface-btn"
|
||||||
|
|||||||
@@ -246,6 +246,39 @@ describe('AgentAssistantPanel stream recovery', () => {
|
|||||||
wrapper.unmount()
|
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 () => {
|
it('renders interleaved assistant text and tool events in their SSE order', async () => {
|
||||||
const serverSessionId = 'web-agent:ordered-segments'
|
const serverSessionId = 'web-agent:ordered-segments'
|
||||||
const streamEvents = [
|
const streamEvents = [
|
||||||
@@ -354,6 +387,56 @@ describe('AgentAssistantPanel stream recovery', () => {
|
|||||||
wrapper.unmount()
|
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 () => {
|
it('coalesces consecutive text deltas into one UI update before a terminal event', async () => {
|
||||||
const streamEvents = [
|
const streamEvents = [
|
||||||
{ type: 'start', session_id: 'web-agent:coalesced' },
|
{ type: 'start', session_id: 'web-agent:coalesced' },
|
||||||
|
|||||||
@@ -782,7 +782,7 @@ export default {
|
|||||||
emptyTitle: 'What should we handle today?',
|
emptyTitle: 'What should we handle today?',
|
||||||
emptySubtitle: 'Ask about sites, subscriptions, downloads, or organization tasks.',
|
emptySubtitle: 'Ask about sites, subscriptions, downloads, or organization tasks.',
|
||||||
placeholder: 'Ask MoviePilot, Type / for commands',
|
placeholder: 'Ask MoviePilot, Type / for commands',
|
||||||
processingPlaceholder: 'MoviePilot is working, please wait...',
|
processingPlaceholder: 'Processing...',
|
||||||
commandLoading: 'Loading commands...',
|
commandLoading: 'Loading commands...',
|
||||||
commandLoadFailed: 'Failed to load commands',
|
commandLoadFailed: 'Failed to load commands',
|
||||||
stop: 'Stop generating',
|
stop: 'Stop generating',
|
||||||
|
|||||||
@@ -771,7 +771,7 @@ export default {
|
|||||||
emptyTitle: '今天想处理什么?',
|
emptyTitle: '今天想处理什么?',
|
||||||
emptySubtitle: '站点、订阅、下载、整理任务,都可以直接问我。',
|
emptySubtitle: '站点、订阅、下载、整理任务,都可以直接问我。',
|
||||||
placeholder: '询问 MoviePilot,输入 / 使用命令',
|
placeholder: '询问 MoviePilot,输入 / 使用命令',
|
||||||
processingPlaceholder: '智能体正在处理,请稍候...',
|
processingPlaceholder: '处理中...',
|
||||||
commandLoading: '正在加载命令...',
|
commandLoading: '正在加载命令...',
|
||||||
commandLoadFailed: '命令列表加载失败',
|
commandLoadFailed: '命令列表加载失败',
|
||||||
stop: '停止生成',
|
stop: '停止生成',
|
||||||
|
|||||||
@@ -771,7 +771,7 @@ export default {
|
|||||||
emptyTitle: '今天想處理什麼?',
|
emptyTitle: '今天想處理什麼?',
|
||||||
emptySubtitle: '站點、訂閱、下載、整理任務,都可以直接問我。',
|
emptySubtitle: '站點、訂閱、下載、整理任務,都可以直接問我。',
|
||||||
placeholder: '詢問 MoviePilot,輸入 / 使用命令',
|
placeholder: '詢問 MoviePilot,輸入 / 使用命令',
|
||||||
processingPlaceholder: '智能體正在處理,請稍候...',
|
processingPlaceholder: '處理中...',
|
||||||
commandLoading: '正在載入命令...',
|
commandLoading: '正在載入命令...',
|
||||||
commandLoadFailed: '命令列表載入失敗',
|
commandLoadFailed: '命令列表載入失敗',
|
||||||
stop: '停止生成',
|
stop: '停止生成',
|
||||||
|
|||||||
Reference in New Issue
Block a user