mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-08 17:26:41 +08:00
fix(agent): scope secret confirmation controls (#671)
This commit is contained in:
@@ -181,13 +181,6 @@ interface AgentStreamReadResult {
|
|||||||
receivedTerminalEvent: boolean
|
receivedTerminalEvent: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AgentProtectedDelivery {
|
|
||||||
/** 一次性投递标识,只用于当前可见范围去重。 */
|
|
||||||
id: string
|
|
||||||
/** 仅驻留组件内存并按纯文本渲染的受保护内容。 */
|
|
||||||
content: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ParsedSseBlock {
|
interface ParsedSseBlock {
|
||||||
eventName: string
|
eventName: string
|
||||||
data: string
|
data: string
|
||||||
@@ -249,7 +242,8 @@ const STREAM_STATE_PERSIST_DELAY = 1000
|
|||||||
|
|
||||||
const inputText = ref('')
|
const inputText = ref('')
|
||||||
const messages = ref<AgentChatMessage[]>([])
|
const messages = ref<AgentChatMessage[]>([])
|
||||||
const protectedDeliveries = ref<AgentProtectedDelivery[]>([])
|
// 受保护内容只驻留当前组件内存,不进入消息历史或本地持久化。
|
||||||
|
const protectedDeliveries = ref<string[]>([])
|
||||||
const historySessions = ref<AgentSessionHistoryItem[]>([])
|
const historySessions = ref<AgentSessionHistoryItem[]>([])
|
||||||
const sessionId = ref('')
|
const sessionId = ref('')
|
||||||
const sending = ref(false)
|
const sending = ref(false)
|
||||||
@@ -290,7 +284,6 @@ let streamRecoveryAbortRequested = false
|
|||||||
let streamRecoveryTimer: number | null = null
|
let streamRecoveryTimer: number | null = null
|
||||||
let activeStreamStartedAt = 0
|
let activeStreamStartedAt = 0
|
||||||
let protectedDeliveryGeneration = 0
|
let protectedDeliveryGeneration = 0
|
||||||
const protectedDeliveryIds = new Set<string>()
|
|
||||||
|
|
||||||
// 汇总实时请求与后台恢复状态,保证恢复期间仍展示处理中并锁定会话操作。
|
// 汇总实时请求与后台恢复状态,保证恢复期间仍展示处理中并锁定会话操作。
|
||||||
const isBusy = computed(() => sending.value || Boolean(pendingStreamRecovery.value))
|
const isBusy = computed(() => sending.value || Boolean(pendingStreamRecovery.value))
|
||||||
@@ -365,12 +358,6 @@ function createSessionId() {
|
|||||||
function invalidateProtectedDeliveries() {
|
function invalidateProtectedDeliveries() {
|
||||||
protectedDeliveryGeneration += 1
|
protectedDeliveryGeneration += 1
|
||||||
protectedDeliveries.value = []
|
protectedDeliveries.value = []
|
||||||
protectedDeliveryIds.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 识别前端需要隔离展示和持久化的确认控制文本,授权判断仍由后端完成。
|
|
||||||
function isReservedConfirmationControl(value: string) {
|
|
||||||
return /^(确认|取消) ([A-Za-z0-9]{4}-[A-Za-z0-9]{4})$/.test(value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将未知字段安全转换为可展示文本。
|
// 将未知字段安全转换为可展示文本。
|
||||||
@@ -1574,25 +1561,10 @@ function splitSseBlock(block: string) {
|
|||||||
return { eventName, data } satisfies ParsedSseBlock
|
return { eventName, data } satisfies ParsedSseBlock
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseProtectedTransportFrame(data: string): AgentProtectedDelivery | null {
|
function parseProtectedTransportFrame(data: string): string | null {
|
||||||
try {
|
try {
|
||||||
const frame = JSON.parse(data) as Record<string, unknown>
|
const frame = JSON.parse(data) as Record<string, unknown>
|
||||||
const schemaVersion = typeof frame.schema_version === 'string' ? frame.schema_version : ''
|
return typeof frame.content === 'string' ? frame.content : null
|
||||||
const versionMatch = /^(\d+)\.(\d+)$/.exec(schemaVersion)
|
|
||||||
const deliveryId = frame.delivery_id
|
|
||||||
|
|
||||||
if (
|
|
||||||
!versionMatch ||
|
|
||||||
versionMatch[1] !== '1' ||
|
|
||||||
typeof deliveryId !== 'string' ||
|
|
||||||
!deliveryId.trim() ||
|
|
||||||
frame.content_type !== 'text/plain' ||
|
|
||||||
typeof frame.content !== 'string'
|
|
||||||
) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return { id: deliveryId, content: frame.content }
|
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -1601,11 +1573,10 @@ function parseProtectedTransportFrame(data: string): AgentProtectedDelivery | nu
|
|||||||
function consumeProtectedTransportFrame(data: string, streamGeneration: number) {
|
function consumeProtectedTransportFrame(data: string, streamGeneration: number) {
|
||||||
if (!isOpen.value || streamGeneration !== protectedDeliveryGeneration) return
|
if (!isOpen.value || streamGeneration !== protectedDeliveryGeneration) return
|
||||||
|
|
||||||
const delivery = parseProtectedTransportFrame(data)
|
const content = parseProtectedTransportFrame(data)
|
||||||
if (!delivery || protectedDeliveryIds.has(delivery.id)) return
|
if (content === null) return
|
||||||
|
|
||||||
protectedDeliveryIds.add(delivery.id)
|
protectedDeliveries.value.push(content)
|
||||||
protectedDeliveries.value.push(delivery)
|
|
||||||
nextTick(() => scheduleMessageScrollerUpdate({ toBottom: messageScrollerShouldFollow }))
|
nextTick(() => scheduleMessageScrollerUpdate({ toBottom: messageScrollerShouldFollow }))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1859,7 +1830,9 @@ 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
|
||||||
|
|
||||||
if (echoUser) addMessage('user', displayContent || content, 'done', userAttachments, choiceSelection)
|
const userMessage = echoUser
|
||||||
|
? addMessage('user', displayContent || content, 'done', userAttachments, choiceSelection)
|
||||||
|
: null
|
||||||
const assistantMessage = addMessage('assistant', '', 'streaming')
|
const assistantMessage = addMessage('assistant', '', 'streaming')
|
||||||
|
|
||||||
abortController = new AbortController()
|
abortController = new AbortController()
|
||||||
@@ -1897,6 +1870,11 @@ async function streamAgentMessage(
|
|||||||
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()
|
||||||
@@ -1979,10 +1957,7 @@ async function sendMessage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const prepared = await prepareAgentAttachments(attachments)
|
const prepared = await prepareAgentAttachments(attachments)
|
||||||
const reservedControl = attachments.length === 0 && isReservedConfirmationControl(rawText)
|
await streamAgentMessage(text, prepared.images, prepared.files, prepared.audioRefs, prepared.userAttachments)
|
||||||
await streamAgentMessage(text, prepared.images, prepared.files, prepared.audioRefs, prepared.userAttachments, {
|
|
||||||
echoUser: !reservedControl,
|
|
||||||
})
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// 附件准备失败同样落到对话消息里,底部提示条只保留给没有消息承载的本地错误。
|
// 附件准备失败同样落到对话消息里,底部提示条只保留给没有消息承载的本地错误。
|
||||||
addMessage('assistant', error?.message || t('agentAssistant.uploadFailed'), 'error')
|
addMessage('assistant', error?.message || t('agentAssistant.uploadFailed'), 'error')
|
||||||
@@ -2725,13 +2700,12 @@ onScopeDispose(() => {
|
|||||||
|
|
||||||
<div v-if="protectedDeliveries.length" class="agent-assistant-protected-deliveries">
|
<div v-if="protectedDeliveries.length" class="agent-assistant-protected-deliveries">
|
||||||
<div
|
<div
|
||||||
v-for="delivery in protectedDeliveries"
|
v-for="(content, index) in protectedDeliveries"
|
||||||
:key="delivery.id"
|
:key="index"
|
||||||
class="agent-assistant-protected-delivery"
|
class="agent-assistant-protected-delivery"
|
||||||
:data-protected-delivery-id="delivery.id"
|
|
||||||
>
|
>
|
||||||
<VIcon icon="mdi-shield-lock-outline" size="16" aria-hidden="true" />
|
<VIcon icon="mdi-shield-lock-outline" size="16" aria-hidden="true" />
|
||||||
<span v-text="delivery.content" />
|
<span v-text="content" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -107,14 +107,14 @@ function protectedSseFrame(data: Record<string, unknown>): SyntheticSseFrame {
|
|||||||
return { eventName: 'interaction-protected', data }
|
return { eventName: 'interaction-protected', data }
|
||||||
}
|
}
|
||||||
|
|
||||||
function createAgentStreamResponse(frames: SyntheticSseFrame[]) {
|
function createAgentStreamResponse(frames: SyntheticSseFrame[], headers: Record<string, string> = {}) {
|
||||||
const body = frames
|
const body = frames
|
||||||
.map(frame => `${frame.eventName ? `event: ${frame.eventName}\n` : ''}data: ${JSON.stringify(frame.data)}\n\n`)
|
.map(frame => `${frame.eventName ? `event: ${frame.eventName}\n` : ''}data: ${JSON.stringify(frame.data)}\n\n`)
|
||||||
.join('')
|
.join('')
|
||||||
|
|
||||||
return new Response(body, {
|
return new Response(body, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { 'Content-Type': 'text/event-stream' },
|
headers: { 'Content-Type': 'text/event-stream', ...headers },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,12 +502,7 @@ describe('AgentAssistantPanel stream recovery', () => {
|
|||||||
return createAgentStreamResponse([
|
return createAgentStreamResponse([
|
||||||
legacySseFrame({ type: 'start', session_id: 'web-agent:protected' }),
|
legacySseFrame({ type: 'start', session_id: 'web-agent:protected' }),
|
||||||
legacySseFrame({ type: 'delta', content: '普通回复' }),
|
legacySseFrame({ type: 'delta', content: '普通回复' }),
|
||||||
protectedSseFrame({
|
protectedSseFrame({ content: protectedMarker }),
|
||||||
schema_version: '1.0',
|
|
||||||
delivery_id: 'delivery-1',
|
|
||||||
content_type: 'text/plain',
|
|
||||||
content: protectedMarker,
|
|
||||||
}),
|
|
||||||
legacySseFrame({ type: 'tool', message: '(查询了 1 次数据)' }),
|
legacySseFrame({ type: 'tool', message: '(查询了 1 次数据)' }),
|
||||||
legacySseFrame({ type: 'done' }),
|
legacySseFrame({ type: 'done' }),
|
||||||
])
|
])
|
||||||
@@ -522,7 +517,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()
|
||||||
|
|
||||||
const protectedNode = wrapper.get('[data-protected-delivery-id="delivery-1"]')
|
const protectedNode = wrapper.get('.agent-assistant-protected-delivery')
|
||||||
expect(protectedNode.text()).toBe(protectedMarker)
|
expect(protectedNode.text()).toBe(protectedMarker)
|
||||||
expect(protectedNode.find('script').exists()).toBe(false)
|
expect(protectedNode.find('script').exists()).toBe(false)
|
||||||
const assistantBubble = wrapper.get('.agent-assistant-message--assistant .agent-assistant-message__bubble')
|
const assistantBubble = wrapper.get('.agent-assistant-message--assistant .agent-assistant-message__bubble')
|
||||||
@@ -698,7 +693,7 @@ describe('AgentAssistantPanel stream recovery', () => {
|
|||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('ignores malformed and duplicate protected delivery events without creating a normal message', async () => {
|
it('accepts only string content from the named protected event', async () => {
|
||||||
const acceptedMarker = 'MP-ACCEPTED-PROTECTED-MARKER'
|
const acceptedMarker = 'MP-ACCEPTED-PROTECTED-MARKER'
|
||||||
const rejectedMarker = 'MP-REJECTED-PROTECTED-MARKER'
|
const rejectedMarker = 'MP-REJECTED-PROTECTED-MARKER'
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
@@ -707,45 +702,10 @@ describe('AgentAssistantPanel stream recovery', () => {
|
|||||||
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
|
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
|
||||||
return createAgentStreamResponse([
|
return createAgentStreamResponse([
|
||||||
legacySseFrame({ type: 'start', session_id: 'web-agent:protected-validation' }),
|
legacySseFrame({ type: 'start', session_id: 'web-agent:protected-validation' }),
|
||||||
protectedSseFrame({
|
protectedSseFrame({ content: 42 }),
|
||||||
schema_version: '2.0',
|
|
||||||
delivery_id: 'wrong-major',
|
|
||||||
content_type: 'text/plain',
|
|
||||||
content: rejectedMarker,
|
|
||||||
}),
|
|
||||||
protectedSseFrame({
|
|
||||||
schema_version: '1.0',
|
|
||||||
delivery_id: '',
|
|
||||||
content_type: 'text/plain',
|
|
||||||
content: rejectedMarker,
|
|
||||||
}),
|
|
||||||
protectedSseFrame({
|
|
||||||
schema_version: '1.0',
|
|
||||||
delivery_id: 'wrong-content-type',
|
|
||||||
content_type: 'text/html',
|
|
||||||
content: rejectedMarker,
|
|
||||||
}),
|
|
||||||
protectedSseFrame({
|
|
||||||
schema_version: '1.0',
|
|
||||||
delivery_id: 'bad-content',
|
|
||||||
content_type: 'text/plain',
|
|
||||||
content: 42,
|
|
||||||
}),
|
|
||||||
{ eventName: 'interaction', data: { content: rejectedMarker } },
|
{ eventName: 'interaction', data: { content: rejectedMarker } },
|
||||||
legacySseFrame({ type: 'protected_delivery', delivery_id: 'legacy-shape', content: rejectedMarker }),
|
legacySseFrame({ type: 'protected_delivery', delivery_id: 'legacy-shape', content: rejectedMarker }),
|
||||||
protectedSseFrame({
|
protectedSseFrame({ content: acceptedMarker }),
|
||||||
schema_version: '1.7',
|
|
||||||
delivery_id: 'delivery-valid',
|
|
||||||
content_type: 'text/plain',
|
|
||||||
content: acceptedMarker,
|
|
||||||
future_field: true,
|
|
||||||
}),
|
|
||||||
protectedSseFrame({
|
|
||||||
schema_version: '1.0',
|
|
||||||
delivery_id: 'delivery-valid',
|
|
||||||
content_type: 'text/plain',
|
|
||||||
content: rejectedMarker,
|
|
||||||
}),
|
|
||||||
legacySseFrame({ type: 'done' }),
|
legacySseFrame({ type: 'done' }),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
@@ -786,12 +746,7 @@ describe('AgentAssistantPanel stream recovery', () => {
|
|||||||
|
|
||||||
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({
|
protectedSseFrame({ content: protectedMarker }),
|
||||||
schema_version: '1.0',
|
|
||||||
delivery_id: 'delivery-before-close',
|
|
||||||
content_type: 'text/plain',
|
|
||||||
content: protectedMarker,
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(wrapper.text()).toContain(protectedMarker)
|
expect(wrapper.text()).toContain(protectedMarker)
|
||||||
@@ -800,12 +755,7 @@ describe('AgentAssistantPanel stream recovery', () => {
|
|||||||
expect(wrapper.text()).not.toContain(protectedMarker)
|
expect(wrapper.text()).not.toContain(protectedMarker)
|
||||||
|
|
||||||
stream.emit(
|
stream.emit(
|
||||||
protectedSseFrame({
|
protectedSseFrame({ content: protectedMarker }),
|
||||||
schema_version: '1.0',
|
|
||||||
delivery_id: 'delivery-after-close',
|
|
||||||
content_type: 'text/plain',
|
|
||||||
content: protectedMarker,
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
await wrapper.setProps({ modelValue: true })
|
await wrapper.setProps({ modelValue: true })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
@@ -832,12 +782,7 @@ describe('AgentAssistantPanel stream recovery', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
stream.emit(
|
stream.emit(
|
||||||
protectedSseFrame({
|
protectedSseFrame({ content: protectedMarker }),
|
||||||
schema_version: '1.0',
|
|
||||||
delivery_id: 'delivery-before-eof',
|
|
||||||
content_type: 'text/plain',
|
|
||||||
content: protectedMarker,
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(wrapper.text()).toContain(protectedMarker)
|
expect(wrapper.text()).toContain(protectedMarker)
|
||||||
@@ -873,12 +818,7 @@ describe('AgentAssistantPanel stream recovery', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
stream.emit(
|
stream.emit(
|
||||||
protectedSseFrame({
|
protectedSseFrame({ content: protectedMarker }),
|
||||||
schema_version: '1.0',
|
|
||||||
delivery_id: 'delivery-before-network-error',
|
|
||||||
content_type: 'text/plain',
|
|
||||||
content: protectedMarker,
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(wrapper.text()).toContain(protectedMarker)
|
expect(wrapper.text()).toContain(protectedMarker)
|
||||||
@@ -905,12 +845,7 @@ describe('AgentAssistantPanel stream recovery', () => {
|
|||||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
|
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
|
||||||
return createAgentStreamResponse([
|
return createAgentStreamResponse([
|
||||||
protectedSseFrame({
|
protectedSseFrame({ content: protectedMarker }),
|
||||||
schema_version: '1.0',
|
|
||||||
delivery_id: 'delivery-session',
|
|
||||||
content_type: 'text/plain',
|
|
||||||
content: protectedMarker,
|
|
||||||
}),
|
|
||||||
legacySseFrame({ type: 'done' }),
|
legacySseFrame({ type: 'done' }),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
@@ -964,12 +899,7 @@ describe('AgentAssistantPanel stream recovery', () => {
|
|||||||
const url = String(input)
|
const url = String(input)
|
||||||
if (url.endsWith('/message/agent/stream') && init?.method === 'POST') {
|
if (url.endsWith('/message/agent/stream') && init?.method === 'POST') {
|
||||||
return createAgentStreamResponse([
|
return createAgentStreamResponse([
|
||||||
protectedSseFrame({
|
protectedSseFrame({ content: protectedMarker }),
|
||||||
schema_version: '1.0',
|
|
||||||
delivery_id: 'delivery-history',
|
|
||||||
content_type: 'text/plain',
|
|
||||||
content: protectedMarker,
|
|
||||||
}),
|
|
||||||
legacySseFrame({ type: 'done' }),
|
legacySseFrame({ type: 'done' }),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
@@ -993,16 +923,19 @@ describe('AgentAssistantPanel stream recovery', () => {
|
|||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps exact confirmation controls out of Web history while preserving the old-backend response', async () => {
|
it('keeps exact confirmation controls out of Web history while preserving the backend response', async () => {
|
||||||
const controlText = '确认 aB12-cD34'
|
const controlText = '确认'
|
||||||
const backendFeedback = '确认无效或已过期'
|
const backendFeedback = '确认无效或已过期'
|
||||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
|
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
|
||||||
return createAgentStreamResponse([
|
return createAgentStreamResponse(
|
||||||
|
[
|
||||||
legacySseFrame({ type: 'start', session_id: 'web-agent:control' }),
|
legacySseFrame({ type: 'start', session_id: 'web-agent:control' }),
|
||||||
legacySseFrame({ type: 'delta', content: backendFeedback }),
|
legacySseFrame({ type: 'delta', content: backendFeedback }),
|
||||||
legacySseFrame({ type: 'done' }),
|
legacySseFrame({ type: 'done' }),
|
||||||
])
|
],
|
||||||
|
{ 'X-MoviePilot-Agent-Control': 'secret-confirmation' },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return createAgentResponse([])
|
return createAgentResponse([])
|
||||||
@@ -1016,28 +949,52 @@ describe('AgentAssistantPanel stream recovery', () => {
|
|||||||
|
|
||||||
const streamCall = fetchMock.mock.calls.find(([input]) => String(input).endsWith('/message/agent/stream'))
|
const streamCall = fetchMock.mock.calls.find(([input]) => String(input).endsWith('/message/agent/stream'))
|
||||||
const streamBody = JSON.parse(String(streamCall?.[1]?.body || '{}'))
|
const streamBody = JSON.parse(String(streamCall?.[1]?.body || '{}'))
|
||||||
expect(streamBody).toMatchObject({ text: controlText, display_text: controlText, echo_user: false })
|
expect(streamBody).toMatchObject({ text: controlText, display_text: controlText, echo_user: true })
|
||||||
expect(wrapper.text()).toContain(backendFeedback)
|
expect(wrapper.text()).toContain(backendFeedback)
|
||||||
expect(wrapper.find('.agent-assistant-message--user').exists()).toBe(false)
|
expect(wrapper.find('.agent-assistant-message--user').exists()).toBe(false)
|
||||||
expect(localStorage.getItem('moviepilot-agent-assistant-state')).not.toContain(controlText)
|
const localState = JSON.parse(localStorage.getItem('moviepilot-agent-assistant-state') || '{}')
|
||||||
expect(localStorage.getItem('moviepilot-agent-assistant-history')).not.toContain(controlText)
|
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
|
fetchMock.mock.calls
|
||||||
.filter(([input]) => String(input).includes('/display'))
|
.filter(([input]) => String(input).includes('/display'))
|
||||||
.forEach(([, init]) => expect(String(init?.body)).not.toContain(controlText))
|
.forEach(([, init]) => {
|
||||||
|
const displayBody = JSON.parse(String(init?.body || '{}'))
|
||||||
|
expect(displayBody.messages || []).not.toContainEqual(
|
||||||
|
expect.objectContaining({ role: 'user', content: controlText }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps an exact confirmation word on the ordinary path without a pending control', async () => {
|
||||||
|
const controlText = '确认'
|
||||||
|
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
|
||||||
|
return createAgentStreamResponse([legacySseFrame({ type: 'done' })])
|
||||||
|
}
|
||||||
|
return createAgentResponse([])
|
||||||
|
})
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
|
||||||
|
const wrapper = mountPanel()
|
||||||
|
await wrapper.find('textarea').setValue(controlText)
|
||||||
|
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const streamCall = fetchMock.mock.calls.find(([input]) => String(input).endsWith('/message/agent/stream'))
|
||||||
|
const streamBody = JSON.parse(String(streamCall?.[1]?.body || '{}'))
|
||||||
|
expect(streamBody.echo_user).toBe(true)
|
||||||
|
expect(wrapper.find('.agent-assistant-message--user').text()).toContain(controlText)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['leading space', ' 确认 AB12-CD34'],
|
['confirmation with details', '确认 TMDB_API_KEY'],
|
||||||
['trailing space', '确认 AB12-CD34 '],
|
['cancellation with details', '取消 TMDB_API_KEY'],
|
||||||
['repeated separator', '确认 AB12-CD34'],
|
['extra prose', '确认,请执行'],
|
||||||
['trailing newline', '确认 AB12-CD34\n'],
|
['different verb', '同意'],
|
||||||
['non-breaking space', '确认\u00a0AB12-CD34'],
|
|
||||||
['full-width space', '确认 AB12-CD34'],
|
|
||||||
['extra prose', '确认 AB12-CD34 请执行'],
|
|
||||||
['malformed code', '确认 AB12-CD3'],
|
|
||||||
['different verb', '同意 AB12-CD34'],
|
|
||||||
])('keeps the %s control-like text on the ordinary echo path', async (_caseName, controlLikeText) => {
|
])('keeps the %s control-like text on the ordinary echo path', async (_caseName, controlLikeText) => {
|
||||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
|
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
|
||||||
@@ -1060,7 +1017,7 @@ describe('AgentAssistantPanel stream recovery', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('keeps an exact confirmation string with an attachment on the ordinary echo path', async () => {
|
it('keeps an exact confirmation string with an attachment on the ordinary echo path', async () => {
|
||||||
const controlText = '确认 AB12-CD34'
|
const controlText = '确认'
|
||||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
const url = String(input)
|
const url = String(input)
|
||||||
if (url.endsWith('/message/agent/upload') && init?.method === 'POST') {
|
if (url.endsWith('/message/agent/upload') && init?.method === 'POST') {
|
||||||
@@ -1142,12 +1099,7 @@ describe('AgentAssistantPanel stream recovery', () => {
|
|||||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
|
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
|
||||||
return createAgentStreamResponse([
|
return createAgentStreamResponse([
|
||||||
protectedSseFrame({
|
protectedSseFrame({ content: protectedMarker }),
|
||||||
schema_version: '1.0',
|
|
||||||
delivery_id: `delivery-${fetchMock.mock.calls.length}`,
|
|
||||||
content_type: 'text/plain',
|
|
||||||
content: protectedMarker,
|
|
||||||
}),
|
|
||||||
legacySseFrame({ type: 'done' }),
|
legacySseFrame({ type: 'done' }),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user