Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions src/clis/doubao/README.md
Original file line number Diff line number Diff line change
@@ -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
54 changes: 54 additions & 0 deletions src/clis/doubao/README.zh-CN.md
Original file line number Diff line number Diff line change
@@ -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` 指定主聊天窗口
139 changes: 139 additions & 0 deletions src/clis/doubao/ask.ts
Original file line number Diff line number Diff line change
@@ -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 },
];
},
});
29 changes: 29 additions & 0 deletions src/clis/doubao/dump.ts
Original file line number Diff line number Diff line change
@@ -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 },
];
},
});
35 changes: 35 additions & 0 deletions src/clis/doubao/new.ts
Original file line number Diff line number Diff line change
@@ -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' }];
},
});
55 changes: 55 additions & 0 deletions src/clis/doubao/read.ts
Original file line number Diff line number Diff line change
@@ -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 }));
},
});
Loading