diff --git a/apps/desktop-client/src/main/adapters/qwen.test.ts b/apps/desktop-client/src/main/adapters/qwen.test.ts index 3a56783..ae14e0c 100644 --- a/apps/desktop-client/src/main/adapters/qwen.test.ts +++ b/apps/desktop-client/src/main/adapters/qwen.test.ts @@ -1,9 +1,18 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { AdapterContext } from './base' import { __qwenTestUtils, qwenAdapter } from './qwen' -const { extractQuestionText, parseQwenSSEResponse, resolveQwenAnswerText } = __qwenTestUtils +const { + extractQuestionText, + parseQwenSSEResponse, + qwenCollectSettledPageSources, + resolveQwenAnswerText, +} = __qwenTestUtils + +afterEach(() => { + vi.unstubAllGlobals() +}) function buildSSEBody(messages: Record[]): string { return messages @@ -66,6 +75,77 @@ describe('qwen official SSE response parsing', () => { }) }) +describe('qwen settled page source fallback', () => { + it('extracts the reference source payload rendered by the Mac ARM page state', async () => { + vi.stubGlobal('window', { + setTimeout, + __qianwenChatAPI: { + sharedValues: { + chatAPI: { + state: { + chatRounds: [ + { + currentQuestionIndex: 0, + questions: [ + { + currentAnswerIndex: 0, + cards: [{ content: '合肥全屋定制公司推荐' }], + answers: [ + { + content: { + cards: [ + { + metaData: { + sources: [ + { + content: { + list: [ + { + url: 'https://example.com/reference', + title: '真实引用来源', + name: '示例站', + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + ], + }, + ], + }, + }, + }, + }, + }) + + const result = await qwenCollectSettledPageSources({ + questionText: '合肥全屋定制公司推荐', + timeoutMs: 0, + }) + + expect(result.sourcePayloads).toEqual([ + { + content: { + list: [ + { + url: 'https://example.com/reference', + title: '真实引用来源', + name: '示例站', + }, + ], + }, + }, + ]) + }) +}) + describe('qwen adapter query result validation', () => { it('expands an internal tab-container reference into the selected answer', async () => { const page = { @@ -254,8 +334,78 @@ describe('qwen adapter query result validation', () => { expect(result.status).toBe('succeeded') expect(result.payload?.answer).toBe('官方 SSE 完整答案') - expect( - (result.payload?.raw_response_json as Record).response_source, - ).toBe('api_sse') + expect((result.payload?.raw_response_json as Record).response_source).toBe( + 'api_sse', + ) + }) + + it('falls back to the settled page state when sources arrive after answer finish', async () => { + let evaluateCallCount = 0 + const page = { + waitForLoadState: async () => undefined, + on: () => undefined, + off: () => undefined, + url: () => 'https://www.qianwen.com/chat/demo', + goto: async () => undefined, + evaluate: async () => { + evaluateCallCount += 1 + if (evaluateCallCount === 1) { + return { + ok: true as const, + url: 'https://www.qianwen.com/chat/demo', + question: { + model: 'Qwen', + deepSearch: '0', + enableSearch: null, + cardText: '测试问题', + }, + answer: { + reqId: 'req-delayed-sources', + status: 'finish', + content: { barIframe: { content: '页面先完成回答。' } }, + extraInfo: null, + communication: null, + }, + } + } + + return { + elapsedMs: 350, + sourcePayloads: [ + { + content: { + list: [ + { + url: 'https://example.com/settled-source', + title: '稍后到达的引用来源', + name: '示例站', + }, + ], + }, + }, + ], + } + }, + } + 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?.search_results).toEqual([ + expect.objectContaining({ + url: 'https://example.com/settled-source', + title: '稍后到达的引用来源', + site_name: '示例站', + }), + ]) + expect((result.payload?.raw_response_json as Record).source_fallback).toEqual({ + used: true, + elapsed_ms: 350, + payload_count: 1, + }) }) }) diff --git a/apps/desktop-client/src/main/adapters/qwen.ts b/apps/desktop-client/src/main/adapters/qwen.ts index c495b95..754a32b 100644 --- a/apps/desktop-client/src/main/adapters/qwen.ts +++ b/apps/desktop-client/src/main/adapters/qwen.ts @@ -7,6 +7,7 @@ import { normalizeText } from './common' 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_SOURCE_FALLBACK_TIMEOUT_MS = 5_000 const QWEN_INTERNAL_TAB_PLACEHOLDER_PATTERN = /^(?:\[\(tab_container_\d+\)\]\s*)+$/i const QWEN_INTERNAL_TAB_REFERENCE_PATTERN = /\[\(tab_container_\d+\)\]/i @@ -50,6 +51,11 @@ type QwenParsedSSEResponse = { events: Record[] } +type QwenPageSourceFallbackResult = { + elapsedMs: number + sourcePayloads: unknown[] +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } @@ -220,9 +226,7 @@ function resolveQwenTabContainerText(card: unknown): string | null { return null } - const tabs = Array.isArray(card.content.tabs) - ? card.content.tabs.filter(isRecord) - : [] + const tabs = Array.isArray(card.content.tabs) ? card.content.tabs.filter(isRecord) : [] const selectedTabId = normalizeOptionalString(card.content.selected_tab) ?? normalizeOptionalString(card.content.default_tab) @@ -384,10 +388,7 @@ function parseQwenSSEResponse(body: string): QwenParsedSSEResponse { 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, - ) + communication = mergeQwenSSERecord(communication, data.communication ?? data.communication_info) } const content: Record = {} @@ -810,6 +811,150 @@ function qwenQueryInPage(parameters: { })() } +function qwenCollectSettledPageSources(parameters: { + questionText: string + timeoutMs: number +}): Promise { + const startedAt = Date.now() + const wait = (timeMs: number) => + new Promise((resolve) => { + window.setTimeout(resolve, timeMs) + }) + const isObjectRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + const normalizeInlineText = (value: unknown): string | null => { + if (typeof value !== 'string') { + return null + } + const trimmed = value.trim() + return trimmed ? trimmed : null + } + const toSerializable = (value: unknown): unknown[] => { + try { + const serialized = JSON.parse(JSON.stringify(value)) as unknown + return Array.isArray(serialized) ? serialized : [] + } catch { + return [] + } + } + + const findLatestAnswerContent = (): Record | null => { + const state = ( + window as typeof window & { + __qianwenChatAPI?: { + sharedValues?: { + chatAPI?: { + state?: { + chatRounds?: Array> + } + } + } + } + } + ).__qianwenChatAPI?.sharedValues?.chatAPI?.state + const chatRounds = Array.isArray(state?.chatRounds) ? state.chatRounds : [] + + for (let roundIndex = chatRounds.length - 1; roundIndex >= 0; roundIndex -= 1) { + const round = chatRounds[roundIndex] + if (!isObjectRecord(round)) { + continue + } + + const questions = Array.isArray(round.questions) ? round.questions : [] + const currentQuestionIndex = + typeof round.currentQuestionIndex === 'number' ? round.currentQuestionIndex : 0 + const question = questions[currentQuestionIndex] + if (!isObjectRecord(question)) { + continue + } + + const cards = Array.isArray(question.cards) ? question.cards : [] + const firstCard = cards[0] + const cardText = + isObjectRecord(firstCard) && 'content' in firstCard + ? normalizeInlineText(firstCard.content) + : null + if (cardText !== parameters.questionText) { + continue + } + + const answers = Array.isArray(question.answers) ? question.answers : [] + const currentAnswerIndex = + typeof question.currentAnswerIndex === 'number' ? question.currentAnswerIndex : 0 + const answer = answers[currentAnswerIndex] + return isObjectRecord(answer) && isObjectRecord(answer.content) ? answer.content : null + } + + return null + } + + const collectSourcePayloads = (payload: unknown): unknown[] => { + const result: unknown[] = [] + const visit = (value: unknown, depth: number) => { + if (depth > 12 || value == null) { + return + } + if (Array.isArray(value)) { + for (const item of value) { + visit(item, depth + 1) + } + return + } + if (!isObjectRecord(value)) { + return + } + + for (const [key, child] of Object.entries(value)) { + if (key.toLowerCase() === 'sources' && Array.isArray(child)) { + result.push(...child) + continue + } + if (Array.isArray(child) || isObjectRecord(child)) { + visit(child, depth + 1) + } + } + } + visit(payload, 0) + return toSerializable(result) + } + + const containsSourceURL = (payload: unknown, depth = 0): boolean => { + if (depth > 12 || payload == null) { + return false + } + if (Array.isArray(payload)) { + return payload.some((item) => containsSourceURL(item, depth + 1)) + } + if (!isObjectRecord(payload)) { + return false + } + + const directURL = normalizeInlineText(payload.url ?? payload.href ?? payload.link) + if (directURL) { + return true + } + return Object.values(payload).some((value) => containsSourceURL(value, depth + 1)) + } + + return (async () => { + const deadline = startedAt + parameters.timeoutMs + let latestSourcePayloads: unknown[] = [] + + while (Date.now() <= deadline) { + latestSourcePayloads = collectSourcePayloads(findLatestAnswerContent()) + if (containsSourceURL(latestSourcePayloads)) { + break + } + await wait(250) + } + + return { + elapsedMs: Date.now() - startedAt, + sourcePayloads: latestSourcePayloads, + } + })() +} + export const qwenAdapter: MonitorAdapter = { provider: 'qwen', executionMode: 'playwright', @@ -890,7 +1035,26 @@ export const qwenAdapter: MonitorAdapter = { const searchResults: MonitoringSourceItem[] = [] collectQwenSources(apiResult?.events, searchResults) collectQwenSources(pageResult.answer.content, searchResults) - const dedupedSearchResults = dedupeSourceItems(searchResults) + let dedupedSearchResults = dedupeSourceItems(searchResults) + let sourceFallbackAttempted = false + let sourceFallbackResult: QwenPageSourceFallbackResult | null = null + let sourceFallbackPayloads: unknown[] = [] + + if (answerText && !dedupedSearchResults.length) { + sourceFallbackAttempted = true + context.reportProgress('qwen.sources_fallback') + sourceFallbackResult = await page + .evaluate(qwenCollectSettledPageSources, { + questionText, + timeoutMs: QWEN_SOURCE_FALLBACK_TIMEOUT_MS, + }) + .catch(() => null) + sourceFallbackPayloads = Array.isArray(sourceFallbackResult?.sourcePayloads) + ? sourceFallbackResult.sourcePayloads + : [] + collectQwenSources(sourceFallbackPayloads, searchResults) + dedupedSearchResults = dedupeSourceItems(searchResults) + } if (!answerText && unresolvedPlaceholder) { return { @@ -941,6 +1105,11 @@ export const qwenAdapter: MonitorAdapter = { question: toJsonValue(pageResult.question), source_count: dedupedSearchResults.length, search_results: toJsonSources(dedupedSearchResults), + source_fallback: { + used: sourceFallbackAttempted, + elapsed_ms: sourceFallbackResult?.elapsedMs ?? null, + payload_count: sourceFallbackPayloads.length, + }, content: toJsonValue(apiResult?.content ?? pageResult.answer.content), extra_info: toJsonValue(apiResult?.extraInfo ?? pageResult.answer.extraInfo), communication: toJsonValue(apiResult?.communication ?? pageResult.answer.communication), @@ -956,4 +1125,5 @@ export const __qwenTestUtils = { isQwenInternalPlaceholderOnly, resolveQwenAnswerText, parseQwenSSEResponse, + qwenCollectSettledPageSources, }