From 96106abd26f563989ee2c9d861d0cad1cbf6110c Mon Sep 17 00:00:00 2001 From: East Date: Sun, 22 Mar 2026 21:08:00 +0800 Subject: [PATCH] feat(doubao): add Doubao AI desktop app CLI adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add CDP-based adapter for Doubao AI chat app (豆包) - Commands: status, send, read, new, ask, screenshot, dump - Supports Chrome DevTools Protocol control via --remote-debugging-port - Works on macOS with Doubao.app Closes #247 --- src/clis/doubao/README.md | 55 +++++++++++++ src/clis/doubao/README.zh-CN.md | 54 +++++++++++++ src/clis/doubao/ask.ts | 139 ++++++++++++++++++++++++++++++++ src/clis/doubao/dump.ts | 29 +++++++ src/clis/doubao/new.ts | 35 ++++++++ src/clis/doubao/read.ts | 55 +++++++++++++ src/clis/doubao/screenshot.ts | 26 ++++++ src/clis/doubao/send.ts | 66 +++++++++++++++ src/clis/doubao/status.ts | 17 ++++ 9 files changed, 476 insertions(+) create mode 100644 src/clis/doubao/README.md create mode 100644 src/clis/doubao/README.zh-CN.md create mode 100644 src/clis/doubao/ask.ts create mode 100644 src/clis/doubao/dump.ts create mode 100644 src/clis/doubao/new.ts create mode 100644 src/clis/doubao/read.ts create mode 100644 src/clis/doubao/screenshot.ts create mode 100644 src/clis/doubao/send.ts create mode 100644 src/clis/doubao/status.ts diff --git a/src/clis/doubao/README.md b/src/clis/doubao/README.md new file mode 100644 index 0000000..1ff7b51 --- /dev/null +++ b/src/clis/doubao/README.md @@ -0,0 +1,55 @@ +# Doubao (豆包) CLI Adapter + +Control the Doubao AI desktop app via CLI using Chrome DevTools Protocol (CDP). + +## Prerequisites + +1. Launch Doubao with remote debugging port: + +```bash +"/Applications/Doubao.app/Contents/MacOS/Doubao" --remote-debugging-port=9226 +``` + +2. Set environment variable: + +```bash +export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9226" +export OPENCLI_CDP_TARGET="doubao" +``` + +## Commands + +| Command | Description | +|---------|-------------| +| `opencli doubao status` | Check CDP connection status | +| `opencli doubao send "message"` | Send a message to Doubao AI | +| `opencli doubao read` | Read current chat history | +| `opencli doubao new` | Start a new chat | +| `opencli doubao ask "question"` | Send message and wait for response | +| `opencli doubao screenshot` | Capture screenshot to /tmp/doubao-screenshot.png | +| `opencli doubao dump` | Dump DOM to /tmp/doubao-dom.html | + +## Examples + +```bash +# Check connection +opencli doubao status + +# Send a message +opencli doubao send "What is the capital of France?" + +# Ask and get response (waits up to 30s) +opencli doubao ask "What is 2+2?" + +# Read conversation +opencli doubao read + +# New conversation +opencli doubao new +``` + +## Notes + +- Doubao must be running with `--remote-debugging-port=9226` +- The app URL scheme is `doubao://doubao-chat/chat` +- If multiple targets exist, set `OPENCLI_CDP_TARGET=doubao` to select the correct one \ No newline at end of file diff --git a/src/clis/doubao/README.zh-CN.md b/src/clis/doubao/README.zh-CN.md new file mode 100644 index 0000000..dc21165 --- /dev/null +++ b/src/clis/doubao/README.zh-CN.md @@ -0,0 +1,54 @@ +# 豆包 (Doubao) CLI 适配器 + +通过 Chrome DevTools Protocol (CDP) 控制豆包 AI 桌面应用。 + +## 前置条件 + +1. 启动豆包并开启远程调试端口: + +```bash +"/Applications/Doubao.app/Contents/MacOS/Doubao" --remote-debugging-port=9226 +``` + +2. 设置环境变量: + +```bash +export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9226" +export OPENCLI_CDP_TARGET="doubao-chat" +``` + +## 命令 + +| 命令 | 说明 | +|------|------| +| `opencli doubao status` | 检查 CDP 连接状态 | +| `opencli doubao send "消息"` | 发送消息到豆包 | +| `opencli doubao read` | 读取当前聊天历史 | +| `opencli doubao new` | 开始新对话 | +| `opencli doubao ask "问题"` | 发送消息并等待 AI 回复 | +| `opencli doubao screenshot` | 截图保存到 /tmp/doubao-screenshot.png | +| `opencli doubao dump` | 导出 DOM 到 /tmp/doubao-dom.html | + +## 示例 + +```bash +# 检查连接 +opencli doubao status + +# 发送消息 +opencli doubao send "今天天气怎么样?" + +# 提问并等待回复(默认超时30秒) +opencli doubao ask "用一句话介绍北京" + +# 读取对话历史 +opencli doubao read + +# 开始新对话 +opencli doubao new +``` + +## 注意事项 + +- 豆包必须使用 `--remote-debugging-port=9226` 参数启动 +- 如果有多个目标,使用 `OPENCLI_CDP_TARGET=doubao-chat` 指定主聊天窗口 \ No newline at end of file diff --git a/src/clis/doubao/ask.ts b/src/clis/doubao/ask.ts new file mode 100644 index 0000000..afbc2be --- /dev/null +++ b/src/clis/doubao/ask.ts @@ -0,0 +1,139 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +export const askCommand = cli({ + site: 'doubao', + name: 'ask', + description: 'Send a message to Doubao and wait for the AI response', + domain: 'doubao', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'text', required: true, positional: true, help: 'Prompt to send' }, + { name: 'timeout', required: false, default: '30', help: 'Max seconds to wait for response (default: 30)' }, + ], + columns: ['Role', 'Text'], + func: async (page: IPage, kwargs: any) => { + const text = kwargs.text as string; + const timeout = parseInt(kwargs.timeout as string, 10) || 30; + + // Count existing messages before sending + const beforeCount = await page.evaluate(` + document.querySelectorAll('[data-testid="message_content"]').length + `); + + // Inject text + const injected = await page.evaluate( + `(function(t) { + const textarea = document.querySelector('[data-testid="chat_input_input"]'); + if (!textarea) return false; + + textarea.focus(); + const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set; + if (nativeInputValueSetter) { + nativeInputValueSetter.call(textarea, t); + } else { + textarea.value = t; + } + textarea.dispatchEvent(new Event('input', { bubbles: true })); + textarea.dispatchEvent(new Event('change', { bubbles: true })); + return true; + })(${JSON.stringify(text)})` + ); + + if (!injected) throw new Error('Could not find chat input element.'); + await page.wait(0.5); + + // Click send button + const clicked = await page.evaluate(` + (function() { + const btn = document.querySelector('[data-testid="chat_input_send_button"]'); + if (!btn) return false; + btn.click(); + return true; + })() + `); + if (!clicked) await page.pressKey('Enter'); + + // Poll: first wait for assistant message to appear, then wait for streaming to finish + const pollInterval = 1; + const maxPolls = Math.ceil(timeout / pollInterval); + let response = ''; + let phase: 'waiting' | 'streaming' = 'waiting'; + + for (let i = 0; i < maxPolls; i++) { + await page.wait(pollInterval); + + const result = await page.evaluate( + `(function(prevCount) { + const msgs = document.querySelectorAll('[data-testid="message_content"]'); + + // Phase 1: wait for new assistant message + if (msgs.length <= prevCount) { + return { phase: 'waiting', text: null }; + } + + const lastMsg = msgs[msgs.length - 1]; + const isUser = lastMsg.classList.contains('justify-end'); + if (isUser) { + return { phase: 'waiting', text: null }; // Still waiting for assistant + } + + const textEl = lastMsg.querySelector('[data-testid="message_text_content"]'); + if (!textEl) return { phase: 'waiting', text: null }; + + // Check if still streaming + const isStreaming = textEl.querySelector('[data-testid="indicator"]') !== null || + textEl.getAttribute('data-show-indicator') === 'true'; + + if (isStreaming) { + // Get partial text + 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: 'streaming', text: text.substring(0, 100) }; + } + + // Streaming complete - get full text + 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})` + ); + + if (!result) continue; + + if (result.phase === 'done' && result.text) { + response = result.text; + break; + } else if (result.phase === 'streaming') { + // Stay in streaming phase, continue polling + phase = 'streaming'; + } else { + phase = 'waiting'; + } + } + + 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/dump.ts b/src/clis/doubao/dump.ts new file mode 100644 index 0000000..26d730b --- /dev/null +++ b/src/clis/doubao/dump.ts @@ -0,0 +1,29 @@ +import * as fs from 'fs'; +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +export const dumpCommand = cli({ + site: 'doubao', + name: 'dump', + description: 'Dump Doubao DOM and accessibility tree to /tmp/doubao-*.html', + domain: 'doubao', + strategy: Strategy.UI, + browser: true, + args: [], + columns: ['Status', 'File'], + func: async (page: IPage) => { + 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/new.ts b/src/clis/doubao/new.ts new file mode 100644 index 0000000..f286754 --- /dev/null +++ b/src/clis/doubao/new.ts @@ -0,0 +1,35 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +export const newCommand = cli({ + site: 'doubao', + name: 'new', + description: 'Start a new chat in Doubao AI', + domain: 'doubao', + strategy: Strategy.UI, + browser: true, + args: [], + columns: ['Status'], + func: async (page: IPage) => { + // Try clicking the new chat button first + const clicked = await page.evaluate(` + (function() { + // Try new_chat_button first (in the chat area) + let btn = document.querySelector('[data-testid="new_chat_button"]'); + if (btn) { btn.click(); return true; } + + // Try app-open-newChat (in sidebar) + btn = document.querySelector('[data-testid="app-open-newChat"]'); + if (btn) { btn.click(); return true; } + + return false; + })() + `); + + 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/read.ts b/src/clis/doubao/read.ts new file mode 100644 index 0000000..d36f509 --- /dev/null +++ b/src/clis/doubao/read.ts @@ -0,0 +1,55 @@ +import { cli, Strategy } from '../../registry.js'; + +export const readCommand = cli({ + site: 'doubao', + name: 'read', + description: 'Read chat history from Doubao AI', + domain: 'doubao', + strategy: Strategy.UI, + browser: true, + columns: ['Role', 'Text'], + func: async (page) => { + const messages = await page.evaluate(` + (function() { + const results = []; + const msgContainers = document.querySelectorAll('[data-testid="message_content"]'); + + for (const container of msgContainers) { + const textEl = container.querySelector('[data-testid="message_text_content"]'); + if (!textEl) continue; + + // Skip if still streaming (indicator present or show-indicator="true") + const isStreaming = textEl.querySelector('[data-testid="indicator"]') !== null || + textEl.getAttribute('data-show-indicator') === 'true'; + if (isStreaming) continue; + + const isUser = container.classList.contains('justify-end'); + + // Get text content from markdown body + 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; + })() + `); + + 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/screenshot.ts b/src/clis/doubao/screenshot.ts new file mode 100644 index 0000000..650cafb --- /dev/null +++ b/src/clis/doubao/screenshot.ts @@ -0,0 +1,26 @@ +import * as fs from 'fs'; +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +export const screenshotCommand = cli({ + site: 'doubao', + name: 'screenshot', + description: 'Capture a screenshot of the Doubao AI window', + domain: 'doubao', + 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: IPage, kwargs: any) => { + const outputPath = (kwargs.output as string) || '/tmp/doubao-screenshot.png'; + + try { + const base64 = await page.screenshot({ path: outputPath }); + return [{ Status: 'Success', File: outputPath }]; + } catch (e: any) { + return [{ Status: 'Error: ' + e.message, File: '' }]; + } + }, +}); \ No newline at end of file diff --git a/src/clis/doubao/send.ts b/src/clis/doubao/send.ts new file mode 100644 index 0000000..fa25364 --- /dev/null +++ b/src/clis/doubao/send.ts @@ -0,0 +1,66 @@ +import { cli, Strategy } from '../../registry.js'; + +export const sendCommand = cli({ + site: 'doubao', + name: 'send', + description: 'Send a message to Doubao AI chat', + domain: 'doubao', + 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; + + // Doubao uses data-testid="chat_input_input" for the textarea + const injected = await page.evaluate( + `(function(t) { + const textarea = document.querySelector('[data-testid="chat_input_input"]'); + if (!textarea) return { ok: false, error: 'No textarea found' }; + + textarea.focus(); + + // Set value directly and dispatch events + const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set; + if (nativeInputValueSetter) { + nativeInputValueSetter.call(textarea, t); + } else { + textarea.value = t; + } + + // Dispatch input event (needed for React/Semi UI to detect change) + textarea.dispatchEvent(new Event('input', { bubbles: true })); + textarea.dispatchEvent(new Event('change', { bubbles: true })); + + return { ok: true }; + })(${JSON.stringify(text)})` + ); + + if (!injected || !injected.ok) { + throw new Error('Could not find chat input element: ' + (injected?.error || 'unknown error')); + } + + await page.wait(0.5); + + // Click the send button instead of pressing Enter + const clicked = await page.evaluate(` + (function() { + const btn = document.querySelector('[data-testid="chat_input_send_button"]'); + if (!btn) return false; + btn.click(); + return true; + })() + `); + + if (!clicked) { + // Fallback: try pressing Enter + 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/status.ts b/src/clis/doubao/status.ts new file mode 100644 index 0000000..142d628 --- /dev/null +++ b/src/clis/doubao/status.ts @@ -0,0 +1,17 @@ +import { cli, Strategy } from '../../registry.js'; + +export const statusCommand = cli({ + site: 'doubao', + name: 'status', + description: 'Check CDP connection to Doubao AI chat app', + domain: 'doubao', + 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