实现Debug EML实时Trace调试链路
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<string, string> {
|
||||
export async function uploadDebugEmlSuperAgentRunStream(
|
||||
input: DebugEmlUploadInput,
|
||||
handlers: DebugEmlStreamHandlers = {},
|
||||
): Promise<DebugEmlSuperAgentRunResult> {
|
||||
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<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
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<Uint8Array>,
|
||||
onEvent: (event: { event: string; data: string }) => void,
|
||||
): Promise<void> {
|
||||
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***')
|
||||
}
|
||||
|
||||
@@ -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<Uint8Array>({
|
||||
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<DebugEmlUploadError>)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<typeof mountView>) {
|
||||
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',
|
||||
}),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -14,13 +14,13 @@
|
||||
<div>
|
||||
<dt>{{ t('debugEml.debugRunId') }}</dt>
|
||||
<dd class="th-code">
|
||||
{{ result?.debug_run_id ?? '-' }}
|
||||
{{ currentDebugRunId ?? '-' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('debugEml.backendStatus') }}</dt>
|
||||
<dd class="th-code">
|
||||
{{ result?.status ?? '-' }}
|
||||
{{ currentBackendStatus ?? '-' }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@@ -127,13 +127,13 @@
|
||||
<div>
|
||||
<dt>{{ t('debugEml.debugRunId') }}</dt>
|
||||
<dd class="th-code">
|
||||
{{ result?.debug_run_id ?? '-' }}
|
||||
{{ currentDebugRunId ?? '-' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('debugEml.backendStatus') }}</dt>
|
||||
<dd class="th-code">
|
||||
{{ result?.status ?? '-' }}
|
||||
{{ currentBackendStatus ?? '-' }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@@ -157,6 +157,95 @@
|
||||
</aside>
|
||||
|
||||
<main class="debug-eml-results">
|
||||
<section
|
||||
v-if="stageEvents.length || busy"
|
||||
class="debug-eml-card debug-eml-stream-section"
|
||||
>
|
||||
<div class="th-section-header">
|
||||
<h2 class="th-section-title">
|
||||
<i
|
||||
class="pi pi-list-check"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ t('debugEml.systemStages') }}
|
||||
</h2>
|
||||
</div>
|
||||
<ol
|
||||
v-if="stageEvents.length"
|
||||
class="debug-eml-event-list debug-eml-event-list--stages"
|
||||
>
|
||||
<li
|
||||
v-for="(stage, index) in stageEvents"
|
||||
:key="`${stage.status ?? 'stage'}-${index}`"
|
||||
>
|
||||
<strong>{{ stage.status ?? '-' }}</strong>
|
||||
<span>{{ stage.safe_summary ?? '-' }}</span>
|
||||
<small class="th-code">{{ stage.debug_run_id ?? '-' }}</small>
|
||||
</li>
|
||||
</ol>
|
||||
<div
|
||||
v-else
|
||||
class="empty-state empty-state--compact"
|
||||
>
|
||||
{{ t('debugEml.waitingSuperAgent') }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="visibleTraceEvents.length || busy"
|
||||
class="debug-eml-card debug-eml-stream-section"
|
||||
>
|
||||
<div class="th-section-header">
|
||||
<h2 class="th-section-title">
|
||||
<i
|
||||
class="pi pi-wave-pulse"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ t('debugEml.superAgentTrace') }}
|
||||
</h2>
|
||||
</div>
|
||||
<ol
|
||||
v-if="visibleTraceEvents.length"
|
||||
class="debug-eml-event-list debug-eml-event-list--trace"
|
||||
>
|
||||
<li
|
||||
v-for="(trace, index) in visibleTraceEvents"
|
||||
:key="`${trace.event ?? 'trace'}-${trace.tool_call_id ?? index}`"
|
||||
>
|
||||
<strong>{{ trace.event ?? '-' }}</strong>
|
||||
<span v-if="trace.text">{{ trace.text }}</span>
|
||||
<dl class="debug-eml-trace-fields">
|
||||
<div v-if="trace.tool_name">
|
||||
<dt>{{ t('debugEml.traceToolName') }}</dt>
|
||||
<dd class="th-code">{{ trace.tool_name }}</dd>
|
||||
</div>
|
||||
<div v-if="trace.input_summary">
|
||||
<dt>{{ t('debugEml.traceInputSummary') }}</dt>
|
||||
<dd class="th-code">{{ trace.input_summary }}</dd>
|
||||
</div>
|
||||
<div v-if="trace.output_summary">
|
||||
<dt>{{ t('debugEml.traceOutputSummary') }}</dt>
|
||||
<dd class="th-code">{{ trace.output_summary }}</dd>
|
||||
</div>
|
||||
<div v-if="trace.status">
|
||||
<dt>{{ t('debugEml.traceStatus') }}</dt>
|
||||
<dd class="th-code">{{ trace.status }}</dd>
|
||||
</div>
|
||||
<div v-if="trace.ts">
|
||||
<dt>{{ t('debugEml.traceTime') }}</dt>
|
||||
<dd class="th-code">{{ trace.ts }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</li>
|
||||
</ol>
|
||||
<div
|
||||
v-else
|
||||
class="empty-state empty-state--compact"
|
||||
>
|
||||
{{ t('common.noData') }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<template v-if="result">
|
||||
<section class="debug-eml-card">
|
||||
<div class="th-section-header">
|
||||
@@ -341,8 +430,13 @@ import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { reservationHotelId } from '@/config/reservationConfig'
|
||||
import { DebugEmlUploadError, uploadDebugEmlSuperAgentRun } from '@/services/debugEmlService'
|
||||
import type { DebugEmlErrorCode, DebugEmlSuperAgentRunResult } from '@/types/debugEml'
|
||||
import { DebugEmlUploadError, uploadDebugEmlSuperAgentRunStream } from '@/services/debugEmlService'
|
||||
import type {
|
||||
DebugEmlErrorCode,
|
||||
DebugEmlStageEvent,
|
||||
DebugEmlSuperAgentRunResult,
|
||||
DebugEmlSuperAgentTraceEvent,
|
||||
} from '@/types/debugEml'
|
||||
|
||||
type DebugEmlStatus = 'idle' | 'uploading' | 'succeeded' | 'failed'
|
||||
|
||||
@@ -354,6 +448,8 @@ const runLabel = ref('')
|
||||
const selectedFile = ref<File | null>(null)
|
||||
const status = ref<DebugEmlStatus>('idle')
|
||||
const result = ref<DebugEmlSuperAgentRunResult | null>(null)
|
||||
const stageEvents = ref<DebugEmlStageEvent[]>([])
|
||||
const traceEvents = ref<DebugEmlSuperAgentTraceEvent[]>([])
|
||||
const errorCode = ref<DebugEmlErrorCode | ''>('')
|
||||
const errorMessage = ref('')
|
||||
|
||||
@@ -362,6 +458,16 @@ const submitDisabled = computed(
|
||||
() => busy.value || !debugUploadKey.value.trim() || !selectedFile.value,
|
||||
)
|
||||
const statusLabel = computed(() => t(`debugEml.statuses.${status.value}`))
|
||||
const latestStageEvent = computed(() => stageEvents.value[stageEvents.value.length - 1] ?? null)
|
||||
const currentDebugRunId = computed(
|
||||
() => result.value?.debug_run_id || latestStageEvent.value?.debug_run_id || null,
|
||||
)
|
||||
const currentBackendStatus = computed(
|
||||
() => result.value?.status || latestStageEvent.value?.status || null,
|
||||
)
|
||||
const visibleTraceEvents = computed(() =>
|
||||
traceEvents.value.length ? traceEvents.value : result.value?.superagent_trace_events ?? [],
|
||||
)
|
||||
const errorDisplayMessage = computed(() => {
|
||||
if (!errorCode.value) {
|
||||
return ''
|
||||
@@ -394,14 +500,33 @@ async function submitUpload(): Promise<void> {
|
||||
errorCode.value = ''
|
||||
errorMessage.value = ''
|
||||
result.value = null
|
||||
stageEvents.value = []
|
||||
traceEvents.value = []
|
||||
|
||||
try {
|
||||
result.value = await uploadDebugEmlSuperAgentRun({
|
||||
hotelId: hotelId.value,
|
||||
debugUploadKey: debugUploadKey.value.trim(),
|
||||
runLabel: runLabel.value,
|
||||
file: selectedFile.value,
|
||||
})
|
||||
const finalResult = await uploadDebugEmlSuperAgentRunStream(
|
||||
{
|
||||
hotelId: hotelId.value,
|
||||
debugUploadKey: debugUploadKey.value.trim(),
|
||||
runLabel: runLabel.value,
|
||||
file: selectedFile.value,
|
||||
},
|
||||
{
|
||||
onStage: (stage) => {
|
||||
stageEvents.value = [...stageEvents.value, stage]
|
||||
},
|
||||
onTrace: (trace) => {
|
||||
traceEvents.value = [...traceEvents.value, trace]
|
||||
},
|
||||
onResult: (streamResult) => {
|
||||
result.value = streamResult
|
||||
},
|
||||
},
|
||||
)
|
||||
result.value = finalResult
|
||||
if (!traceEvents.value.length && finalResult.superagent_trace_events.length) {
|
||||
traceEvents.value = finalResult.superagent_trace_events
|
||||
}
|
||||
status.value = 'succeeded'
|
||||
} catch (error) {
|
||||
status.value = 'failed'
|
||||
@@ -680,6 +805,11 @@ function formatMediaSize(sizeBytes: number | null): string {
|
||||
color: var(--th-color-slate-900);
|
||||
}
|
||||
|
||||
.empty-state--compact {
|
||||
min-height: 84px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.empty-state--error {
|
||||
place-items: start;
|
||||
color: var(--th-color-danger);
|
||||
@@ -732,6 +862,78 @@ function formatMediaSize(sizeBytes: number | null): string {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.debug-eml-stream-section {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.debug-eml-event-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.debug-eml-event-list li {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||
border-radius: 10px;
|
||||
background: var(--th-color-slate-50);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.debug-eml-event-list strong {
|
||||
color: var(--th-color-slate-900);
|
||||
font-size: 13px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.debug-eml-event-list span,
|
||||
.debug-eml-event-list small {
|
||||
color: var(--th-color-slate-600);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.debug-eml-event-list--stages li {
|
||||
grid-template-columns: minmax(150px, 210px) minmax(0, 1fr) minmax(100px, 160px);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.debug-eml-event-list--trace li {
|
||||
background: linear-gradient(180deg, var(--th-color-white), var(--th-color-slate-50));
|
||||
}
|
||||
|
||||
.debug-eml-trace-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.debug-eml-trace-fields div {
|
||||
min-width: 0;
|
||||
border-radius: 8px;
|
||||
background: rgb(255 255 255 / 76%);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.debug-eml-trace-fields dt {
|
||||
color: var(--th-color-slate-500);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.debug-eml-trace-fields dd {
|
||||
margin: 4px 0 0;
|
||||
color: var(--th-color-slate-900);
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.debug-eml-html-preview {
|
||||
max-height: 520px;
|
||||
overflow: auto;
|
||||
@@ -886,6 +1088,8 @@ pre {
|
||||
.debug-eml-kv,
|
||||
.debug-eml-kv--grid,
|
||||
.debug-eml-two-column,
|
||||
.debug-eml-event-list--stages li,
|
||||
.debug-eml-trace-fields,
|
||||
.debug-eml-media-list li {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user