Skip to content
Merged
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
60 changes: 60 additions & 0 deletions src/clis/doubao-app/ask.ts
Original file line number Diff line number Diff line change
@@ -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 },
];
},
});
116 changes: 116 additions & 0 deletions src/clis/doubao-app/common.ts
Original file line number Diff line number Diff line change
@@ -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})`;
}
28 changes: 28 additions & 0 deletions src/clis/doubao-app/dump.ts
Original file line number Diff line number Diff line change
@@ -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 },
];
},
});
21 changes: 21 additions & 0 deletions src/clis/doubao-app/new.ts
Original file line number Diff line number Diff line change
@@ -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' }];
},
});
21 changes: 21 additions & 0 deletions src/clis/doubao-app/read.ts
Original file line number Diff line number Diff line change
@@ -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 }));
},
});
19 changes: 19 additions & 0 deletions src/clis/doubao-app/screenshot.ts
Original file line number Diff line number Diff line change
@@ -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 }];
},
});
30 changes: 30 additions & 0 deletions src/clis/doubao-app/send.ts
Original file line number Diff line number Diff line change
@@ -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 }];
},
});
17 changes: 17 additions & 0 deletions src/clis/doubao-app/status.ts
Original file line number Diff line number Diff line change
@@ -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 }];
},
});
Loading