diff --git a/apps/desktop-client/src/main/adapters/doubao.test.ts b/apps/desktop-client/src/main/adapters/doubao.test.ts index eb33906..2e9c087 100644 --- a/apps/desktop-client/src/main/adapters/doubao.test.ts +++ b/apps/desktop-client/src/main/adapters/doubao.test.ts @@ -5,7 +5,9 @@ import { __doubaoTestUtils } from './doubao' const { buildSourceItem, dedupeSourceItems, + doubaoModeKindFromLabelText, extractQuestionText, + isDoubaoRecoverablePageError, isNonCitationAssetDomain, isNonCitationAssetUrl, parseDoubaoStreamBody, @@ -20,6 +22,19 @@ describe('doubao adapter helpers', () => { ) }) + it('treats query timeouts as recoverable page failures', () => { + expect(isDoubaoRecoverablePageError('doubao_query_timeout')).toBe(true) + expect(isDoubaoRecoverablePageError('doubao_page_navigation_interrupted')).toBe(true) + expect(isDoubaoRecoverablePageError('doubao_login_expired')).toBe(false) + }) + + it('maps the new expert/deep-thinking mode label to expert mode', () => { + expect(doubaoModeKindFromLabelText('专家')).toBe('expert') + expect(doubaoModeKindFromLabelText('专家 深度思考 研究级智能模型')).toBe('expert') + expect(doubaoModeKindFromLabelText('思考')).toBe('thinking') + expect(doubaoModeKindFromLabelText('快速')).toBe('fast') + }) + it('filters byteimg and bytednsdoc asset CDN links from source items', () => { expect(isNonCitationAssetDomain('p3-spider-image-sign.byteimg.com')).toBe(true) expect(isNonCitationAssetDomain('lf3-static.bytednsdoc.com')).toBe(true) @@ -99,6 +114,22 @@ describe('doubao adapter helpers', () => { }) }) + it('extracts source URLs embedded in Doubao thinking-panel text', () => { + const item = buildSourceItem({ + url: '1. 合肥全屋定制榜单 https://www.doubao.com/link?target=https%3A%2F%2Fexample.com%2Fsource%3Ffrom%3Dthink#card', + title: '思考链路里的来源', + site_name: '示例站点', + }) + + expect(item).toMatchObject({ + url: 'https://example.com/source?from=think', + normalized_url: 'https://example.com/source?from=think', + host: 'example.com', + title: '思考链路里的来源', + site_name: '示例站点', + }) + }) + it('parses Doubao answer deltas and source cards from the completion SSE stream', () => { const sourceChunkPayload = { patch_op: [ diff --git a/apps/desktop-client/src/main/adapters/doubao.ts b/apps/desktop-client/src/main/adapters/doubao.ts index 915a42f..611031a 100644 --- a/apps/desktop-client/src/main/adapters/doubao.ts +++ b/apps/desktop-client/src/main/adapters/doubao.ts @@ -491,13 +491,27 @@ function looksLikeSourceUrl(value: string | null): boolean { return false } - if (/^https?:\/\//i.test(normalized) || /^\/\//.test(normalized)) { + if (/https?:\/\//i.test(normalized) || /^\/\//.test(normalized)) { return true } return /^[a-z0-9-]+(?:\.[a-z0-9-]+)+(?:[/?#].*)?$/i.test(normalized) } +function extractEmbeddedHttpUrl(value: string | null): string | null { + const input = normalizeText(value) + if (!input) { + return null + } + + const match = input.match(/https?:\/\/[^\s"'<>\\]+/i)?.[0] ?? null + if (!match) { + return null + } + + return match.replace(/[),,。;;、]+$/u, '') +} + function extractDoubaoEmbeddedSourceUrl(url: URL, depth = 0): string | null { if (depth > 3) { return null @@ -550,7 +564,8 @@ function normalizeSourceUrl(value: string | null, depth = 0): string | null { return null } - const candidates = [input] + const embeddedURL = extractEmbeddedHttpUrl(input) + const candidates = embeddedURL && embeddedURL !== input ? [embeddedURL, input] : [input] try { const decoded = decodeURIComponent(input) if (decoded && decoded !== input) { @@ -1313,12 +1328,7 @@ async function ensurePageOnDoubaoChat(page: PlaywrightPage, signal: AbortSignal) } const currentURL = safePageURL(page) - if (!currentURL.startsWith('https://www.doubao.com/')) { - await page.goto(DOUBAO_BOOTSTRAP_URL, { - waitUntil: 'domcontentloaded', - timeout: DOUBAO_PAGE_READY_TIMEOUT_MS, - }) - } else if (!currentURL.startsWith('https://www.doubao.com/chat')) { + if (currentURL !== DOUBAO_BOOTSTRAP_URL) { await page.goto(DOUBAO_BOOTSTRAP_URL, { waitUntil: 'domcontentloaded', timeout: DOUBAO_PAGE_READY_TIMEOUT_MS, @@ -1337,6 +1347,26 @@ async function ensurePageOnDoubaoChat(page: PlaywrightPage, signal: AbortSignal) } } +async function resetDoubaoPageAfterRecoverableFailure( + page: PlaywrightPage, + error: string, + signal: AbortSignal, +): Promise { + if (!isDoubaoRecoverablePageError(error) || signal.aborted) { + return + } + + try { + await page.goto(DOUBAO_BOOTSTRAP_URL, { + waitUntil: 'domcontentloaded', + timeout: DOUBAO_PAGE_READY_TIMEOUT_MS, + }) + await page.waitForLoadState('networkidle', { timeout: 3_000 }).catch(() => undefined) + } catch { + // Best effort: timeout recovery must not mask the original unknown result. + } +} + function attachDoubaoResponseCapture(page: PlaywrightPage): { captures: DoubaoCaptureRecord[] flush: (timeoutMs: number) => Promise @@ -1483,7 +1513,16 @@ function doubaoModeProviderModel(mode: DoubaoAnswerModeKind | null): string | nu } function normalizeDoubaoModeLabel(value: string | null): DoubaoAnswerModeKind | null { - switch (normalizeText(value)) { + return doubaoModeKindFromLabelText(value) +} + +function isDoubaoThinkingMode(mode: DoubaoAnswerModeKind | null): boolean { + return mode === 'thinking' || mode === 'expert' +} + +function doubaoModeKindFromLabelText(value: string | null): DoubaoAnswerModeKind | null { + const label = normalizeText(value) + switch (label) { case '快速': return 'fast' case '思考': @@ -1491,6 +1530,9 @@ function normalizeDoubaoModeLabel(value: string | null): DoubaoAnswerModeKind | case '专家': return 'expert' default: + if (label?.includes('深度思考') || label?.includes('研究级智能模型')) { + return 'expert' + } return null } } @@ -1505,7 +1547,7 @@ function resolveProviderModel(pageResult: DoubaoPageQueryResult): string { return label } if (pageResult.thinkingModeApplied) { - return 'doubao-web-thinking' + return pageResult.modeLabel === '专家' ? 'doubao-web-expert' : 'doubao-web-thinking' } return pageResult.deepThinkEnabled ? 'doubao-web-thinking' : 'doubao-web' } @@ -1807,13 +1849,22 @@ const doubaoQueryInPage = async ( if (label === ANSWER_MODE_LABELS.expert) { return 'expert' } + if (label.includes('专家')) { + return 'expert' + } + if (label.includes('思考') || label.includes('深度思考')) { + return 'thinking' + } return null } + const isThinkingAnswerMode = (mode: AnswerModeKind | null): boolean => + mode === 'thinking' || mode === 'expert' + const classifyAnswerModeText = (value: unknown): AnswerModeKind | null => { const multiline = normalizeMultiline(typeof value === 'string' ? value : null) const normalized = multiline ?? normalize(value) - if (!normalized || /深度思考|正在思考|思考中/.test(normalized)) { + if (!normalized || /正在思考|思考中/.test(normalized)) { return null } @@ -1845,7 +1896,7 @@ const doubaoQueryInPage = async ( if (compact.startsWith(label)) { return answerModeKindFromLabel(label) } - return null + return answerModeKindFromLabel(label) } const readElementAnswerMode = (element: Element | null | undefined): AnswerModeSnapshot => { @@ -2049,7 +2100,7 @@ const doubaoQueryInPage = async ( ): Promise => { const initialTrigger = findAnswerModeTrigger(shell, composer) const initialMode = readElementAnswerMode(initialTrigger) - if (initialMode.kind === 'thinking') { + if (isThinkingAnswerMode(initialMode.kind)) { return { ...initialMode, applied: true } } if (!initialTrigger) { @@ -2059,12 +2110,14 @@ const doubaoQueryInPage = async ( clickModeElement(initialTrigger) await wait(260) - const thinkingOption = findAnswerModeOption('thinking', initialTrigger) + const thinkingOption = + findAnswerModeOption('expert', initialTrigger) ?? + findAnswerModeOption('thinking', initialTrigger) if (!thinkingOption) { pressEscape() await wait(120) const current = readCurrentAnswerMode(shell, composer) - return { ...current, applied: current.kind === 'thinking' ? true : null } + return { ...current, applied: isThinkingAnswerMode(current.kind) ? true : null } } clickModeElement(thinkingOption) @@ -2073,7 +2126,7 @@ const doubaoQueryInPage = async ( const current = readCurrentAnswerMode(shell, composer) return { ...current, - applied: current.kind === 'thinking' ? true : null, + applied: isThinkingAnswerMode(current.kind) ? true : null, } } @@ -2442,25 +2495,76 @@ const doubaoQueryInPage = async ( } const links: DoubaoDomLink[] = [] const seen = new Set() - for (const node of Array.from(scope.querySelectorAll('a[href]'))) { - if (!(node instanceof HTMLAnchorElement) || !isVisible(node)) { - continue - } - const href = normalizeHref(node.getAttribute('href') ?? node.href) + const pushLink = ( + hrefCandidate: string | null | undefined, + element: HTMLElement, + fallbackText: string | null, + ): void => { + const href = normalizeHref(hrefCandidate ?? null) if (!href || !isExternalHref(href) || seen.has(href)) { - continue + return } seen.add(href) - const visibleText = normalize(node.innerText) ?? normalize(node.textContent) + const visibleText = normalize(element.innerText) ?? normalize(element.textContent) ?? fallbackText links.push({ url: href, - title: normalize(node.title) ?? normalize(node.getAttribute('aria-label')) ?? visibleText, + title: + normalize(element.getAttribute('title')) ?? + normalize(element.getAttribute('aria-label')) ?? + visibleText, text: visibleText, siteName: - normalize(node.getAttribute('data-site-name')) ?? - normalize(node.dataset.siteName ?? null), + normalize(element.getAttribute('data-site-name')) ?? + normalize(element.dataset.siteName ?? null) ?? + normalize(element.getAttribute('data-source-name')) ?? + normalize(element.dataset.sourceName ?? null), }) } + + const selector = [ + 'a[href]', + '[data-url]', + '[data-href]', + '[data-link]', + '[data-target-url]', + '[data-targetUrl]', + '[data-source-url]', + '[data-sourceUrl]', + '[data-jump-url]', + '[data-jumpUrl]', + '[aria-label*="http"]', + '[title*="http"]', + ].join(',') + + for (const node of Array.from(scope.querySelectorAll(selector))) { + if (!(node instanceof HTMLElement) || !isVisible(node)) { + continue + } + + const candidates = [ + node.getAttribute('href'), + node.getAttribute('data-url'), + node.getAttribute('data-href'), + node.getAttribute('data-link'), + node.getAttribute('data-target-url'), + node.getAttribute('data-targetUrl'), + node.getAttribute('data-source-url'), + node.getAttribute('data-sourceUrl'), + node.getAttribute('data-jump-url'), + node.getAttribute('data-jumpUrl'), + node.getAttribute('aria-label'), + node.getAttribute('title'), + ] + + for (const candidate of candidates) { + pushLink(candidate, node, null) + } + } + + const text = normalizeMultiline(scope.innerText) + for (const match of text?.matchAll(/https?:\/\/[^\s"'<>\\]+/gi) ?? []) { + pushLink(match[0], scope, null) + } return links } @@ -2535,6 +2639,21 @@ const doubaoQueryInPage = async ( const normalizedQuestion = normalizeMultiline(questionText) + const isReferenceHeaderLine = (line: string): boolean => + /搜索\s*\d+\s*个关键词|参考\s*\d+\s*篇资料|查看\d+篇资料|参考资料|资料来源/.test(line) + + const isReferenceQueryLine = (line: string): boolean => + /[“"「].+[”"」]/.test(line) || + /、/.test(line) || + /关键词|搜索词|查询词/.test(line) + + const isReferenceItemLine = (line: string): boolean => + /^\d+[.、]\s*\S+/.test(line) && + (/https?:\/\//i.test(line) || + /新闻|新浪|搜狐|网易|腾讯|百度|知乎|什么值得买|列举网|装修网|家居|资料|来源|推荐|排名|榜|厂家|品牌|公司|工厂|门店/.test( + line, + )) + // Tokens that only appear on Doubao's empty-chat surface (prompt templates, nav, feature chips). const EMPTY_CHAT_TOKENS = [ '快速', @@ -2580,7 +2699,30 @@ const doubaoQueryInPage = async ( .split(/\n+/) .map((line) => line.trim()) .filter(Boolean) + let inReferenceBlock = false + let referenceIntroBudget = 0 const kept = lines.filter((line) => { + if (/^-{4,}$/.test(line.replace(/\s+/g, ''))) { + inReferenceBlock = false + referenceIntroBudget = 0 + return false + } + if (isReferenceHeaderLine(line)) { + inReferenceBlock = true + referenceIntroBudget = 1 + return false + } + if (inReferenceBlock) { + if (referenceIntroBudget > 0 && isReferenceQueryLine(line)) { + referenceIntroBudget -= 1 + return false + } + referenceIntroBudget = 0 + if (isReferenceItemLine(line)) { + return false + } + inReferenceBlock = false + } if (EMPTY_CHAT_TOKENS.includes(line)) { return false } @@ -2632,11 +2774,31 @@ const doubaoQueryInPage = async ( return best?.element ?? null } + const collectReferencePanelLinks = (): DoubaoDomLink[] => { + const groups: DoubaoDomLink[][] = [] + const nodes = Array.from(document.querySelectorAll('article, section, div, main, li')) + for (const node of nodes) { + if (!(node instanceof HTMLElement) || !isVisible(node)) { + continue + } + const text = normalizeMultiline(node.innerText) + if (!text || !isReferenceHeaderLine(text)) { + continue + } + const links = collectLinks(node) + if (links.length > 0) { + groups.push(links) + } + } + return mergeLinks(...groups) + } + const snapshotConversation = (composerTop: number | null): ConversationSnapshot => { expandReferenceControls() const questionAnchor = findQuestionAnchor() const anchorRect = questionAnchor?.getBoundingClientRect() ?? null + const referencePanelLinks = collectReferencePanelLinks() const candidates: Array<{ element: HTMLElement @@ -2708,6 +2870,9 @@ const doubaoQueryInPage = async ( if (questionAnchor && rect.top > (anchorRect?.bottom ?? 0)) { score += 1_000 } + if (rawText && isReferenceHeaderLine(rawText)) { + score -= 1_200 + } if (score <= 0) { continue @@ -2801,6 +2966,7 @@ const doubaoQueryInPage = async ( } const scopedReferenceLinks = mergeLinks( + referencePanelLinks, ...orderedCandidates .filter((candidate) => { if (!candidate.text) { @@ -2856,7 +3022,7 @@ const doubaoQueryInPage = async ( title: normalize(document.title), modelLabel: resolveModelLabel(), modeLabel, - thinkingModeApplied: modeKind === 'thinking' ? true : (modeSnapshot.applied ?? null), + thinkingModeApplied: isThinkingAnswerMode(modeKind) ? true : (modeSnapshot.applied ?? null), deepThinkEnabled: toggleState(deepThinkButton), domAnswer: snapshot.answerText, domReasoning: snapshot.reasoningText, @@ -2973,7 +3139,9 @@ const doubaoQueryInPage = async ( answerMode = { kind: currentMode.kind ?? answerMode.kind, label: currentMode.label ?? answerMode.label, - applied: (currentMode.kind ?? answerMode.kind) === 'thinking' ? true : answerMode.applied, + applied: isThinkingAnswerMode(currentMode.kind ?? answerMode.kind) + ? true + : answerMode.applied, } return { ok: true, @@ -3077,6 +3245,10 @@ export const doubaoAdapter: MonitorAdapter = { const hasRecoveredContent = hasRecoveredAnswer || searchResults.length > 0 const retryableStreamError = isDoubaoRetryableErrorMessage(streamSummary.error_message) + if (pageResult.ok === false && pageRecoverableError && !hasRecoveredAnswer) { + await resetDoubaoPageAfterRecoverableFailure(page, pageResult.error, context.signal) + } + if (pageResult.ok === false) { const failed = pageResult const detail = normalizeText(failed.detail) ?? 'unknown' @@ -3255,9 +3427,11 @@ export const doubaoAdapter: MonitorAdapter = { export const __doubaoTestUtils = { buildSourceItem, dedupeSourceItems, + doubaoModeKindFromLabelText, extractQuestionText, isNonCitationAssetDomain, isNonCitationAssetUrl, + isDoubaoRecoverablePageError, parseDoubaoStreamBody, resolveDoubaoSubmissionAnswer, } diff --git a/server/internal/tenant/app/desktop_task_service.go b/server/internal/tenant/app/desktop_task_service.go index 5c87ba2..0873b35 100644 --- a/server/internal/tenant/app/desktop_task_service.go +++ b/server/internal/tenant/app/desktop_task_service.go @@ -947,7 +947,7 @@ func (s *DesktopTaskService) Complete(ctx context.Context, client *repository.De return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required") } - finalStatus := normalizeDesktopTaskTerminalStatus(req.Status) + finalStatus := normalizeDesktopTaskCompletionStatus(req.Status) resultJSON, err := marshalOptionalJSON(req.Payload) if err != nil { return nil, response.ErrBadRequest(40086, "invalid_desktop_task_payload", "payload must be serializable") @@ -1520,7 +1520,7 @@ func scanDesktopTaskRows(rows pgx.Rows) ([]DesktopTaskView, error) { Platform: platform, Kind: kind, Payload: json.RawMessage(payload), - Status: normalizeDesktopTaskTerminalStatus(status), + Status: normalizeDesktopTaskViewStatus(kind, status), PublishJobStatus: publishJobStatus, ComplianceBlockedRecordID: complianceBlockedRecordID, ComplianceBlockedAt: complianceBlockedAt, @@ -1598,7 +1598,7 @@ func buildDesktopTaskView(task *repository.DesktopTask) DesktopTaskView { Platform: task.Platform, Kind: task.Kind, Payload: json.RawMessage(task.Payload), - Status: normalizeDesktopTaskTerminalStatus(task.Status), + Status: normalizeDesktopTaskViewStatus(task.Kind, task.Status), DedupKey: task.DedupKey, ActiveAttemptID: activeAttemptID, LeaseExpiresAt: task.LeaseExpiresAt, @@ -1637,6 +1637,22 @@ func normalizeDesktopTaskTerminalStatus(status string) string { } } +func normalizeDesktopTaskCompletionStatus(status string) string { + switch strings.TrimSpace(status) { + case "succeeded", "failed", "unknown": + return strings.TrimSpace(status) + default: + return strings.TrimSpace(status) + } +} + +func normalizeDesktopTaskViewStatus(kind string, status string) string { + if strings.TrimSpace(kind) == "publish" { + return normalizeDesktopTaskTerminalStatus(status) + } + return strings.TrimSpace(status) +} + type desktopTaskRecoveryMode string const ( diff --git a/server/internal/tenant/app/desktop_task_service_test.go b/server/internal/tenant/app/desktop_task_service_test.go index 43ee6f5..5e45069 100644 --- a/server/internal/tenant/app/desktop_task_service_test.go +++ b/server/internal/tenant/app/desktop_task_service_test.go @@ -138,6 +138,25 @@ func TestResolvePublishRecoveryOutcome(t *testing.T) { } } +func TestNormalizeDesktopTaskCompletionStatusPreservesUnknown(t *testing.T) { + t.Parallel() + + if got := normalizeDesktopTaskCompletionStatus("unknown"); got != "unknown" { + t.Fatalf("normalizeDesktopTaskCompletionStatus(unknown) = %q, want unknown", got) + } +} + +func TestNormalizeDesktopTaskViewStatusOnlyMapsPublishUnknownToFailed(t *testing.T) { + t.Parallel() + + if got := normalizeDesktopTaskViewStatus("monitor", "unknown"); got != "unknown" { + t.Fatalf("monitor unknown view status = %q, want unknown", got) + } + if got := normalizeDesktopTaskViewStatus("publish", "unknown"); got != "failed" { + t.Fatalf("publish unknown view status = %q, want failed", got) + } +} + func TestIsComplianceInvalidArticleVersionError(t *testing.T) { t.Parallel()