Files
jackwener--opencli/clis/qoder/_utils.js
T
wehub-resource-sync 9b395f5cc3
Build Chrome Extension / build (push) Waiting to run
Trigger Website Rebuild (Docs Updated) / dispatch (push) Waiting to run
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
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

192 lines
8.2 KiB
JavaScript

// Shared helpers for the Qoder adapter.
//
// Qoder is an Electron-based AI IDE (Alibaba; com.qoder.ide). It's
// VSCode-derived (same Electron + Monaco shell) and shares many DOM
// patterns with Trae SOLO. CDP port: 9237 (declared in
// src/electron-apps.ts and launched by ~/.claude/bin/qoder-launch-with-cdp.sh).
//
// Terminology:
// - "Quest" = conversation (Qoder's term for a chat thread)
// - "Workspace" = open folder (VSCode-style)
// - "Knowledge" = personal/team knowledge base
// - "Marketplace"= plugin/skill marketplace
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
export const IS_VISIBLE_JS = `
const isVisible = (el) => {
if (!el) return false;
const r = el.getBoundingClientRect();
if (r.width < 1 || r.height < 1) return false;
const cs = getComputedStyle(el);
if (cs.visibility === 'hidden' || cs.display === 'none' || cs.opacity === '0') return false;
return true;
};
`;
export function unwrapEvaluateResult(payload) {
if (payload && typeof payload === 'object' && !Array.isArray(payload) && 'session' in payload && 'data' in payload) {
return payload.data;
}
return payload;
}
export async function evaluateQoder(page, script) {
return unwrapEvaluateResult(await page.evaluate(script));
}
export function requireArrayResult(value, label) {
if (!Array.isArray(value)) {
throw new CommandExecutionError(`${label}: unexpected evaluate result shape`);
}
return value;
}
export function parsePositiveInt(raw, fallback, label) {
const value = raw == null || raw === '' ? fallback : Number(raw);
if (!Number.isInteger(value) || value < 1) {
throw new ArgumentError(`${label} must be a positive integer`);
}
return value;
}
// Build a JS snippet that clicks the first visible element matching any
// of the given CSS selectors. Uses the full pointer-event chain to
// satisfy radix/headless menu libraries.
export function clickFirstScript(selectors) {
return `(() => {
${IS_VISIBLE_JS}
const sels = ${JSON.stringify(selectors)};
for (const sel of sels) {
const target = Array.from(document.querySelectorAll(sel)).filter(isVisible)[0];
if (target) {
const r = target.getBoundingClientRect();
const opts = { bubbles: true, cancelable: true, clientX: r.x + r.width/2, clientY: r.y + r.height/2 };
target.dispatchEvent(new PointerEvent('pointerdown', opts));
target.dispatchEvent(new MouseEvent('mousedown', opts));
target.dispatchEvent(new PointerEvent('pointerup', opts));
target.dispatchEvent(new MouseEvent('mouseup', opts));
target.click();
return { ok: true, sel };
}
}
return { ok: false, reason: 'No matching visible element.' };
})()`;
}
// Variant: match a button by visible innerText (substring or full match).
// Useful when Qoder buttons lack aria-label.
export function clickByTextScript(textPatterns, opts = {}) {
const { exact = false, maxLen = 60 } = opts;
return `(() => {
${IS_VISIBLE_JS}
const patterns = ${JSON.stringify(textPatterns)};
const exact = ${exact ? 'true' : 'false'};
const maxLen = ${maxLen};
const isVis = isVisible;
const candidates = Array.from(document.querySelectorAll('button, [role="button"], a, [role="tab"]')).filter(isVis);
for (const pat of patterns) {
const target = candidates.find((b) => {
const tx = (b.innerText || b.textContent || '').trim();
if (tx.length > maxLen) return false;
return exact ? tx === pat : tx.toLowerCase().includes(pat.toLowerCase());
});
if (target) {
const r = target.getBoundingClientRect();
const opts = { bubbles: true, cancelable: true, clientX: r.x + r.width/2, clientY: r.y + r.height/2 };
target.dispatchEvent(new PointerEvent('pointerdown', opts));
target.dispatchEvent(new MouseEvent('mousedown', opts));
target.dispatchEvent(new PointerEvent('pointerup', opts));
target.dispatchEvent(new MouseEvent('mouseup', opts));
target.click();
return { ok: true, matched: pat };
}
}
return { ok: false, reason: 'No button matching: ' + patterns.join(' / ') };
})()`;
}
export const QODER_TURNS_JS = `(() => {
${IS_VISIBLE_JS}
const chatPanes = Array.from(document.querySelectorAll('[class*="chat"i], [class*="conversation"i], [class*="message"i]')).filter(isVisible);
if (!chatPanes.length) return [];
const pane = chatPanes.sort((a, b) => b.getBoundingClientRect().height - a.getBoundingClientRect().height)[0];
const candidates = Array.from(pane.querySelectorAll('div, article, [class*="message"i], [class*="turn"i], [class*="bubble"i]'))
.filter(isVisible)
.filter((el) => {
const tx = (el.innerText || '').trim();
return tx.length > 5 && tx.length < 4000 && el.children.length < 20;
});
const seen = new Set();
return candidates.map((el) => {
const tx = (el.innerText || '').trim().replace(/\\s+/g, ' ');
if (seen.has(tx)) return null;
seen.add(tx);
const cls = (el.className || '').toString().toLowerCase();
const role = /user|me-|right/.test(cls) ? 'User' : (/assistant|ai|bot|response/.test(cls) ? 'Assistant' : 'Turn');
return { role, text: tx };
}).filter(Boolean);
})()`;
export function buildQoderInjectTextScript(text) {
return `(() => {
${IS_VISIBLE_JS}
const editors = Array.from(document.querySelectorAll('[contenteditable="true"]')).filter(isVisible);
const candidates = editors.map((editor, index) => {
const rect = editor.getBoundingClientRect();
const attrs = [
editor.getAttribute('aria-label') || '',
editor.getAttribute('placeholder') || '',
editor.getAttribute('data-placeholder') || '',
editor.textContent || '',
editor.className || '',
editor.closest('[class*="composer"i], [class*="input"i], [class*="chat"i]')?.className || '',
].join(' ').toLowerCase();
let score = 0;
if (editor.getAttribute('role') === 'textbox') score += 25;
if (attrs.includes('message') || attrs.includes('prompt') || attrs.includes('ask')) score += 120;
if (attrs.includes('composer') || attrs.includes('input')) score += 60;
if (attrs.includes('optional description') || attrs.includes('user context document') || attrs.includes('knowledge')) score -= 240;
if (window.innerHeight > 0) score += Math.max(0, 80 - Math.abs(window.innerHeight - (rect.y + rect.height)) / 8);
return { editor, index, score };
}).sort((a, b) => b.score - a.score || b.index - a.index);
const best = candidates[0];
if (!best || best.score < 40) return { ok: false, reason: 'No high-confidence Qoder composer found.' };
const editor = best.editor;
editor.focus();
document.execCommand('selectAll', false);
const inserted = document.execCommand('insertText', false, ${JSON.stringify(String(text ?? ''))});
if (!inserted) {
editor.textContent = ${JSON.stringify(String(text ?? ''))};
}
editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: ${JSON.stringify(String(text ?? ''))} }));
return { ok: true, score: best.score };
})()`;
}
export const QODER_MESSAGE_COUNT_JS = `(() => {
const turns = ${QODER_TURNS_JS};
return Array.isArray(turns) ? turns.length : 0;
})()`;
export function qoderResponseAfterScript(previousCount, userText) {
return `(() => {
const turns = ${QODER_TURNS_JS};
if (!Array.isArray(turns) || turns.length <= ${Number(previousCount) || 0}) return null;
const fresh = turns.slice(${Number(previousCount) || 0})
.filter((turn) => turn && turn.text && turn.text !== ${JSON.stringify(String(userText ?? ''))});
return fresh.length ? fresh[fresh.length - 1] : null;
})()`;
}
// Wait for an element to appear by polling page.evaluate.
export async function waitForSelector(page, selector, timeoutMs = 5000, intervalMs = 200) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const exists = await page.evaluate(`!!document.querySelector(${JSON.stringify(selector)})`);
if (exists) return true;
await new Promise((r) => setTimeout(r, intervalMs));
}
return false;
}