mirror of
https://github.com/jackwener/OpenCLI.git
synced 2026-09-14 18:25:42 +08:00
094ff0da80
* feat(deepseek): add vision mode support DeepSeek added a third model "识图模式" (Vision Mode) that accepts image uploads for visual understanding. Add vision to the --model choices, update selectModel to use explicit index mapping for all three models, skip the search toggle in vision mode (not available), and extend waitForFilePreview to detect image thumbnails via send button state since vision mode shows a preview image instead of a filename label. Also catch "Not allowed" errors from setFileInput (Cloudflare may block CDP file operations) so the DataTransfer fallback can run. Closes #1215 * fix(deepseek): harden vision upload mode --------- Co-authored-by: jackwener <jakevingoo@gmail.com>
415 lines
17 KiB
JavaScript
415 lines
17 KiB
JavaScript
export const DEEPSEEK_DOMAIN = 'chat.deepseek.com';
|
|
export const DEEPSEEK_URL = 'https://chat.deepseek.com/';
|
|
export const TEXTAREA_SELECTOR = 'textarea[placeholder*="DeepSeek"]';
|
|
export const MESSAGE_SELECTOR = '.ds-message';
|
|
|
|
export async function isOnDeepSeek(page) {
|
|
const url = await page.evaluate('window.location.href').catch(() => '');
|
|
if (typeof url !== 'string' || !url) return false;
|
|
try {
|
|
const h = new URL(url).hostname;
|
|
return h === 'deepseek.com' || h.endsWith('.deepseek.com');
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export async function ensureOnDeepSeek(page) {
|
|
if (await isOnDeepSeek(page)) return false;
|
|
await page.goto(DEEPSEEK_URL);
|
|
await page.wait(3);
|
|
return true;
|
|
}
|
|
|
|
export async function getPageState(page) {
|
|
return page.evaluate(`(() => {
|
|
const url = window.location.href;
|
|
const title = document.title;
|
|
const textarea = document.querySelector('${TEXTAREA_SELECTOR}');
|
|
const avatar = document.querySelector('img[src*="user-avatar"]');
|
|
return {
|
|
url,
|
|
title,
|
|
hasTextarea: !!textarea,
|
|
isLoggedIn: !!avatar,
|
|
};
|
|
})()`);
|
|
}
|
|
|
|
export async function selectModel(page, modelName) {
|
|
return page.evaluate(`(() => {
|
|
var radios = document.querySelectorAll('div[role="radio"]');
|
|
if (radios.length === 0) return { ok: false };
|
|
var name = '${modelName}'.toLowerCase();
|
|
var index = name === 'instant' ? 0 : name === 'expert' ? 1 : name === 'vision' ? 2 : -1;
|
|
if (index < 0 || index >= radios.length) return { ok: false };
|
|
var target = radios[index];
|
|
var alreadySelected = target.getAttribute('aria-checked') === 'true';
|
|
if (!alreadySelected) target.click();
|
|
return { ok: true, toggled: !alreadySelected };
|
|
})()`);
|
|
}
|
|
|
|
export async function setFeature(page, featureName, enabled) {
|
|
// Match by position: DeepThink is the first toggle, Search is the second
|
|
var index = featureName === 'DeepThink' ? 0 : 1;
|
|
return page.evaluate(`(() => {
|
|
var toggles = Array.from(document.querySelectorAll('.ds-toggle-button'));
|
|
var btn = toggles[${index}];
|
|
if (!btn) return { ok: false };
|
|
var isActive = btn.classList.contains('ds-toggle-button--selected');
|
|
if (${enabled} !== isActive) btn.click();
|
|
return { ok: true, toggled: ${enabled} !== isActive };
|
|
})()`);
|
|
}
|
|
|
|
export async function sendMessage(page, prompt) {
|
|
const promptJson = JSON.stringify(prompt);
|
|
return page.evaluate(`(async () => {
|
|
const box = document.querySelector('${TEXTAREA_SELECTOR}');
|
|
if (!box) return { ok: false, reason: 'textarea not found' };
|
|
|
|
box.focus();
|
|
box.value = '';
|
|
document.execCommand('selectAll');
|
|
document.execCommand('insertText', false, ${promptJson});
|
|
await new Promise(r => setTimeout(r, 800));
|
|
|
|
// Find the send button: last non-toggle button in the textarea's container
|
|
var container = box.parentElement;
|
|
while (container && !container.querySelector('div[role="button"]')) {
|
|
container = container.parentElement;
|
|
}
|
|
if (container) {
|
|
var btns = container.querySelectorAll('div[role="button"]:not(.ds-toggle-button)');
|
|
var sendBtn = btns[btns.length - 1];
|
|
if (sendBtn && sendBtn.getAttribute('aria-disabled') === 'false'
|
|
&& sendBtn.querySelectorAll('svg').length > 0) {
|
|
sendBtn.click();
|
|
return { ok: true };
|
|
}
|
|
}
|
|
|
|
box.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true }));
|
|
return { ok: true, method: 'enter' };
|
|
})()`);
|
|
}
|
|
|
|
export async function getBubbleCount(page) {
|
|
const count = await page.evaluate(`(() => {
|
|
return document.querySelectorAll('${MESSAGE_SELECTOR}').length;
|
|
})()`);
|
|
return count || 0;
|
|
}
|
|
|
|
// Parse thinking response using text as a fallback when DOM-level extraction
|
|
// is not available. Does NOT split on \n\n — that heuristic silently corrupts
|
|
// multi-paragraph thinking or multi-paragraph answers. Instead, everything
|
|
// after the header is treated as thinking content, and `response` stays empty
|
|
// until the caller provides a DOM-separated answer.
|
|
export function parseThinkingResponse(rawText) {
|
|
if (!rawText) return null;
|
|
|
|
// Match thinking header patterns: "Thought for X seconds" or "已思考(用时 X 秒)"
|
|
const thinkHeaderMatch = rawText.match(/^(Thought for ([\d.]+) seconds?|已思考(用时 ([\d.]+) 秒))\s*/);
|
|
|
|
if (!thinkHeaderMatch) {
|
|
// No thinking section found, return plain response
|
|
return { response: rawText, thinking: null, thinking_time: null };
|
|
}
|
|
|
|
const thinkingTime = thinkHeaderMatch[2] || thinkHeaderMatch[3];
|
|
const afterHeader = rawText.slice(thinkHeaderMatch[0].length);
|
|
|
|
// Treat everything after the header as thinking. The response will be
|
|
// populated by the DOM-level extraction in waitForResponse().
|
|
return {
|
|
response: '',
|
|
thinking: afterHeader.trim(),
|
|
thinking_time: thinkingTime,
|
|
};
|
|
}
|
|
|
|
export async function waitForResponse(page, baselineCount, prompt, timeoutMs, parseThinking = false) {
|
|
const startTime = Date.now();
|
|
let lastText = '';
|
|
let stableCount = 0;
|
|
|
|
while (Date.now() - startTime < timeoutMs) {
|
|
await page.wait(3);
|
|
|
|
let result;
|
|
try {
|
|
result = await page.evaluate(`(() => {
|
|
const bubbles = document.querySelectorAll('${MESSAGE_SELECTOR}');
|
|
const texts = Array.from(bubbles).map(b => (b.innerText || '').trim()).filter(Boolean);
|
|
var last = texts[texts.length - 1] || '';
|
|
|
|
// DOM-level thinking/response separation.
|
|
// DeepSeek renders thinking in a collapsible container with a
|
|
// distinct class (e.g. .ds-markdown--think or similar) and the
|
|
// final answer in the main .ds-markdown region. By querying
|
|
// these separately we avoid any text-heuristic split.
|
|
var thinkEl = null, answerEl = null, thinkTime = null;
|
|
if (${parseThinking} && bubbles.length > 0) {
|
|
var lastBubble = bubbles[bubbles.length - 1];
|
|
// Thinking container — DeepSeek uses various class names;
|
|
// try common selectors.
|
|
thinkEl = lastBubble.querySelector('.ds-markdown--think')
|
|
|| lastBubble.querySelector('[class*="think"]');
|
|
// Final answer container — the main markdown block that is
|
|
// NOT the thinking section.
|
|
var markdownEls = lastBubble.querySelectorAll('.ds-markdown');
|
|
for (var i = 0; i < markdownEls.length; i++) {
|
|
if (markdownEls[i] !== thinkEl
|
|
&& !(thinkEl && thinkEl.contains(markdownEls[i]))
|
|
&& !markdownEls[i].classList.contains('ds-markdown--think')) {
|
|
answerEl = markdownEls[i];
|
|
}
|
|
}
|
|
// Thinking time from the toggle/header element
|
|
var timeEl = lastBubble.querySelector('[class*="think"] ~ *')
|
|
|| lastBubble.querySelector('.ds-thinking-header');
|
|
if (!timeEl) {
|
|
// Fallback: parse from raw text header
|
|
var m = last.match(/^(?:Thought for ([\\d.]+) seconds?|已思考(用时 ([\\d.]+) 秒))/);
|
|
if (m) thinkTime = m[1] || m[2];
|
|
} else {
|
|
var tm = (timeEl.textContent || '').match(/([\\d.]+)/);
|
|
if (tm) thinkTime = tm[1];
|
|
}
|
|
}
|
|
|
|
return {
|
|
count: texts.length,
|
|
last: last,
|
|
// DOM-separated fields (null when not available)
|
|
thinkText: thinkEl ? (thinkEl.innerText || '').trim() : null,
|
|
answerText: answerEl ? (answerEl.innerText || '').trim() : null,
|
|
thinkTime: thinkTime,
|
|
};
|
|
})()`);
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
if (!result) continue;
|
|
|
|
const candidate = result.last;
|
|
if (candidate && result.count > baselineCount && candidate !== prompt.trim()) {
|
|
if (candidate === lastText) {
|
|
stableCount++;
|
|
if (stableCount >= 3) {
|
|
if (parseThinking) {
|
|
// Prefer DOM-level separation
|
|
if (result.thinkText != null || result.answerText != null) {
|
|
return {
|
|
thinking: result.thinkText || '',
|
|
response: result.answerText || '',
|
|
thinking_time: result.thinkTime || null,
|
|
};
|
|
}
|
|
// Fallback to text-header parsing (no \n\n split)
|
|
return parseThinkingResponse(candidate);
|
|
}
|
|
return candidate;
|
|
}
|
|
} else {
|
|
stableCount = 0;
|
|
}
|
|
lastText = candidate;
|
|
}
|
|
}
|
|
|
|
if (parseThinking && lastText) {
|
|
return parseThinkingResponse(lastText);
|
|
}
|
|
return lastText || null;
|
|
}
|
|
|
|
export async function getVisibleMessages(page) {
|
|
const result = await page.evaluate(`(() => {
|
|
const msgs = document.querySelectorAll('${MESSAGE_SELECTOR}');
|
|
return Array.from(msgs).map(m => {
|
|
// User messages carry an extra hash-class alongside ds-message
|
|
const isUser = m.className.split(/\\s+/).length > 2;
|
|
return {
|
|
Role: isUser ? 'user' : 'assistant',
|
|
Text: (m.innerText || '').trim(),
|
|
};
|
|
}).filter(m => m.Text);
|
|
})()`);
|
|
return Array.isArray(result) ? result : [];
|
|
}
|
|
|
|
export async function getConversationList(page) {
|
|
await ensureOnDeepSeek(page);
|
|
// Expand sidebar if collapsed
|
|
await page.evaluate(`(() => {
|
|
if (document.querySelectorAll('a[href*="/a/chat/s/"]').length === 0) {
|
|
const btn = document.querySelector('div[tabindex="0"][role="button"]');
|
|
if (btn) btn.click();
|
|
}
|
|
})()`);
|
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
await page.wait(2);
|
|
const items = await page.evaluate(`(() => {
|
|
const items = [];
|
|
const links = document.querySelectorAll('a[href*="/a/chat/s/"]');
|
|
links.forEach((link, i) => {
|
|
const title = (link.innerText || '').trim().split('\\n')[0].trim();
|
|
const href = link.getAttribute('href') || '';
|
|
const idMatch = href.match(/\\/s\\/([a-f0-9-]+)/);
|
|
items.push({
|
|
Index: i + 1,
|
|
Id: idMatch ? idMatch[1] : href,
|
|
Title: title || '(untitled)',
|
|
Url: 'https://chat.deepseek.com' + href,
|
|
});
|
|
});
|
|
return items;
|
|
})()`);
|
|
if (Array.isArray(items) && items.length > 0) return items;
|
|
}
|
|
return [];
|
|
}
|
|
|
|
async function waitForFilePreview(page, fileName) {
|
|
for (let attempt = 0; attempt < 8; attempt++) {
|
|
await page.wait(2);
|
|
const ready = await page.evaluate(`(() => {
|
|
var name = ${JSON.stringify(fileName)};
|
|
var hasFileName = Array.from(document.querySelectorAll('div'))
|
|
.some(function(el) { return el.children.length === 0 && (el.textContent || '').trim() === name; });
|
|
if (hasFileName) return true;
|
|
// Vision mode shows an image thumbnail, not filename text. Require
|
|
// a preview-like node here; send-button readiness is checked later.
|
|
var box = document.querySelector('${TEXTAREA_SELECTOR}');
|
|
if (!box) return false;
|
|
var c = box.parentElement;
|
|
while (c && !c.querySelector('div[role="button"]')) c = c.parentElement;
|
|
if (!c) return false;
|
|
return !!c.querySelector('img[src], canvas, video, [style*="background-image"], [class*="preview"], [class*="upload"]');
|
|
})()`);
|
|
if (ready) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export async function sendWithFile(page, filePath, prompt) {
|
|
const fs = await import('node:fs');
|
|
const path = await import('node:path');
|
|
const absPath = path.default.resolve(filePath);
|
|
|
|
if (!fs.default.existsSync(absPath)) {
|
|
return { ok: false, reason: `File not found: ${absPath}` };
|
|
}
|
|
|
|
const stats = fs.default.statSync(absPath);
|
|
if (stats.size > 100 * 1024 * 1024) {
|
|
return { ok: false, reason: `File too large (${(stats.size / 1024 / 1024).toFixed(1)} MB). Max: 100 MB` };
|
|
}
|
|
|
|
const fileName = path.default.basename(absPath);
|
|
|
|
// Collapse sidebar to keep DOM simple for send button matching
|
|
await page.evaluate(`(() => {
|
|
if (document.querySelectorAll('a[href*="/a/chat/s/"]').length > 0) {
|
|
const btn = document.querySelector('div[tabindex="0"][role="button"]');
|
|
if (btn) btn.click();
|
|
}
|
|
})()`);
|
|
await page.wait(0.5);
|
|
|
|
let uploaded = false;
|
|
if (page.setFileInput) {
|
|
try {
|
|
await page.setFileInput([absPath], 'input[type="file"]');
|
|
uploaded = true;
|
|
} catch (err) {
|
|
const msg = String(err?.message || err);
|
|
if (!msg.includes('Unknown action') && !msg.includes('not supported') && !msg.includes('Not allowed')) {
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!uploaded) {
|
|
const content = fs.default.readFileSync(absPath);
|
|
const base64 = content.toString('base64');
|
|
const fallbackResult = await page.evaluate(`(async () => {
|
|
var binary = atob('${base64}');
|
|
var bytes = new Uint8Array(binary.length);
|
|
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
|
|
var file = new File([bytes], ${JSON.stringify(fileName)});
|
|
var dt = new DataTransfer();
|
|
dt.items.add(file);
|
|
|
|
var inp = document.querySelector('input[type="file"]');
|
|
if (!inp) return { ok: false, reason: 'file input not found' };
|
|
|
|
var propsKey = Object.keys(inp).find(function(k) { return k.startsWith('__reactProps$'); });
|
|
if (!propsKey || typeof inp[propsKey].onChange !== 'function') {
|
|
return { ok: false, reason: 'React onChange not found' };
|
|
}
|
|
|
|
inp.files = dt.files;
|
|
// Use inp.files, not dt.files; assignment transfers ownership
|
|
inp[propsKey].onChange({ target: { files: inp.files } });
|
|
return { ok: true };
|
|
})()`);
|
|
if (fallbackResult && !fallbackResult.ok) return fallbackResult;
|
|
}
|
|
|
|
const ready = await waitForFilePreview(page, fileName);
|
|
if (!ready) return { ok: false, reason: 'file preview did not appear' };
|
|
|
|
// File preview appears immediately but send button stays disabled until
|
|
// the server upload finishes. Wait for it.
|
|
let sendEnabled = false;
|
|
for (let tick = 0; tick < 15; tick++) {
|
|
const enabled = await page.evaluate(`(() => {
|
|
var box = document.querySelector('${TEXTAREA_SELECTOR}');
|
|
if (!box) return false;
|
|
var c = box.parentElement;
|
|
while (c && !c.querySelector('div[role="button"]')) c = c.parentElement;
|
|
if (!c) return false;
|
|
var btns = c.querySelectorAll('div[role="button"]:not(.ds-toggle-button)');
|
|
var last = btns[btns.length - 1];
|
|
return !!(last && last.getAttribute('aria-disabled') === 'false');
|
|
})()`);
|
|
if (enabled) {
|
|
sendEnabled = true;
|
|
break;
|
|
}
|
|
await page.wait(1);
|
|
}
|
|
if (!sendEnabled) {
|
|
return { ok: false, reason: 'send button did not enable after upload' };
|
|
}
|
|
|
|
return sendMessage(page, prompt);
|
|
}
|
|
|
|
// Retries on CDP "Promise was collected" errors caused by DeepSeek's SPA router transitions.
|
|
export async function withRetry(fn, retries = 2) {
|
|
for (let i = 0; i <= retries; i++) {
|
|
try {
|
|
return await fn();
|
|
} catch (err) {
|
|
const msg = String(err?.message || err);
|
|
if (i < retries && msg.includes('Promise was collected')) {
|
|
await new Promise(r => setTimeout(r, 2000));
|
|
continue;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
|
|
export function parseBoolFlag(value) {
|
|
if (typeof value === 'boolean') return value;
|
|
return String(value ?? '').trim().toLowerCase() === 'true';
|
|
}
|