Files
wehub-resource-sync 9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

342 lines
14 KiB
JavaScript

import { ArgumentError, AuthRequiredError } from '@jackwener/opencli/errors';
export const YUANBAO_DOMAIN = 'yuanbao.tencent.com';
export const YUANBAO_URL = 'https://yuanbao.tencent.com/';
const SESSION_HINT = 'Likely login/auth/challenge/session issue in the existing yuanbao.tencent.com browser session.';
const AGENT_ID_RE = /^[A-Za-z0-9_-]{4,40}$/;
const CONV_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* Reusable visibility check for injected browser scripts.
* Embed in page.evaluate strings via `${IS_VISIBLE_JS}`.
*/
export const IS_VISIBLE_JS = `const isVisible = (node) => {
if (!(node instanceof HTMLElement)) return false;
const rect = node.getBoundingClientRect();
const style = window.getComputedStyle(node);
return rect.width > 0
&& rect.height > 0
&& style.display !== 'none'
&& style.visibility !== 'hidden';
};`;
export function authRequired(message) {
return new AuthRequiredError(YUANBAO_DOMAIN, `${message} ${SESSION_HINT}`);
}
export function normalizeBooleanFlag(value, fallback = false) {
if (typeof value === 'boolean') return value;
if (value == null || value === '') return fallback;
const normalized = String(value).trim().toLowerCase();
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
}
export async function isOnYuanbao(page) {
const url = await page.evaluate('window.location.href').catch(() => '');
if (typeof url !== 'string' || !url) return false;
try {
const hostname = new URL(url).hostname;
return hostname === YUANBAO_DOMAIN || hostname.endsWith(`.${YUANBAO_DOMAIN}`);
} catch {
return false;
}
}
export async function ensureYuanbaoPage(page) {
if (!(await isOnYuanbao(page))) {
await page.goto(YUANBAO_URL, { waitUntil: 'load', settleMs: 2500 });
await page.wait(1);
}
}
export async function hasLoginGate(page) {
const result = await page.evaluate(`(() => {
const bodyText = document.body.innerText || '';
const hasWechatLoginText = bodyText.includes('微信扫码登录');
const hasWechatIframe = Array.from(document.querySelectorAll('iframe'))
.some((frame) => (frame.getAttribute('src') || '').includes('open.weixin.qq.com/connect/qrconnect'));
return hasWechatLoginText || hasWechatIframe;
})()`);
return Boolean(result);
}
export async function isLoggedIn(page) {
return !(await hasLoginGate(page));
}
/**
* Extract Yuanbao session identity from a raw input.
*
* Yuanbao chat URLs are `/chat/<agentId>/<convId>`. Both parts are required
* to navigate — there is no stable default agentId we can fall back to. So we
* only accept inputs that resolve a complete `{agentId, convId}` pair:
* - full `https://yuanbao.tencent.com/chat/<agentId>/<convId>` URL
* - bare slash form `<agentId>/<convId>`
*
* A bare convId UUID is rejected with an actionable message — opening the
* wrong agent silently is a much worse failure mode than throwing.
*
* The trailing `(?:[/?#]|$)` boundary in the URL regex prevents over-long
* suffixes (e.g. `<id>extra`) from silently truncating to a valid-looking ID.
*/
export function parseYuanbaoSessionId(input) {
const raw = String(input ?? '').trim();
if (!raw) {
throw new ArgumentError(
'id',
'must be a non-empty Yuanbao chat URL or "<agentId>/<convId>" pair',
);
}
const urlMatch = raw.match(/yuanbao\.tencent\.com\/chat\/([A-Za-z0-9_-]+)\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:[/?#]|$)/i);
if (urlMatch) {
const [, agentId, convId] = urlMatch;
if (!AGENT_ID_RE.test(agentId) || !CONV_ID_RE.test(convId)) {
throw new ArgumentError(
'id',
`not a valid Yuanbao chat URL (got "${input}"); expected https://yuanbao.tencent.com/chat/<agentId>/<convId>`,
);
}
return { agentId, convId: convId.toLowerCase() };
}
const slashMatch = raw.match(/^([A-Za-z0-9_-]+)\/([0-9a-f-]{36})$/i);
if (slashMatch) {
const [, agentId, convId] = slashMatch;
if (!AGENT_ID_RE.test(agentId) || !CONV_ID_RE.test(convId)) {
throw new ArgumentError(
'id',
`not a valid Yuanbao "<agentId>/<convId>" pair (got "${input}"); agentId must be 4-40 word chars, convId must be a UUID`,
);
}
return { agentId, convId: convId.toLowerCase() };
}
throw new ArgumentError(
'id',
`not a valid Yuanbao session reference (got "${input}"); pass either a full https://yuanbao.tencent.com/chat/<agentId>/<convId> URL or a bare "<agentId>/<convId>" pair. A UUID alone is not enough — Yuanbao requires the agentId.`,
);
}
export async function getCurrentYuanbaoSessionId(page) {
const url = await page.evaluate('window.location.href').catch(() => '');
if (typeof url !== 'string') return null;
const match = url.match(/yuanbao\.tencent\.com\/chat\/([A-Za-z0-9_-]+)\/([0-9a-f-]{36})(?:[/?#]|$)/i);
if (!match) return null;
const [, agentId, convId] = match;
if (!AGENT_ID_RE.test(agentId) || !CONV_ID_RE.test(convId)) return null;
return { agentId, convId: convId.toLowerCase() };
}
export async function getYuanbaoModelLabel(page) {
const result = await page.evaluate(`(() => {
const btn = document.querySelector('[dt-button-id="model_switch"]');
if (!(btn instanceof HTMLElement)) return null;
const label = (btn.querySelector('.t-button__text')?.textContent || btn.textContent || '').trim();
const modelId = btn.getAttribute('dt-model-id') || '';
return { label, modelId };
})()`);
if (!result || typeof result !== 'object') return { label: null, modelId: null };
return {
label: typeof result.label === 'string' && result.label ? result.label : null,
modelId: typeof result.modelId === 'string' && result.modelId ? result.modelId : null,
};
}
/**
* Read the current conversation transcript as `{id, role, text, html}` rows.
*
* Each `.agent-chat__list__item` carries `data-conv-id` (`<convId>_<idx>`),
* `data-conv-speaker` (`human`/`ai`), and `data-conv-idx`. We use those as the
* stable per-turn identity so polling/dedup logic in `ask` stays correct after
* future re-renders.
*
* Image-only assistant turns may have empty visible text; we keep them when
* `html` is non-empty so callers can render images downstream.
*/
export async function getYuanbaoMessageBubbles(page) {
const result = await page.evaluate(`(() => {
${IS_VISIBLE_JS}
const items = Array.from(document.querySelectorAll('.agent-chat__list__item--human, .agent-chat__list__item--ai'))
.filter((node) => isVisible(node));
return items.map((node, positional) => {
const isAi = node.classList.contains('agent-chat__list__item--ai');
const role = isAi ? 'Assistant' : 'User';
const id = node.getAttribute('data-conv-id') || ('pos-' + positional + (isAi ? '-ai' : '-human'));
const idx = Number(node.getAttribute('data-conv-idx') || positional + 1);
const contentNode = isAi
? (node.querySelector('.hyc-content-md-done')
|| node.querySelector('.hyc-content-md')
|| node.querySelector('.agent-chat__speech-text')
|| node.querySelector('.agent-chat__bubble__content'))
: (node.querySelector('.hyc-component-text .hyc-content-text')
|| node.querySelector('.hyc-content-text')
|| node.querySelector('.agent-chat__bubble__content'));
const html = contentNode instanceof HTMLElement ? (contentNode.innerHTML || '') : '';
const rawText = contentNode instanceof HTMLElement
? (contentNode.innerText || contentNode.textContent || '')
: ((node.innerText || node.textContent) || '');
return {
id,
idx,
role,
text: String(rawText || '').replace(/\\u00a0/g, ' ').trim(),
html,
};
});
})()`);
if (!Array.isArray(result)) return [];
return result
.map((item) => ({
id: String(item?.id || ''),
idx: Number.isInteger(item?.idx) ? item.idx : 0,
role: item?.role === 'Assistant' ? 'Assistant' : 'User',
text: String(item?.text || '').trim(),
html: String(item?.html || ''),
}))
.filter((item) => item.id && (item.text || item.html));
}
/**
* Enumerate sidebar conversation entries.
*
* Each `.yb-recent-conv-list__item` exposes:
* - `dt-cid` — conversation UUID
* - `dt-agent-id`— agent slug
* - `[data-item-name]` — display title
*
* We do NOT trigger sidebar virtual scroll here — Yuanbao loads the visible
* window only, so callers requesting a higher `limit` than the rendered count
* get whatever is currently rendered. That matches Yuanbao's own UX.
*/
export async function getYuanbaoSessionList(page, limit) {
const cap = Number(limit ?? 20);
if (!Number.isInteger(cap) || cap <= 0) {
throw new ArgumentError('limit', 'must be a positive integer');
}
const result = await page.evaluate(`(() => {
${IS_VISIBLE_JS}
const nodes = Array.from(document.querySelectorAll('.yb-recent-conv-list__item'))
.filter((node) => isVisible(node));
return nodes.map((node) => {
const cid = node.getAttribute('dt-cid') || '';
const agentId = node.getAttribute('dt-agent-id') || '';
const titleEl = node.querySelector('[data-item-name]');
const title = (titleEl?.getAttribute('data-item-name') || titleEl?.textContent || '').trim();
return { cid, agentId, title };
});
})()`);
if (!Array.isArray(result)) return [];
return result
.map((item) => ({
cid: String(item?.cid || '').toLowerCase(),
agentId: String(item?.agentId || ''),
title: String(item?.title || '').trim(),
}))
.filter((item) => CONV_ID_RE.test(item.cid) && AGENT_ID_RE.test(item.agentId))
.slice(0, cap);
}
export async function startNewYuanbaoChat(page) {
await ensureYuanbaoPage(page);
if (await hasLoginGate(page)) return 'blocked';
const beforeUrl = await page.evaluate('window.location.href').catch(() => '');
const action = await page.evaluate(`(() => {
${IS_VISIBLE_JS}
const trigger = Array.from(document.querySelectorAll('.yb-common-nav__trigger[data-desc="new-chat"], [dt-button-id="new_temp_chat"]'))
.find((node) => isVisible(node));
if (trigger instanceof HTMLElement) {
trigger.click();
return 'clicked';
}
return 'navigate';
})()`);
if (action === 'navigate') {
await page.goto(YUANBAO_URL, { waitUntil: 'load', settleMs: 2500 });
await page.wait(1);
return (await hasLoginGate(page)) ? 'blocked' : 'navigate';
}
await page.wait(1);
if (await hasLoginGate(page)) return 'blocked';
const afterUrl = await page.evaluate('window.location.href').catch(() => '');
if (typeof afterUrl === 'string' && typeof beforeUrl === 'string' && afterUrl !== beforeUrl) {
return 'clicked';
}
// Click had no observable effect — fall back to homepage navigation.
await page.goto(YUANBAO_URL, { waitUntil: 'load', settleMs: 2500 });
await page.wait(1);
return 'navigate';
}
/**
* Drive the Quill composer to insert `prompt` and click the send button.
*
* Yuanbao toggles the send button between `style__send-btn___*` (enabled) and
* `style__send-btn--disabled___*` based on a debounced React re-render after
* the composer's input event. A naive 200ms wait races that re-render — the
* button is still `--disabled` at click time, the click is a no-op, and
* Enter-key fallback in Quill does not always submit. We poll the
* disabled-state for up to ~3s and only fall back to Enter when the React
* update never arrives.
*
* Returns `{ok: true, action}` or `{ok: false, reason, detail?}`.
*/
export async function sendYuanbaoMessage(page, prompt) {
return await page.evaluate(`(async () => {
const waitFor = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
${IS_VISIBLE_JS}
const composer = Array.from(document.querySelectorAll('.ql-editor[contenteditable="true"], .ql-editor, [contenteditable="true"]'))
.find(isVisible);
if (!(composer instanceof HTMLElement)) {
return {
ok: false,
reason: 'Yuanbao composer was not found.',
};
}
try {
composer.focus();
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(composer);
range.collapse(false);
selection?.removeAllRanges();
selection?.addRange(range);
composer.textContent = '';
document.execCommand('insertText', false, ${JSON.stringify(prompt)});
composer.dispatchEvent(new InputEvent('input', { bubbles: true, data: ${JSON.stringify(prompt)}, inputType: 'insertText' }));
} catch (error) {
return {
ok: false,
reason: 'Failed to insert the prompt into the Yuanbao composer.',
detail: error instanceof Error ? error.message : String(error),
};
}
const findEnabledSubmit = () => Array.from(document.querySelectorAll('a[class*="send-btn"], button[class*="send-btn"]'))
.find((node) => {
if (!(node instanceof HTMLElement) || !isVisible(node)) return false;
const className = typeof node.className === 'string' ? node.className : '';
return !className.includes('send-btn--disabled') && !className.includes('disabled');
});
let submit = null;
const deadline = Date.now() + 3_000;
while (Date.now() < deadline) {
submit = findEnabledSubmit();
if (submit) break;
await waitFor(150);
}
if (submit instanceof HTMLElement) {
submit.click();
return { ok: true, action: 'click' };
}
composer.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true }));
composer.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true }));
return { ok: true, action: 'enter' };
})()`);
}