From ba0914bc3e8057054b8c25738cd15901de42c24c Mon Sep 17 00:00:00 2001 From: liangxu Date: Wed, 24 Jun 2026 12:32:17 +0800 Subject: [PATCH] fix desktop doubao history cleanup --- .../src/main/adapters/doubao.test.ts | 655 +++++++ .../src/main/adapters/doubao.ts | 1582 +++++++++++++++++ 2 files changed, 2237 insertions(+) diff --git a/apps/desktop-client/src/main/adapters/doubao.test.ts b/apps/desktop-client/src/main/adapters/doubao.test.ts index 0523a0c..db868c6 100644 --- a/apps/desktop-client/src/main/adapters/doubao.test.ts +++ b/apps/desktop-client/src/main/adapters/doubao.test.ts @@ -13,6 +13,7 @@ const { isNonCitationAssetDomain, isNonCitationAssetUrl, hasDoubaoFinishedStreamCapture, + deleteDoubaoCurrentConversationInPage, parseDoubaoCaptures, parseDoubaoStreamBody, resolveDoubaoSubmissionAnswer, @@ -140,6 +141,660 @@ describe('doubao adapter helpers', () => { expect(doubaoModeProviderModel('expert')).toBe('doubao-web-fast') }) + it('clicks through Doubao current conversation deletion controls', async () => { + const previousWindow = globalThis.window + const previousDocument = globalThis.document + const previousHTMLElement = globalThis.HTMLElement + const previousMouseEvent = globalThis.MouseEvent + const previousURL = globalThis.URL + + const makeElement = (input: { + tagName?: string + text?: string + attrs?: Record + rect?: { left: number; top: number; width: number; height: number } + visible?: boolean + className?: string + onClick?: () => void + children?: MockElement[] + }): MockElement => new MockElement(input) + + class MockMouseEvent { + readonly type: string + constructor(type: string) { + this.type = type + } + } + + class MockElement { + tagName: string + innerText: string + textContent: string + className: string + parentElement: MockElement | null = null + children: MockElement[] = [] + tabIndex = -1 + style = { + display: 'block', + visibility: 'visible', + opacity: '1', + cursor: 'default', + backgroundColor: 'rgba(0, 0, 0, 0)', + } + + private readonly attrs: Map + private readonly rect: { left: number; top: number; width: number; height: number } + private readonly onClick?: () => void + + constructor(input: { + tagName?: string + text?: string + attrs?: Record + rect?: { left: number; top: number; width: number; height: number } + visible?: boolean + className?: string + onClick?: () => void + children?: MockElement[] + }) { + this.tagName = (input.tagName ?? 'div').toUpperCase() + this.innerText = input.text ?? '' + this.textContent = input.text ?? '' + this.className = input.className ?? '' + this.attrs = new Map(Object.entries(input.attrs ?? {})) + this.rect = input.rect ?? { left: 0, top: 0, width: 120, height: 32 } + this.onClick = input.onClick + if (input.visible === false) { + this.style.display = 'none' + } + for (const child of input.children ?? []) { + this.appendChild(child) + } + } + + appendChild(child: MockElement): void { + child.parentElement = this + this.children.push(child) + } + + getAttribute(name: string): string | null { + return this.attrs.get(name) ?? null + } + + hasAttribute(name: string): boolean { + return this.attrs.has(name) + } + + querySelector(selector: string): MockElement | null { + return this.querySelectorAll(selector)[0] ?? null + } + + querySelectorAll(selector: string): MockElement[] { + const selectors = selector + .split(',') + .map((item) => item.trim()) + .filter(Boolean) + const results: MockElement[] = [] + const visit = (node: MockElement) => { + for (const child of node.children) { + if (selectors.some((item) => matchesSelector(child, item))) { + results.push(child) + } + visit(child) + } + } + visit(this) + return results + } + + getBoundingClientRect(): { + left: number + top: number + width: number + height: number + right: number + bottom: number + } { + let current: MockElement | null = this + while (current) { + if (current.style.display === 'none' || current.style.visibility === 'hidden') { + return { + left: 0, + top: 0, + width: 0, + height: 0, + right: 0, + bottom: 0, + } + } + current = current.parentElement + } + return { + ...this.rect, + right: this.rect.left + this.rect.width, + bottom: this.rect.top + this.rect.height, + } + } + + scrollIntoView(): void {} + + dispatchEvent(): boolean { + return true + } + + click(): void { + this.onClick?.() + } + } + + const matchesSelector = (element: MockElement, selector: string): boolean => { + if (selector === '*') { + return true + } + if (/^[a-z]+$/i.test(selector)) { + return element.tagName.toLowerCase() === selector.toLowerCase() + } + const tagMatch = selector.match(/^([a-z]+)(.+)$/i) + if (tagMatch) { + return ( + element.tagName.toLowerCase() === tagMatch[1]?.toLowerCase() && + matchesSelector(element, tagMatch[2] ?? '') + ) + } + const attrMatch = selector.match(/^\[([^=\]~*^$]+)(?:[*^$]?=['"]?([^'"\]]+)['"]?)?\]$/) + if (attrMatch) { + const attr = attrMatch[1] ?? '' + const expected = attrMatch[2] + const value = element.getAttribute(attr) + if (expected === undefined) { + return value !== null + } + if (selector.includes('*=')) { + return value?.includes(expected) ?? false + } + return value === expected + } + return false + } + + const documentRoot = makeElement({ rect: { left: 0, top: 0, width: 1280, height: 900 } }) + const body = makeElement({ rect: { left: 0, top: 0, width: 1280, height: 900 } }) + documentRoot.appendChild(body) + + let dialog: MockElement | null = null + let currentHistory: MockElement | null = null + let confirmed = false + + const confirmButton = makeElement({ + tagName: 'button', + text: '删除', + attrs: { role: 'button' }, + rect: { left: 680, top: 478, width: 80, height: 32 }, + onClick: () => { + confirmed = true + if (dialog) { + body.children = body.children.filter((child) => child !== dialog) + } + if (currentHistory) { + body.children = body.children.filter((child) => child !== currentHistory) + } + }, + }) + dialog = makeElement({ + tagName: 'div', + text: '确定删除对话 删除后不可恢复 取消 删除', + attrs: { role: 'dialog', 'aria-modal': 'true' }, + rect: { left: 440, top: 320, width: 400, height: 220 }, + visible: false, + children: [ + makeElement({ + tagName: 'button', + text: '取消', + attrs: { role: 'button' }, + rect: { left: 580, top: 478, width: 80, height: 32 }, + }), + confirmButton, + ], + }) + + const deleteItem = makeElement({ + tagName: 'div', + text: '删除', + attrs: { role: 'menuitem' }, + rect: { left: 240, top: 124, width: 96, height: 32 }, + onClick: () => { + dialog!.style.display = 'block' + }, + }) + const menu = makeElement({ + tagName: 'div', + text: '删除', + className: 'dropdown-menu', + rect: { left: 228, top: 112, width: 128, height: 48 }, + visible: false, + children: [deleteItem], + }) + + const moreButton = makeElement({ + tagName: 'button', + text: '...', + attrs: { 'aria-label': '更多', role: 'button' }, + rect: { left: 252, top: 74, width: 28, height: 28 }, + onClick: () => { + menu.style.display = 'block' + deleteItem.style.display = 'block' + }, + }) + currentHistory = makeElement({ + tagName: 'div', + text: '室内门锁供货商', + attrs: { 'data-active': 'true' }, + rect: { left: 16, top: 64, width: 272, height: 48 }, + className: 'history-item active', + children: [ + makeElement({ + tagName: 'a', + text: '室内门锁供货商', + attrs: { href: '/chat/local_4995117315817271' }, + rect: { left: 24, top: 72, width: 210, height: 28 }, + }), + moreButton, + ], + }) + + body.appendChild(currentHistory) + body.appendChild(menu) + body.appendChild(dialog) + + const documentMock = { + body, + querySelectorAll: (selector: string) => body.querySelectorAll(selector), + querySelector: (selector: string) => body.querySelector(selector), + } + const windowMock = { + location: { + href: 'https://www.doubao.com/chat/local_4995117315817271', + origin: 'https://www.doubao.com', + }, + innerWidth: 1280, + innerHeight: 900, + setTimeout: (callback: () => void) => { + callback() + return 1 + }, + getComputedStyle: (element: MockElement) => element.style, + } + + try { + const mutableGlobal = globalThis as Record + mutableGlobal.window = windowMock + mutableGlobal.document = documentMock + mutableGlobal.HTMLElement = MockElement + mutableGlobal.MouseEvent = MockMouseEvent + mutableGlobal.URL = URL + + const result = await deleteDoubaoCurrentConversationInPage({ + conversationId: 'local_4995117315817271', + pageUrl: 'https://www.doubao.com/chat/local_4995117315817271', + title: '豆包', + }) + + expect(result).toMatchObject({ + ok: true, + conversation_id: 'local_4995117315817271', + menu_clicked: true, + delete_clicked: true, + confirm_clicked: true, + }) + expect(confirmed).toBe(true) + } finally { + const mutableGlobal = globalThis as Record + mutableGlobal.window = previousWindow + mutableGlobal.document = previousDocument + mutableGlobal.HTMLElement = previousHTMLElement + mutableGlobal.MouseEvent = previousMouseEvent + mutableGlobal.URL = previousURL + } + }) + + it('uses the generated Doubao history link when cleanup only has the question text', async () => { + const previousWindow = globalThis.window + const previousDocument = globalThis.document + const previousHTMLElement = globalThis.HTMLElement + const previousMouseEvent = globalThis.MouseEvent + const previousURL = globalThis.URL + + const makeElement = (input: { + tagName?: string + text?: string + attrs?: Record + rect?: { left: number; top: number; width: number; height: number } + visible?: boolean + className?: string + onClick?: () => void + children?: MockElement[] + }): MockElement => new MockElement(input) + + class MockMouseEvent { + readonly type: string + constructor(type: string) { + this.type = type + } + } + + class MockElement { + tagName: string + innerText: string + textContent: string + className: string + parentElement: MockElement | null = null + children: MockElement[] = [] + tabIndex = -1 + style = { + display: 'block', + visibility: 'visible', + opacity: '1', + cursor: 'pointer', + backgroundColor: 'rgba(0, 0, 0, 0)', + } + + private readonly attrs: Map + private readonly rect: { left: number; top: number; width: number; height: number } + private readonly onClick?: () => void + + constructor(input: { + tagName?: string + text?: string + attrs?: Record + rect?: { left: number; top: number; width: number; height: number } + visible?: boolean + className?: string + onClick?: () => void + children?: MockElement[] + }) { + this.tagName = (input.tagName ?? 'div').toUpperCase() + this.innerText = input.text ?? '' + this.textContent = input.text ?? '' + this.className = input.className ?? '' + this.attrs = new Map(Object.entries(input.attrs ?? {})) + this.rect = input.rect ?? { left: 0, top: 0, width: 120, height: 32 } + this.onClick = input.onClick + if (input.visible === false) { + this.style.display = 'none' + } + for (const child of input.children ?? []) { + this.appendChild(child) + } + } + + appendChild(child: MockElement): void { + child.parentElement = this + this.children.push(child) + } + + getAttribute(name: string): string | null { + return this.attrs.get(name) ?? null + } + + hasAttribute(name: string): boolean { + return this.attrs.has(name) + } + + querySelector(selector: string): MockElement | null { + return this.querySelectorAll(selector)[0] ?? null + } + + querySelectorAll(selector: string): MockElement[] { + const selectors = selector + .split(',') + .map((item) => item.trim()) + .filter(Boolean) + const results: MockElement[] = [] + const visit = (node: MockElement) => { + for (const child of node.children) { + if (selectors.some((item) => matchesSelector(child, item))) { + results.push(child) + } + visit(child) + } + } + visit(this) + return results + } + + getBoundingClientRect(): { + left: number + top: number + width: number + height: number + right: number + bottom: number + } { + let current: MockElement | null = this + while (current) { + if (current.style.display === 'none' || current.style.visibility === 'hidden') { + return { + left: 0, + top: 0, + width: 0, + height: 0, + right: 0, + bottom: 0, + } + } + current = current.parentElement + } + return { + ...this.rect, + right: this.rect.left + this.rect.width, + bottom: this.rect.top + this.rect.height, + } + } + + scrollIntoView(): void {} + + focus(): void {} + + dispatchEvent(): boolean { + return true + } + + click(): void { + this.onClick?.() + } + } + + const matchesSelector = (element: MockElement, selector: string): boolean => { + if (selector === '*') { + return true + } + if (selector.includes(' ')) { + const parts = selector.split(/\s+/).filter(Boolean) + const last = parts.at(-1) + return last ? matchesSelector(element, last) : false + } + if (/^[a-z]+$/i.test(selector)) { + return element.tagName.toLowerCase() === selector.toLowerCase() + } + const tagMatch = selector.match(/^([a-z]+)(.+)$/i) + if (tagMatch) { + return ( + element.tagName.toLowerCase() === tagMatch[1]?.toLowerCase() && + matchesSelector(element, tagMatch[2] ?? '') + ) + } + const attrMatch = selector.match(/^\[([^=\]~*^$]+)([*^$]?=)?['"]?([^'"\]]*)['"]?\]$/) + if (attrMatch) { + const attr = attrMatch[1] ?? '' + const operator = attrMatch[2] + const expected = attrMatch[3] ?? '' + const value = element.getAttribute(attr) + if (!operator) { + return value !== null + } + if (operator === '*=') { + return value?.includes(expected) ?? false + } + if (operator === '^=') { + return value?.startsWith(expected) ?? false + } + return value === expected + } + return false + } + + const documentRoot = makeElement({ rect: { left: 0, top: 0, width: 1280, height: 900 } }) + const body = makeElement({ rect: { left: 0, top: 0, width: 1280, height: 900 } }) + documentRoot.appendChild(body) + + let dialog: MockElement | null = null + let currentHistory: MockElement | null = null + let nextHistory: MockElement | null = null + + const confirmButton = makeElement({ + tagName: 'button', + text: '删除', + attrs: { role: 'button' }, + rect: { left: 680, top: 478, width: 80, height: 32 }, + onClick: () => { + if (dialog) { + body.children = body.children.filter((child) => child !== dialog) + } + if (currentHistory) { + body.children = body.children.filter((child) => child !== currentHistory) + } + }, + }) + dialog = makeElement({ + tagName: 'div', + text: '确定删除对话 删除后不可恢复 取消 删除', + attrs: { role: 'dialog', 'aria-modal': 'true' }, + rect: { left: 440, top: 320, width: 400, height: 220 }, + visible: false, + children: [ + makeElement({ + tagName: 'button', + text: '取消', + attrs: { role: 'button' }, + rect: { left: 580, top: 478, width: 80, height: 32 }, + }), + confirmButton, + ], + }) + + const deleteItem = makeElement({ + tagName: 'div', + text: '删除', + attrs: { role: 'menuitem' }, + rect: { left: 240, top: 124, width: 96, height: 32 }, + visible: false, + onClick: () => { + dialog!.style.display = 'block' + }, + }) + const menu = makeElement({ + tagName: 'div', + text: '删除', + attrs: { role: 'menu' }, + className: 'dropdown-menu', + rect: { left: 228, top: 112, width: 128, height: 48 }, + children: [deleteItem], + }) + + const moreButton = makeElement({ + tagName: 'button', + text: '', + attrs: { role: 'button' }, + rect: { left: 252, top: 74, width: 28, height: 28 }, + onClick: () => { + menu.style.display = 'block' + deleteItem.style.display = 'block' + }, + }) + currentHistory = makeElement({ + tagName: 'a', + text: '合肥全屋定制服务商选择指南', + attrs: { href: '/chat/38432306062349826', id: 'conversation_38432306062349826' }, + rect: { left: 16, top: 64, width: 272, height: 48 }, + className: 'chat-item', + children: [ + makeElement({ + tagName: 'span', + text: '合肥全屋定制服务商选择指南', + rect: { left: 24, top: 72, width: 210, height: 28 }, + }), + makeElement({ + tagName: 'div', + className: 'chat-item-menu-wrapper-DPvhf1', + rect: { left: 248, top: 72, width: 32, height: 28 }, + children: [moreButton], + }), + ], + }) + nextHistory = makeElement({ + tagName: 'a', + text: '合肥全屋定制公司推荐', + attrs: { href: '/chat/38432306010828802', id: 'conversation_38432306010828802' }, + rect: { left: 16, top: 116, width: 272, height: 48 }, + className: 'chat-item', + }) + + body.appendChild(currentHistory) + body.appendChild(nextHistory) + body.appendChild(menu) + body.appendChild(dialog) + + const documentMock = { + body, + querySelectorAll: (selector: string) => body.querySelectorAll(selector), + querySelector: (selector: string) => body.querySelector(selector), + } + const windowMock = { + location: { + href: 'https://www.doubao.com/chat/', + origin: 'https://www.doubao.com', + }, + innerWidth: 1280, + innerHeight: 900, + setTimeout: (callback: () => void) => { + callback() + return 1 + }, + getComputedStyle: (element: MockElement) => element.style, + } + + try { + const mutableGlobal = globalThis as Record + mutableGlobal.window = windowMock + mutableGlobal.document = documentMock + mutableGlobal.HTMLElement = MockElement + mutableGlobal.MouseEvent = MockMouseEvent + mutableGlobal.URL = URL + + const result = await deleteDoubaoCurrentConversationInPage({ + conversationId: null, + pageUrl: 'https://www.doubao.com/chat/', + title: '合肥全屋定制服务商怎么选', + }) + + expect(result).toMatchObject({ + ok: true, + conversation_id: '38432306062349826', + menu_clicked: true, + delete_clicked: true, + confirm_clicked: true, + }) + expect(body.children.includes(currentHistory)).toBe(false) + expect(body.children.includes(nextHistory)).toBe(true) + } finally { + const mutableGlobal = globalThis as Record + mutableGlobal.window = previousWindow + mutableGlobal.document = previousDocument + mutableGlobal.HTMLElement = previousHTMLElement + mutableGlobal.MouseEvent = previousMouseEvent + mutableGlobal.URL = previousURL + } + }) + 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) diff --git a/apps/desktop-client/src/main/adapters/doubao.ts b/apps/desktop-client/src/main/adapters/doubao.ts index 30f60fa..79f3b6c 100644 --- a/apps/desktop-client/src/main/adapters/doubao.ts +++ b/apps/desktop-client/src/main/adapters/doubao.ts @@ -352,6 +352,31 @@ type DoubaoExternalSubmitResult = { pageUrl: string | null } +type DoubaoHistoryCleanupParameters = { + conversationId: string | null + pageUrl: string | null + title: string | null + dryRun?: boolean +} + +type DoubaoHistoryCleanupResult = { + ok: boolean + reason: string | null + conversation_id: string | null + before_url: string | null + after_url: string | null + target_text: string | null + target_rect: { + x: number + y: number + width: number + height: number + } | null + menu_clicked: boolean + delete_clicked: boolean + confirm_clicked: boolean +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } @@ -1927,6 +1952,1550 @@ function hasDoubaoFinishedStreamCapture(captures: DoubaoCaptureRecord[]): boolea ) } +const deleteDoubaoCurrentConversationInPage = async ( + parameters: DoubaoHistoryCleanupParameters, +): Promise => { + const wait = (timeMs: number) => + new Promise((resolve) => { + window.setTimeout(resolve, timeMs) + }) + + const normalize = (value: unknown): string | null => { + if (typeof value !== 'string') { + return null + } + const trimmed = value.trim().replace(/\s+/g, ' ') + return trimmed ? trimmed : null + } + + const normalizeLower = (value: unknown): string | null => normalize(value)?.toLowerCase() ?? null + + const isVisible = ( + element: Element | null | undefined, + options: { allowTransparent?: boolean } = {}, + ): element is HTMLElement => { + if (!(element instanceof HTMLElement)) { + return false + } + let current: HTMLElement | null = element + for (let depth = 0; current && depth < 8; depth += 1) { + const style = window.getComputedStyle(current) + if (style.display === 'none' || style.visibility === 'hidden') { + return false + } + if (current === element && !options.allowTransparent && Number(style.opacity || '1') <= 0.02) { + return false + } + current = current.parentElement + } + const rect = element.getBoundingClientRect() + return rect.width > 0 && rect.height > 0 + } + + const textOf = (element: Element | null | undefined): string | null => { + if (!(element instanceof HTMLElement)) { + return null + } + return normalize(element.innerText) ?? normalize(element.textContent) + } + + const hintText = (element: Element | null | undefined): string => { + if (!(element instanceof HTMLElement)) { + return '' + } + return [ + element.getAttribute('aria-label'), + element.getAttribute('title'), + element.getAttribute('data-testid'), + element.getAttribute('data-test-id'), + element.getAttribute('name'), + element.getAttribute('id'), + element.getAttribute('role'), + typeof element.className === 'string' ? element.className : '', + textOf(element), + ] + .map((value) => normalizeLower(value) ?? '') + .filter(Boolean) + .join(' ') + .slice(0, 600) + } + + const safeDecode = (value: string): string => { + try { + return decodeURIComponent(value) + } catch { + return value + } + } + + const extractConversationIDFromURL = (url: string | null): string | null => { + const input = normalize(url) + if (!input) { + return null + } + try { + const parsed = new URL(input, window.location.origin) + const matched = parsed.pathname.match(/\/chat\/([^/?#]+)/i) + return matched?.[1]?.trim() || null + } catch { + const matched = input.match(/\/chat\/([^/?#]+)/i) + return matched?.[1]?.trim() || null + } + } + + const conversationId = + normalize(parameters.conversationId) ?? + extractConversationIDFromURL(parameters.pageUrl) ?? + extractConversationIDFromURL(window.location.href) + const beforeUrl = normalize(parameters.pageUrl) ?? normalize(window.location.href) + const expectedTitle = normalize(parameters.title) + const expectedTitleKey = expectedTitle + ?.toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, '') + .trim() || null + let lockedConversationId: string | null = null + + const rectOf = ( + element: HTMLElement | null, + ): DoubaoHistoryCleanupResult['target_rect'] => { + if (!element) { + return null + } + const rect = element.getBoundingClientRect() + return { + x: Math.round(rect.left), + y: Math.round(rect.top), + width: Math.round(rect.width), + height: Math.round(rect.height), + } + } + + const makeResult = ( + reason: string | null, + target: HTMLElement | null, + updates: Partial = {}, + ): DoubaoHistoryCleanupResult => ({ + ok: false, + reason, + conversation_id: lockedConversationId ?? conversationId, + before_url: beforeUrl, + after_url: normalize(window.location.href), + target_text: target ? textOf(target) : null, + target_rect: rectOf(target), + menu_clicked: false, + delete_clicked: false, + confirm_clicked: false, + ...updates, + }) + + if (!conversationId && !expectedTitle) { + return makeResult('doubao_history_conversation_id_missing', null) + } + + const waitFor = async ( + finder: () => T | null, + timeoutMs: number, + intervalMs = 120, + ): Promise => { + const startedAt = Date.now() + while (Date.now() - startedAt <= timeoutMs) { + const found = finder() + if (found) { + return found + } + await wait(intervalMs) + } + return null + } + + const isLeftRailElement = (element: HTMLElement): boolean => { + const rect = element.getBoundingClientRect() + const maxRailRight = Math.max(360, window.innerWidth * 0.38) + return rect.left <= maxRailRight && rect.width <= Math.max(220, window.innerWidth * 0.45) + } + + const isActiveElement = (element: HTMLElement): boolean => { + const attrs = [ + element.getAttribute('aria-current'), + element.getAttribute('aria-selected'), + element.getAttribute('data-active'), + element.getAttribute('data-selected'), + element.getAttribute('data-state'), + ] + .map((value) => normalizeLower(value)) + .filter((value): value is string => value !== null) + if ( + attrs.some((value) => + ['page', 'true', 'selected', 'active', 'checked', 'open', 'on'].includes(value), + ) + ) { + return true + } + return /(^|[\s_-])(active|selected|current)([\s_-]|$)/i.test(hintText(element)) + } + + const elementAttributeValues = (element: Element): string[] => { + const attributes = [ + 'id', + 'href', + 'data-url', + 'data-href', + 'data-link', + 'data-target-url', + 'data-targetUrl', + 'data-chat-id', + 'data-conversation-id', + 'aria-label', + 'title', + ] + return attributes + .map((attribute) => normalize(element.getAttribute(attribute))) + .filter((value): value is string => value !== null) + } + + const extractConversationIDFromElement = (element: Element): string | null => { + const inspect = (candidate: Element): string | null => { + for (const value of elementAttributeValues(candidate)) { + const fromURL = extractConversationIDFromURL(value) + if (fromURL) { + return fromURL + } + const idMatch = safeDecode(value).match(/(?:conversation|chat)[_-]?([A-Za-z0-9_-]{6,})/i) + if (idMatch?.[1]) { + return idMatch[1] + } + } + return null + } + + let current: Element | null = element + for (let depth = 0; current && depth < 5; depth += 1) { + const found = inspect(current) + if (found) { + return found + } + current = current.parentElement + } + + for (const node of Array.from( + element.querySelectorAll( + 'a[href], [data-url], [data-href], [data-link], [data-chat-id], [data-conversation-id], [id]', + ), + )) { + const found = inspect(node) + if (found) { + return found + } + } + return null + } + + const containsConversationID = (element: Element): boolean => { + const targetConversationId = lockedConversationId ?? conversationId + if (!targetConversationId) { + return false + } + const encoded = encodeURIComponent(targetConversationId) + return elementAttributeValues(element).some((value) => { + const decoded = safeDecode(value) + return ( + decoded.includes(`/chat/${targetConversationId}`) || + decoded.includes(targetConversationId) || + value.includes(encoded) + ) + }) + } + + const titleMatchScore = (text: string | null): number => { + if (!text || !expectedTitle || !expectedTitleKey) { + return 0 + } + const textKey = text + .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, '') + .trim() + if (!textKey) { + return 0 + } + if (textKey === expectedTitleKey) { + return 900 + } + if (textKey.includes(expectedTitleKey)) { + return 780 + } + if (expectedTitleKey.includes(textKey) && textKey.length >= Math.min(8, expectedTitleKey.length)) { + return 700 + } + + const shortLength = Math.min(textKey.length, expectedTitleKey.length) + if (shortLength >= 8) { + const prefixLength = Math.min(18, shortLength) + let sharedPrefixLength = 0 + while ( + sharedPrefixLength < prefixLength && + textKey[sharedPrefixLength] === expectedTitleKey[sharedPrefixLength] + ) { + sharedPrefixLength += 1 + } + if (sharedPrefixLength >= Math.min(8, prefixLength)) { + return 560 + sharedPrefixLength + } + } + + const bigrams = (value: string): Set => { + const result = new Set() + for (let index = 0; index < value.length - 1; index += 1) { + result.add(value.slice(index, index + 2)) + } + return result + } + const left = bigrams(textKey) + const right = bigrams(expectedTitleKey) + if (left.size < 3 || right.size < 3) { + return 0 + } + let intersection = 0 + for (const item of left) { + if (right.has(item)) { + intersection += 1 + } + } + const overlap = intersection / Math.min(left.size, right.size) + if (intersection >= 3 && overlap >= 0.48) { + return 420 + Math.round(overlap * 100) + } + return 0 + } + + const findHistoryItemRoot = (seed: HTMLElement): HTMLElement => { + const nextParent = (element: HTMLElement): HTMLElement | null => + element.parentNode instanceof HTMLElement ? element.parentNode : null + let current: HTMLElement | null = seed + let best: HTMLElement = seed + let bestScore = Number.NEGATIVE_INFINITY + + for (let depth = 0; current && depth < 8; depth += 1) { + if (!isVisible(current, { allowTransparent: true })) { + current = nextParent(current) + continue + } + const rect = current.getBoundingClientRect() + const text = textOf(current) + const hasUsefulSize = rect.width >= 90 && rect.width <= 640 && rect.height >= 20 && rect.height <= 112 + const hasChatText = Boolean(text && text.length <= 160) + if (hasUsefulSize && hasChatText) { + let score = 500 - depth * 12 + if (isLeftRailElement(current)) { + score += 260 + } + if (isActiveElement(current)) { + score += 180 + } + if ( + current.querySelector( + "button, [role='button'], [aria-haspopup], [class*='more'], [class*='More'], [class*='menu'], [class*='Menu']", + ) + ) { + score += 100 + } + if (containsConversationID(current)) { + score += 220 + } + if (score > bestScore) { + best = current + bestScore = score + } + } + current = nextParent(current) + } + + return best + } + + type TargetCandidate = { + element: HTMLElement + conversationId: string | null + score: number + } + + const pushTargetCandidate = ( + candidates: TargetCandidate[], + seed: HTMLElement, + baseScore: number, + ) => { + const root = findHistoryItemRoot(seed) + if (!isVisible(root, { allowTransparent: true })) { + return + } + const rect = root.getBoundingClientRect() + const text = textOf(root) + if (!text || rect.height > 130 || rect.width > 700) { + return + } + let score = baseScore + const candidateConversationId = + extractConversationIDFromElement(root) ?? extractConversationIDFromElement(seed) + const matchScore = titleMatchScore(text) + const compactText = text.replace(/\s+/g, '') + const rootAndSeedHint = `${hintText(root)} ${hintText(seed)}` + const hasConversationMarker = + /^conversation_/i.test(root.getAttribute('id') ?? '') || + /^conversation_/i.test(seed.getAttribute('id') ?? '') || + Boolean(root.querySelector("[id^='conversation_'], a[id^='conversation_']")) || + /(?:^|[\s_-])(?:chat-item|history-item|conversation)(?:[\s_-]|$)/i.test(rootAndSeedHint) + const isSidebarNavigationItem = + /sidebar[_-]?nav|sidebar_nav_item|nav[_-]?item/i.test(rootAndSeedHint) && + /^(?:豆包|AI创作|云盘|发现智能体)$/i.test(compactText) + if (isSidebarNavigationItem && !hasConversationMarker) { + return + } + if (!hasConversationMarker && matchScore <= 0 && /^(?:豆包|AI创作|云盘|发现智能体)$/i.test(compactText)) { + return + } + if (matchScore > 0) { + score += matchScore + } + if (isLeftRailElement(root)) { + score += 260 + } + if (isActiveElement(root)) { + score += 160 + } + if (containsConversationID(root)) { + score += 220 + } + if (candidateConversationId && candidateConversationId === conversationId) { + score += 260 + } + candidates.push({ element: root, conversationId: candidateConversationId, score }) + } + + const findTargetConversation = (): HTMLElement | null => { + const candidates: TargetCandidate[] = [] + const seen = new Set() + + const linkSelectors = [ + 'a[href]', + "[role='link']", + '[data-url]', + '[data-href]', + '[data-link]', + '[data-chat-id]', + '[data-conversation-id]', + ].join(',') + for (const node of Array.from(document.querySelectorAll(linkSelectors))) { + if (!(node instanceof HTMLElement) || seen.has(node)) { + continue + } + if (containsConversationID(node)) { + seen.add(node) + pushTargetCandidate(candidates, node, 1_800) + } + } + if (lockedConversationId && candidates.length === 0) { + return null + } + + const activeSelectors = [ + "[aria-current='page']", + "[aria-selected='true']", + "[data-active='true']", + "[data-selected='true']", + "[class*='active']", + "[class*='selected']", + "[class*='current']", + ].join(',') + for (const node of Array.from(document.querySelectorAll(activeSelectors))) { + if (!(node instanceof HTMLElement) || seen.has(node) || !isLeftRailElement(node)) { + continue + } + const activeText = textOf(node) + const activeConversationId = extractConversationIDFromElement(node) + if (!activeConversationId && titleMatchScore(activeText) <= 0) { + continue + } + seen.add(node) + pushTargetCandidate(candidates, node, 760) + } + + if (expectedTitle && !/^(豆包|doubao)$/i.test(expectedTitle)) { + for (const node of Array.from(document.querySelectorAll('a, [role="link"], div, li'))) { + if (!(node instanceof HTMLElement) || seen.has(node) || !isLeftRailElement(node)) { + continue + } + const text = textOf(node) + if (titleMatchScore(text) > 0) { + seen.add(node) + pushTargetCandidate(candidates, node, 460) + } + } + } + + if (!conversationId && expectedTitle) { + for (const node of Array.from(document.querySelectorAll('a[href^="/chat/"]'))) { + if (!(node instanceof HTMLElement) || seen.has(node) || !isLeftRailElement(node)) { + continue + } + if (titleMatchScore(textOf(node)) <= 0) { + continue + } + seen.add(node) + pushTargetCandidate(candidates, node, 1_100) + } + } + + if (candidates.length === 0 && !lockedConversationId) { + for (const node of Array.from(document.querySelectorAll('a[href^="/chat/"]'))) { + if (!(node instanceof HTMLElement) || seen.has(node) || !isLeftRailElement(node)) { + continue + } + const href = node.getAttribute('href') ?? '' + if (!/^\/chat\/[^/?#]+$/.test(href)) { + continue + } + const text = textOf(node) + if (!text || /^(豆包|AI\s*创作|云盘|主对话)$/i.test(text)) { + continue + } + seen.add(node) + pushTargetCandidate(candidates, node, 260) + } + } + + const unique = new Map() + for (const candidate of candidates) { + const existing = unique.get(candidate.element) + if (!existing || candidate.score > existing.score) { + unique.set(candidate.element, candidate) + } + } + + const best = + Array.from(unique.values()).sort((left, right) => right.score - left.score)[0] ?? null + lockedConversationId = best?.conversationId ?? lockedConversationId + return best?.element ?? null + } + + const dispatchHover = (element: HTMLElement): void => { + try { + element.scrollIntoView({ block: 'center', inline: 'nearest' }) + } catch { + // Best effort only. + } + for (const type of ['pointerover', 'mouseover', 'mouseenter', 'mousemove']) { + try { + element.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window })) + } catch { + // Best effort only. + } + } + } + + const isClickable = (element: HTMLElement): boolean => { + const tagName = element.tagName.toLowerCase() + const role = normalizeLower(element.getAttribute('role')) + if (tagName === 'button' || tagName === 'a' || role === 'button' || role === 'menuitem') { + return true + } + if (element.tabIndex >= 0 || element.hasAttribute('aria-haspopup')) { + return true + } + const style = window.getComputedStyle(element) + return style.cursor === 'pointer' || typeof (element as { onclick?: unknown }).onclick === 'function' + } + + const clickableAncestor = (seed: HTMLElement, maxDepth = 4): HTMLElement => { + let current: HTMLElement | null = seed + let fallback: HTMLElement = seed + for (let depth = 0; current && depth <= maxDepth; depth += 1) { + if (isVisible(current)) { + fallback = current + if (isClickable(current)) { + return current + } + } + current = current.parentElement + } + return fallback + } + + const clickElement = (element: HTMLElement): void => { + try { + element.scrollIntoView({ block: 'center', inline: 'nearest' }) + } catch { + // Best effort only. + } + const rect = element.getBoundingClientRect() + const eventInit = { + bubbles: true, + cancelable: true, + composed: true, + view: window, + button: 0, + buttons: 1, + clientX: Math.round(rect.left + rect.width / 2), + clientY: Math.round(rect.top + rect.height / 2), + } + try { + element.focus() + } catch { + // Best effort only. + } + for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup']) { + try { + const EventConstructor = + type.startsWith('pointer') && typeof PointerEvent !== 'undefined' + ? PointerEvent + : MouseEvent + element.dispatchEvent(new EventConstructor(type, eventInit)) + } catch { + // Best effort only. + } + } + element.click() + } + + const findConversationMenuButton = (target: HTMLElement): HTMLElement | null => { + dispatchHover(target) + const targetRect = target.getBoundingClientRect() + const scopes = [target, target.parentElement, target.parentElement?.parentElement].filter( + (scope): scope is HTMLElement => scope instanceof HTMLElement, + ) + const candidates: Array<{ element: HTMLElement; score: number }> = [] + const seen = new Set() + + const selectors = [ + "[class*='chat-item-menu-wrapper'] button", + 'button', + "[role='button']", + '[aria-haspopup]', + '[aria-label]', + '[title]', + '[tabindex]', + "[class*='more']", + "[class*='More']", + "[class*='menu']", + "[class*='Menu']", + "[class*='operation']", + "[class*='Operation']", + ].join(',') + + const pushCandidate = (node: HTMLElement, baseScore: number) => { + if (seen.has(node) || !isVisible(node)) { + return + } + seen.add(node) + const rect = node.getBoundingClientRect() + const text = textOf(node) ?? '' + const hint = hintText(node) + const compactText = text.replace(/\s+/g, '') + const isTiny = rect.width <= 56 && rect.height <= 56 + const isNearTarget = + rect.left >= targetRect.left - 12 && + rect.left <= targetRect.right + 28 && + rect.top >= targetRect.top - 18 && + rect.bottom <= targetRect.bottom + 18 + const isRightSide = rect.left >= targetRect.right - Math.max(96, targetRect.width * 0.28) + const looksLikeMenu = + /more|menu|overflow|ellipsis|operation|action|option|更多|菜单|操作|选项|三点|省略/.test( + hint, + ) || /^(?:\.{3}|…|⋯|···|更多)$/.test(compactText) + + if (!looksLikeMenu && !isTiny && !isRightSide) { + return + } + if (text.length > 18 && !looksLikeMenu) { + return + } + + let score = baseScore + if (looksLikeMenu) { + score += 700 + } + if (isTiny) { + score += 220 + } + if (isRightSide) { + score += 180 + } + if (isNearTarget) { + score += 160 + } + score += Math.max(0, rect.left - targetRect.left) + candidates.push({ element: node, score }) + } + + for (const scope of scopes) { + for (const node of Array.from(scope.querySelectorAll(selectors))) { + if (node instanceof HTMLElement && node !== target) { + pushCandidate(node, 320) + } + } + } + + for (const node of Array.from(document.querySelectorAll(selectors))) { + if (!(node instanceof HTMLElement)) { + continue + } + const rect = node.getBoundingClientRect() + if ( + rect.left >= targetRect.left - 16 && + rect.left <= targetRect.right + 42 && + rect.top >= targetRect.top - 20 && + rect.bottom <= targetRect.bottom + 20 + ) { + pushCandidate(node, 180) + } + } + + return candidates.sort((left, right) => right.score - left.score)[0]?.element ?? null + } + + const isDeleteActionText = (text: string | null): boolean => { + if (!text) { + return false + } + const compact = text.replace(/\s+/g, '') + return /^(?:删除|删除对话|删除聊天|删除记录|delete|remove)$/i.test(compact) + } + + const ancestorHintMatches = ( + element: HTMLElement, + matcher: (candidate: HTMLElement) => boolean, + maxDepth = 6, + ): boolean => { + let current: HTMLElement | null = element + for (let depth = 0; current && depth <= maxDepth; depth += 1) { + if (matcher(current)) { + return true + } + current = current.parentElement + } + return false + } + + const findDeleteMenuItem = (): HTMLElement | null => { + const candidates: Array<{ element: HTMLElement; score: number }> = [] + const selectors = "button, [role='button'], [role='menuitem'], li, div, span" + const menuHintPattern = /menu|popover|dropdown|portal|float|popup|context|菜单|弹层|下拉/ + for (const node of Array.from(document.querySelectorAll(selectors))) { + if (!(node instanceof HTMLElement) || !isVisible(node)) { + continue + } + const text = textOf(node) + if (!isDeleteActionText(text)) { + continue + } + const clickable = clickableAncestor(node) + const hint = hintText(clickable) + const rect = clickable.getBoundingClientRect() + if (rect.width > 260 || rect.height > 72) { + continue + } + const isInsideDialog = ancestorHintMatches( + clickable, + (candidate) => + candidate.getAttribute('role') === 'dialog' || + candidate.getAttribute('aria-modal') === 'true' || + /dialog|modal|confirm|alert-dialog/.test(hintText(candidate)), + ) + if (isInsideDialog) { + continue + } + const isMenuItem = clickable.getAttribute('role') === 'menuitem' + const isInsideMenu = ancestorHintMatches( + clickable, + (candidate) => + candidate.getAttribute('role') === 'menu' || + candidate.hasAttribute('data-radix-menu-content') || + /dropdown-menu|menu-content|popover|popup|context/.test(hintText(candidate)), + ) + if (!isMenuItem && !isInsideMenu) { + continue + } + let score = 500 + if (text === '删除') { + score += 300 + } + if (isMenuItem) { + score += 300 + } + if (isInsideMenu) { + score += 180 + } + if (/menuitem|button/.test(hint)) { + score += 120 + } + let current: HTMLElement | null = clickable + for (let depth = 0; current && depth < 5; depth += 1) { + if (menuHintPattern.test(hintText(current))) { + score += 150 + break + } + current = current.parentElement + } + candidates.push({ element: clickable, score }) + } + + return candidates.sort((left, right) => right.score - left.score)[0]?.element ?? null + } + + const findDeleteDialog = (): HTMLElement | null => { + const selectors = [ + "[role='dialog']", + "[aria-modal='true']", + "[class*='modal']", + "[class*='Modal']", + "[class*='dialog']", + "[class*='Dialog']", + "[class*='confirm']", + "[class*='Confirm']", + '[data-testid*="modal"]', + '[data-testid*="dialog"]', + 'section', + 'div', + 'body > div', + ].join(',') + const candidates: Array<{ element: HTMLElement; score: number }> = [] + for (const node of Array.from(document.querySelectorAll(selectors))) { + if (!(node instanceof HTMLElement) || !isVisible(node)) { + continue + } + const text = textOf(node) + if (!text || !/删除|确定|确认|取消/.test(text)) { + continue + } + if (!/删除.*(?:对话|聊天|记录)|(?:对话|聊天|记录).*删除|确定删除|确认删除|删除后|是否删除|删除此|取消.*删除/.test(text)) { + continue + } + const rect = node.getBoundingClientRect() + const area = rect.width * rect.height + if (area < 1_000 || area > window.innerWidth * window.innerHeight * 0.92) { + continue + } + let score = 500 + if (node.getAttribute('role') === 'dialog' || node.getAttribute('aria-modal') === 'true') { + score += 300 + } + if (/modal|dialog|confirm/.test(hintText(node))) { + score += 180 + } + const centerX = rect.left + rect.width / 2 + const centerY = rect.top + rect.height / 2 + const viewportCenterDistance = + Math.abs(centerX - window.innerWidth / 2) + Math.abs(centerY - window.innerHeight / 2) + score -= viewportCenterDistance / 20 + candidates.push({ element: node, score }) + } + + return candidates.sort((left, right) => right.score - left.score)[0]?.element ?? null + } + + const findConfirmDeleteButton = (dialog: HTMLElement): HTMLElement | null => { + const candidates: Array<{ element: HTMLElement; score: number }> = [] + for (const node of Array.from( + dialog.querySelectorAll("button, [role='button'], [tabindex], div, span"), + )) { + if (!(node instanceof HTMLElement) || !isVisible(node)) { + continue + } + const text = textOf(node) + if (!text || /取消|cancel/i.test(text)) { + continue + } + const compact = text.replace(/\s+/g, '') + const looksConfirm = + /^(?:删除|确认删除|确定删除|确定|确认|delete|remove|ok)$/i.test(compact) || + /删除/.test(compact) + if (!looksConfirm || compact.length > 12) { + continue + } + const clickable = clickableAncestor(node) + const rect = clickable.getBoundingClientRect() + if (rect.width > 220 || rect.height > 72) { + continue + } + let score = 500 + if (compact === '删除') { + score += 360 + } + if (/button/.test(hintText(clickable))) { + score += 120 + } + const style = window.getComputedStyle(clickable) + if (/rgb\((?:2[0-5][0-9]|1[6-9][0-9]),\s*[0-9]{1,3},\s*[0-9]{1,3}\)/.test(style.backgroundColor)) { + score += 80 + } + candidates.push({ element: clickable, score }) + } + + return candidates.sort((left, right) => right.score - left.score)[0]?.element ?? null + } + + let target = findTargetConversation() + if (!target) { + return makeResult('doubao_history_target_not_found', null) + } + + dispatchHover(target) + if (parameters.dryRun) { + return makeResult('doubao_history_target_found', target) + } + + await wait(120) + const menuButton = findConversationMenuButton(target) + if (!menuButton) { + return makeResult('doubao_history_menu_not_found', target) + } + + clickElement(menuButton) + await wait(260) + const deleteItem = await waitFor(findDeleteMenuItem, 1_500) + if (!deleteItem) { + return makeResult('doubao_history_delete_action_not_found', target, { + menu_clicked: true, + }) + } + + clickElement(deleteItem) + await wait(360) + let dialog = await waitFor(findDeleteDialog, 2_000) + if (!dialog) { + await wait(320) + target = findTargetConversation() + if (!target) { + return makeResult(null, null, { + ok: true, + reason: 'doubao_history_deleted_without_confirm', + menu_clicked: true, + delete_clicked: true, + }) + } + dialog = findDeleteDialog() + } + + if (!dialog) { + return makeResult('doubao_history_confirm_dialog_not_found', target, { + menu_clicked: true, + delete_clicked: true, + }) + } + + const confirmButton = await waitFor(() => findConfirmDeleteButton(dialog), 1_500) + if (!confirmButton) { + return makeResult('doubao_history_confirm_button_not_found', target, { + menu_clicked: true, + delete_clicked: true, + }) + } + + clickElement(confirmButton) + const remainingTarget = await waitFor( + () => (findTargetConversation() ? null : target), + 2_200, + 180, + ) + if (!remainingTarget) { + return makeResult('doubao_history_delete_not_verified', findTargetConversation(), { + menu_clicked: true, + delete_clicked: true, + confirm_clicked: true, + }) + } + return makeResult(null, null, { + ok: true, + reason: null, + menu_clicked: true, + delete_clicked: true, + confirm_clicked: true, + }) +} + +function buildDoubaoHistoryCleanupResult( + input: DoubaoHistoryCleanupParameters, + reason: string, + afterUrl: string | null = null, +): DoubaoHistoryCleanupResult { + return { + ok: false, + reason, + conversation_id: input.conversationId, + before_url: input.pageUrl, + after_url: afterUrl, + target_text: null, + target_rect: null, + menu_clicked: false, + delete_clicked: false, + confirm_clicked: false, + } +} + +async function deleteDoubaoCurrentConversationHistory( + page: PlaywrightPage, + input: Omit, + signal?: AbortSignal, +): Promise { + const pageUrl = normalizeOptionalString(input.pageUrl) ?? safePageURL(page) + const pageConversationId = extractConversationID(pageUrl) ?? extractConversationID(safePageURL(page)) + const cleanupInput: DoubaoHistoryCleanupParameters = { + conversationId: pageConversationId ?? normalizeOptionalString(input.conversationId), + pageUrl, + title: normalizeOptionalString(input.title), + } + + if (signal?.aborted) { + return buildDoubaoHistoryCleanupResult(cleanupInput, 'adapter_aborted', safePageURL(page)) + } + + try { + const dryRun = await page.evaluate(deleteDoubaoCurrentConversationInPage, { + ...cleanupInput, + dryRun: true, + }) + if (!dryRun.target_rect) { + return dryRun + } + + const centerX = dryRun.target_rect.x + dryRun.target_rect.width - 16 + const centerY = dryRun.target_rect.y + dryRun.target_rect.height / 2 + if (Number.isFinite(centerX) && Number.isFinite(centerY)) { + await page.mouse.move(centerX, centerY).catch(() => undefined) + await sleep(220, signal).catch(() => undefined) + } + + if (signal?.aborted) { + return buildDoubaoHistoryCleanupResult(cleanupInput, 'adapter_aborted', safePageURL(page)) + } + + const pointerResult = await deleteDoubaoCurrentConversationWithPointer( + page, + cleanupInput, + dryRun, + signal, + ) + if (pointerResult.ok) { + return pointerResult + } + + const result = await page.evaluate(deleteDoubaoCurrentConversationInPage, cleanupInput) + if (result.ok || result.reason !== 'doubao_history_menu_not_found' || !result.target_rect) { + if (result.ok || !result.target_rect) { + return pointerResult.target_rect ? pointerResult : result + } + const retryPointerResult = await deleteDoubaoCurrentConversationWithPointer( + page, + cleanupInput, + result, + signal, + ) + return retryPointerResult.ok ? retryPointerResult : pointerResult + } + + const retryX = result.target_rect.x + result.target_rect.width - 8 + const retryY = result.target_rect.y + result.target_rect.height / 2 + if (Number.isFinite(retryX) && Number.isFinite(retryY)) { + await page.mouse.move(retryX, retryY).catch(() => undefined) + await sleep(300, signal).catch(() => undefined) + } + const retryResult = await page.evaluate(deleteDoubaoCurrentConversationInPage, cleanupInput) + if (retryResult.ok || !retryResult.target_rect) { + return retryResult + } + const retryPointerResult = await deleteDoubaoCurrentConversationWithPointer( + page, + cleanupInput, + retryResult, + signal, + ) + return retryPointerResult.ok ? retryPointerResult : pointerResult + } catch (error) { + return buildDoubaoHistoryCleanupResult( + cleanupInput, + `doubao_history_cleanup_error: ${formatUnknownError(error)}`, + safePageURL(page), + ) + } +} + +async function deleteDoubaoCurrentConversationWithPointer( + page: PlaywrightPage, + input: DoubaoHistoryCleanupParameters, + previous: DoubaoHistoryCleanupResult, + signal?: AbortSignal, +): Promise { + if (signal?.aborted || !previous.target_rect) { + return buildDoubaoHistoryCleanupResult(input, 'adapter_aborted', safePageURL(page)) + } + + const effectiveInput: DoubaoHistoryCleanupParameters = { + ...input, + conversationId: previous.conversation_id ?? input.conversationId, + } + const rect = previous.target_rect + + const clickRect = async (targetRect: NonNullable) => { + const x = targetRect.x + targetRect.width / 2 + const y = targetRect.y + targetRect.height / 2 + await page.mouse.move(x, y) + await page.mouse.down() + await page.mouse.up() + } + + const readMenuButtonRect = async (): Promise< + NonNullable | null + > => + page + .evaluate((targetRect) => { + const normalize = (value: unknown): string | null => { + if (typeof value !== 'string') { + return null + } + const trimmed = value.trim().replace(/\s+/g, ' ') + return trimmed ? trimmed : null + } + const isVisible = (element: Element | null | undefined): element is HTMLElement => { + if (!(element instanceof HTMLElement)) { + return false + } + let current: HTMLElement | null = element + for (let depth = 0; current && depth < 8; depth += 1) { + const style = window.getComputedStyle(current) + if ( + style.display === 'none' || + style.visibility === 'hidden' || + Number(style.opacity || '1') <= 0.02 + ) { + return false + } + current = current.parentElement + } + const rect = element.getBoundingClientRect() + return rect.width > 0 && rect.height > 0 + } + const textOf = (element: Element | null | undefined): string | null => + element instanceof HTMLElement + ? normalize(element.innerText) ?? normalize(element.textContent) + : null + const hintText = (element: HTMLElement): string => + [ + element.getAttribute('aria-label'), + element.getAttribute('title'), + element.getAttribute('data-testid'), + element.getAttribute('data-test-id'), + element.getAttribute('id'), + element.getAttribute('role'), + typeof element.className === 'string' ? element.className : '', + textOf(element), + ] + .map((value) => normalize(value)?.toLowerCase() ?? '') + .filter(Boolean) + .join(' ') + const rectOf = (element: HTMLElement) => { + const currentRect = element.getBoundingClientRect() + return { + x: Math.round(currentRect.left), + y: Math.round(currentRect.top), + width: Math.round(currentRect.width), + height: Math.round(currentRect.height), + } + } + const selectors = [ + "[class*='chat-item-menu-wrapper'] button", + "[class*='chat-item-menu-wrapper'] [role='button']", + 'button', + "[role='button']", + '[aria-haspopup]', + "[class*='more']", + "[class*='More']", + "[class*='menu']", + "[class*='Menu']", + "[class*='operation']", + "[class*='Operation']", + ].join(',') + const targetRight = targetRect.x + targetRect.width + const targetBottom = targetRect.y + targetRect.height + const candidates: Array<{ rect: ReturnType; score: number }> = [] + for (const node of Array.from(document.querySelectorAll(selectors))) { + if (!(node instanceof HTMLElement) || !isVisible(node)) { + continue + } + const nodeRect = node.getBoundingClientRect() + if ( + nodeRect.left < targetRect.x - 16 || + nodeRect.left > targetRight + 44 || + nodeRect.top < targetRect.y - 20 || + nodeRect.bottom > targetBottom + 20 + ) { + continue + } + const hint = hintText(node) + const compactText = (textOf(node) ?? '').replace(/\s+/g, '') + const looksLikeMenu = + /more|menu|overflow|ellipsis|operation|action|option|更多|菜单|操作|选项|三点|省略/.test( + hint, + ) || /^(?:\.{3}|…|⋯|···|更多)?$/.test(compactText) + const isTiny = nodeRect.width <= 56 && nodeRect.height <= 56 + const isRightSide = nodeRect.left >= targetRight - Math.max(96, targetRect.width * 0.28) + if (!looksLikeMenu && !isTiny && !isRightSide) { + continue + } + let score = 400 + if (looksLikeMenu) { + score += 700 + } + if (/chat-item-menu-wrapper/.test(hint)) { + score += 500 + } + if (isRightSide) { + score += 220 + } + if (isTiny) { + score += 120 + } + score += Math.max(0, nodeRect.left - targetRect.x) + candidates.push({ rect: rectOf(node), score }) + } + return candidates.sort((left, right) => right.score - left.score)[0]?.rect ?? null + }, rect) + .catch(() => null) + + const readDeleteMenuItemRect = async (): Promise< + NonNullable | null + > => + page + .evaluate(() => { + const normalize = (value: unknown): string | null => { + if (typeof value !== 'string') { + return null + } + const trimmed = value.trim().replace(/\s+/g, ' ') + return trimmed ? trimmed : null + } + const isVisible = (element: Element | null | undefined): element is HTMLElement => { + if (!(element instanceof HTMLElement)) { + return false + } + let current: HTMLElement | null = element + for (let depth = 0; current && depth < 8; depth += 1) { + const style = window.getComputedStyle(current) + if ( + style.display === 'none' || + style.visibility === 'hidden' || + Number(style.opacity || '1') <= 0.02 + ) { + return false + } + current = current.parentElement + } + const rect = element.getBoundingClientRect() + return rect.width > 0 && rect.height > 0 + } + const textOf = (element: Element | null | undefined): string | null => + element instanceof HTMLElement ? normalize(element.innerText) ?? normalize(element.textContent) : null + const hintText = (element: HTMLElement): string => + [ + element.getAttribute('aria-label'), + element.getAttribute('title'), + element.getAttribute('data-testid'), + element.getAttribute('data-test-id'), + element.getAttribute('id'), + element.getAttribute('role'), + typeof element.className === 'string' ? element.className : '', + textOf(element), + ] + .map((value) => normalize(value)?.toLowerCase() ?? '') + .filter(Boolean) + .join(' ') + const clickableAncestor = (seed: HTMLElement): HTMLElement => { + let current: HTMLElement | null = seed + for (let depth = 0; current && depth < 4; depth += 1) { + const tagName = current.tagName.toLowerCase() + const role = normalize(current.getAttribute('role')) + if (tagName === 'button' || role === 'button' || role === 'menuitem' || current.tabIndex >= 0) { + return current + } + current = current.parentElement + } + return seed + } + const ancestorMatches = ( + element: HTMLElement, + matcher: (candidate: HTMLElement) => boolean, + maxDepth = 6, + ): boolean => { + let current: HTMLElement | null = element + for (let depth = 0; current && depth <= maxDepth; depth += 1) { + if (matcher(current)) { + return true + } + current = current.parentElement + } + return false + } + const candidates: Array<{ rect: { x: number; y: number; width: number; height: number }; score: number }> = [] + for (const node of Array.from(document.querySelectorAll("button, [role='button'], [role='menuitem'], li, div, span"))) { + if (!(node instanceof HTMLElement) || !isVisible(node)) { + continue + } + const text = textOf(node)?.replace(/\s+/g, '') ?? '' + if (!/^(?:删除|删除对话|删除聊天|删除记录)$/.test(text)) { + continue + } + const clickable = clickableAncestor(node) + const rect = clickable.getBoundingClientRect() + if (rect.width > 280 || rect.height > 84) { + continue + } + const isInsideDialog = ancestorMatches( + clickable, + (candidate) => + candidate.getAttribute('role') === 'dialog' || + candidate.getAttribute('aria-modal') === 'true' || + /dialog|modal|confirm|alert-dialog/.test(hintText(candidate)), + ) + if (isInsideDialog) { + continue + } + const isMenuItem = clickable.getAttribute('role') === 'menuitem' + const isInsideMenu = ancestorMatches( + clickable, + (candidate) => + candidate.getAttribute('role') === 'menu' || + candidate.hasAttribute('data-radix-menu-content') || + /dropdown-menu|menu-content|popover|popup|context/.test(hintText(candidate)), + ) + if (!isMenuItem && !isInsideMenu) { + continue + } + candidates.push({ + rect: { + x: Math.round(rect.left), + y: Math.round(rect.top), + width: Math.round(rect.width), + height: Math.round(rect.height), + }, + score: (text === '删除' ? 400 : 100) + (isMenuItem ? 240 : 0) + (isInsideMenu ? 180 : 0), + }) + } + return candidates.sort((left, right) => right.score - left.score)[0]?.rect ?? null + }) + .catch(() => null) + + const readConfirmDeleteButtonRect = async (): Promise< + NonNullable | null + > => + page + .evaluate(() => { + const normalize = (value: unknown): string | null => { + if (typeof value !== 'string') { + return null + } + const trimmed = value.trim().replace(/\s+/g, ' ') + return trimmed ? trimmed : null + } + const isVisible = (element: Element | null | undefined): element is HTMLElement => { + if (!(element instanceof HTMLElement)) { + return false + } + let current: HTMLElement | null = element + for (let depth = 0; current && depth < 8; depth += 1) { + const style = window.getComputedStyle(current) + if ( + style.display === 'none' || + style.visibility === 'hidden' || + Number(style.opacity || '1') <= 0.02 + ) { + return false + } + current = current.parentElement + } + const rect = element.getBoundingClientRect() + return rect.width > 0 && rect.height > 0 + } + const textOf = (element: Element | null | undefined): string | null => + element instanceof HTMLElement ? normalize(element.innerText) ?? normalize(element.textContent) : null + const clickableAncestor = (seed: HTMLElement): HTMLElement => { + let current: HTMLElement | null = seed + for (let depth = 0; current && depth < 4; depth += 1) { + const tagName = current.tagName.toLowerCase() + const role = normalize(current.getAttribute('role')) + if (tagName === 'button' || role === 'button' || current.tabIndex >= 0) { + return current + } + current = current.parentElement + } + return seed + } + const dialogs = Array.from( + document.querySelectorAll("[role='dialog'], [aria-modal='true'], [class*='modal'], [class*='Modal'], [class*='dialog'], [class*='Dialog'], div"), + ).filter((node): node is HTMLElement => node instanceof HTMLElement && isVisible(node)) + const dialog = dialogs + .map((node) => { + const rect = node.getBoundingClientRect() + const isModal = + node.getAttribute('role') === 'dialog' || node.getAttribute('aria-modal') === 'true' + return { + node, + text: textOf(node) ?? '', + area: rect.width * rect.height, + isModal, + } + }) + .filter( + (item) => + item.area >= 4_000 && + item.area <= window.innerWidth * window.innerHeight * 0.92 && + /删除|确定|确认|取消/.test(item.text) && + /删除.*(?:对话|聊天|记录)|确定删除|确认删除|删除后|是否删除|删除此/.test(item.text), + ) + .sort((left, right) => { + if (left.isModal !== right.isModal) { + return left.isModal ? -1 : 1 + } + return left.area - right.area + })[0]?.node + const scope = dialog ?? document.body + const candidates: Array<{ rect: { x: number; y: number; width: number; height: number }; score: number }> = [] + for (const node of Array.from(scope.querySelectorAll("button, [role='button'], [tabindex], div, span"))) { + if (!(node instanceof HTMLElement) || !isVisible(node)) { + continue + } + const text = textOf(node)?.replace(/\s+/g, '') ?? '' + if (!text || /取消|cancel/i.test(text)) { + continue + } + if (!/^(?:删除|确认删除|确定删除|确定|确认)$/.test(text)) { + continue + } + const clickable = clickableAncestor(node) + const rect = clickable.getBoundingClientRect() + if (rect.width > 240 || rect.height > 84) { + continue + } + candidates.push({ + rect: { + x: Math.round(rect.left), + y: Math.round(rect.top), + width: Math.round(rect.width), + height: Math.round(rect.height), + }, + score: (text === '删除' ? 500 : 100) + Math.round(rect.left), + }) + } + return candidates.sort((left, right) => right.score - left.score)[0]?.rect ?? null + }) + .catch(() => null) + + const waitForRect = async ( + readRect: () => Promise | null>, + timeoutMs: number, + intervalMs = 160, + ): Promise | null> => { + const startedAt = Date.now() + while (Date.now() - startedAt <= timeoutMs) { + if (signal?.aborted) { + return null + } + const found = await readRect() + if (found) { + return found + } + await sleep(intervalMs, signal).catch(() => undefined) + } + return null + } + + const verifyDeleted = async ( + failureReason: string, + flags: Pick, + ): Promise => { + const verified = await page.evaluate(deleteDoubaoCurrentConversationInPage, { + ...effectiveInput, + dryRun: true, + }) + if (!verified.target_rect) { + return { + ...buildDoubaoHistoryCleanupResult(effectiveInput, '', safePageURL(page)), + ok: true, + reason: null, + target_text: previous.target_text, + target_rect: previous.target_rect, + ...flags, + } + } + + return { + ...verified, + ok: false, + reason: failureReason, + ...flags, + } + } + + const menuX = rect.x + rect.width - 18 + const menuY = rect.y + rect.height / 2 + try { + const alreadyOpenConfirmRect = await readConfirmDeleteButtonRect() + if (alreadyOpenConfirmRect) { + await clickRect(alreadyOpenConfirmRect) + await sleep(900, signal).catch(() => undefined) + return await verifyDeleted('doubao_history_pointer_delete_not_verified', { + menu_clicked: true, + delete_clicked: true, + confirm_clicked: true, + }) + } + + let deleteRect = await readDeleteMenuItemRect() + if (!deleteRect) { + await page.mouse.move(menuX, menuY) + await sleep(220, signal).catch(() => undefined) + const menuButtonRect = await readMenuButtonRect() + if (menuButtonRect) { + await clickRect(menuButtonRect) + } else { + await page.mouse.click(menuX, menuY) + } + await sleep(350, signal).catch(() => undefined) + deleteRect = await waitForRect(readDeleteMenuItemRect, 1_800) + } + + if (!deleteRect) { + return buildDoubaoHistoryCleanupResult( + effectiveInput, + 'doubao_history_pointer_delete_not_found', + safePageURL(page), + ) + } + await clickRect(deleteRect) + await sleep(500, signal).catch(() => undefined) + + const confirmRect = await waitForRect(readConfirmDeleteButtonRect, 2_200) + + if (!confirmRect) { + return await verifyDeleted('doubao_history_pointer_confirm_not_found', { + menu_clicked: true, + delete_clicked: true, + confirm_clicked: false, + }) + } + await clickRect(confirmRect) + await sleep(900, signal).catch(() => undefined) + + return await verifyDeleted('doubao_history_pointer_delete_not_verified', { + menu_clicked: true, + delete_clicked: true, + confirm_clicked: true, + }) + } catch (error) { + return buildDoubaoHistoryCleanupResult( + effectiveInput, + `doubao_history_pointer_cleanup_error: ${formatUnknownError(error)}`, + safePageURL(page), + ) + } +} + async function attachDoubaoResponseCapture(page: PlaywrightPage): Promise<{ captures: DoubaoCaptureRecord[] flush: (timeoutMs: number) => Promise @@ -5040,6 +6609,17 @@ export const doubaoAdapter: MonitorAdapter = { } } + context.reportProgress('doubao.cleanup_history') + const historyCleanup = await deleteDoubaoCurrentConversationHistory( + page, + { + conversationId: conversationID, + pageUrl: pageResult.url, + title: questionText, + }, + context.signal, + ) + context.reportProgress('doubao.parse_result') return { status: 'succeeded', @@ -5086,6 +6666,7 @@ export const doubaoAdapter: MonitorAdapter = { stream_reasoning_length: streamSummary.reasoning?.length ?? 0, dom_reasoning_length: pageResult.domReasoning?.length ?? 0, selected_answer_length: submission.answer?.length ?? 0, + history_cleanup: historyCleanup, captures: captureDebug, }, }, @@ -5109,6 +6690,7 @@ export const __doubaoTestUtils = { shouldAllowDoubaoDomAnswer, shouldAllowDoubaoReasoningAnswer, hasDoubaoFinishedStreamCapture, + deleteDoubaoCurrentConversationInPage, parseDoubaoCaptures, parseDoubaoStreamBody, resolveDoubaoSubmissionAnswer,