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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ git clone git@github.com:jackwener/opencli.git && cd opencli && npm install && n
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `user` `user-posts` `user-comments` `read` `save` `saved` `subscribe` `upvote` `upvoted` `comment` |
| **amazon** | `bestsellers` `search` `product` `offer` `discussion` |
| **gemini** | `new` `ask` `image` |
| **yuanbao** | `new` `ask` |
| **notebooklm** | `status` `list` `open` `select` `current` `get` `metadata` `source-list` `source-get` `source-fulltext` `source-guide` `history` `note-list` `notes-list` `notes-get` `summary` |
| **spotify** | `auth` `status` `play` `pause` `next` `prev` `volume` `search` `queue` `shuffle` `repeat` |

Expand Down
1 change: 1 addition & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ npx skills add jackwener/opencli --skill opencli-oneshot # 快速命令参
| **tiktok** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `live` `notifications` `friends` | 浏览器 |
| **bluesky** | `search` `trending` `user` `profile` `thread` `feeds` `followers` `following` `starter-packs` | 公开 |
| **douyin** | `videos` `publish` `drafts` `draft` `delete` `stats` `profile` `update` `hashtag` `location` `activities` `collections` | 浏览器 |
| **yuanbao** | `new` `ask` | 浏览器 |

66+ 适配器 — **[→ 查看完整命令列表](./docs/adapters/index.md)**

Expand Down
1 change: 1 addition & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export default defineConfig({
{ text: 'Grok', link: '/adapters/browser/grok' },
{ text: 'Amazon', link: '/adapters/browser/amazon' },
{ text: 'Gemini', link: '/adapters/browser/gemini' },
{ text: 'Yuanbao', link: '/adapters/browser/yuanbao' },
{ text: 'NotebookLM', link: '/adapters/browser/notebooklm' },
{ text: 'WeRead', link: '/adapters/browser/weread' },
{ text: 'Douban', link: '/adapters/browser/douban' },
Expand Down
64 changes: 64 additions & 0 deletions docs/adapters/browser/yuanbao.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Yuanbao

**Mode**: 🔐 Browser · **Domain**: `yuanbao.tencent.com`

## Commands

| Command | Description |
|---------|-------------|
| `opencli yuanbao new` | Start a new Yuanbao conversation |
| `opencli yuanbao ask <prompt>` | Send a prompt to Yuanbao web chat and wait for the reply |

## Usage Examples

```bash
# Start a fresh chat
opencli yuanbao new

# Basic ask (internet search on by default, deep thinking off by default)
opencli yuanbao ask "你好"

# Wait longer for a longer answer
opencli yuanbao ask "帮我总结这篇文章" --timeout 90

# Disable internet search explicitly
opencli yuanbao ask "你好" --search false

# Enable deep thinking explicitly
opencli yuanbao ask "你好" --think true
```

## Options

### `new`

- No options

### `ask`

| Option | Description |
|--------|-------------|
| `prompt` | Prompt to send (required positional argument) |
| `--timeout` | Max seconds to wait for a reply (default: `60`) |
| `--search` | Enable internet search before sending (default: `true`) |
| `--think` | Enable deep thinking before sending (default: `false`) |

## Behavior

- The adapter targets the Yuanbao consumer web UI and sends the prompt through the visible Quill composer.
- `new` clicks the left-side Yuanbao new-chat trigger and falls back to reloading the Yuanbao homepage if needed.
- Before sending, it aligns the `联网搜索` and `深度思考` buttons to the requested `--search` / `--think` state.
- It waits for transcript changes to stabilize before returning the assistant reply.
- If Yuanbao opens a login gate instead of answering, the command returns a `[BLOCKED]` system message with a session hint.

## Prerequisites

- Chrome is running
- You are already logged into `yuanbao.tencent.com`
- [Browser Bridge extension](/guide/browser-bridge) is installed

## Caveats

- This adapter drives the Yuanbao web UI, not a public API.
- It depends on the current browser session and may fail if Yuanbao shows login, consent, challenge, or other gating UI.
- DOM or product changes on Yuanbao can break composer detection, submit behavior, or transcript extraction.
1 change: 1 addition & 0 deletions docs/adapters/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Run `opencli list` for the live registry.
| **[chaoxing](./browser/chaoxing)** | `assignments` `exams` | 🔐 Browser |
| **[grok](./browser/grok)** | `ask` | 🔐 Browser |
| **[gemini](./browser/gemini)** | `new` `ask` `image` | 🔐 Browser |
| **[yuanbao](./browser/yuanbao)** | `new` `ask` | 🔐 Browser |
| **[notebooklm](./browser/notebooklm)** | `status` `list` `open` `select` `current` `get` `metadata` `source-list` `source-get` `source-fulltext` `source-guide` `history` `note-list` `notes-list` `notes-get` `summary` | 🔐 Browser |
| **[doubao](./browser/doubao)** | `status` `new` `send` `read` `ask` `history` `detail` `meeting-summary` `meeting-transcript` | 🔐 Browser |
| **[weread](./browser/weread)** | `shelf` `search` `book` `ranking` `notebooks` `highlights` `notes` | 🔐 Browser |
Expand Down
156 changes: 156 additions & 0 deletions src/clis/yuanbao/ask.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { describe, expect, it, vi } from 'vitest';
import type { IPage } from '../../types.js';
import { AuthRequiredError, CommandExecutionError, TimeoutError } from '../../errors.js';
import { __test__ } from './ask.js';
import { askCommand } from './ask.js';

describe('yuanbao ask helpers', () => {
describe('isOnYuanbao', () => {
const fakePage = (url: string | Error): IPage =>
({ evaluate: () => url instanceof Error ? Promise.reject(url) : Promise.resolve(url) }) as unknown as IPage;

it('returns true for yuanbao.tencent.com URLs', async () => {
expect(await __test__.isOnYuanbao(fakePage('https://yuanbao.tencent.com/'))).toBe(true);
expect(await __test__.isOnYuanbao(fakePage('https://yuanbao.tencent.com/chat/abc'))).toBe(true);
});

it('returns false for non-yuanbao domains', async () => {
expect(await __test__.isOnYuanbao(fakePage('https://example.com/?next=yuanbao.tencent.com'))).toBe(false);
expect(await __test__.isOnYuanbao(fakePage('about:blank'))).toBe(false);
});

it('returns false when evaluate throws', async () => {
expect(await __test__.isOnYuanbao(fakePage(new Error('detached')))).toBe(false);
});
});

it('removes echoed prompt prefixes from transcript additions', () => {
expect(__test__.sanitizeYuanbaoResponseText('你好\n你好,我是元宝。', '你好')).toBe('你好,我是元宝。');
});

it('filters transient in-progress assistant placeholders', () => {
expect(__test__.sanitizeYuanbaoResponseText('正在搜索资料', '张雪机车相关的股票有哪些?')).toBe('');
});

it('normalizes boolean flags with explicit defaults', () => {
expect(__test__.normalizeBooleanFlag(undefined, true)).toBe(true);
expect(__test__.normalizeBooleanFlag(undefined, false)).toBe(false);
expect(__test__.normalizeBooleanFlag('true', false)).toBe(true);
expect(__test__.normalizeBooleanFlag('1', false)).toBe(true);
expect(__test__.normalizeBooleanFlag('yes', false)).toBe(true);
expect(__test__.normalizeBooleanFlag('false', true)).toBe(false);
});

it('ignores baseline lines and echoed prompts when collecting additions', () => {
const response = __test__.collectYuanbaoTranscriptAdditions(
['旧消息'],
['旧消息', '你好', '你好\n你好,我是元宝。'],
'你好',
);

expect(response).toBe('你好,我是元宝。');
});

it('prefers fresh assistant messages over echoed prompts and older messages', () => {
const response = __test__.pickLatestYuanbaoAssistantCandidate(
['旧回复', '你好', '你好!我是元宝,由腾讯推出的AI助手。'],
1,
'你好',
);

expect(response).toBe('你好!我是元宝,由腾讯推出的AI助手。');
});

it('converts assistant html tables to markdown tables via turndown', () => {
const markdown = __test__.convertYuanbaoHtmlToMarkdown(`
<h3>核心产业链概念股一览</h3>
<table>
<thead>
<tr><th>细分赛道</th><th>核心标的</th></tr>
</thead>
<tbody>
<tr><td>光模块</td><td>中际旭创</td></tr>
</tbody>
</table>
`);

expect(markdown).toContain('### 核心产业链概念股一览');
expect(markdown).toContain('| 细分赛道 | 核心标的 |');
expect(markdown).toContain('| --- | --- |');
expect(markdown).toContain('| 光模块 | 中际旭创 |');
});

it('tracks stabilization by incrementing repeats and resetting on changes', () => {
expect(__test__.updateStableState('', 0, '第一段')).toEqual({
previousText: '第一段',
stableCount: 0,
});

expect(__test__.updateStableState('第一段', 0, '第一段')).toEqual({
previousText: '第一段',
stableCount: 1,
});

expect(__test__.updateStableState('第一段', 1, '第二段')).toEqual({
previousText: '第二段',
stableCount: 0,
});
});
});

function createAskPageMock(overrides: {
currentUrl?: string;
hasLoginGate?: boolean;
sendResult?: { ok?: boolean; reason?: string; detail?: string; action?: string };
} = {}): IPage {
const currentUrl = overrides.currentUrl ?? 'https://yuanbao.tencent.com/';
const hasLoginGate = overrides.hasLoginGate ?? false;
const sendResult = overrides.sendResult;

return {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockImplementation(async (script: string) => {
if (script === 'window.location.href') return currentUrl;
if (script.includes('微信扫码登录')) return hasLoginGate;
if (script.includes('[dt-button-id="internet_search"]')) return { found: false, enabled: false };
if (script.includes('[dt-button-id="deep_think"]')) return { found: false, enabled: false };
if (script.includes('.agent-chat__list__item--ai')) return [];
if (script.includes('const stopLines = new Set([')) return [];
if (script.includes('Failed to insert the prompt into the Yuanbao composer.')) {
return sendResult ?? { ok: true, action: 'click' };
}
throw new Error(`Unexpected evaluate script in test: ${script.slice(0, 80)}`);
}),
} as unknown as IPage;
}

describe('yuanbao ask command', () => {
it('throws AuthRequiredError when Yuanbao shows a login gate before sending', async () => {
const page = createAskPageMock({ hasLoginGate: true });

await expect(askCommand.func!(page, { prompt: '你好', timeout: '60', search: true, think: false }))
.rejects.toBeInstanceOf(AuthRequiredError);
});

it('throws CommandExecutionError when the prompt cannot be sent', async () => {
const page = createAskPageMock({
sendResult: {
ok: false,
reason: 'Yuanbao composer was not found.',
},
});

await expect(askCommand.func!(page, { prompt: '你好', timeout: '60', search: true, think: false }))
.rejects.toBeInstanceOf(CommandExecutionError);
});

it('throws TimeoutError when no response arrives before timeout', async () => {
const page = createAskPageMock({
sendResult: { ok: true, action: 'click' },
});

await expect(askCommand.func!(page, { prompt: '你好', timeout: '-1', search: true, think: false }))
.rejects.toBeInstanceOf(TimeoutError);
});
});
Loading
Loading