Skip to content

Commit f377ec0

Browse files
tiaot33jackwener
andauthored
feat(元宝): add browser adapter and docs (#693)
* feat(yuanbao): add browser adapter and docs * refactor(yuanbao): normalize adapter failures to CliError * refactor: extract shared yuanbao helpers to reduce duplication Move isOnYuanbao, ensureYuanbaoPage, hasLoginGate, authRequired, and IS_VISIBLE_JS to shared.ts. This eliminates identical copies across ask.ts and new.ts, reducing correctness risk when modifying shared logic. --------- Co-authored-by: jackwener <jakevingoo@gmail.com>
1 parent 988908f commit f377ec0

11 files changed

Lines changed: 929 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ git clone git@github.com:jackwener/opencli.git && cd opencli && npm install && n
130130
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `user` `user-posts` `user-comments` `read` `save` `saved` `subscribe` `upvote` `upvoted` `comment` |
131131
| **amazon** | `bestsellers` `search` `product` `offer` `discussion` |
132132
| **gemini** | `new` `ask` `image` |
133+
| **yuanbao** | `new` `ask` |
133134
| **notebooklm** | `status` `list` `open` `select` `current` `get` `metadata` `source-list` `source-get` `source-fulltext` `source-guide` `history` `note-list` `notes-list` `notes-get` `summary` |
134135
| **spotify** | `auth` `status` `play` `pause` `next` `prev` `volume` `search` `queue` `shuffle` `repeat` |
135136
| **xianyu** | `search` `item` `chat` |

README.zh-CN.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ npx skills add jackwener/opencli --skill opencli-oneshot # 快速命令参
197197
| **bluesky** | `search` `trending` `user` `profile` `thread` `feeds` `followers` `following` `starter-packs` | 公开 |
198198
| **xianyu** | `search` `item` `chat` | 浏览器 |
199199
| **douyin** | `videos` `publish` `drafts` `draft` `delete` `stats` `profile` `update` `hashtag` `location` `activities` `collections` | 浏览器 |
200+
| **yuanbao** | `new` `ask` | 浏览器 |
200201

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

docs/.vitepress/config.mts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export default defineConfig({
7474
{ text: 'Grok', link: '/adapters/browser/grok' },
7575
{ text: 'Amazon', link: '/adapters/browser/amazon' },
7676
{ text: 'Gemini', link: '/adapters/browser/gemini' },
77+
{ text: 'Yuanbao', link: '/adapters/browser/yuanbao' },
7778
{ text: 'NotebookLM', link: '/adapters/browser/notebooklm' },
7879
{ text: 'WeRead', link: '/adapters/browser/weread' },
7980
{ text: 'Douban', link: '/adapters/browser/douban' },

docs/adapters/browser/yuanbao.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Yuanbao
2+
3+
**Mode**: 🔐 Browser · **Domain**: `yuanbao.tencent.com`
4+
5+
## Commands
6+
7+
| Command | Description |
8+
|---------|-------------|
9+
| `opencli yuanbao new` | Start a new Yuanbao conversation |
10+
| `opencli yuanbao ask <prompt>` | Send a prompt to Yuanbao web chat and wait for the reply |
11+
12+
## Usage Examples
13+
14+
```bash
15+
# Start a fresh chat
16+
opencli yuanbao new
17+
18+
# Basic ask (internet search on by default, deep thinking off by default)
19+
opencli yuanbao ask "你好"
20+
21+
# Wait longer for a longer answer
22+
opencli yuanbao ask "帮我总结这篇文章" --timeout 90
23+
24+
# Disable internet search explicitly
25+
opencli yuanbao ask "你好" --search false
26+
27+
# Enable deep thinking explicitly
28+
opencli yuanbao ask "你好" --think true
29+
```
30+
31+
## Options
32+
33+
### `new`
34+
35+
- No options
36+
37+
### `ask`
38+
39+
| Option | Description |
40+
|--------|-------------|
41+
| `prompt` | Prompt to send (required positional argument) |
42+
| `--timeout` | Max seconds to wait for a reply (default: `60`) |
43+
| `--search` | Enable internet search before sending (default: `true`) |
44+
| `--think` | Enable deep thinking before sending (default: `false`) |
45+
46+
## Behavior
47+
48+
- The adapter targets the Yuanbao consumer web UI and sends the prompt through the visible Quill composer.
49+
- `new` clicks the left-side Yuanbao new-chat trigger and falls back to reloading the Yuanbao homepage if needed.
50+
- Before sending, it aligns the `联网搜索` and `深度思考` buttons to the requested `--search` / `--think` state.
51+
- It waits for transcript changes to stabilize before returning the assistant reply.
52+
- If Yuanbao opens a login gate instead of answering, the command returns a `[BLOCKED]` system message with a session hint.
53+
54+
## Prerequisites
55+
56+
- Chrome is running
57+
- You are already logged into `yuanbao.tencent.com`
58+
- [Browser Bridge extension](/guide/browser-bridge) is installed
59+
60+
## Caveats
61+
62+
- This adapter drives the Yuanbao web UI, not a public API.
63+
- It depends on the current browser session and may fail if Yuanbao shows login, consent, challenge, or other gating UI.
64+
- DOM or product changes on Yuanbao can break composer detection, submit behavior, or transcript extraction.

docs/adapters/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ Run `opencli list` for the live registry.
3030
| **[chaoxing](./browser/chaoxing)** | `assignments` `exams` | 🔐 Browser |
3131
| **[grok](./browser/grok)** | `ask` | 🔐 Browser |
3232
| **[gemini](./browser/gemini)** | `new` `ask` `image` | 🔐 Browser |
33+
| **[yuanbao](./browser/yuanbao)** | `new` `ask` | 🔐 Browser |
3334
| **[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 |
3435
| **[doubao](./browser/doubao)** | `status` `new` `send` `read` `ask` `history` `detail` `meeting-summary` `meeting-transcript` | 🔐 Browser |
3536
| **[weread](./browser/weread)** | `shelf` `search` `book` `ranking` `notebooks` `highlights` `notes` | 🔐 Browser |

src/clis/yuanbao/ask.test.ts

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import type { IPage } from '../../types.js';
3+
import { AuthRequiredError, CommandExecutionError, TimeoutError } from '../../errors.js';
4+
import { __test__ } from './ask.js';
5+
import { askCommand } from './ask.js';
6+
7+
describe('yuanbao ask helpers', () => {
8+
describe('isOnYuanbao', () => {
9+
const fakePage = (url: string | Error): IPage =>
10+
({ evaluate: () => url instanceof Error ? Promise.reject(url) : Promise.resolve(url) }) as unknown as IPage;
11+
12+
it('returns true for yuanbao.tencent.com URLs', async () => {
13+
expect(await __test__.isOnYuanbao(fakePage('https://yuanbao.tencent.com/'))).toBe(true);
14+
expect(await __test__.isOnYuanbao(fakePage('https://yuanbao.tencent.com/chat/abc'))).toBe(true);
15+
});
16+
17+
it('returns false for non-yuanbao domains', async () => {
18+
expect(await __test__.isOnYuanbao(fakePage('https://example.com/?next=yuanbao.tencent.com'))).toBe(false);
19+
expect(await __test__.isOnYuanbao(fakePage('about:blank'))).toBe(false);
20+
});
21+
22+
it('returns false when evaluate throws', async () => {
23+
expect(await __test__.isOnYuanbao(fakePage(new Error('detached')))).toBe(false);
24+
});
25+
});
26+
27+
it('removes echoed prompt prefixes from transcript additions', () => {
28+
expect(__test__.sanitizeYuanbaoResponseText('你好\n你好,我是元宝。', '你好')).toBe('你好,我是元宝。');
29+
});
30+
31+
it('filters transient in-progress assistant placeholders', () => {
32+
expect(__test__.sanitizeYuanbaoResponseText('正在搜索资料', '张雪机车相关的股票有哪些?')).toBe('');
33+
});
34+
35+
it('normalizes boolean flags with explicit defaults', () => {
36+
expect(__test__.normalizeBooleanFlag(undefined, true)).toBe(true);
37+
expect(__test__.normalizeBooleanFlag(undefined, false)).toBe(false);
38+
expect(__test__.normalizeBooleanFlag('true', false)).toBe(true);
39+
expect(__test__.normalizeBooleanFlag('1', false)).toBe(true);
40+
expect(__test__.normalizeBooleanFlag('yes', false)).toBe(true);
41+
expect(__test__.normalizeBooleanFlag('false', true)).toBe(false);
42+
});
43+
44+
it('ignores baseline lines and echoed prompts when collecting additions', () => {
45+
const response = __test__.collectYuanbaoTranscriptAdditions(
46+
['旧消息'],
47+
['旧消息', '你好', '你好\n你好,我是元宝。'],
48+
'你好',
49+
);
50+
51+
expect(response).toBe('你好,我是元宝。');
52+
});
53+
54+
it('prefers fresh assistant messages over echoed prompts and older messages', () => {
55+
const response = __test__.pickLatestYuanbaoAssistantCandidate(
56+
['旧回复', '你好', '你好!我是元宝,由腾讯推出的AI助手。'],
57+
1,
58+
'你好',
59+
);
60+
61+
expect(response).toBe('你好!我是元宝,由腾讯推出的AI助手。');
62+
});
63+
64+
it('converts assistant html tables to markdown tables via turndown', () => {
65+
const markdown = __test__.convertYuanbaoHtmlToMarkdown(`
66+
<h3>核心产业链概念股一览</h3>
67+
<table>
68+
<thead>
69+
<tr><th>细分赛道</th><th>核心标的</th></tr>
70+
</thead>
71+
<tbody>
72+
<tr><td>光模块</td><td>中际旭创</td></tr>
73+
</tbody>
74+
</table>
75+
`);
76+
77+
expect(markdown).toContain('### 核心产业链概念股一览');
78+
expect(markdown).toContain('| 细分赛道 | 核心标的 |');
79+
expect(markdown).toContain('| --- | --- |');
80+
expect(markdown).toContain('| 光模块 | 中际旭创 |');
81+
});
82+
83+
it('tracks stabilization by incrementing repeats and resetting on changes', () => {
84+
expect(__test__.updateStableState('', 0, '第一段')).toEqual({
85+
previousText: '第一段',
86+
stableCount: 0,
87+
});
88+
89+
expect(__test__.updateStableState('第一段', 0, '第一段')).toEqual({
90+
previousText: '第一段',
91+
stableCount: 1,
92+
});
93+
94+
expect(__test__.updateStableState('第一段', 1, '第二段')).toEqual({
95+
previousText: '第二段',
96+
stableCount: 0,
97+
});
98+
});
99+
});
100+
101+
function createAskPageMock(overrides: {
102+
currentUrl?: string;
103+
hasLoginGate?: boolean;
104+
sendResult?: { ok?: boolean; reason?: string; detail?: string; action?: string };
105+
} = {}): IPage {
106+
const currentUrl = overrides.currentUrl ?? 'https://yuanbao.tencent.com/';
107+
const hasLoginGate = overrides.hasLoginGate ?? false;
108+
const sendResult = overrides.sendResult;
109+
110+
return {
111+
goto: vi.fn().mockResolvedValue(undefined),
112+
wait: vi.fn().mockResolvedValue(undefined),
113+
evaluate: vi.fn().mockImplementation(async (script: string) => {
114+
if (script === 'window.location.href') return currentUrl;
115+
if (script.includes('微信扫码登录')) return hasLoginGate;
116+
if (script.includes('[dt-button-id="internet_search"]')) return { found: false, enabled: false };
117+
if (script.includes('[dt-button-id="deep_think"]')) return { found: false, enabled: false };
118+
if (script.includes('.agent-chat__list__item--ai')) return [];
119+
if (script.includes('const stopLines = new Set([')) return [];
120+
if (script.includes('Failed to insert the prompt into the Yuanbao composer.')) {
121+
return sendResult ?? { ok: true, action: 'click' };
122+
}
123+
throw new Error(`Unexpected evaluate script in test: ${script.slice(0, 80)}`);
124+
}),
125+
} as unknown as IPage;
126+
}
127+
128+
describe('yuanbao ask command', () => {
129+
it('throws AuthRequiredError when Yuanbao shows a login gate before sending', async () => {
130+
const page = createAskPageMock({ hasLoginGate: true });
131+
132+
await expect(askCommand.func!(page, { prompt: '你好', timeout: '60', search: true, think: false }))
133+
.rejects.toBeInstanceOf(AuthRequiredError);
134+
});
135+
136+
it('throws CommandExecutionError when the prompt cannot be sent', async () => {
137+
const page = createAskPageMock({
138+
sendResult: {
139+
ok: false,
140+
reason: 'Yuanbao composer was not found.',
141+
},
142+
});
143+
144+
await expect(askCommand.func!(page, { prompt: '你好', timeout: '60', search: true, think: false }))
145+
.rejects.toBeInstanceOf(CommandExecutionError);
146+
});
147+
148+
it('throws TimeoutError when no response arrives before timeout', async () => {
149+
const page = createAskPageMock({
150+
sendResult: { ok: true, action: 'click' },
151+
});
152+
153+
await expect(askCommand.func!(page, { prompt: '你好', timeout: '-1', search: true, think: false }))
154+
.rejects.toBeInstanceOf(TimeoutError);
155+
});
156+
});

0 commit comments

Comments
 (0)