diff --git a/apps/desktop-client/src/main/adapters/qwen.test.ts b/apps/desktop-client/src/main/adapters/qwen.test.ts index fa2f1ce..3a56783 100644 --- a/apps/desktop-client/src/main/adapters/qwen.test.ts +++ b/apps/desktop-client/src/main/adapters/qwen.test.ts @@ -3,7 +3,13 @@ import { describe, expect, it } from 'vitest' import type { AdapterContext } from './base' import { __qwenTestUtils, qwenAdapter } from './qwen' -const { extractQuestionText } = __qwenTestUtils +const { extractQuestionText, parseQwenSSEResponse, resolveQwenAnswerText } = __qwenTestUtils + +function buildSSEBody(messages: Record[]): string { + return messages + .map((message) => `data:${JSON.stringify({ data: { messages: [message] } })}`) + .join('\n\n') +} describe('qwen adapter helpers', () => { 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', () => { - 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 = { waitForLoadState: async () => undefined, + on: () => undefined, + off: () => undefined, url: () => 'https://www.qianwen.com/chat/demo', goto: async () => undefined, evaluate: async () => ({ @@ -38,8 +89,57 @@ describe('qwen adapter query result validation', () => { content: { multiLoadIframe: { 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, 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 () => { const page = { waitForLoadState: async () => undefined, + on: () => undefined, + off: () => undefined, url: () => 'https://www.qianwen.com/chat/demo', goto: async () => undefined, evaluate: async () => ({ @@ -93,4 +195,67 @@ describe('qwen adapter query result validation', () => { expect(result.status).toBe('succeeded') 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 + } + 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).response_source, + ).toBe('api_sse') + }) }) diff --git a/apps/desktop-client/src/main/adapters/qwen.ts b/apps/desktop-client/src/main/adapters/qwen.ts index f61b85d..c495b95 100644 --- a/apps/desktop-client/src/main/adapters/qwen.ts +++ b/apps/desktop-client/src/main/adapters/qwen.ts @@ -1,4 +1,5 @@ import type { JsonValue, MonitoringSourceItem } from '@geo/shared-types' +import type { Response as PlaywrightResponse } from 'playwright-core' import type { MonitorAdapter } from './base' 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_QUERY_TIMEOUT_MS = 90_000 const QWEN_INTERNAL_TAB_PLACEHOLDER_PATTERN = /^(?:\[\(tab_container_\d+\)\]\s*)+$/i +const QWEN_INTERNAL_TAB_REFERENCE_PATTERN = /\[\(tab_container_\d+\)\]/i type QwenPageQuestionSnapshot = { model: string | null @@ -41,6 +43,13 @@ type QwenPageQueryFailureResult = { type QwenPageQueryResult = QwenPageQuerySuccessResult | QwenPageQueryFailureResult +type QwenParsedSSEResponse = { + content: Record | null + extraInfo: Record | null + communication: Record | null + events: Record[] +} + function isRecord(value: unknown): value is Record { 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)) } +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): { answerText: string | null unresolvedPlaceholder: string | null } { - const candidates = [ + const rawCandidates = [ readNestedString(content, ['multiLoadIframe', 'content']), readNestedString(content, ['barIframe', 'content']), ] - const answerText = candidates.find( - (candidate) => candidate && !isQwenInternalPlaceholderOnly(candidate), + const candidates = rawCandidates.map((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 { answerText: answerText ?? null, @@ -225,6 +283,152 @@ function resolveQwenAnswerText(content: unknown): { } } +function parseQwenSSEPayloads(body: string): Record[] { + const payloads: Record[] = [] + 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): Record[] { + 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[], + mimeType: string, +): Record | null { + let selected: Record | 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[]): Record { + const cardMap: Record = {} + 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 | null, + value: unknown, +): Record | 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 | null = null + let communication: Record | 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 = {} + 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 { if (value == null || depth > 16) { return null @@ -636,12 +840,21 @@ export const qwenAdapter: MonitorAdapter = { } context.reportProgress('qwen.query') - const pageResult = await page.evaluate(qwenQueryInPage, { - questionText, - timeoutMs: QWEN_QUERY_TIMEOUT_MS, - readyTimeoutMs: QWEN_PAGE_READY_TIMEOUT_MS, - deepThink: true, - }) + let apiResponse: PlaywrightResponse | null = null + const captureAPIResponse = (response: PlaywrightResponse) => { + if (!apiResponse && isQwenChatAPIResponse(response)) { + apiResponse = response + } + } + 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) { const message = formatQwenQueryError(pageResult) @@ -655,9 +868,18 @@ export const qwenAdapter: MonitorAdapter = { } } - const { answerText, unresolvedPlaceholder } = resolveQwenAnswerText( - pageResult.answer.content, - ) + const capturedAPIResponse = apiResponse as PlaywrightResponse | null + 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 = normalizeText(pageResult.answer.reqId) ?? readNestedString(pageResult.answer.communication, ['reqid']) ?? @@ -666,6 +888,7 @@ export const qwenAdapter: MonitorAdapter = { const providerModel = resolveQwenProviderModel(pageResult) const searchResults: MonitoringSourceItem[] = [] + collectQwenSources(apiResult?.events, searchResults) collectQwenSources(pageResult.answer.content, searchResults) const dedupedSearchResults = dedupeSourceItems(searchResults) @@ -708,6 +931,8 @@ export const qwenAdapter: MonitorAdapter = { raw_response_json: { platform: 'qwen', mode: pageResult.question?.deepSearch === '1' ? 'deep_think' : 'standard', + response_source: responseSource, + api_event_count: apiResult?.events.length ?? 0, page_url: pageResult.url, session_id: sessionId, provider_request_id: requestId, @@ -716,9 +941,9 @@ export const qwenAdapter: MonitorAdapter = { question: toJsonValue(pageResult.question), source_count: dedupedSearchResults.length, search_results: toJsonSources(dedupedSearchResults), - content: toJsonValue(pageResult.answer.content), - extra_info: toJsonValue(pageResult.answer.extraInfo), - communication: toJsonValue(pageResult.answer.communication), + content: toJsonValue(apiResult?.content ?? pageResult.answer.content), + extra_info: toJsonValue(apiResult?.extraInfo ?? pageResult.answer.extraInfo), + communication: toJsonValue(apiResult?.communication ?? pageResult.answer.communication), status: 'succeeded', }, }, @@ -730,4 +955,5 @@ export const __qwenTestUtils = { extractQuestionText, isQwenInternalPlaceholderOnly, resolveQwenAnswerText, + parseQwenSSEResponse, }