mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-15 10:34:35 +08:00
fix(agent): scope secret confirmation controls (#671)
This commit is contained in:
@@ -181,13 +181,6 @@ interface AgentStreamReadResult {
|
||||
receivedTerminalEvent: boolean
|
||||
}
|
||||
|
||||
interface AgentProtectedDelivery {
|
||||
/** 一次性投递标识,只用于当前可见范围去重。 */
|
||||
id: string
|
||||
/** 仅驻留组件内存并按纯文本渲染的受保护内容。 */
|
||||
content: string
|
||||
}
|
||||
|
||||
interface ParsedSseBlock {
|
||||
eventName: string
|
||||
data: string
|
||||
@@ -249,7 +242,8 @@ const STREAM_STATE_PERSIST_DELAY = 1000
|
||||
|
||||
const inputText = ref('')
|
||||
const messages = ref<AgentChatMessage[]>([])
|
||||
const protectedDeliveries = ref<AgentProtectedDelivery[]>([])
|
||||
// 受保护内容只驻留当前组件内存,不进入消息历史或本地持久化。
|
||||
const protectedDeliveries = ref<string[]>([])
|
||||
const historySessions = ref<AgentSessionHistoryItem[]>([])
|
||||
const sessionId = ref('')
|
||||
const sending = ref(false)
|
||||
@@ -290,7 +284,6 @@ let streamRecoveryAbortRequested = false
|
||||
let streamRecoveryTimer: number | null = null
|
||||
let activeStreamStartedAt = 0
|
||||
let protectedDeliveryGeneration = 0
|
||||
const protectedDeliveryIds = new Set<string>()
|
||||
|
||||
// 汇总实时请求与后台恢复状态,保证恢复期间仍展示处理中并锁定会话操作。
|
||||
const isBusy = computed(() => sending.value || Boolean(pendingStreamRecovery.value))
|
||||
@@ -365,12 +358,6 @@ function createSessionId() {
|
||||
function invalidateProtectedDeliveries() {
|
||||
protectedDeliveryGeneration += 1
|
||||
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
|
||||
}
|
||||
|
||||
function parseProtectedTransportFrame(data: string): AgentProtectedDelivery | null {
|
||||
function parseProtectedTransportFrame(data: string): string | null {
|
||||
try {
|
||||
const frame = JSON.parse(data) as Record<string, unknown>
|
||||
const schemaVersion = typeof frame.schema_version === 'string' ? frame.schema_version : ''
|
||||
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 }
|
||||
return typeof frame.content === 'string' ? frame.content : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -1601,11 +1573,10 @@ function parseProtectedTransportFrame(data: string): AgentProtectedDelivery | nu
|
||||
function consumeProtectedTransportFrame(data: string, streamGeneration: number) {
|
||||
if (!isOpen.value || streamGeneration !== protectedDeliveryGeneration) return
|
||||
|
||||
const delivery = parseProtectedTransportFrame(data)
|
||||
if (!delivery || protectedDeliveryIds.has(delivery.id)) return
|
||||
const content = parseProtectedTransportFrame(data)
|
||||
if (content === null) return
|
||||
|
||||
protectedDeliveryIds.add(delivery.id)
|
||||
protectedDeliveries.value.push(delivery)
|
||||
protectedDeliveries.value.push(content)
|
||||
nextTick(() => scheduleMessageScrollerUpdate({ toBottom: messageScrollerShouldFollow }))
|
||||
}
|
||||
|
||||
@@ -1859,7 +1830,9 @@ async function streamAgentMessage(
|
||||
const displayContent = (displayText ?? content).trim()
|
||||
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')
|
||||
|
||||
abortController = new AbortController()
|
||||
@@ -1897,6 +1870,11 @@ async function streamAgentMessage(
|
||||
if (!response.ok) {
|
||||
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)
|
||||
shouldFollowBottomAfterStream = isMessageScrollerNearBottom()
|
||||
@@ -1979,10 +1957,7 @@ async function sendMessage() {
|
||||
|
||||
try {
|
||||
const prepared = await prepareAgentAttachments(attachments)
|
||||
const reservedControl = attachments.length === 0 && isReservedConfirmationControl(rawText)
|
||||
await streamAgentMessage(text, prepared.images, prepared.files, prepared.audioRefs, prepared.userAttachments, {
|
||||
echoUser: !reservedControl,
|
||||
})
|
||||
await streamAgentMessage(text, prepared.images, prepared.files, prepared.audioRefs, prepared.userAttachments)
|
||||
} catch (error: any) {
|
||||
// 附件准备失败同样落到对话消息里,底部提示条只保留给没有消息承载的本地错误。
|
||||
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-for="delivery in protectedDeliveries"
|
||||
:key="delivery.id"
|
||||
v-for="(content, index) in protectedDeliveries"
|
||||
:key="index"
|
||||
class="agent-assistant-protected-delivery"
|
||||
:data-protected-delivery-id="delivery.id"
|
||||
>
|
||||
<VIcon icon="mdi-shield-lock-outline" size="16" aria-hidden="true" />
|
||||
<span v-text="delivery.content" />
|
||||
<span v-text="content" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -107,14 +107,14 @@ function protectedSseFrame(data: Record<string, unknown>): SyntheticSseFrame {
|
||||
return { eventName: 'interaction-protected', data }
|
||||
}
|
||||
|
||||
function createAgentStreamResponse(frames: SyntheticSseFrame[]) {
|
||||
function createAgentStreamResponse(frames: SyntheticSseFrame[], headers: Record<string, string> = {}) {
|
||||
const body = frames
|
||||
.map(frame => `${frame.eventName ? `event: ${frame.eventName}\n` : ''}data: ${JSON.stringify(frame.data)}\n\n`)
|
||||
.join('')
|
||||
|
||||
return new Response(body, {
|
||||
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([
|
||||
legacySseFrame({ type: 'start', session_id: 'web-agent:protected' }),
|
||||
legacySseFrame({ type: 'delta', content: '普通回复' }),
|
||||
protectedSseFrame({
|
||||
schema_version: '1.0',
|
||||
delivery_id: 'delivery-1',
|
||||
content_type: 'text/plain',
|
||||
content: protectedMarker,
|
||||
}),
|
||||
protectedSseFrame({ content: protectedMarker }),
|
||||
legacySseFrame({ type: 'tool', message: '(查询了 1 次数据)' }),
|
||||
legacySseFrame({ type: 'done' }),
|
||||
])
|
||||
@@ -522,7 +517,7 @@ describe('AgentAssistantPanel stream recovery', () => {
|
||||
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
|
||||
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.find('script').exists()).toBe(false)
|
||||
const assistantBubble = wrapper.get('.agent-assistant-message--assistant .agent-assistant-message__bubble')
|
||||
@@ -698,7 +693,7 @@ describe('AgentAssistantPanel stream recovery', () => {
|
||||
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 rejectedMarker = 'MP-REJECTED-PROTECTED-MARKER'
|
||||
vi.stubGlobal(
|
||||
@@ -707,45 +702,10 @@ describe('AgentAssistantPanel stream recovery', () => {
|
||||
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
|
||||
return createAgentStreamResponse([
|
||||
legacySseFrame({ type: 'start', session_id: 'web-agent:protected-validation' }),
|
||||
protectedSseFrame({
|
||||
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,
|
||||
}),
|
||||
protectedSseFrame({ content: 42 }),
|
||||
{ eventName: 'interaction', data: { content: rejectedMarker } },
|
||||
legacySseFrame({ type: 'protected_delivery', delivery_id: 'legacy-shape', content: rejectedMarker }),
|
||||
protectedSseFrame({
|
||||
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,
|
||||
}),
|
||||
protectedSseFrame({ content: acceptedMarker }),
|
||||
legacySseFrame({ type: 'done' }),
|
||||
])
|
||||
}
|
||||
@@ -786,12 +746,7 @@ describe('AgentAssistantPanel stream recovery', () => {
|
||||
|
||||
stream.emit(legacySseFrame({ type: 'start', session_id: 'web-agent:late-protected' }))
|
||||
stream.emit(
|
||||
protectedSseFrame({
|
||||
schema_version: '1.0',
|
||||
delivery_id: 'delivery-before-close',
|
||||
content_type: 'text/plain',
|
||||
content: protectedMarker,
|
||||
}),
|
||||
protectedSseFrame({ content: protectedMarker }),
|
||||
)
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain(protectedMarker)
|
||||
@@ -800,12 +755,7 @@ describe('AgentAssistantPanel stream recovery', () => {
|
||||
expect(wrapper.text()).not.toContain(protectedMarker)
|
||||
|
||||
stream.emit(
|
||||
protectedSseFrame({
|
||||
schema_version: '1.0',
|
||||
delivery_id: 'delivery-after-close',
|
||||
content_type: 'text/plain',
|
||||
content: protectedMarker,
|
||||
}),
|
||||
protectedSseFrame({ content: protectedMarker }),
|
||||
)
|
||||
await wrapper.setProps({ modelValue: true })
|
||||
await flushPromises()
|
||||
@@ -832,12 +782,7 @@ describe('AgentAssistantPanel stream recovery', () => {
|
||||
await flushPromises()
|
||||
|
||||
stream.emit(
|
||||
protectedSseFrame({
|
||||
schema_version: '1.0',
|
||||
delivery_id: 'delivery-before-eof',
|
||||
content_type: 'text/plain',
|
||||
content: protectedMarker,
|
||||
}),
|
||||
protectedSseFrame({ content: protectedMarker }),
|
||||
)
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain(protectedMarker)
|
||||
@@ -873,12 +818,7 @@ describe('AgentAssistantPanel stream recovery', () => {
|
||||
await flushPromises()
|
||||
|
||||
stream.emit(
|
||||
protectedSseFrame({
|
||||
schema_version: '1.0',
|
||||
delivery_id: 'delivery-before-network-error',
|
||||
content_type: 'text/plain',
|
||||
content: protectedMarker,
|
||||
}),
|
||||
protectedSseFrame({ content: protectedMarker }),
|
||||
)
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain(protectedMarker)
|
||||
@@ -905,12 +845,7 @@ describe('AgentAssistantPanel stream recovery', () => {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
|
||||
return createAgentStreamResponse([
|
||||
protectedSseFrame({
|
||||
schema_version: '1.0',
|
||||
delivery_id: 'delivery-session',
|
||||
content_type: 'text/plain',
|
||||
content: protectedMarker,
|
||||
}),
|
||||
protectedSseFrame({ content: protectedMarker }),
|
||||
legacySseFrame({ type: 'done' }),
|
||||
])
|
||||
}
|
||||
@@ -964,12 +899,7 @@ describe('AgentAssistantPanel stream recovery', () => {
|
||||
const url = String(input)
|
||||
if (url.endsWith('/message/agent/stream') && init?.method === 'POST') {
|
||||
return createAgentStreamResponse([
|
||||
protectedSseFrame({
|
||||
schema_version: '1.0',
|
||||
delivery_id: 'delivery-history',
|
||||
content_type: 'text/plain',
|
||||
content: protectedMarker,
|
||||
}),
|
||||
protectedSseFrame({ content: protectedMarker }),
|
||||
legacySseFrame({ type: 'done' }),
|
||||
])
|
||||
}
|
||||
@@ -993,16 +923,19 @@ describe('AgentAssistantPanel stream recovery', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps exact confirmation controls out of Web history while preserving the old-backend response', async () => {
|
||||
const controlText = '确认 aB12-cD34'
|
||||
it('keeps exact confirmation controls out of Web history while preserving the backend response', async () => {
|
||||
const controlText = '确认'
|
||||
const backendFeedback = '确认无效或已过期'
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
|
||||
return createAgentStreamResponse([
|
||||
legacySseFrame({ type: 'start', session_id: 'web-agent:control' }),
|
||||
legacySseFrame({ type: 'delta', content: backendFeedback }),
|
||||
legacySseFrame({ type: 'done' }),
|
||||
])
|
||||
return createAgentStreamResponse(
|
||||
[
|
||||
legacySseFrame({ type: 'start', session_id: 'web-agent:control' }),
|
||||
legacySseFrame({ type: 'delta', content: backendFeedback }),
|
||||
legacySseFrame({ type: 'done' }),
|
||||
],
|
||||
{ 'X-MoviePilot-Agent-Control': 'secret-confirmation' },
|
||||
)
|
||||
}
|
||||
|
||||
return createAgentResponse([])
|
||||
@@ -1016,28 +949,52 @@ describe('AgentAssistantPanel stream recovery', () => {
|
||||
|
||||
const streamCall = fetchMock.mock.calls.find(([input]) => String(input).endsWith('/message/agent/stream'))
|
||||
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.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)
|
||||
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]) => 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()
|
||||
})
|
||||
|
||||
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([
|
||||
['leading space', ' 确认 AB12-CD34'],
|
||||
['trailing space', '确认 AB12-CD34 '],
|
||||
['repeated separator', '确认 AB12-CD34'],
|
||||
['trailing newline', '确认 AB12-CD34\n'],
|
||||
['non-breaking space', '确认\u00a0AB12-CD34'],
|
||||
['full-width space', '确认 AB12-CD34'],
|
||||
['extra prose', '确认 AB12-CD34 请执行'],
|
||||
['malformed code', '确认 AB12-CD3'],
|
||||
['different verb', '同意 AB12-CD34'],
|
||||
['confirmation with details', '确认 TMDB_API_KEY'],
|
||||
['cancellation with details', '取消 TMDB_API_KEY'],
|
||||
['extra prose', '确认,请执行'],
|
||||
['different verb', '同意'],
|
||||
])('keeps the %s control-like text on the ordinary echo path', async (_caseName, controlLikeText) => {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
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 () => {
|
||||
const controlText = '确认 AB12-CD34'
|
||||
const controlText = '确认'
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input)
|
||||
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) => {
|
||||
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
|
||||
return createAgentStreamResponse([
|
||||
protectedSseFrame({
|
||||
schema_version: '1.0',
|
||||
delivery_id: `delivery-${fetchMock.mock.calls.length}`,
|
||||
content_type: 'text/plain',
|
||||
content: protectedMarker,
|
||||
}),
|
||||
protectedSseFrame({ content: protectedMarker }),
|
||||
legacySseFrame({ type: 'done' }),
|
||||
])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user