import { htmlToMarkdown } from '@jackwener/opencli/utils'; import { ArgumentError, AuthRequiredError, CommandExecutionError, TimeoutError } from '@jackwener/opencli/errors'; export const QIANWEN_DOMAIN = 'www.qianwen.com'; export const QIANWEN_URL = 'https://www.qianwen.com/'; export const QIANWEN_API_DOMAIN = 'chat2-api.qianwen.com'; export const IS_VISIBLE_JS = ` const isVisible = (node) => { if (!(node instanceof Element)) return false; const style = window.getComputedStyle(node); if (style.visibility === 'hidden' || style.display === 'none') return false; const rect = node.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; }; `; const POLL_INTERVAL_SECONDS = 2; const MIN_WAIT_MS = 6_000; const STABLE_POLLS_REQUIRED = 2; export function authRequired(detail) { return new AuthRequiredError(QIANWEN_DOMAIN, detail || '请在浏览器里用千问 APP 扫码登录 qianwen.com 后再重试。'); } 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 isOnQianwen(page) { const url = await page.evaluate('window.location.href').catch(() => ''); return typeof url === 'string' && url.includes('qianwen.com'); } export async function ensureOnQianwen(page) { if (await isOnQianwen(page)) return; await page.goto(QIANWEN_URL); await page.wait(2); } export async function dismissLoginModal(page) { return await page.evaluate(`(() => { ${IS_VISIBLE_JS} const modal = document.querySelector('[role=alert-biz-modal]'); if (!modal || !isVisible(modal)) return { dismissed: false }; const close = modal.querySelector('[data-opencli-ref]:last-of-type') || modal.querySelector('svg')?.closest('[role=button], button, div[class*="close"]'); const closeCandidates = Array.from(modal.querySelectorAll('div, button, span')) .filter((node) => node instanceof HTMLElement && isVisible(node)) .filter((node) => { const cls = node.className || ''; if (typeof cls !== 'string') return false; return /close|dismiss|cancel/i.test(cls) || node.getAttribute('aria-label') === '关闭'; }); const target = closeCandidates[0] || modal.querySelector('svg')?.parentElement; if (target instanceof HTMLElement) { target.click(); return { dismissed: true }; } // Last resort: synth ESC key on document document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', bubbles: true })); return { dismissed: true, method: 'escape' }; })()`); } export async function hasLoginGate(page) { const result = await page.evaluate(`(() => { ${IS_VISIBLE_JS} const modal = document.querySelector('[role=alert-biz-modal]'); if (modal && isVisible(modal)) { const iframe = modal.querySelector('iframe'); const src = iframe?.getAttribute('src') || ''; if (src.includes('passport.qianwen.com') || src.includes('login')) return true; } return false; })()`); return Boolean(result); } export async function isLoggedIn(page) { const result = await page.evaluate(`(() => { ${IS_VISIBLE_JS} const loginBtn = Array.from(document.querySelectorAll('button')) .find((node) => (node.textContent || '').trim() === '登录' && isVisible(node)); if (loginBtn) return false; const hint = Array.from(document.querySelectorAll('p')) .find((node) => (node.textContent || '').includes('登录可同步历史对话')); if (hint && isVisible(hint)) return false; return true; })()`); return Boolean(result); } export async function getCurrentSessionId(page) { const url = await page.evaluate('window.location.href').catch(() => ''); if (typeof url !== 'string') return ''; const match = url.match(/\/chat\/([A-Za-z0-9_-]+)/); return match ? match[1] : ''; } const QIANWEN_SESSION_ID_RE = /^[a-f0-9]{32}$/i; export function parseQianwenSessionId(input) { const raw = String(input ?? '').trim(); if (!raw) { throw new ArgumentError('id', 'must be a non-empty session ID or qianwen.com chat URL'); } // Anchor the right-hand side so a 33+ hex URL does not silently truncate // to its first 32 chars. Acceptable terminators: end-of-string, path slash, // query string, or fragment. Without the boundary, // `https://www.qianwen.com/chat/<33 hex>` would parse as a valid 32-char // ID instead of being rejected — opening the wrong conversation is a // worse failure mode than throwing. const urlMatch = raw.match(/qianwen\.com\/chat\/([a-f0-9]{32})(?:[/?#]|$)/i); const candidate = urlMatch ? urlMatch[1] : raw; if (!QIANWEN_SESSION_ID_RE.test(candidate)) { throw new ArgumentError( 'id', `not a valid Qianwen session ID (got "${input}"); expected a 32-char hex ID like "abcd1234ef567890abcd1234ef567890" or a full https://www.qianwen.com/chat/ URL`, ); } return candidate.toLowerCase(); } export async function getModelLabel(page) { const result = await page.evaluate(`(() => { ${IS_VISIBLE_JS} const trigger = Array.from(document.querySelectorAll('[aria-haspopup=dialog]')) .find((node) => isVisible(node) && (node.innerText || '').includes('Qwen')); if (!trigger) return ''; const label = (trigger.innerText || '').split('\\n')[0].trim(); return label; })()`); return typeof result === 'string' ? result : ''; } export async function startNewChat(page) { const result = await page.evaluate(`(() => { ${IS_VISIBLE_JS} const button = Array.from(document.querySelectorAll('button')) .find((node) => isVisible(node) && (node.innerText || '').trim() === '新建对话'); if (button instanceof HTMLElement) { button.click(); return { ok: true, method: 'button' }; } return { ok: false }; })()`); if (result?.ok) { await page.wait(1.5); return true; } await page.goto(QIANWEN_URL); await page.wait(2); return true; } export async function getComposer(page) { return await page.evaluate(`(() => { ${IS_VISIBLE_JS} const editor = Array.from(document.querySelectorAll('[role=textbox][contenteditable=true]')) .find((node) => isVisible(node)); return { found: !!editor, text: editor?.textContent || '' }; })()`); } export async function sendMessage(page, prompt) { return await page.evaluate(`(async () => { ${IS_VISIBLE_JS} const waitFor = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const editor = Array.from(document.querySelectorAll('[role=textbox][contenteditable=true]')) .find((node) => isVisible(node)); if (!(editor instanceof HTMLElement)) { return { ok: false, reason: 'Qianwen composer (contenteditable) not found.' }; } editor.focus(); // Clear existing content first const selection = window.getSelection(); const range = document.createRange(); range.selectNodeContents(editor); selection?.removeAllRanges(); selection?.addRange(range); document.execCommand('delete', false); await waitFor(100); // Slate editor accepts content via beforeinput InputEvent editor.dispatchEvent(new InputEvent('beforeinput', { inputType: 'insertText', data: ${JSON.stringify(prompt)}, bubbles: true, cancelable: true, })); await waitFor(400); const sendBtn = document.querySelector('button[aria-label="发送消息"]'); if (sendBtn instanceof HTMLElement && !sendBtn.disabled) { sendBtn.click(); return { ok: true, action: 'click' }; } // Fallback: dispatch Enter key editor.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true })); editor.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true })); return { ok: true, action: 'enter-fallback' }; })()`); } export async function getMessageBubbles(page) { // Qianwen's chat DOM marks each turn with two siblings: // [data-chat-question-wrap] - user message // [data-chat-answers-wrap] - assistant message // The earlier `[data-msgid]` selector matched citation cards inside // assistant responses (which use `data-message-id`), so it would silently // miss the actual chat turns after Qianwen reshipped its frontend. // // We walk both wrap selectors in DOM order to interleave Q/A correctly, // and use the nearest sibling `data-req-id` (anchored on // `.chat-msg-bottom-anchor`) as the stable per-turn identifier so polling // dedupe + waitForAnswer's seenAssistantId tracking still work. const result = await page.evaluate(`(() => { ${IS_VISIBLE_JS} const wraps = Array.from(document.querySelectorAll('[data-chat-question-wrap], [data-chat-answers-wrap]')) .filter((node) => node instanceof HTMLElement && isVisible(node)); const findTurnReqId = (node) => { let parent = node.parentElement; while (parent && parent !== document.body) { const reqEl = parent.querySelector('[data-req-id]'); if (reqEl && reqEl.getAttribute('data-req-id')) { return reqEl.getAttribute('data-req-id'); } parent = parent.parentElement; } return ''; }; const out = []; let positional = 0; for (const node of wraps) { const isAnswer = node.hasAttribute('data-chat-answers-wrap'); const reqId = findTurnReqId(node); const baseId = reqId || ('pos-' + positional); const id = baseId + (isAnswer ? '-answer' : '-question'); positional += 1; const contentNode = isAnswer ? (node.querySelector('#qk-markdown-react') || node.querySelector('[class*="markdown"]') || node) : node; const html = (contentNode instanceof HTMLElement) ? (contentNode.innerHTML || '') : ''; const text = (contentNode instanceof HTMLElement) ? (contentNode.innerText || contentNode.textContent || '') : ''; const role = isAnswer ? 'Assistant' : 'User'; out.push({ id, role, text: (text || '').replace(/\\s+/g, ' ').trim(), html }); } return out; })()`); if (!Array.isArray(result)) return []; return result .map((item) => ({ id: String(item?.id || ''), role: item?.role === 'Assistant' ? 'Assistant' : 'User', text: String(item?.text || '').trim(), html: String(item?.html || ''), })) .filter((item) => item.id && item.text); } export function bubbleHtmlToMarkdown(html) { try { return htmlToMarkdown(html).trim(); } catch { return (html || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); } } function stripNoise(text) { return (text || '') .replace(/\u00a0/g, ' ') .replace(/复制\s*$/g, '') .replace(/重新生成\s*$/g, '') .trim(); } function normalizePromptText(text) { return stripNoise(text).replace(/\s+/g, ' ').trim(); } function normalizeWaitAnchor(anchor) { if (typeof anchor === 'string') { return { lastBubbleId: '', lastAssistantId: anchor }; } return { lastBubbleId: String(anchor?.lastBubbleId || ''), lastAssistantId: String(anchor?.lastAssistantId || ''), }; } function findAssistantAfterPrompt(bubbles, prompt, anchor) { const normalizedPrompt = normalizePromptText(prompt); let startIndex = -1; const hasAnchor = Boolean(anchor.lastBubbleId || anchor.lastAssistantId); let foundAnchor = false; if (anchor.lastBubbleId) { const lastBubbleIndex = bubbles.findIndex((bubble) => bubble.id === anchor.lastBubbleId); if (lastBubbleIndex >= 0) { startIndex = lastBubbleIndex; foundAnchor = true; } } if (anchor.lastAssistantId) { const lastAssistantIndex = bubbles.findIndex((bubble) => bubble.id === anchor.lastAssistantId); if (lastAssistantIndex >= 0) { startIndex = Math.max(startIndex, lastAssistantIndex); foundAnchor = true; } } if (hasAnchor && !foundAnchor) return null; let promptIndex = -1; for (let i = startIndex + 1; i < bubbles.length; i += 1) { if (bubbles[i].role === 'User' && normalizePromptText(bubbles[i].text) === normalizedPrompt) { promptIndex = i; } } if (promptIndex < 0) return null; for (let i = promptIndex + 1; i < bubbles.length; i += 1) { if (bubbles[i].role === 'Assistant') return bubbles[i]; } return null; } export async function waitForAnswer(page, prompt, timeoutSeconds, baselineAnchor) { const anchor = normalizeWaitAnchor(baselineAnchor); const startTime = Date.now(); let previousText = ''; let stableCount = 0; let lastCandidate = ''; let lastAssistantCandidate = null; while (Date.now() - startTime < timeoutSeconds * 1000) { await page.wait(POLL_INTERVAL_SECONDS); if (await hasLoginGate(page)) { return { status: 'auth_required' }; } const bubbles = await getMessageBubbles(page); const lastAssistant = findAssistantAfterPrompt(bubbles, prompt, anchor); if (!lastAssistant) continue; // Guard against stale assistant turns that existed before our send even // if the DOM briefly reorders or rehydrates while the new turn loads. if (anchor.lastAssistantId && lastAssistant.id === anchor.lastAssistantId) continue; const text = stripNoise(lastAssistant.text); if (!text || text === prompt) continue; lastCandidate = text; lastAssistantCandidate = lastAssistant; const waitedLongEnough = Date.now() - startTime >= MIN_WAIT_MS; if (text === previousText) { stableCount += 1; if (waitedLongEnough && stableCount >= STABLE_POLLS_REQUIRED) { return { status: 'ok', assistant: lastAssistant }; } } else { previousText = text; stableCount = 0; } } if (lastCandidate) { return { status: 'partial', assistant: lastAssistantCandidate }; } return { status: 'timeout' }; } const FEATURE_LABELS = { think: '深度思考', research: '深度研究', task: '任务助理', image: 'AI生图', ppt: 'PPT创作', }; export async function setFeatureToggle(page, feature, enabled) { const label = FEATURE_LABELS[feature]; if (!label) return false; const result = await page.evaluate(`(async () => { ${IS_VISIBLE_JS} const waitFor = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const label = ${JSON.stringify(label)}; const button = Array.from(document.querySelectorAll('button[aria-label]')) .find((node) => isVisible(node) && node.getAttribute('aria-label') === label); if (!(button instanceof HTMLElement)) return { found: false }; const selected = button.getAttribute('aria-pressed') === 'true' || /active|selected|bg-primary/.test(button.className || ''); if (selected === ${Boolean(enabled)}) return { found: true, changed: false, selected }; button.click(); await waitFor(300); return { found: true, changed: true }; })()`); return Boolean(result?.found); } export async function getSessionListFromApi(page, limit = 30) { const pageSize = Number(limit ?? 30); if (!Number.isInteger(pageSize) || pageSize <= 0 || pageSize > 100) { throw new CommandExecutionError('Qianwen history page_size must be an integer between 1 and 100'); } const result = await page.evaluate(`(async () => { try { const utdid = (document.cookie.match(/(?:^|;\\s*)b-user-id=([^;]+)/)?.[1]) || (document.cookie.match(/(?:^|;\\s*)utdid=([^;]+)/)?.[1]) || ''; const query = new URLSearchParams({ biz_id: 'ai_qwen', chat_client: 'h5', device: 'pc', fr: 'pc', pr: 'qwen', ut: utdid, la: 'zh-CN', tz: 'Asia/Shanghai', ve: '2.4.9', }).toString(); const res = await fetch('https://${QIANWEN_API_DOMAIN}/api/v2/session/page/list?' + query, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ page_num: 1, page_size: ${pageSize}, page_no: 1 }), }); const text = await res.text(); let body = null; try { body = text ? JSON.parse(text) : null; } catch { body = null; } return { ok: res.ok, status: res.status, body, utdid }; } catch (error) { return { ok: false, status: 0, error: String(error?.message || error) }; } })()`); if (!result || !result.ok) { return { ok: false, status: result?.status || 0, error: result?.error || '', sessions: [] }; } const data = result.body?.data || result.body?.result || {}; const rawList = Array.isArray(data?.list) ? data.list : Array.isArray(data?.items) ? data.items : Array.isArray(data?.page_list) ? data.page_list : Array.isArray(result.body?.list) ? result.body.list : []; const sessions = rawList.map((item) => ({ id: String(item?.session_id || item?.sessionId || item?.id || ''), title: String(item?.title || item?.name || item?.summary || '').trim(), updated_at: Number(item?.updated_at || item?.last_req_timestamp || item?.updatedAt || item?.gmt_modified || item?.update_time || 0), })).filter((item) => item.id); return { ok: true, status: result.status, sessions }; }