From 9e43a9f25ad79e50be8daa1c4266c2982ffeeff1 Mon Sep 17 00:00:00 2001 From: jackwener Date: Mon, 23 Mar 2026 14:50:10 +0800 Subject: [PATCH] feat(doubao-app): add Doubao AI desktop app CLI adapter - Commands: status, send, read, new, ask, screenshot, dump - Uses Strategy.UI for desktop CDP connection - Shared common.ts with selectors and evaluate script builders - Requires Doubao launched with --remote-debugging-port=9226 --- src/clis/doubao-app/ask.ts | 60 ++++++++++++++++ src/clis/doubao-app/common.ts | 116 ++++++++++++++++++++++++++++++ src/clis/doubao-app/dump.ts | 28 ++++++++ src/clis/doubao-app/new.ts | 21 ++++++ src/clis/doubao-app/read.ts | 21 ++++++ src/clis/doubao-app/screenshot.ts | 19 +++++ src/clis/doubao-app/send.ts | 30 ++++++++ src/clis/doubao-app/status.ts | 17 +++++ 8 files changed, 312 insertions(+) create mode 100644 src/clis/doubao-app/ask.ts create mode 100644 src/clis/doubao-app/common.ts create mode 100644 src/clis/doubao-app/dump.ts create mode 100644 src/clis/doubao-app/new.ts create mode 100644 src/clis/doubao-app/read.ts create mode 100644 src/clis/doubao-app/screenshot.ts create mode 100644 src/clis/doubao-app/send.ts create mode 100644 src/clis/doubao-app/status.ts diff --git a/src/clis/doubao-app/ask.ts b/src/clis/doubao-app/ask.ts new file mode 100644 index 0000000..6101215 --- /dev/null +++ b/src/clis/doubao-app/ask.ts @@ -0,0 +1,60 @@ +import { cli, Strategy } from '../../registry.js'; +import { SEL, injectTextScript, clickSendScript, pollResponseScript } from './common.js'; + +export const askCommand = cli({ + site: 'doubao-app', + name: 'ask', + description: 'Send a message to Doubao desktop app and wait for the AI response', + domain: 'doubao-app', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'text', required: true, positional: true, help: 'Prompt to send' }, + { name: 'timeout', type: 'int', default: 30, help: 'Max seconds to wait for response' }, + ], + columns: ['Role', 'Text'], + func: async (page, kwargs) => { + const text = kwargs.text as string; + const timeout = (kwargs.timeout as number) || 30; + + // Count existing messages before sending + const beforeCount = await page.evaluate( + `document.querySelectorAll('${SEL.MESSAGE}').length` + ); + + // Inject text + send + const injected = await page.evaluate(injectTextScript(text)); + if (!injected?.ok) throw new Error('Could not find chat input.'); + await page.wait(0.5); + + const clicked = await page.evaluate(clickSendScript()); + if (!clicked) await page.pressKey('Enter'); + + // Poll for response + const pollInterval = 1; + const maxPolls = Math.ceil(timeout / pollInterval); + let response = ''; + + for (let i = 0; i < maxPolls; i++) { + await page.wait(pollInterval); + const result = await page.evaluate(pollResponseScript(beforeCount)); + if (!result) continue; + if (result.phase === 'done' && result.text) { + response = result.text; + break; + } + } + + if (!response) { + return [ + { Role: 'User', Text: text }, + { Role: 'System', Text: `No response received within ${timeout}s.` }, + ]; + } + + return [ + { Role: 'User', Text: text }, + { Role: 'Assistant', Text: response }, + ]; + }, +}); \ No newline at end of file diff --git a/src/clis/doubao-app/common.ts b/src/clis/doubao-app/common.ts new file mode 100644 index 0000000..3d62f28 --- /dev/null +++ b/src/clis/doubao-app/common.ts @@ -0,0 +1,116 @@ +/** + * Shared constants and helpers for Doubao desktop app (Electron + CDP). + * + * Requires: Doubao launched with --remote-debugging-port=9226 + */ + +/** Selectors discovered via data-testid attributes */ +export const SEL = { + INPUT: '[data-testid="chat_input_input"]', + SEND_BTN: '[data-testid="chat_input_send_button"]', + MESSAGE: '[data-testid="message_content"]', + MESSAGE_TEXT: '[data-testid="message_text_content"]', + INDICATOR: '[data-testid="indicator"]', + NEW_CHAT: '[data-testid="new_chat_button"]', + NEW_CHAT_SIDEBAR: '[data-testid="app-open-newChat"]', +} as const; + +/** + * Inject text into the Doubao chat textarea via React-compatible value setter. + * Returns an evaluate script string. + */ +export function injectTextScript(text: string): string { + return `(function(t) { + const textarea = document.querySelector('${SEL.INPUT}'); + if (!textarea) return { ok: false, error: 'No textarea found' }; + textarea.focus(); + const setter = Object.getOwnPropertyDescriptor( + window.HTMLTextAreaElement.prototype, 'value' + )?.set; + if (setter) setter.call(textarea, t); + else textarea.value = t; + textarea.dispatchEvent(new Event('input', { bubbles: true })); + textarea.dispatchEvent(new Event('change', { bubbles: true })); + return { ok: true }; + })(${JSON.stringify(text)})`; +} + +/** + * Click the send button. Returns an evaluate script string. + */ +export function clickSendScript(): string { + return `(function() { + const btn = document.querySelector('${SEL.SEND_BTN}'); + if (!btn) return false; + btn.click(); + return true; + })()`; +} + +/** + * Read all chat messages from the DOM. Returns an evaluate script string. + */ +export function readMessagesScript(): string { + return `(function() { + const results = []; + const containers = document.querySelectorAll('${SEL.MESSAGE}'); + for (const container of containers) { + const textEl = container.querySelector('${SEL.MESSAGE_TEXT}'); + if (!textEl) continue; + // Skip streaming messages + if (textEl.querySelector('${SEL.INDICATOR}') || + textEl.getAttribute('data-show-indicator') === 'true') continue; + const isUser = container.classList.contains('justify-end'); + let text = ''; + const children = textEl.querySelectorAll('div[dir]'); + if (children.length > 0) { + text = Array.from(children).map(c => c.innerText || c.textContent || '').join(''); + } else { + text = textEl.innerText?.trim() || textEl.textContent?.trim() || ''; + } + if (!text) continue; + results.push({ role: isUser ? 'User' : 'Assistant', text: text.substring(0, 2000) }); + } + return results; + })()`; +} + +/** + * Click the new-chat button. Returns an evaluate script string. + */ +export function clickNewChatScript(): string { + return `(function() { + let btn = document.querySelector('${SEL.NEW_CHAT}'); + if (btn) { btn.click(); return true; } + btn = document.querySelector('${SEL.NEW_CHAT_SIDEBAR}'); + if (btn) { btn.click(); return true; } + return false; + })()`; +} + +/** + * Poll for a new assistant response after sending. + * Returns evaluate script that checks message count vs baseline. + */ +export function pollResponseScript(beforeCount: number): string { + return `(function(prevCount) { + const msgs = document.querySelectorAll('${SEL.MESSAGE}'); + if (msgs.length <= prevCount) return { phase: 'waiting', text: null }; + const lastMsg = msgs[msgs.length - 1]; + if (lastMsg.classList.contains('justify-end')) return { phase: 'waiting', text: null }; + const textEl = lastMsg.querySelector('${SEL.MESSAGE_TEXT}'); + if (!textEl) return { phase: 'waiting', text: null }; + if (textEl.querySelector('${SEL.INDICATOR}') || + textEl.getAttribute('data-show-indicator') === 'true') { + return { phase: 'streaming', text: null }; + } + let text = ''; + const children = textEl.querySelectorAll('div[dir]'); + if (children.length > 0) { + text = Array.from(children).map(c => c.innerText || c.textContent || '').join(''); + } else { + text = textEl.innerText?.trim() || textEl.textContent?.trim() || ''; + } + return { phase: 'done', text }; + })(${beforeCount})`; +} diff --git a/src/clis/doubao-app/dump.ts b/src/clis/doubao-app/dump.ts new file mode 100644 index 0000000..8ae247f --- /dev/null +++ b/src/clis/doubao-app/dump.ts @@ -0,0 +1,28 @@ +import * as fs from 'node:fs'; +import { cli, Strategy } from '../../registry.js'; + +export const dumpCommand = cli({ + site: 'doubao-app', + name: 'dump', + description: 'Dump Doubao desktop app DOM and snapshot to /tmp for debugging', + domain: 'doubao-app', + strategy: Strategy.UI, + browser: true, + args: [], + columns: ['Status', 'File'], + func: async (page) => { + const htmlPath = '/tmp/doubao-dom.html'; + const snapPath = '/tmp/doubao-snapshot.json'; + + const html = await page.evaluate('document.documentElement.outerHTML'); + const snap = await page.snapshot({ compact: true }); + + fs.writeFileSync(htmlPath, html); + fs.writeFileSync(snapPath, typeof snap === 'string' ? snap : JSON.stringify(snap, null, 2)); + + return [ + { Status: 'Success', File: htmlPath }, + { Status: 'Success', File: snapPath }, + ]; + }, +}); \ No newline at end of file diff --git a/src/clis/doubao-app/new.ts b/src/clis/doubao-app/new.ts new file mode 100644 index 0000000..eaddb13 --- /dev/null +++ b/src/clis/doubao-app/new.ts @@ -0,0 +1,21 @@ +import { cli, Strategy } from '../../registry.js'; +import { clickNewChatScript } from './common.js'; + +export const newCommand = cli({ + site: 'doubao-app', + name: 'new', + description: 'Start a new chat in Doubao desktop app', + domain: 'doubao-app', + strategy: Strategy.UI, + browser: true, + args: [], + columns: ['Status'], + func: async (page) => { + const clicked = await page.evaluate(clickNewChatScript()); + if (!clicked) { + await page.pressKey('Meta+N'); + } + await page.wait(3); + return [{ Status: 'Success' }]; + }, +}); \ No newline at end of file diff --git a/src/clis/doubao-app/read.ts b/src/clis/doubao-app/read.ts new file mode 100644 index 0000000..51032b6 --- /dev/null +++ b/src/clis/doubao-app/read.ts @@ -0,0 +1,21 @@ +import { cli, Strategy } from '../../registry.js'; +import { readMessagesScript } from './common.js'; + +export const readCommand = cli({ + site: 'doubao-app', + name: 'read', + description: 'Read chat history from Doubao desktop app', + domain: 'doubao-app', + strategy: Strategy.UI, + browser: true, + columns: ['Role', 'Text'], + func: async (page) => { + const messages = await page.evaluate(readMessagesScript()); + + if (!messages || messages.length === 0) { + return [{ Role: 'System', Text: 'No conversation found' }]; + } + + return messages.map((m: any) => ({ Role: m.role, Text: m.text })); + }, +}); \ No newline at end of file diff --git a/src/clis/doubao-app/screenshot.ts b/src/clis/doubao-app/screenshot.ts new file mode 100644 index 0000000..4f1d96a --- /dev/null +++ b/src/clis/doubao-app/screenshot.ts @@ -0,0 +1,19 @@ +import { cli, Strategy } from '../../registry.js'; + +export const screenshotCommand = cli({ + site: 'doubao-app', + name: 'screenshot', + description: 'Capture a screenshot of the Doubao desktop app window', + domain: 'doubao-app', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'output', required: false, help: 'Output file path (default: /tmp/doubao-screenshot.png)' }, + ], + columns: ['Status', 'File'], + func: async (page, kwargs) => { + const outputPath = (kwargs.output as string) || '/tmp/doubao-screenshot.png'; + await page.screenshot({ path: outputPath }); + return [{ Status: 'Success', File: outputPath }]; + }, +}); \ No newline at end of file diff --git a/src/clis/doubao-app/send.ts b/src/clis/doubao-app/send.ts new file mode 100644 index 0000000..adba4b3 --- /dev/null +++ b/src/clis/doubao-app/send.ts @@ -0,0 +1,30 @@ +import { cli, Strategy } from '../../registry.js'; +import { injectTextScript, clickSendScript } from './common.js'; + +export const sendCommand = cli({ + site: 'doubao-app', + name: 'send', + description: 'Send a message to Doubao desktop app', + domain: 'doubao-app', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'text', required: true, positional: true, help: 'Message text to send' }, + ], + columns: ['Status', 'Text'], + func: async (page, kwargs) => { + const text = kwargs.text as string; + + const injected = await page.evaluate(injectTextScript(text)); + if (!injected || !injected.ok) { + throw new Error('Could not find chat input: ' + (injected?.error || 'unknown')); + } + await page.wait(0.5); + + const clicked = await page.evaluate(clickSendScript()); + if (!clicked) await page.pressKey('Enter'); + + await page.wait(1); + return [{ Status: 'Sent', Text: text }]; + }, +}); \ No newline at end of file diff --git a/src/clis/doubao-app/status.ts b/src/clis/doubao-app/status.ts new file mode 100644 index 0000000..c735d20 --- /dev/null +++ b/src/clis/doubao-app/status.ts @@ -0,0 +1,17 @@ +import { cli, Strategy } from '../../registry.js'; + +export const statusCommand = cli({ + site: 'doubao-app', + name: 'status', + description: 'Check CDP connection to Doubao desktop app', + domain: 'doubao-app', + strategy: Strategy.UI, + browser: true, + args: [], + columns: ['Status', 'Url', 'Title'], + func: async (page) => { + const url = await page.evaluate('window.location.href'); + const title = await page.evaluate('document.title'); + return [{ Status: 'Connected', Url: url, Title: title }]; + }, +}); \ No newline at end of file