diff --git a/src/build-manifest.test.ts b/src/build-manifest.test.ts index f8dcd6d3..96a4c944 100644 --- a/src/build-manifest.test.ts +++ b/src/build-manifest.test.ts @@ -130,6 +130,28 @@ describe('manifest helper rules', () => { expect(scanTs(file, 'demo')).toBeNull(); }); + it('detects shared desktop factory commands in TS adapters', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-manifest-shared-')); + tempDirs.push(dir); + const file = path.join(dir, 'status.ts'); + fs.writeFileSync( + file, + `import { makeStatusCommand } from '../_shared/desktop-commands.js'; + export const statusCommand = makeStatusCommand('chatwise', 'ChatWise Desktop');`, + ); + + expect(scanTs(file, 'chatwise')).toMatchObject({ + site: 'chatwise', + name: 'status', + description: 'Check active CDP connection to ChatWise Desktop', + strategy: 'ui', + browser: true, + columns: ['Status', 'Url', 'Title'], + type: 'ts', + modulePath: 'chatwise/status.js', + }); + }); + it('keeps literal domain and navigateBefore for TS adapters', () => { const file = path.join(process.cwd(), 'src', 'clis', 'xueqiu', 'fund-holdings.ts'); const entry = scanTs(file, 'xueqiu'); diff --git a/src/build-manifest.ts b/src/build-manifest.ts index 3d9be7c1..1c9f1165 100644 --- a/src/build-manifest.ts +++ b/src/build-manifest.ts @@ -50,6 +50,8 @@ import type { YamlCliDefinition } from './yaml-schema.js'; import { isRecord } from './utils.js'; +const SHARED_DESKTOP_FACTORY_PATTERN = /\bmake(Status|New|Screenshot|Dump)Command\s*\(\s*['"`]([^'"`]+)['"`](?:\s*,\s*['"`]([^'"`]+)['"`])?/; + function extractBalancedBlock( source: string, @@ -217,6 +219,74 @@ export function scanTs(filePath: string, site: string): ManifestEntry | null { try { const src = fs.readFileSync(filePath, 'utf-8'); + const sharedFactoryMatch = src.match(SHARED_DESKTOP_FACTORY_PATTERN); + if (sharedFactoryMatch) { + const [, factoryName, siteName, displayName] = sharedFactoryMatch; + const label = displayName || siteName; + + switch (factoryName) { + case 'Status': + return { + site: siteName, + name: baseName, + description: `Check active CDP connection to ${label}`, + domain: 'localhost', + strategy: 'ui', + browser: true, + args: [], + columns: ['Status', 'Url', 'Title'], + type: 'ts', + modulePath: relativePath, + }; + case 'New': + return { + site: siteName, + name: baseName, + description: `Start a new ${label} session`, + domain: 'localhost', + strategy: 'ui', + browser: true, + args: [], + columns: ['Status'], + type: 'ts', + modulePath: relativePath, + }; + case 'Screenshot': + return { + site: siteName, + name: baseName, + description: `Capture a snapshot of the current ${label} window (DOM + Accessibility tree)`, + domain: 'localhost', + strategy: 'ui', + browser: true, + args: [ + { + name: 'output', + type: 'str', + required: false, + help: `Output file path (default: /tmp/${siteName}-snapshot.txt)`, + }, + ], + columns: ['Status', 'File'], + type: 'ts', + modulePath: relativePath, + }; + case 'Dump': + return { + site: siteName, + name: baseName, + description: `Dump the DOM and Accessibility tree of ${siteName} for reverse-engineering`, + domain: 'localhost', + strategy: 'ui', + browser: true, + args: [], + columns: ['action', 'files'], + type: 'ts', + modulePath: relativePath, + }; + } + } + // Helper/test modules should not appear as CLI commands in the manifest. if (!/\bcli\s*\(/.test(src)) return null; diff --git a/src/chatwise-composer.test.ts b/src/chatwise-composer.test.ts new file mode 100644 index 00000000..7e89c181 --- /dev/null +++ b/src/chatwise-composer.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { + scoreChatwiseComposerCandidate, + selectBestChatwiseComposer, + type ChatwiseComposerCandidate, +} from './clis/chatwise/utils.js'; + +function candidate(overrides: Partial): ChatwiseComposerCandidate { + return { + index: 0, + hidden: false, + role: 'textbox', + classes: 'cm-content cm-lineWrapping', + editorClasses: 'cm-editor', + placeholder: '', + text: '', + rect: { y: 0, h: 30 }, + ...overrides, + }; +} + +describe('scoreChatwiseComposerCandidate', () => { + it('strongly prefers the main chat composer over auxiliary 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), + ); + }); +}); + +describe('selectBestChatwiseComposer', () => { + it('returns the candidate that matches the main message composer', () => { + const picked = selectBestChatwiseComposer([ + candidate({ + index: 0, + placeholder: 'placeholder Enter a message here, press ⏎ to send', + rect: { y: 858, h: 33 }, + }), + candidate({ + index: 1, + placeholder: 'placeholder Optional description', + editorClasses: 'cm-editor simple-editor', + rect: { y: 395, h: 30 }, + }), + candidate({ + index: 2, + text: '# User Context Document', + editorClasses: 'cm-editor simple-editor', + rect: { y: 464, h: 1200 }, + }), + ], 900); + + expect(picked?.index).toBe(0); + }); +}); diff --git a/src/clis/chatwise/ask.ts b/src/clis/chatwise/ask.ts index cc85e260..f30b20dc 100644 --- a/src/clis/chatwise/ask.ts +++ b/src/clis/chatwise/ask.ts @@ -2,6 +2,9 @@ import { cli, Strategy } from '../../registry.js'; import { SelectorError } from '../../errors.js'; import type { IPage } from '../../types.js'; import { chatwiseRequiredEnv } from './shared.js'; +import { buildChatwiseInjectTextJs } from './utils.js'; + +const MESSAGE_WRAPPER_SELECTOR = '[class*="group/message"]'; export const askCommand = cli({ site: 'chatwise', @@ -23,31 +26,13 @@ export const askCommand = cli({ // Snapshot content length const beforeLen = await page.evaluate(` (function() { - const msgs = document.querySelectorAll('[data-message-id], [class*="message"], [class*="bubble"]'); + const msgs = document.querySelectorAll(${JSON.stringify(MESSAGE_WRAPPER_SELECTOR)}); return msgs.length; })() `); // 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 new SelectorError('ChatWise input element'); await page.wait(0.5); @@ -63,17 +48,22 @@ export const askCommand = cli({ const result = await page.evaluate(` (function(prevLen) { - const msgs = document.querySelectorAll('[data-message-id], [class*="message"], [class*="bubble"]'); + const msgs = Array.from(document.querySelectorAll(${JSON.stringify(MESSAGE_WRAPPER_SELECTOR)})) + .map(node => (node.innerText || node.textContent || '').trim()) + .filter(Boolean); if (msgs.length <= prevLen) return null; - const last = msgs[msgs.length - 1]; - const text = last.innerText || last.textContent; - return text ? text.trim() : null; + const fresh = msgs.slice(prevLen).filter(text => text !== ${JSON.stringify(text)}); + if (fresh.length === 0) return null; + return fresh[fresh.length - 1]; })(${beforeLen}) `); if (result) { - response = result; - break; + const next = String(result).trim(); + if (next === response) { + break; + } + response = next; } } diff --git a/src/clis/chatwise/send.ts b/src/clis/chatwise/send.ts index 401b282a..a1c4aca7 100644 --- a/src/clis/chatwise/send.ts +++ b/src/clis/chatwise/send.ts @@ -2,6 +2,7 @@ import { cli, Strategy } from '../../registry.js'; import { SelectorError } from '../../errors.js'; import type { IPage } from '../../types.js'; import { chatwiseRequiredEnv } from './shared.js'; +import { buildChatwiseInjectTextJs } from './utils.js'; export const sendCommand = cli({ site: 'chatwise', @@ -16,30 +17,7 @@ export const sendCommand = cli({ func: async (page: IPage, kwargs: any) => { const text = kwargs.text as string; - 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 new SelectorError('ChatWise input element'); await page.wait(0.5); diff --git a/src/clis/chatwise/utils.ts b/src/clis/chatwise/utils.ts new file mode 100644 index 00000000..82323d3a --- /dev/null +++ b/src/clis/chatwise/utils.ts @@ -0,0 +1,112 @@ +export interface ChatwiseComposerCandidate { + index: number; + hidden: boolean; + role: string | null; + classes: string; + editorClasses: string; + placeholder: string; + text: string; + rect?: { + y: number; + h: number; + }; +} + +export function scoreChatwiseComposerCandidate( + candidate: ChatwiseComposerCandidate, + viewportHeight: number = 0, +): number { + if (candidate.hidden) return -1000; + + let score = 0; + if (candidate.role === 'textbox') score += 10; + + const normalizedEditorClasses = candidate.editorClasses.toLowerCase(); + if (normalizedEditorClasses.includes('cm-editor')) score += 30; + if (normalizedEditorClasses.includes('simple-editor')) score -= 140; + + const searchableText = `${candidate.placeholder} ${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('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: ChatwiseComposerCandidate[], + viewportHeight: number = 0, +): ChatwiseComposerCandidate | null { + if (candidates.length === 0) return null; + + return [...candidates] + .sort((left, right) => { + const delta = scoreChatwiseComposerCandidate(right, viewportHeight) + - scoreChatwiseComposerCandidate(left, viewportHeight); + return delta !== 0 ? delta : left.index - right.index; + })[0] ?? null; +} + +export function buildChatwiseInjectTextJs(text: string): string { + const scoreFn = scoreChatwiseComposerCandidate.toString(); + const selectFn = selectBestChatwiseComposer.toString(); + const textJs = JSON.stringify(text); + + return ` + (function(text) { + const scoreChatwiseComposerCandidate = ${scoreFn}; + const selectBestChatwiseComposer = ${selectFn}; + + const composers = Array.from(document.querySelectorAll('textarea, [contenteditable="true"]')); + 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 || '', + 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); + if (!best) return false; + + const composer = composers[best.index]; + 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 })); + 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}) + `; +} diff --git a/src/discovery.test.ts b/src/discovery.test.ts new file mode 100644 index 00000000..46d58184 --- /dev/null +++ b/src/discovery.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { discoverClis } from './discovery.js'; +import { getRegistry } from './registry.js'; + +describe('discoverClis', () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + getRegistry().delete('chatwise-test/status'); + }); + + it('loads shared desktop factory modules during filesystem discovery', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-discovery-')); + tempDirs.push(dir); + + const sharedDir = path.join(dir, '_shared'); + const siteDir = path.join(dir, 'chatwise-test'); + fs.mkdirSync(sharedDir, { recursive: true }); + fs.mkdirSync(siteDir, { recursive: true }); + + const registryImport = path.join(process.cwd(), 'src', 'registry.ts').replace(/\\/g, '/'); + + fs.writeFileSync( + path.join(sharedDir, 'desktop-commands.ts'), + ` + import { cli, Strategy } from '${registryImport}'; + export function makeStatusCommand(site, displayName) { + const label = displayName ?? site; + return cli({ + site, + name: 'status', + description: \`Check active CDP connection to \${label}\`, + strategy: Strategy.UI, + browser: true, + columns: ['Status', 'Url', 'Title'], + }); + } + `, + ); + + fs.writeFileSync( + path.join(siteDir, 'status.ts'), + ` + import { makeStatusCommand } from '../_shared/desktop-commands.ts'; + export const statusCommand = makeStatusCommand('chatwise-test', 'ChatWise Test'); + `, + ); + + await discoverClis(dir); + + expect(getRegistry().get('chatwise-test/status')).toMatchObject({ + site: 'chatwise-test', + name: 'status', + description: 'Check active CDP connection to ChatWise Test', + }); + }); +}); diff --git a/src/discovery.ts b/src/discovery.ts index 62dd63bb..5af26668 100644 --- a/src/discovery.ts +++ b/src/discovery.ts @@ -20,8 +20,8 @@ import type { ManifestEntry } from './build-manifest.js'; /** Plugins directory: ~/.opencli/plugins/ */ export const PLUGINS_DIR = path.join(os.homedir(), '.opencli', 'plugins'); -/** Matches files that register commands via cli() or lifecycle hooks */ -const PLUGIN_MODULE_PATTERN = /\b(?:cli|onStartup|onBeforeExecute|onAfterExecute)\s*\(/; +/** Matches files that register commands via cli(), shared desktop factories, or lifecycle hooks */ +const PLUGIN_MODULE_PATTERN = /\b(?:cli|makeStatusCommand|makeNewCommand|makeScreenshotCommand|makeDumpCommand|onStartup|onBeforeExecute|onAfterExecute)\s*\(/; import type { YamlCliDefinition } from './yaml-schema.js';