mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-08 17:26:41 +08:00
fix(agent): defer secret control history (#672)
This commit is contained in:
@@ -1830,11 +1830,6 @@ async function streamAgentMessage(
|
|||||||
const displayContent = (displayText ?? content).trim()
|
const displayContent = (displayText ?? content).trim()
|
||||||
if (!content && !images.length && !files.length && !audioRefs.length) return
|
if (!content && !images.length && !files.length && !audioRefs.length) return
|
||||||
|
|
||||||
const userMessage = echoUser
|
|
||||||
? addMessage('user', displayContent || content, 'done', userAttachments, choiceSelection)
|
|
||||||
: null
|
|
||||||
const assistantMessage = addMessage('assistant', '', 'streaming')
|
|
||||||
|
|
||||||
abortController = new AbortController()
|
abortController = new AbortController()
|
||||||
userAbortRequested = false
|
userAbortRequested = false
|
||||||
streamRecoveryAbortRequested = false
|
streamRecoveryAbortRequested = false
|
||||||
@@ -1843,6 +1838,7 @@ async function streamAgentMessage(
|
|||||||
activeStreamStartedAt = streamStartedAt
|
activeStreamStartedAt = streamStartedAt
|
||||||
let shouldFollowBottomAfterStream = true
|
let shouldFollowBottomAfterStream = true
|
||||||
let shouldSaveClientSnapshot = true
|
let shouldSaveClientSnapshot = true
|
||||||
|
let assistantMessage: AgentChatMessage | null = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(resolveApiUrl('message/agent/stream'), {
|
const response = await fetch(resolveApiUrl('message/agent/stream'), {
|
||||||
@@ -1867,14 +1863,14 @@ async function streamAgentMessage(
|
|||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isSecretConfirmation = response.headers.get('X-MoviePilot-Agent-Control') === 'secret-confirmation'
|
||||||
|
if (!isSecretConfirmation && echoUser) {
|
||||||
|
addMessage('user', displayContent || content, 'done', userAttachments, choiceSelection)
|
||||||
|
}
|
||||||
|
assistantMessage = addMessage('assistant', '', 'streaming')
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(await resolveAgentResponseErrorMessage(response))
|
throw new Error(await resolveAgentResponseErrorMessage(response))
|
||||||
}
|
}
|
||||||
if (response.headers.get('X-MoviePilot-Agent-Control') === 'secret-confirmation' && userMessage) {
|
|
||||||
messages.value = messages.value.filter(message => message.id !== userMessage.id)
|
|
||||||
refreshMessageList()
|
|
||||||
persistState()
|
|
||||||
}
|
|
||||||
|
|
||||||
const streamResult = await readAgentStream(response, assistantMessage, streamProtectedDeliveryGeneration)
|
const streamResult = await readAgentStream(response, assistantMessage, streamProtectedDeliveryGeneration)
|
||||||
shouldFollowBottomAfterStream = isMessageScrollerNearBottom()
|
shouldFollowBottomAfterStream = isMessageScrollerNearBottom()
|
||||||
@@ -1890,7 +1886,8 @@ async function streamAgentMessage(
|
|||||||
pendingStreamRecovery.value = null
|
pendingStreamRecovery.value = null
|
||||||
clearStreamRecoveryTimer()
|
clearStreamRecoveryTimer()
|
||||||
if (isEmptyAssistantMessage(assistantMessage)) {
|
if (isEmptyAssistantMessage(assistantMessage)) {
|
||||||
messages.value = messages.value.filter(message => message.id !== assistantMessage.id)
|
const emptyAssistantMessageId = assistantMessage.id
|
||||||
|
messages.value = messages.value.filter(message => message.id !== emptyAssistantMessageId)
|
||||||
refreshMessageList()
|
refreshMessageList()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1903,6 +1900,7 @@ async function streamAgentMessage(
|
|||||||
if (error?.name === 'AbortError' && streamRecoveryAbortRequested) return
|
if (error?.name === 'AbortError' && streamRecoveryAbortRequested) return
|
||||||
|
|
||||||
if (error?.name === 'AbortError' && userAbortRequested) {
|
if (error?.name === 'AbortError' && userAbortRequested) {
|
||||||
|
if (!assistantMessage) return
|
||||||
assistantMessage.status = 'done'
|
assistantMessage.status = 'done'
|
||||||
markToolsDone(assistantMessage)
|
markToolsDone(assistantMessage)
|
||||||
refreshMessageList()
|
refreshMessageList()
|
||||||
@@ -1910,6 +1908,7 @@ async function streamAgentMessage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isRecoverableStreamDisconnect(error)) {
|
if (isRecoverableStreamDisconnect(error)) {
|
||||||
|
if (!assistantMessage) return
|
||||||
shouldSaveClientSnapshot = false
|
shouldSaveClientSnapshot = false
|
||||||
invalidateProtectedDeliveries()
|
invalidateProtectedDeliveries()
|
||||||
beginStreamRecovery(sessionId.value, streamStartedAt)
|
beginStreamRecovery(sessionId.value, streamStartedAt)
|
||||||
@@ -1919,6 +1918,7 @@ async function streamAgentMessage(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assistantMessage ||= addMessage('assistant', '', 'streaming')
|
||||||
assistantMessage.status = 'error'
|
assistantMessage.status = 'error'
|
||||||
replaceAssistantTextSegments(assistantMessage, error?.message || t('agentAssistant.error'))
|
replaceAssistantTextSegments(assistantMessage, error?.message || t('agentAssistant.error'))
|
||||||
markToolsDone(assistantMessage)
|
markToolsDone(assistantMessage)
|
||||||
|
|||||||
@@ -152,6 +152,15 @@ function createControllableAgentStream() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createDeferred<T>() {
|
||||||
|
let resolve!: (value: T | PromiseLike<T>) => void
|
||||||
|
const promise = new Promise<T>(resolvePromise => {
|
||||||
|
resolve = resolvePromise
|
||||||
|
})
|
||||||
|
|
||||||
|
return { promise, resolve }
|
||||||
|
}
|
||||||
|
|
||||||
const slotContainerStub = { template: '<div><slot /></div>' }
|
const slotContainerStub = { template: '<div><slot /></div>' }
|
||||||
const menuStub = defineComponent({
|
const menuStub = defineComponent({
|
||||||
setup(_props, { slots }) {
|
setup(_props, { slots }) {
|
||||||
@@ -745,18 +754,14 @@ describe('AgentAssistantPanel stream recovery', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
stream.emit(legacySseFrame({ type: 'start', session_id: 'web-agent:late-protected' }))
|
stream.emit(legacySseFrame({ type: 'start', session_id: 'web-agent:late-protected' }))
|
||||||
stream.emit(
|
stream.emit(protectedSseFrame({ content: protectedMarker }))
|
||||||
protectedSseFrame({ content: protectedMarker }),
|
|
||||||
)
|
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(wrapper.text()).toContain(protectedMarker)
|
expect(wrapper.text()).toContain(protectedMarker)
|
||||||
|
|
||||||
await wrapper.setProps({ modelValue: false })
|
await wrapper.setProps({ modelValue: false })
|
||||||
expect(wrapper.text()).not.toContain(protectedMarker)
|
expect(wrapper.text()).not.toContain(protectedMarker)
|
||||||
|
|
||||||
stream.emit(
|
stream.emit(protectedSseFrame({ content: protectedMarker }))
|
||||||
protectedSseFrame({ content: protectedMarker }),
|
|
||||||
)
|
|
||||||
await wrapper.setProps({ modelValue: true })
|
await wrapper.setProps({ modelValue: true })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(wrapper.text()).not.toContain(protectedMarker)
|
expect(wrapper.text()).not.toContain(protectedMarker)
|
||||||
@@ -781,9 +786,7 @@ describe('AgentAssistantPanel stream recovery', () => {
|
|||||||
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
|
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
stream.emit(
|
stream.emit(protectedSseFrame({ content: protectedMarker }))
|
||||||
protectedSseFrame({ content: protectedMarker }),
|
|
||||||
)
|
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(wrapper.text()).toContain(protectedMarker)
|
expect(wrapper.text()).toContain(protectedMarker)
|
||||||
|
|
||||||
@@ -817,9 +820,7 @@ describe('AgentAssistantPanel stream recovery', () => {
|
|||||||
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
|
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
stream.emit(
|
stream.emit(protectedSseFrame({ content: protectedMarker }))
|
||||||
protectedSseFrame({ content: protectedMarker }),
|
|
||||||
)
|
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(wrapper.text()).toContain(protectedMarker)
|
expect(wrapper.text()).toContain(protectedMarker)
|
||||||
|
|
||||||
@@ -954,7 +955,9 @@ describe('AgentAssistantPanel stream recovery', () => {
|
|||||||
expect(wrapper.find('.agent-assistant-message--user').exists()).toBe(false)
|
expect(wrapper.find('.agent-assistant-message--user').exists()).toBe(false)
|
||||||
const localState = JSON.parse(localStorage.getItem('moviepilot-agent-assistant-state') || '{}')
|
const localState = JSON.parse(localStorage.getItem('moviepilot-agent-assistant-state') || '{}')
|
||||||
const localHistory = JSON.parse(localStorage.getItem('moviepilot-agent-assistant-history') || '[]')
|
const localHistory = JSON.parse(localStorage.getItem('moviepilot-agent-assistant-history') || '[]')
|
||||||
expect(localState.messages || []).not.toContainEqual(expect.objectContaining({ role: 'user', content: controlText }))
|
expect(localState.messages || []).not.toContainEqual(
|
||||||
|
expect.objectContaining({ role: 'user', content: controlText }),
|
||||||
|
)
|
||||||
expect(localHistory).not.toContainEqual(expect.objectContaining({ role: 'user', content: controlText }))
|
expect(localHistory).not.toContainEqual(expect.objectContaining({ role: 'user', content: controlText }))
|
||||||
fetchMock.mock.calls
|
fetchMock.mock.calls
|
||||||
.filter(([input]) => String(input).includes('/display'))
|
.filter(([input]) => String(input).includes('/display'))
|
||||||
@@ -968,6 +971,95 @@ describe('AgentAssistantPanel stream recovery', () => {
|
|||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does not commit confirmation controls before the response header classifies the request', async () => {
|
||||||
|
const controlText = '确认'
|
||||||
|
const backendFeedback = '确认请求已处理'
|
||||||
|
const deferredStreamResponse = createDeferred<Response>()
|
||||||
|
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
|
||||||
|
return deferredStreamResponse.promise
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(createAgentResponse([]))
|
||||||
|
})
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
|
||||||
|
const wrapper = mountPanel()
|
||||||
|
await wrapper.find('textarea').setValue(controlText)
|
||||||
|
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.agent-assistant-message--user').exists()).toBe(false)
|
||||||
|
expect(localStorage.getItem('moviepilot-agent-assistant-state') || '').not.toContain(controlText)
|
||||||
|
expect(localStorage.getItem('moviepilot-agent-assistant-history') || '').not.toContain(controlText)
|
||||||
|
expect(fetchMock.mock.calls.some(([input]) => String(input).includes('/display'))).toBe(false)
|
||||||
|
|
||||||
|
deferredStreamResponse.resolve(
|
||||||
|
createAgentStreamResponse(
|
||||||
|
[legacySseFrame({ type: 'delta', content: backendFeedback }), legacySseFrame({ type: 'done' })],
|
||||||
|
{ 'X-MoviePilot-Agent-Control': 'secret-confirmation' },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain(backendFeedback)
|
||||||
|
expect(wrapper.find('.agent-assistant-message--user').exists()).toBe(false)
|
||||||
|
const localState = JSON.parse(localStorage.getItem('moviepilot-agent-assistant-state') || '{}')
|
||||||
|
const localHistory = JSON.parse(localStorage.getItem('moviepilot-agent-assistant-history') || '[]')
|
||||||
|
expect(localState.messages || []).not.toContainEqual(
|
||||||
|
expect.objectContaining({ role: 'user', content: controlText }),
|
||||||
|
)
|
||||||
|
expect(localHistory).not.toContainEqual(expect.objectContaining({ role: 'user', content: controlText }))
|
||||||
|
fetchMock.mock.calls
|
||||||
|
.filter(([input]) => String(input).includes('/display'))
|
||||||
|
.forEach(([, init]) => {
|
||||||
|
const displayBody = JSON.parse(String(init?.body || '{}'))
|
||||||
|
expect(displayBody.messages || []).not.toContainEqual(
|
||||||
|
expect.objectContaining({ role: 'user', content: controlText }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('commits ordinary messages in user and assistant order after response classification', async () => {
|
||||||
|
const userText = '检查下载任务'
|
||||||
|
const assistantText = '检查完成'
|
||||||
|
const deferredStreamResponse = createDeferred<Response>()
|
||||||
|
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
|
||||||
|
return deferredStreamResponse.promise
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(createAgentResponse([]))
|
||||||
|
})
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
|
||||||
|
const wrapper = mountPanel()
|
||||||
|
await wrapper.find('textarea').setValue(userText)
|
||||||
|
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.findAll('.agent-assistant-message')).toHaveLength(0)
|
||||||
|
|
||||||
|
deferredStreamResponse.resolve(
|
||||||
|
createAgentStreamResponse([
|
||||||
|
legacySseFrame({ type: 'delta', content: assistantText }),
|
||||||
|
legacySseFrame({ type: 'done' }),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const messages = wrapper.findAll('.agent-assistant-message')
|
||||||
|
expect(messages).toHaveLength(2)
|
||||||
|
expect(messages[0].classes()).toContain('agent-assistant-message--user')
|
||||||
|
expect(messages[0].text()).toContain(userText)
|
||||||
|
expect(messages[1].classes()).toContain('agent-assistant-message--assistant')
|
||||||
|
expect(messages[1].text()).toContain(assistantText)
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps an exact confirmation word on the ordinary path without a pending control', async () => {
|
it('keeps an exact confirmation word on the ordinary path without a pending control', async () => {
|
||||||
const controlText = '确认'
|
const controlText = '确认'
|
||||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user