feat(qwen-adapter): capture chat SSE and expand tab_container cards

Attach a response listener to the `/api/v2/chat` POST stream, parse its
SSE payloads, and reconstruct answer content/extra_info/communication
from the `multi_load/iframe` and `bar/iframe` messages. Expand embedded
`[(tab_container_N)]` references against the SSE cardMap so nested tab
content is inlined instead of leaking a raw placeholder. Prefer the API
answer, falling back to page state, and record `response_source` plus
`api_event_count` in the raw response for diagnosis.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 14:17:14 +08:00
parent b70dc9dc6d
commit bbc2d89450
2 changed files with 410 additions and 19 deletions
@@ -3,7 +3,13 @@ import { describe, expect, it } from 'vitest'
import type { AdapterContext } from './base' import type { AdapterContext } from './base'
import { __qwenTestUtils, qwenAdapter } from './qwen' import { __qwenTestUtils, qwenAdapter } from './qwen'
const { extractQuestionText } = __qwenTestUtils const { extractQuestionText, parseQwenSSEResponse, resolveQwenAnswerText } = __qwenTestUtils
function buildSSEBody(messages: Record<string, unknown>[]): string {
return messages
.map((message) => `data:${JSON.stringify({ data: { messages: [message] } })}`)
.join('\n\n')
}
describe('qwen adapter helpers', () => { describe('qwen adapter helpers', () => {
it('extracts question text from monitoring payload', () => { it('extracts question text from monitoring payload', () => {
@@ -17,10 +23,55 @@ describe('qwen adapter helpers', () => {
}) })
}) })
describe('qwen official SSE response parsing', () => {
it('uses the complete API snapshot instead of an earlier incremental snapshot', () => {
const parsed = parseQwenSSEResponse(
buildSSEBody([
{ mime_type: 'multi_load/iframe', status: 'processing', content: '部分' },
{ mime_type: 'multi_load/iframe', status: 'complete', content: '完整 API 答案' },
]),
)
expect(resolveQwenAnswerText(parsed.content).answerText).toBe('完整 API 答案')
expect(parsed.events).toHaveLength(2)
})
it('builds the tab card map from API metadata and expands the selected tab', () => {
const parsed = parseQwenSSEResponse(
buildSSEBody([
{
mime_type: 'multi_load/iframe',
status: 'complete',
content: '[(tab_container_1)]',
meta_data: {
multi_load: [
{
source_seq: 'tab_container_1',
type: 'tab_container',
content: {
default_tab: 'answer_2',
tabs: [
{ tab_id: 'answer_1', content: '备选回答' },
{ tab_id: 'answer_2', content: 'API 卡片中的完整回答' },
],
},
},
],
},
},
]),
)
expect(resolveQwenAnswerText(parsed.content).answerText).toBe('API 卡片中的完整回答')
})
})
describe('qwen adapter query result validation', () => { describe('qwen adapter query result validation', () => {
it('does not submit an internal tab-container placeholder as a successful answer', async () => { it('expands an internal tab-container reference into the selected answer', async () => {
const page = { const page = {
waitForLoadState: async () => undefined, waitForLoadState: async () => undefined,
on: () => undefined,
off: () => undefined,
url: () => 'https://www.qianwen.com/chat/demo', url: () => 'https://www.qianwen.com/chat/demo',
goto: async () => undefined, goto: async () => undefined,
evaluate: async () => ({ evaluate: async () => ({
@@ -38,8 +89,57 @@ describe('qwen adapter query result validation', () => {
content: { content: {
multiLoadIframe: { multiLoadIframe: {
content: '[(tab_container_1)]', content: '[(tab_container_1)]',
cardMap: {
tab_container_1: {
type: 'tab_container',
content: {
default_tab: 'answer_2',
tabs: [
{ tab_id: 'answer_1', content: '备选答案。' },
{ tab_id: 'answer_2', content: '这是千问实际展示的完整答案。' },
],
},
},
},
}, },
searchResults: [{ url: 'https://example.com/source', title: '来源' }], },
extraInfo: null,
communication: null,
},
}),
}
const context = {
playwright: { page, browser: {} },
reportProgress: () => undefined,
} as unknown as AdapterContext
const result = await qwenAdapter.query(context, { question_text: '测试问题' })
expect(result.status).toBe('succeeded')
expect(result.payload?.answer).toBe('这是千问实际展示的完整答案。')
})
it('does not submit a tab-container reference when its card data is missing', async () => {
const page = {
waitForLoadState: async () => undefined,
on: () => undefined,
off: () => undefined,
url: () => 'https://www.qianwen.com/chat/demo',
goto: async () => undefined,
evaluate: async () => ({
ok: true as const,
url: 'https://www.qianwen.com/chat/demo',
question: {
model: 'Qwen',
deepSearch: '1',
enableSearch: true,
cardText: '测试问题',
},
answer: {
reqId: 'req-missing-card',
status: 'finish',
content: {
multiLoadIframe: { content: '[(tab_container_1)]', cardMap: {} },
}, },
extraInfo: null, extraInfo: null,
communication: null, communication: null,
@@ -60,6 +160,8 @@ describe('qwen adapter query result validation', () => {
it('uses a resolved fallback answer when another content field is still a placeholder', async () => { it('uses a resolved fallback answer when another content field is still a placeholder', async () => {
const page = { const page = {
waitForLoadState: async () => undefined, waitForLoadState: async () => undefined,
on: () => undefined,
off: () => undefined,
url: () => 'https://www.qianwen.com/chat/demo', url: () => 'https://www.qianwen.com/chat/demo',
goto: async () => undefined, goto: async () => undefined,
evaluate: async () => ({ evaluate: async () => ({
@@ -93,4 +195,67 @@ describe('qwen adapter query result validation', () => {
expect(result.status).toBe('succeeded') expect(result.status).toBe('succeeded')
expect(result.payload?.answer).toBe('这是完整答案。') expect(result.payload?.answer).toBe('这是完整答案。')
}) })
it('prefers the official SSE answer over the page-state fallback', async () => {
const apiBody = buildSSEBody([
{
mime_type: 'multi_load/iframe',
status: 'complete',
content: '官方 SSE 完整答案',
},
])
type MockResponse = {
url(): string
request(): { method(): string }
text(): Promise<string>
}
let responseListener: ((response: MockResponse) => void) | null = null
const page = {
waitForLoadState: async () => undefined,
on: (_event: string, listener: (response: MockResponse) => void) => {
responseListener = listener
},
off: () => {
responseListener = null
},
url: () => 'https://www.qianwen.com/chat/demo',
goto: async () => undefined,
evaluate: async () => {
responseListener?.({
url: () => 'https://chat2.qianwen.com/api/v2/chat',
request: () => ({ method: () => 'POST' }),
text: async () => apiBody,
})
return {
ok: true as const,
url: 'https://www.qianwen.com/chat/demo',
question: {
model: 'Qwen',
deepSearch: '1',
enableSearch: true,
cardText: '测试问题',
},
answer: {
reqId: 'req-api',
status: 'finish',
content: { multiLoadIframe: { content: '页面 state 兜底答案' } },
extraInfo: null,
communication: null,
},
}
},
}
const context = {
playwright: { page, browser: {} },
reportProgress: () => undefined,
} as unknown as AdapterContext
const result = await qwenAdapter.query(context, { question_text: '测试问题' })
expect(result.status).toBe('succeeded')
expect(result.payload?.answer).toBe('官方 SSE 完整答案')
expect(
(result.payload?.raw_response_json as Record<string, unknown>).response_source,
).toBe('api_sse')
})
}) })
+242 -16
View File
@@ -1,4 +1,5 @@
import type { JsonValue, MonitoringSourceItem } from '@geo/shared-types' import type { JsonValue, MonitoringSourceItem } from '@geo/shared-types'
import type { Response as PlaywrightResponse } from 'playwright-core'
import type { MonitorAdapter } from './base' import type { MonitorAdapter } from './base'
import { normalizeText } from './common' import { normalizeText } from './common'
@@ -7,6 +8,7 @@ const QWEN_BOOTSTRAP_URL = 'https://www.qianwen.com/'
const QWEN_PAGE_READY_TIMEOUT_MS = 20_000 const QWEN_PAGE_READY_TIMEOUT_MS = 20_000
const QWEN_QUERY_TIMEOUT_MS = 90_000 const QWEN_QUERY_TIMEOUT_MS = 90_000
const QWEN_INTERNAL_TAB_PLACEHOLDER_PATTERN = /^(?:\[\(tab_container_\d+\)\]\s*)+$/i const QWEN_INTERNAL_TAB_PLACEHOLDER_PATTERN = /^(?:\[\(tab_container_\d+\)\]\s*)+$/i
const QWEN_INTERNAL_TAB_REFERENCE_PATTERN = /\[\(tab_container_\d+\)\]/i
type QwenPageQuestionSnapshot = { type QwenPageQuestionSnapshot = {
model: string | null model: string | null
@@ -41,6 +43,13 @@ type QwenPageQueryFailureResult = {
type QwenPageQueryResult = QwenPageQuerySuccessResult | QwenPageQueryFailureResult type QwenPageQueryResult = QwenPageQuerySuccessResult | QwenPageQueryFailureResult
type QwenParsedSSEResponse = {
content: Record<string, unknown> | null
extraInfo: Record<string, unknown> | null
communication: Record<string, unknown> | null
events: Record<string, unknown>[]
}
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value) return typeof value === 'object' && value !== null && !Array.isArray(value)
} }
@@ -206,18 +215,67 @@ function isQwenInternalPlaceholderOnly(value: unknown): boolean {
return Boolean(text && QWEN_INTERNAL_TAB_PLACEHOLDER_PATTERN.test(text)) return Boolean(text && QWEN_INTERNAL_TAB_PLACEHOLDER_PATTERN.test(text))
} }
function resolveQwenTabContainerText(card: unknown): string | null {
if (!isRecord(card) || card.type !== 'tab_container' || !isRecord(card.content)) {
return null
}
const tabs = Array.isArray(card.content.tabs)
? card.content.tabs.filter(isRecord)
: []
const selectedTabId =
normalizeOptionalString(card.content.selected_tab) ??
normalizeOptionalString(card.content.default_tab)
const selectedTab = selectedTabId
? tabs.find((tab) => normalizeOptionalString(tab.tab_id) === selectedTabId)
: null
const fallbackTab = tabs.find((tab) => normalizeOptionalString(tab.content))
return normalizeOptionalString((selectedTab ?? fallbackTab)?.content)
}
function expandQwenTabContainers(content: unknown, value: string): string {
if (!isRecord(content) || !isRecord(content.multiLoadIframe)) {
return value
}
const cardMap = content.multiLoadIframe.cardMap
if (!isRecord(cardMap)) {
return value
}
let expanded = value
for (let depth = 0; depth < 4; depth += 1) {
const next = expanded.replace(/\[\((tab_container_\d+)\)\]/gi, (reference, sourceSeq) => {
return resolveQwenTabContainerText(cardMap[sourceSeq]) ?? reference
})
if (next === expanded) {
break
}
expanded = next
}
return expanded.trim()
}
function resolveQwenAnswerText(content: unknown): { function resolveQwenAnswerText(content: unknown): {
answerText: string | null answerText: string | null
unresolvedPlaceholder: string | null unresolvedPlaceholder: string | null
} { } {
const candidates = [ const rawCandidates = [
readNestedString(content, ['multiLoadIframe', 'content']), readNestedString(content, ['multiLoadIframe', 'content']),
readNestedString(content, ['barIframe', 'content']), readNestedString(content, ['barIframe', 'content']),
] ]
const answerText = candidates.find( const candidates = rawCandidates.map((candidate) =>
(candidate) => candidate && !isQwenInternalPlaceholderOnly(candidate), candidate ? expandQwenTabContainers(content, candidate) : null,
) )
const unresolvedPlaceholder = candidates.find(isQwenInternalPlaceholderOnly) const answerText = candidates.find(
(candidate) => candidate && !QWEN_INTERNAL_TAB_REFERENCE_PATTERN.test(candidate),
)
const unresolvedPlaceholder =
candidates.find(
(candidate) => candidate && QWEN_INTERNAL_TAB_REFERENCE_PATTERN.test(candidate),
) ?? rawCandidates.find(isQwenInternalPlaceholderOnly)
return { return {
answerText: answerText ?? null, answerText: answerText ?? null,
@@ -225,6 +283,152 @@ function resolveQwenAnswerText(content: unknown): {
} }
} }
function parseQwenSSEPayloads(body: string): Record<string, unknown>[] {
const payloads: Record<string, unknown>[] = []
for (const block of body.split(/\r?\n\r?\n/)) {
const data = block
.split(/\r?\n/)
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).trimStart())
.join('\n')
.trim()
if (!data || data === '[DONE]') {
continue
}
try {
const parsed = JSON.parse(data) as unknown
if (isRecord(parsed)) {
payloads.push(parsed)
}
} catch {
continue
}
}
return payloads
}
function qwenSSEMessages(payload: Record<string, unknown>): Record<string, unknown>[] {
const data = isRecord(payload.data) ? payload.data : payload
const messages = Array.isArray(data.messages)
? data.messages
: Array.isArray(data.response_messages)
? data.response_messages
: []
return messages.filter(isRecord)
}
function selectBestQwenSSEMessage(
messages: Record<string, unknown>[],
mimeType: string,
): Record<string, unknown> | null {
let selected: Record<string, unknown> | null = null
let selectedScore = -1
for (const message of messages) {
if (normalizeOptionalString(message.mime_type) !== mimeType) {
continue
}
const contentLength = normalizeOptionalString(message.content)?.length ?? 0
const score = (message.status === 'complete' ? 1_000_000 : 0) + contentLength
if (score >= selectedScore) {
selected = message
selectedScore = score
}
}
return selected
}
function buildQwenSSECardMap(messages: Record<string, unknown>[]): Record<string, unknown> {
const cardMap: Record<string, unknown> = {}
for (const message of messages) {
if (normalizeOptionalString(message.mime_type) !== 'multi_load/iframe') {
continue
}
const metaData = isRecord(message.meta_data) ? message.meta_data : null
const cards = Array.isArray(metaData?.multi_load) ? metaData.multi_load : []
for (const card of cards) {
if (!isRecord(card)) {
continue
}
const sourceSeq = normalizeOptionalString(card.source_seq ?? card.sourceSeq)
if (!sourceSeq) {
continue
}
cardMap[sourceSeq] = {
sourceSeq,
type: normalizeOptionalString(card.type),
content: card.content ?? null,
html: card.html ?? null,
htmlMeta: card.html_meta ?? card.htmlMeta ?? null,
}
}
}
return cardMap
}
function mergeQwenSSERecord(
current: Record<string, unknown> | null,
value: unknown,
): Record<string, unknown> | null {
return isRecord(value) ? { ...(current ?? {}), ...value } : current
}
function parseQwenSSEResponse(body: string): QwenParsedSSEResponse {
const payloads = parseQwenSSEPayloads(body)
const messages = payloads.flatMap(qwenSSEMessages)
const multiLoadMessage = selectBestQwenSSEMessage(messages, 'multi_load/iframe')
const barMessage = selectBestQwenSSEMessage(messages, 'bar/iframe')
let extraInfo: Record<string, unknown> | null = null
let communication: Record<string, unknown> | null = null
for (const payload of payloads) {
const data = isRecord(payload.data) ? payload.data : payload
extraInfo = mergeQwenSSERecord(extraInfo, data.extra_info ?? data.extraInfo)
communication = mergeQwenSSERecord(
communication,
data.communication ?? data.communication_info,
)
}
const content: Record<string, unknown> = {}
if (multiLoadMessage) {
content.multiLoadIframe = {
status: normalizeOptionalString(multiLoadMessage.status),
content: normalizeOptionalString(multiLoadMessage.content),
metaData: isRecord(multiLoadMessage.meta_data) ? multiLoadMessage.meta_data : null,
cardMap: buildQwenSSECardMap(messages),
}
}
if (barMessage) {
content.barIframe = {
status: normalizeOptionalString(barMessage.status),
content: normalizeOptionalString(barMessage.content),
metaData: isRecord(barMessage.meta_data) ? barMessage.meta_data : null,
}
}
return {
content: Object.keys(content).length ? content : null,
extraInfo,
communication,
events: payloads.map((payload) => (isRecord(payload.data) ? payload.data : payload)),
}
}
function isQwenChatAPIResponse(response: {
url(): string
request(): { method(): string }
}): boolean {
if (response.request().method() !== 'POST') {
return false
}
try {
return new URL(response.url()).pathname === '/api/v2/chat'
} catch {
return false
}
}
function toJsonValue(value: unknown, depth = 0): JsonValue { function toJsonValue(value: unknown, depth = 0): JsonValue {
if (value == null || depth > 16) { if (value == null || depth > 16) {
return null return null
@@ -636,12 +840,21 @@ export const qwenAdapter: MonitorAdapter = {
} }
context.reportProgress('qwen.query') context.reportProgress('qwen.query')
const pageResult = await page.evaluate(qwenQueryInPage, { let apiResponse: PlaywrightResponse | null = null
questionText, const captureAPIResponse = (response: PlaywrightResponse) => {
timeoutMs: QWEN_QUERY_TIMEOUT_MS, if (!apiResponse && isQwenChatAPIResponse(response)) {
readyTimeoutMs: QWEN_PAGE_READY_TIMEOUT_MS, apiResponse = response
deepThink: true, }
}) }
page.on('response', captureAPIResponse)
const pageResult = await page
.evaluate(qwenQueryInPage, {
questionText,
timeoutMs: QWEN_QUERY_TIMEOUT_MS,
readyTimeoutMs: QWEN_PAGE_READY_TIMEOUT_MS,
deepThink: true,
})
.finally(() => page.off('response', captureAPIResponse))
if (!pageResult?.ok || !pageResult.answer) { if (!pageResult?.ok || !pageResult.answer) {
const message = formatQwenQueryError(pageResult) const message = formatQwenQueryError(pageResult)
@@ -655,9 +868,18 @@ export const qwenAdapter: MonitorAdapter = {
} }
} }
const { answerText, unresolvedPlaceholder } = resolveQwenAnswerText( const capturedAPIResponse = apiResponse as PlaywrightResponse | null
pageResult.answer.content, const apiResponseBody = capturedAPIResponse
) ? await capturedAPIResponse.text().catch(() => null)
: null
const apiResult = apiResponseBody ? parseQwenSSEResponse(apiResponseBody) : null
const apiAnswer = resolveQwenAnswerText(apiResult?.content)
const pageAnswer = resolveQwenAnswerText(pageResult.answer.content)
const answerText = apiAnswer.answerText ?? pageAnswer.answerText
const unresolvedPlaceholder =
apiAnswer.unresolvedPlaceholder ?? pageAnswer.unresolvedPlaceholder
const responseSource = apiAnswer.answerText ? 'api_sse' : 'page_state'
const requestId = const requestId =
normalizeText(pageResult.answer.reqId) ?? normalizeText(pageResult.answer.reqId) ??
readNestedString(pageResult.answer.communication, ['reqid']) ?? readNestedString(pageResult.answer.communication, ['reqid']) ??
@@ -666,6 +888,7 @@ export const qwenAdapter: MonitorAdapter = {
const providerModel = resolveQwenProviderModel(pageResult) const providerModel = resolveQwenProviderModel(pageResult)
const searchResults: MonitoringSourceItem[] = [] const searchResults: MonitoringSourceItem[] = []
collectQwenSources(apiResult?.events, searchResults)
collectQwenSources(pageResult.answer.content, searchResults) collectQwenSources(pageResult.answer.content, searchResults)
const dedupedSearchResults = dedupeSourceItems(searchResults) const dedupedSearchResults = dedupeSourceItems(searchResults)
@@ -708,6 +931,8 @@ export const qwenAdapter: MonitorAdapter = {
raw_response_json: { raw_response_json: {
platform: 'qwen', platform: 'qwen',
mode: pageResult.question?.deepSearch === '1' ? 'deep_think' : 'standard', mode: pageResult.question?.deepSearch === '1' ? 'deep_think' : 'standard',
response_source: responseSource,
api_event_count: apiResult?.events.length ?? 0,
page_url: pageResult.url, page_url: pageResult.url,
session_id: sessionId, session_id: sessionId,
provider_request_id: requestId, provider_request_id: requestId,
@@ -716,9 +941,9 @@ export const qwenAdapter: MonitorAdapter = {
question: toJsonValue(pageResult.question), question: toJsonValue(pageResult.question),
source_count: dedupedSearchResults.length, source_count: dedupedSearchResults.length,
search_results: toJsonSources(dedupedSearchResults), search_results: toJsonSources(dedupedSearchResults),
content: toJsonValue(pageResult.answer.content), content: toJsonValue(apiResult?.content ?? pageResult.answer.content),
extra_info: toJsonValue(pageResult.answer.extraInfo), extra_info: toJsonValue(apiResult?.extraInfo ?? pageResult.answer.extraInfo),
communication: toJsonValue(pageResult.answer.communication), communication: toJsonValue(apiResult?.communication ?? pageResult.answer.communication),
status: 'succeeded', status: 'succeeded',
}, },
}, },
@@ -730,4 +955,5 @@ export const __qwenTestUtils = {
extractQuestionText, extractQuestionText,
isQwenInternalPlaceholderOnly, isQwenInternalPlaceholderOnly,
resolveQwenAnswerText, resolveQwenAnswerText,
parseQwenSSEResponse,
} }