diff --git a/clis/chatwise/ask.js b/clis/chatwise/ask.js index 7178697ef..71b2ecdab 100644 --- a/clis/chatwise/ask.js +++ b/clis/chatwise/ask.js @@ -1,5 +1,11 @@ import { cli, Strategy } from '@jackwener/opencli/registry'; -import { selectorError } from '@jackwener/opencli/errors'; +import { selectorError, TimeoutError } from '@jackwener/opencli/errors'; +import { + buildChatwiseInjectTextJs, + buildChatwiseMessageCountJs, + buildChatwiseResponseAfterJs, + normalizeTimeout, +} from './utils.js'; export const askCommand = cli({ site: 'chatwise', name: 'ask', @@ -15,34 +21,11 @@ export const askCommand = cli({ columns: ['Role', 'Text'], func: async (page, kwargs) => { const text = kwargs.text; - const timeout = parseInt(kwargs.timeout, 10) || 30; + const timeout = normalizeTimeout(kwargs.timeout); // Snapshot content length - const beforeLen = await page.evaluate(` - (function() { - const msgs = document.querySelectorAll('[data-message-id], [class*="message"], [class*="bubble"]'); - return msgs.length; - })() - `); + const beforeLen = await page.evaluate(buildChatwiseMessageCountJs()); // Send message - const injected = await page.evaluate(` - (function(text) { - let composer = document.querySelector('textarea'); - if (!composer) { - const editables = Array.from(document.querySelectorAll('[contenteditable="true"]')); - composer = editables.length > 0 ? editables[editables.length - 1] : null; - } - if (!composer) return false; - composer.focus(); - if (composer.tagName === 'TEXTAREA') { - const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; - setter.call(composer, text); - composer.dispatchEvent(new Event('input', { bubbles: true })); - } else { - document.execCommand('insertText', false, text); - } - return true; - })(${JSON.stringify(text)}) - `); + const injected = await page.evaluate(buildChatwiseInjectTextJs(text)); if (!injected) throw selectorError('ChatWise input element'); await page.wait(0.5); @@ -53,25 +36,15 @@ export const askCommand = cli({ let response = ''; for (let i = 0; i < maxPolls; i++) { await page.wait(pollInterval); - const result = await page.evaluate(` - (function(prevLen) { - const msgs = document.querySelectorAll('[data-message-id], [class*="message"], [class*="bubble"]'); - if (msgs.length <= prevLen) return null; - const last = msgs[msgs.length - 1]; - const text = last.innerText || last.textContent; - return text ? text.trim() : null; - })(${beforeLen}) - `); + const result = await page.evaluate(buildChatwiseResponseAfterJs(beforeLen, text)); if (result) { - response = result; - break; + const next = String(result).trim(); + if (next === response) break; + response = next; } } if (!response) { - return [ - { Role: 'User', Text: text }, - { Role: 'System', Text: `No response within ${timeout}s.` }, - ]; + throw new TimeoutError('ChatWise response', timeout, 'Confirm ChatWise is done generating, then retry with a larger --timeout if needed.'); } return [ { Role: 'User', Text: text }, diff --git a/clis/chatwise/composer.test.js b/clis/chatwise/composer.test.js new file mode 100644 index 000000000..f0a92270b --- /dev/null +++ b/clis/chatwise/composer.test.js @@ -0,0 +1,186 @@ +import { JSDOM } from 'jsdom'; +import { describe, expect, it, vi } from 'vitest'; +import { ArgumentError, TimeoutError } from '@jackwener/opencli/errors'; +import { + buildChatwiseInjectTextJs, + buildChatwiseMessageCountJs, + buildChatwiseResponseAfterJs, + normalizeTimeout, + scoreChatwiseComposerCandidate, + selectBestChatwiseComposer, +} from './utils.js'; +import { askCommand } from './ask.js'; + +function candidate(overrides = {}) { + return { + index: 0, + hidden: false, + role: 'textbox', + classes: 'cm-content cm-lineWrapping', + editorClasses: 'cm-editor', + ariaLabel: '', + placeholder: '', + text: '', + rect: { y: 0, h: 30 }, + ...overrides, + }; +} + +function runBrowserScript(html, script) { + const dom = new JSDOM(html, { url: 'app://chatwise.local/', runScripts: 'outside-only' }); + Object.defineProperty(dom.window.HTMLElement.prototype, 'offsetWidth', { configurable: true, get: () => 400 }); + Object.defineProperty(dom.window.HTMLElement.prototype, 'offsetHeight', { configurable: true, get: () => 32 }); + dom.window.HTMLElement.prototype.getClientRects = () => [{ length: 1 }]; + dom.window.document.execCommand = () => false; + return { dom, result: dom.window.eval(script) }; +} + +function makePage(evaluateResults = []) { + const evaluate = vi.fn(); + for (const result of evaluateResults) evaluate.mockResolvedValueOnce(result); + evaluate.mockResolvedValue(null); + return { + evaluate, + wait: vi.fn().mockResolvedValue(undefined), + pressKey: vi.fn().mockResolvedValue(undefined), + }; +} + +describe('chatwise composer selection', () => { + it('prefers the main composer over auxiliary contenteditable editors', () => { + const mainComposer = candidate({ + index: 0, + placeholder: 'placeholder Enter a message here, press ⏎ to send', + rect: { y: 860, h: 32 }, + }); + const optionalDescription = candidate({ + index: 1, + placeholder: 'placeholder Optional description', + editorClasses: 'cm-editor simple-editor', + rect: { y: 400, h: 32 }, + }); + const userContext = candidate({ + index: 2, + text: '# User Context Document', + editorClasses: 'cm-editor simple-editor', + rect: { y: 460, h: 1200 }, + }); + + expect(scoreChatwiseComposerCandidate(mainComposer, 900)).toBeGreaterThan( + scoreChatwiseComposerCandidate(optionalDescription, 900), + ); + expect(scoreChatwiseComposerCandidate(mainComposer, 900)).toBeGreaterThan( + scoreChatwiseComposerCandidate(userContext, 900), + ); + + expect(selectBestChatwiseComposer([ + optionalDescription, + userContext, + mainComposer, + ], 900)?.index).toBe(0); + }); + + it('rejects hidden or low-confidence candidates instead of injecting into the wrong editor', () => { + expect(selectBestChatwiseComposer([ + candidate({ + index: 0, + hidden: true, + placeholder: 'Enter a message here, press ⏎ to send', + rect: { y: 860, h: 32 }, + }), + ], 900)).toBeNull(); + + expect(selectBestChatwiseComposer([ + candidate({ + index: 1, + placeholder: 'Optional description', + editorClasses: 'cm-editor simple-editor', + rect: { y: 860, h: 32 }, + }), + candidate({ + index: 2, + text: '# User Context Document', + editorClasses: 'cm-editor simple-editor', + rect: { y: 870, h: 32 }, + }), + ], 900)).toBeNull(); + }); + + it('injects text into the scored main composer instead of the last contenteditable', () => { + const html = ` +
+
Optional description
+
+
+
+
Enter a message here, press ⏎ to send
+
+
+
+
# User Context Document
+
+ `; + + const { dom, result } = runBrowserScript(html, buildChatwiseInjectTextJs('hello')); + + expect(result).toBe(true); + expect(dom.window.document.querySelector('#main')?.textContent).toBe('hello'); + expect(dom.window.document.querySelector('#optional')?.textContent).toBe(''); + expect(dom.window.document.querySelector('#context')?.textContent).toBe('# User Context Document'); + }); + + it('fails injection when only auxiliary editors are present', () => { + const html = ` +
+
Optional description
+
+
+
+
# User Context Document
+
+ `; + + const { dom, result } = runBrowserScript(html, buildChatwiseInjectTextJs('hello')); + + expect(result).toBe(false); + expect(dom.window.document.querySelector('#optional')?.textContent).toBe(''); + expect(dom.window.document.querySelector('#context')?.textContent).toBe('# User Context Document'); + }); + + it('reads only real message wrapper content after the previous count', () => { + const html = ` +
old message
+
12:00
+
new assistant answer
+ `; + + expect(runBrowserScript(html, buildChatwiseMessageCountJs()).result).toBe(2); + expect(runBrowserScript(html, buildChatwiseResponseAfterJs(1, 'user prompt')).result).toBe('new assistant answer'); + }); + + it('does not treat the user prompt wrapper as an assistant response', () => { + const html = ` +
old message
+
user prompt
+ `; + + expect(runBrowserScript(html, buildChatwiseResponseAfterJs(1, 'user prompt')).result).toBeNull(); + }); + + it('validates timeout explicitly', () => { + expect(normalizeTimeout(undefined)).toBe(30); + expect(() => normalizeTimeout('0')).toThrow(ArgumentError); + expect(() => normalizeTimeout('301')).toThrow(ArgumentError); + }); + + it('fails fast when ask times out instead of returning a System success row', async () => { + const page = makePage([ + 1, + true, + null, + ]); + + await expect(askCommand.func(page, { text: 'hello', timeout: 1 })) + .rejects.toBeInstanceOf(TimeoutError); + }); +}); diff --git a/clis/chatwise/send.js b/clis/chatwise/send.js index 7f7ca9883..f18967b15 100644 --- a/clis/chatwise/send.js +++ b/clis/chatwise/send.js @@ -1,5 +1,6 @@ import { cli, Strategy } from '@jackwener/opencli/registry'; import { selectorError } from '@jackwener/opencli/errors'; +import { buildChatwiseInjectTextJs } from './utils.js'; export const sendCommand = cli({ site: 'chatwise', name: 'send', @@ -12,30 +13,7 @@ export const sendCommand = cli({ columns: ['Status', 'InjectedText'], func: async (page, kwargs) => { const text = kwargs.text; - const injected = await page.evaluate(` - (function(text) { - // ChatWise input can be textarea or contenteditable - let composer = document.querySelector('textarea'); - if (!composer) { - const editables = Array.from(document.querySelectorAll('[contenteditable="true"]')); - composer = editables.length > 0 ? editables[editables.length - 1] : null; - } - - if (!composer) return false; - - composer.focus(); - - if (composer.tagName === 'TEXTAREA') { - // For textarea, set value and dispatch input event - const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; - nativeInputValueSetter.call(composer, text); - composer.dispatchEvent(new Event('input', { bubbles: true })); - } else { - document.execCommand('insertText', false, text); - } - return true; - })(${JSON.stringify(text)}) - `); + const injected = await page.evaluate(buildChatwiseInjectTextJs(text)); if (!injected) throw selectorError('ChatWise input element'); await page.wait(0.5); diff --git a/clis/chatwise/utils.js b/clis/chatwise/utils.js new file mode 100644 index 000000000..dfa8673d9 --- /dev/null +++ b/clis/chatwise/utils.js @@ -0,0 +1,147 @@ +import { ArgumentError } from '@jackwener/opencli/errors'; + +export const MESSAGE_WRAPPER_SELECTOR = '[class*="group/message"]'; +export const MIN_COMPOSER_SCORE = 120; + +export function normalizeTimeout(value, defaultSeconds = 30, maxSeconds = 300) { + const raw = value ?? defaultSeconds; + const timeout = Number(raw); + if (!Number.isInteger(timeout) || timeout <= 0) { + throw new ArgumentError('timeout must be a positive integer', `Example: opencli chatwise ask "hello" --timeout ${defaultSeconds}`); + } + if (timeout > maxSeconds) { + throw new ArgumentError(`timeout must be <= ${maxSeconds}`, `Example: opencli chatwise ask "hello" --timeout ${maxSeconds}`); + } + return timeout; +} + +export function scoreChatwiseComposerCandidate(candidate, viewportHeight = 0) { + if (candidate.hidden) return -1000; + + let score = 0; + const normalizedRole = String(candidate.role || '').toLowerCase(); + if (normalizedRole === 'textbox') score += 10; + + const normalizedClasses = `${candidate.classes || ''} ${candidate.editorClasses || ''} ${candidate.ariaLabel || ''}`.toLowerCase(); + if (normalizedClasses.includes('cm-content')) score += 20; + if (normalizedClasses.includes('cm-editor')) score += 30; + if (normalizedClasses.includes('simple-editor')) score -= 140; + + const searchableText = `${candidate.placeholder || ''} ${candidate.ariaLabel || ''} ${candidate.text || ''}`.toLowerCase(); + if (searchableText.includes('enter a message here')) score += 220; + if (searchableText.includes('press ⏎ to send')) score += 80; + if (searchableText.includes('press enter to send')) score += 80; + if (searchableText.includes('message')) score += 20; + if (searchableText.includes('optional description')) score -= 140; + if (searchableText.includes('user context document')) score -= 220; + + if (viewportHeight > 0 && candidate.rect) { + const bottom = candidate.rect.y + candidate.rect.h; + const distanceFromBottom = Math.abs(viewportHeight - bottom); + score += Math.max(0, 80 - distanceFromBottom / 8); + } + + return score; +} + +export function selectBestChatwiseComposer(candidates, viewportHeight = 0, minScore = MIN_COMPOSER_SCORE) { + if (!Array.isArray(candidates) || candidates.length === 0) return null; + const best = [...candidates] + .sort((left, right) => { + const delta = scoreChatwiseComposerCandidate(right, viewportHeight) + - scoreChatwiseComposerCandidate(left, viewportHeight); + return delta !== 0 ? delta : left.index - right.index; + })[0] ?? null; + if (!best || scoreChatwiseComposerCandidate(best, viewportHeight) < minScore) return null; + return best; +} + +export function buildChatwiseInjectTextJs(text) { + const scoreFn = scoreChatwiseComposerCandidate.toString(); + const selectFn = selectBestChatwiseComposer.toString(); + const textJs = JSON.stringify(String(text ?? '')); + + return ` + (function(text) { + const scoreChatwiseComposerCandidate = ${scoreFn}; + const selectBestChatwiseComposer = ${selectFn}; + const MIN_COMPOSER_SCORE = ${MIN_COMPOSER_SCORE}; + + const composers = Array.from(document.querySelectorAll([ + 'textarea[aria-label*="message" i]', + 'textarea[placeholder*="message" i]', + '[contenteditable="true"][role="textbox"]', + '[contenteditable="true"]' + ].join(','))); + const candidates = composers.map((el, index) => { + const rect = el.getBoundingClientRect(); + const editor = el.closest('.cm-editor'); + const placeholderEl = editor?.querySelector('.cm-placeholder'); + return { + index, + hidden: !(el.offsetWidth || el.offsetHeight || el.getClientRects().length), + role: el.getAttribute('role'), + classes: el.className || '', + editorClasses: editor?.className || '', + ariaLabel: el.getAttribute('aria-label') || '', + placeholder: placeholderEl?.getAttribute('aria-label') || placeholderEl?.textContent || el.getAttribute('placeholder') || '', + text: (el.textContent || '').trim(), + rect: { y: rect.y, h: rect.height }, + }; + }); + + const best = selectBestChatwiseComposer(candidates, window.innerHeight, MIN_COMPOSER_SCORE); + if (!best) return false; + + const composer = composers[best.index]; + composer.focus(); + + if (composer.tagName === 'TEXTAREA') { + const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set; + if (setter) setter.call(composer, text); + else composer.value = text; + composer.dispatchEvent(new Event('input', { bubbles: true })); + return true; + } + + const selection = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(composer); + selection?.removeAllRanges(); + selection?.addRange(range); + + const inserted = document.execCommand?.('insertText', false, text); + if (!inserted) { + composer.textContent = text; + } + composer.dispatchEvent(new Event('input', { bubbles: true })); + return true; + })(${textJs}) + `; +} + +export function buildChatwiseMessageCountJs() { + return ` + (function() { + return Array.from(document.querySelectorAll(${JSON.stringify(MESSAGE_WRAPPER_SELECTOR)})) + .map(node => (node.innerText || node.textContent || '').trim()) + .filter(Boolean) + .length; + })() + `; +} + +export function buildChatwiseResponseAfterJs(previousCount, userText) { + return ` + (function(previousCount, userText) { + const messages = Array.from(document.querySelectorAll(${JSON.stringify(MESSAGE_WRAPPER_SELECTOR)})) + .map(node => (node.innerText || node.textContent || '').trim()) + .filter(Boolean); + if (messages.length <= previousCount) return null; + const fresh = messages.slice(previousCount) + .filter(text => text && text !== userText); + if (fresh.length === 0) return null; + return fresh[fresh.length - 1]; + })(${Number(previousCount) || 0}, ${JSON.stringify(String(userText ?? ''))}) + `; +}