diff --git a/client/src/i18n/locales/en-US.ts b/client/src/i18n/locales/en-US.ts index 58765e8..7489d0b 100644 --- a/client/src/i18n/locales/en-US.ts +++ b/client/src/i18n/locales/en-US.ts @@ -415,6 +415,13 @@ export default { backendStatus: 'Backend status', backendMessage: 'Backend message', waitingSuperAgent: 'Parsing the email and waiting for SuperAgent', + systemStages: 'System stages', + superAgentTrace: 'SuperAgent Trace', + traceToolName: 'Tool', + traceInputSummary: 'Input summary', + traceOutputSummary: 'Output summary', + traceStatus: 'Status', + traceTime: 'Time', sourceTrace: 'SourceMessage trace', sourceMessageId: 'SourceMessage ID', sourceProvider: 'Source provider', diff --git a/client/src/i18n/locales/th-TH.ts b/client/src/i18n/locales/th-TH.ts index 88f429b..b2c895c 100644 --- a/client/src/i18n/locales/th-TH.ts +++ b/client/src/i18n/locales/th-TH.ts @@ -415,6 +415,13 @@ export default { backendStatus: 'สถานะจากระบบหลังบ้าน', backendMessage: 'ข้อความจากระบบหลังบ้าน', waitingSuperAgent: 'กำลังแยกอีเมลและรอ SuperAgent ตอบกลับ', + systemStages: 'ขั้นตอนของระบบ', + superAgentTrace: 'SuperAgent Trace', + traceToolName: 'เครื่องมือ', + traceInputSummary: 'สรุป input', + traceOutputSummary: 'สรุป output', + traceStatus: 'สถานะ', + traceTime: 'เวลา', sourceTrace: 'การติดตาม SourceMessage', sourceMessageId: 'SourceMessage ID', sourceProvider: 'Source provider', diff --git a/client/src/i18n/locales/zh-CN.ts b/client/src/i18n/locales/zh-CN.ts index 79cc5f7..fb57cc0 100644 --- a/client/src/i18n/locales/zh-CN.ts +++ b/client/src/i18n/locales/zh-CN.ts @@ -415,6 +415,13 @@ export default { backendStatus: '后端状态', backendMessage: '后端消息', waitingSuperAgent: '正在解析邮件并等待 SuperAgent 返回', + systemStages: '本系统处理阶段', + superAgentTrace: 'SuperAgent Trace', + traceToolName: '工具', + traceInputSummary: '入参摘要', + traceOutputSummary: '出参摘要', + traceStatus: '状态', + traceTime: '时间', sourceTrace: 'SourceMessage 追溯', sourceMessageId: 'SourceMessage ID', sourceProvider: '来源 Provider', diff --git a/client/src/services/debugEmlService.ts b/client/src/services/debugEmlService.ts index 11a216e..befbc2f 100644 --- a/client/src/services/debugEmlService.ts +++ b/client/src/services/debugEmlService.ts @@ -1,7 +1,10 @@ import type { DebugEmlErrorCode, DebugEmlErrorResponse, + DebugEmlStageEvent, + DebugEmlStreamHandlers, DebugEmlSuperAgentRunResult, + DebugEmlSuperAgentTraceEvent, DebugEmlUploadInput, } from '@/types/debugEml' import { getStoredAccessToken } from '@/services/authSession' @@ -9,6 +12,11 @@ import { isAuthSessionInvalidPayload, notifyUnauthorized } from '@/services/http const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? '' const debugEmlEndpoint = '/api/system/debug/eml-superagent-runs' +const debugEmlStreamEndpoint = `${debugEmlEndpoint}/stream` +const jsonSecretPattern = + /("(?:api[_-]?key|token|secret|password|cookie|authorization)"\s*:\s*")[^"]*(")/gi +const headerSecretPattern = /((?:authorization|cookie)\s*:\s*)[^\s,;]+/gi +const textSecretPattern = /((?:api[_-]?key|token|secret|password)\s*[=:]\s*)[^\s,;}]+/gi export class DebugEmlUploadError extends Error { readonly status: number @@ -61,9 +69,91 @@ export async function uploadDebugEmlSuperAgentRun( return normalizeDebugEmlResult(payload) } -function buildUploadHeaders(debugUploadKey: string): Record { +export async function uploadDebugEmlSuperAgentRunStream( + input: DebugEmlUploadInput, + handlers: DebugEmlStreamHandlers = {}, +): Promise { + const response = await fetch(`${apiBaseUrl}${debugEmlStreamEndpoint}`, { + method: 'POST', + headers: buildUploadHeaders(input.debugUploadKey, 'text/event-stream'), + body: buildUploadForm(input), + }) + + if (!response.ok) { + const payload = await readResponsePayload(response) + if (isAuthSessionInvalidPayload(payload)) { + notifyUnauthorized() + } + const errorPayload = isDebugEmlErrorResponse(payload) ? payload : {} + throw new DebugEmlUploadError( + errorPayload.message || `Request failed with status ${response.status}`, + response.status, + errorPayload.error_code || 'DEBUG_EML_RUN_FAILED', + payload, + ) + } + + if (!response.body) { + throw new DebugEmlUploadError('Debug EML SSE 响应为空。', response.status, 'DEBUG_EML_RUN_FAILED', null) + } + + let finalResult: DebugEmlSuperAgentRunResult | null = null + let streamError: DebugEmlUploadError | null = null + await readSseStream(response.body, (event) => { + const payload = parseSsePayload(event.data) + if (event.event === 'debug_stage') { + handlers.onStage?.(normalizeStageEvent(payload)) + return + } + if (event.event === 'superagent_trace') { + handlers.onTrace?.(normalizeTraceEvent(payload)) + return + } + if (event.event === 'superagent_result') { + finalResult = normalizeDebugEmlResult(payload) + handlers.onResult?.(finalResult) + return + } + if (event.event === 'debug_error') { + const errorPayload = isDebugEmlErrorResponse(payload) ? payload : {} + streamError = new DebugEmlUploadError( + errorPayload.message || 'Debug EML 调试失败。', + response.status, + errorPayload.error_code || 'DEBUG_EML_RUN_FAILED', + payload, + ) + } + }) + + if (streamError) { + throw streamError + } + if (!finalResult) { + throw new DebugEmlUploadError('Debug EML SSE 未收到最终结果。', response.status, 'DEBUG_EML_RUN_FAILED', null) + } + return finalResult +} + +function buildUploadForm(input: DebugEmlUploadInput): FormData { + const form = new FormData() + form.append('file', input.file) + const hotelId = input.hotelId?.trim() + if (hotelId) { + form.append('hotel_id', hotelId) + } + const runLabel = input.runLabel?.trim() + if (runLabel) { + form.append('run_label', runLabel) + } + return form +} + +function buildUploadHeaders( + debugUploadKey: string, + accept: 'application/json' | 'text/event-stream' = 'application/json', +): Record { const headers: Record = { - Accept: 'application/json', + Accept: accept, 'X-TH-Hotel-Debug-Upload-Key': debugUploadKey.trim(), } const token = getStoredAccessToken() @@ -104,11 +194,109 @@ function normalizeDebugEmlResult(payload: unknown): DebugEmlSuperAgentRunResult superagent_run_id: nullableStringValue(result.superagent_run_id), superagent_raw_answer: nullableStringValue(result.superagent_raw_answer), superagent_parsed_json: result.superagent_parsed_json ?? null, + superagent_trace_events: Array.isArray(result.superagent_trace_events) + ? result.superagent_trace_events.map(normalizeTraceEvent) + : [], warnings: Array.isArray(result.warnings) ? result.warnings.filter(isString) : [], status: nullableStringValue(result.status), } } +async function readSseStream( + body: ReadableStream, + onEvent: (event: { event: string; data: string }) => void, +): Promise { + const reader = body.getReader() + const decoder = new TextDecoder() + let buffer = '' + while (true) { + const { value, done } = await reader.read() + if (done) { + break + } + buffer += decoder.decode(value, { stream: true }) + buffer = consumeSseBuffer(buffer, onEvent) + } + buffer += decoder.decode() + consumeSseBuffer(`${buffer}\n\n`, onEvent) +} + +function consumeSseBuffer( + buffer: string, + onEvent: (event: { event: string; data: string }) => void, +): string { + let remaining = buffer + while (true) { + const separatorIndex = remaining.search(/\r?\n\r?\n/) + if (separatorIndex < 0) { + return remaining + } + const block = remaining.slice(0, separatorIndex) + remaining = remaining.slice(separatorIndex + (remaining[separatorIndex] === '\r' ? 4 : 2)) + const event = parseSseBlock(block) + if (event) { + onEvent(event) + } + } +} + +function parseSseBlock(block: string): { event: string; data: string } | null { + let event = 'message' + const dataLines: string[] = [] + for (const line of block.split(/\r?\n/)) { + if (line.startsWith('event:')) { + event = line.slice('event:'.length).trim() + continue + } + if (line.startsWith('data:')) { + dataLines.push(line.slice('data:'.length).trim()) + } + } + if (!dataLines.length && event !== 'done') { + return null + } + return { + event, + data: dataLines.join('\n'), + } +} + +function parseSsePayload(data: string): unknown { + if (!data.trim()) { + return {} + } + try { + return JSON.parse(data) + } catch { + return data + } +} + +function normalizeStageEvent(value: unknown): DebugEmlStageEvent { + const stage = isRecord(value) ? value : {} + return { + debug_run_id: nullableStringValue(stage.debug_run_id), + status: nullableStringValue(stage.status), + safe_summary: nullableStringValue(stage.safe_summary), + } +} + +function normalizeTraceEvent(value: unknown): DebugEmlSuperAgentTraceEvent { + const trace = isRecord(value) ? value : {} + return { + event: nullableMaskedStringValue(trace.event), + run_id: nullableMaskedStringValue(trace.run_id), + message_id: nullableMaskedStringValue(trace.message_id), + tool_call_id: nullableMaskedStringValue(trace.tool_call_id), + tool_name: nullableMaskedStringValue(trace.tool_name), + text: nullableMaskedStringValue(trace.text), + input_summary: nullableMaskedStringValue(trace.input_summary), + output_summary: nullableMaskedStringValue(trace.output_summary), + status: nullableMaskedStringValue(trace.status), + ts: nullableMaskedStringValue(trace.ts), + } +} + function normalizeUploadedMedia(value: unknown): DebugEmlSuperAgentRunResult['uploaded_media'][number] { const media = isRecord(value) ? value : {} return { @@ -141,3 +329,13 @@ function nullableStringValue(value: unknown): string | null { function stringValue(value: unknown): string { return typeof value === 'string' ? value : '' } + +function nullableMaskedStringValue(value: unknown): string | null { + if (typeof value !== 'string') { + return null + } + return value + .replace(jsonSecretPattern, '$1***$2') + .replace(headerSecretPattern, '$1***') + .replace(textSecretPattern, '$1***') +} diff --git a/client/src/tests/debugEmlService.spec.ts b/client/src/tests/debugEmlService.spec.ts index cad0f78..bfb79e8 100644 --- a/client/src/tests/debugEmlService.spec.ts +++ b/client/src/tests/debugEmlService.spec.ts @@ -1,6 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { DebugEmlUploadError, uploadDebugEmlSuperAgentRun } from '@/services/debugEmlService' +import { + DebugEmlUploadError, + uploadDebugEmlSuperAgentRun, + uploadDebugEmlSuperAgentRunStream, +} from '@/services/debugEmlService' import { setUnauthorizedHandler } from '@/services/httpClient' const jsonHeaders = { @@ -19,6 +23,24 @@ function mockJsonResponse(payload: unknown, status = 201): Response { } as Response } +function mockSseResponse(chunks: string[], status = 200): Response { + const encoder = new TextEncoder() + const stream = new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(encoder.encode(chunk)) + } + controller.close() + }, + }) + return new Response(stream, { + status, + headers: { + 'Content-Type': 'text/event-stream', + }, + }) +} + describe('debugEmlService', () => { beforeEach(() => { vi.restoreAllMocks() @@ -155,4 +177,100 @@ describe('debugEmlService', () => { expect(unauthorizedHandler).toHaveBeenCalledOnce() }) + + it('streams debug stages, public SuperAgent trace, and final result from backend SSE', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + mockSseResponse([ + 'event: debug_stage\n', + 'data: {"debug_run_id":"90003","status":"PARSING_EML","safe_summary":"阶段:解析 EML 邮件。"}\n\n', + 'event: superagent_trace\n', + 'data: {"event":"reasoning.summary","run_id":"run-stream-1","text":"正在分析 Debug 邮件。","ts":"2026-07-11T10:00:01Z"}\n\n', + 'event: superagent_trace\n', + 'data: {"event":"tool.call.started","run_id":"run-stream-1","tool_name":"th_hotel_query_case_context","input_summary":"{\\"group_code\\":\\"G001\\"}"}\n\n', + 'event: superagent_result\n', + 'data: {"debug_run_id":"90003","source_message_id":"30003","uploaded_media":[],"superagent_session_id":"session-stream-1","superagent_run_id":"run-stream-1","superagent_raw_answer":"{\\"route_code\\":\\"S10\\"}","superagent_parsed_json":{"route_code":"S10"},"superagent_trace_events":[{"event":"reasoning.summary","text":"正在分析 Debug 邮件。"}],"warnings":[],"status":"SUPERAGENT_SUCCEEDED"}\n\n', + 'event: done\n', + 'data: {"status":"done"}\n\n', + ]), + ) + const stages: unknown[] = [] + const traces: unknown[] = [] + const file = new File(['From: guest@example.test'], 'booking.eml', { type: 'message/rfc822' }) + + const result = await uploadDebugEmlSuperAgentRunStream( + { + hotelId: 'HOTEL-TEST', + debugUploadKey: 'manual-debug-key', + runLabel: 'stream-smoke', + file, + }, + { + onStage: (stage) => stages.push(stage), + onTrace: (trace) => traces.push(trace), + }, + ) + + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0]! + expect(url).toBe('/api/system/debug/eml-superagent-runs/stream') + expect(init).toMatchObject({ + method: 'POST', + headers: { + Accept: 'text/event-stream', + 'X-TH-Hotel-Debug-Upload-Key': 'manual-debug-key', + }, + }) + expect(stages).toMatchObject([ + { + debug_run_id: '90003', + status: 'PARSING_EML', + safe_summary: '阶段:解析 EML 邮件。', + }, + ]) + expect(traces).toMatchObject([ + { + event: 'reasoning.summary', + run_id: 'run-stream-1', + text: '正在分析 Debug 邮件。', + }, + { + event: 'tool.call.started', + tool_name: 'th_hotel_query_case_context', + input_summary: '{"group_code":"G001"}', + }, + ]) + expect(result.superagent_run_id).toBe('run-stream-1') + expect(result.superagent_parsed_json).toEqual({ route_code: 'S10' }) + expect(result.superagent_trace_events).toMatchObject([ + { + event: 'reasoning.summary', + text: '正在分析 Debug 邮件。', + }, + ]) + }) + + it('throws a typed error when the backend SSE stream emits debug_error', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + mockSseResponse([ + 'event: debug_stage\n', + 'data: {"debug_run_id":"90004","status":"CALLING_SUPERAGENT","safe_summary":"阶段:调用 SuperAgent Open API。"}\n\n', + 'event: debug_error\n', + 'data: {"debug_run_id":"90004","error_code":"SUPERAGENT_OPEN_API_FAILED","message":"SuperAgent 调用失败。","status":"SUPERAGENT_FAILED"}\n\n', + 'event: done\n', + 'data: {"status":"done"}\n\n', + ]), + ) + const file = new File(['From: guest@example.test'], 'booking.eml', { type: 'message/rfc822' }) + + await expect( + uploadDebugEmlSuperAgentRunStream({ + hotelId: 'HOTEL-TEST', + debugUploadKey: 'manual-debug-key', + file, + }), + ).rejects.toMatchObject({ + errorCode: 'SUPERAGENT_OPEN_API_FAILED', + message: 'SuperAgent 调用失败。', + } satisfies Partial) + }) }) diff --git a/client/src/tests/debugEmlView.spec.ts b/client/src/tests/debugEmlView.spec.ts index 44a2a2d..9ce4f4e 100644 --- a/client/src/tests/debugEmlView.spec.ts +++ b/client/src/tests/debugEmlView.spec.ts @@ -11,6 +11,7 @@ vi.mock('@/services/debugEmlService', async (importOriginal) => { return { ...actual, uploadDebugEmlSuperAgentRun: vi.fn(), + uploadDebugEmlSuperAgentRunStream: vi.fn(), } }) @@ -66,6 +67,20 @@ function createResult() { superagent_run_id: 'run-1', superagent_raw_answer: '{"ok":true}', superagent_parsed_json: { ok: true }, + superagent_trace_events: [ + { + event: 'reasoning.summary', + run_id: 'run-1', + message_id: null, + tool_call_id: null, + tool_name: null, + text: '正在分析 Debug 邮件。', + input_summary: null, + output_summary: null, + status: null, + ts: '2026-07-11T10:00:01Z', + }, + ], warnings: ['SuperAgent 返回非 JSON 时会展示原始回答'], status: 'SUPERAGENT_SUCCEEDED', } @@ -85,10 +100,47 @@ async function chooseFile(wrapper: ReturnType) { describe('DebugEmlSuperAgentRunView', () => { beforeEach(() => { vi.mocked(service.uploadDebugEmlSuperAgentRun).mockReset() + vi.mocked(service.uploadDebugEmlSuperAgentRunStream).mockReset() }) - it('submits the selected eml file and renders sanitized debug result', async () => { - vi.mocked(service.uploadDebugEmlSuperAgentRun).mockResolvedValue(createResult()) + it('streams the selected eml file and renders stages, trace, and sanitized debug result', async () => { + vi.mocked(service.uploadDebugEmlSuperAgentRunStream).mockImplementation(async (_input, handlers) => { + handlers?.onStage?.({ + debug_run_id: '90001', + status: 'PARSING_EML', + safe_summary: '阶段:解析 EML 邮件。', + }) + handlers?.onStage?.({ + debug_run_id: '90001', + status: 'CALLING_SUPERAGENT', + safe_summary: '阶段:调用 SuperAgent Open API,source_message_id=30001。', + }) + handlers?.onTrace?.({ + event: 'reasoning.summary', + run_id: 'run-1', + message_id: null, + tool_call_id: null, + tool_name: null, + text: '正在分析 Debug 邮件。', + input_summary: null, + output_summary: null, + status: null, + ts: '2026-07-11T10:00:01Z', + }) + handlers?.onTrace?.({ + event: 'tool.call.started', + run_id: 'run-1', + message_id: 'ai-1', + tool_call_id: 'tool-1', + tool_name: 'th_hotel_query_case_context', + text: null, + input_summary: '{"group_code":"G001"}', + output_summary: null, + status: null, + ts: '2026-07-11T10:00:02Z', + }) + return createResult() + }) const wrapper = mountView() await wrapper.find('input[name="hotel_id"]').setValue('HOTEL-TEST') @@ -98,12 +150,24 @@ describe('DebugEmlSuperAgentRunView', () => { await wrapper.find('form').trigger('submit') await flushPromises() - expect(service.uploadDebugEmlSuperAgentRun).toHaveBeenCalledWith({ - hotelId: 'HOTEL-TEST', - debugUploadKey: 'manual-debug-key', - runLabel: 'frontend-smoke', - file, - }) + expect(service.uploadDebugEmlSuperAgentRunStream).toHaveBeenCalledWith( + { + hotelId: 'HOTEL-TEST', + debugUploadKey: 'manual-debug-key', + runLabel: 'frontend-smoke', + file, + }, + expect.objectContaining({ + onStage: expect.any(Function), + onTrace: expect.any(Function), + }), + ) + expect(wrapper.text()).toContain('PARSING_EML') + expect(wrapper.text()).toContain('CALLING_SUPERAGENT') + expect(wrapper.text()).toContain('阶段:解析 EML 邮件。') + expect(wrapper.text()).toContain('reasoning.summary') + expect(wrapper.text()).toContain('正在分析 Debug 邮件。') + expect(wrapper.text()).toContain('th_hotel_query_case_context') expect(wrapper.text()).toContain('90001') expect(wrapper.text()).toContain('30001') expect(wrapper.text()).toContain('DEBUG_EML_UPLOAD') @@ -116,7 +180,7 @@ describe('DebugEmlSuperAgentRunView', () => { }) it('does not render raw html with oss urls by default', async () => { - vi.mocked(service.uploadDebugEmlSuperAgentRun).mockResolvedValue(createResult()) + vi.mocked(service.uploadDebugEmlSuperAgentRunStream).mockResolvedValue(createResult()) const wrapper = mountView() await wrapper.find('input[name="hotel_id"]').setValue('HOTEL-TEST') @@ -130,7 +194,7 @@ describe('DebugEmlSuperAgentRunView', () => { }) it('shows mapped error_code messages without exposing the debug key', async () => { - vi.mocked(service.uploadDebugEmlSuperAgentRun).mockRejectedValue( + vi.mocked(service.uploadDebugEmlSuperAgentRunStream).mockRejectedValue( new DebugEmlUploadError('Debug 上传访问口令缺失或错误。', 401, 'DEBUG_UPLOAD_KEY_INVALID', { error_code: 'DEBUG_UPLOAD_KEY_INVALID', }), diff --git a/client/src/types/debugEml.ts b/client/src/types/debugEml.ts index 75cfa9d..92e4ecb 100644 --- a/client/src/types/debugEml.ts +++ b/client/src/types/debugEml.ts @@ -35,10 +35,30 @@ export interface DebugEmlSuperAgentRunResult { superagent_run_id: string | null superagent_raw_answer: string | null superagent_parsed_json: unknown | null + superagent_trace_events: DebugEmlSuperAgentTraceEvent[] warnings: string[] status: string | null } +export interface DebugEmlSuperAgentTraceEvent { + event: string | null + run_id: string | null + message_id: string | null + tool_call_id: string | null + tool_name: string | null + text: string | null + input_summary: string | null + output_summary: string | null + status: string | null + ts: string | null +} + +export interface DebugEmlStageEvent { + debug_run_id: string | null + status: string | null + safe_summary: string | null +} + export interface DebugEmlUploadedMediaResult { media_type: string | null file_name: string | null @@ -53,3 +73,9 @@ export interface DebugEmlErrorResponse { error_code?: DebugEmlErrorCode message?: string } + +export interface DebugEmlStreamHandlers { + onStage?: (stage: DebugEmlStageEvent) => void + onTrace?: (trace: DebugEmlSuperAgentTraceEvent) => void + onResult?: (result: DebugEmlSuperAgentRunResult) => void +} diff --git a/client/src/views/debug/DebugEmlSuperAgentRunView.vue b/client/src/views/debug/DebugEmlSuperAgentRunView.vue index 2a97036..2d32d37 100644 --- a/client/src/views/debug/DebugEmlSuperAgentRunView.vue +++ b/client/src/views/debug/DebugEmlSuperAgentRunView.vue @@ -14,13 +14,13 @@
{{ t('debugEml.debugRunId') }}
- {{ result?.debug_run_id ?? '-' }} + {{ currentDebugRunId ?? '-' }}
{{ t('debugEml.backendStatus') }}
- {{ result?.status ?? '-' }} + {{ currentBackendStatus ?? '-' }}
@@ -127,13 +127,13 @@
{{ t('debugEml.debugRunId') }}
- {{ result?.debug_run_id ?? '-' }} + {{ currentDebugRunId ?? '-' }}
{{ t('debugEml.backendStatus') }}
- {{ result?.status ?? '-' }} + {{ currentBackendStatus ?? '-' }}
@@ -157,6 +157,95 @@
+
+
+

+