fix desktop doubao history cleanup

This commit is contained in:
2026-06-24 12:32:17 +08:00
parent 6b60438f25
commit ba0914bc3e
2 changed files with 2237 additions and 0 deletions
@@ -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<string, string>
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<string, string>
private readonly rect: { left: number; top: number; width: number; height: number }
private readonly onClick?: () => void
constructor(input: {
tagName?: string
text?: string
attrs?: Record<string, string>
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<string, unknown>
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<string, unknown>
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<string, string>
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<string, string>
private readonly rect: { left: number; top: number; width: number; height: number }
private readonly onClick?: () => void
constructor(input: {
tagName?: string
text?: string
attrs?: Record<string, string>
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<string, unknown>
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<string, unknown>
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)
File diff suppressed because it is too large Load Diff