chore: initialize qiming workspace repository
This commit is contained in:
78
qimingclaw/crates/agent-gui-server/src/desktop/clipboard.ts
Normal file
78
qimingclaw/crates/agent-gui-server/src/desktop/clipboard.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Clipboard operations for CJK paste workflow.
|
||||
*
|
||||
* Uses clipboardy for cross-platform clipboard access
|
||||
* and nut.js for key simulation.
|
||||
*/
|
||||
|
||||
import { DesktopError } from '../utils/errors.js';
|
||||
import { getPlatformPasteKeys } from '../utils/platform.js';
|
||||
|
||||
/**
|
||||
* Read current clipboard content.
|
||||
*/
|
||||
export async function readClipboard(): Promise<string> {
|
||||
try {
|
||||
const { default: clipboard } = await import('clipboardy');
|
||||
return await clipboard.read();
|
||||
} catch (err) {
|
||||
throw new DesktopError('clipboard.read', err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write text to clipboard.
|
||||
*/
|
||||
export async function writeClipboard(text: string): Promise<void> {
|
||||
try {
|
||||
const { default: clipboard } = await import('clipboardy');
|
||||
await clipboard.write(text);
|
||||
} catch (err) {
|
||||
throw new DesktopError('clipboard.write', err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Paste text via clipboard:
|
||||
* 1. Backup current clipboard
|
||||
* 2. Write target text
|
||||
* 3. Simulate Cmd/Ctrl+V
|
||||
* 4. Wait 100ms
|
||||
* 5. Restore clipboard (best-effort)
|
||||
*/
|
||||
export async function pasteText(text: string): Promise<void> {
|
||||
let backup: string | undefined;
|
||||
try {
|
||||
backup = await readClipboard();
|
||||
} catch {
|
||||
// Failed to backup, continue anyway
|
||||
}
|
||||
|
||||
try {
|
||||
await writeClipboard(text);
|
||||
|
||||
const { keyboard, Key } = await import('@nut-tree-fork/nut-js');
|
||||
const pasteKeys = getPlatformPasteKeys();
|
||||
|
||||
const keyObjs = pasteKeys.map(k => {
|
||||
const keyName = k as keyof typeof Key;
|
||||
return Key[keyName];
|
||||
});
|
||||
await keyboard.pressKey(...keyObjs);
|
||||
await keyboard.releaseKey(...[...keyObjs].reverse());
|
||||
|
||||
// Wait for paste to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
} catch (err) {
|
||||
throw new DesktopError('clipboard.pasteText', err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
|
||||
// Restore clipboard (best-effort, do not throw)
|
||||
if (backup !== undefined) {
|
||||
try {
|
||||
await writeClipboard(backup);
|
||||
} catch {
|
||||
// Ignore restore failure
|
||||
}
|
||||
}
|
||||
}
|
||||
185
qimingclaw/crates/agent-gui-server/src/desktop/display.ts
Normal file
185
qimingclaw/crates/agent-gui-server/src/desktop/display.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Display information provider.
|
||||
*
|
||||
* Uses platform-specific APIs for accurate scaleFactor and multi-display support:
|
||||
* - macOS: CoreGraphics via child_process (system_profiler or screen capture metadata)
|
||||
* - Windows/Linux: nut.js fallback with scaleFactor detection
|
||||
*
|
||||
* Falls back to nut.js single-display when platform APIs are unavailable.
|
||||
*/
|
||||
|
||||
import { DesktopError } from '../utils/errors.js';
|
||||
import { getPlatform } from '../utils/platform.js';
|
||||
import { logInfo, logDebug, logWarn } from '../utils/logger.js';
|
||||
|
||||
export interface DisplayDescriptor {
|
||||
index: number;
|
||||
label: string;
|
||||
width: number;
|
||||
height: number;
|
||||
scaleFactor: number;
|
||||
isPrimary: boolean;
|
||||
origin: { x: number; y: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* List all connected displays with accurate metadata.
|
||||
*/
|
||||
export async function listDisplays(): Promise<DisplayDescriptor[]> {
|
||||
try {
|
||||
const platform = getPlatform();
|
||||
|
||||
if (platform === 'macos') {
|
||||
return await listDisplaysMacOS();
|
||||
}
|
||||
|
||||
// Windows/Linux: use nut.js with scaleFactor detection
|
||||
return await listDisplaysFallback();
|
||||
} catch (err) {
|
||||
logWarn(`Platform-specific display detection failed, using fallback: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return await listDisplaysFallback();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* macOS: Use system_profiler to enumerate displays with scaleFactor.
|
||||
*/
|
||||
async function listDisplaysMacOS(): Promise<DisplayDescriptor[]> {
|
||||
const { execSync } = await import('child_process');
|
||||
|
||||
try {
|
||||
const output = execSync(
|
||||
'system_profiler SPDisplaysDataType -json',
|
||||
{ timeout: 5000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] },
|
||||
);
|
||||
const data = JSON.parse(output);
|
||||
const displays: DisplayDescriptor[] = [];
|
||||
|
||||
const gpus = data.SPDisplaysDataType || [];
|
||||
for (const gpu of gpus) {
|
||||
const ndrvs = gpu.spdisplays_ndrvs || [];
|
||||
for (const disp of ndrvs) {
|
||||
// Parse resolution string like "1440 x 900 @ 60.00Hz" or "2560 x 1440"
|
||||
const resStr: string = disp._spdisplays_resolution || disp.spdisplays_resolution || '';
|
||||
const retinaStr: string = disp.spdisplays_retina || '';
|
||||
const isRetina = retinaStr.toLowerCase().includes('yes');
|
||||
|
||||
// Parse pixel resolution
|
||||
const pixelRes: string = disp._spdisplays_pixels || disp.spdisplays_pixels || '';
|
||||
let physicalWidth = 0;
|
||||
let physicalHeight = 0;
|
||||
const pixMatch = pixelRes.match(/(\d+)\s*x\s*(\d+)/);
|
||||
if (pixMatch) {
|
||||
physicalWidth = parseInt(pixMatch[1], 10);
|
||||
physicalHeight = parseInt(pixMatch[2], 10);
|
||||
}
|
||||
|
||||
// Parse logical resolution
|
||||
let logicalWidth = 0;
|
||||
let logicalHeight = 0;
|
||||
const resMatch = resStr.match(/(\d+)\s*x\s*(\d+)/);
|
||||
if (resMatch) {
|
||||
logicalWidth = parseInt(resMatch[1], 10);
|
||||
logicalHeight = parseInt(resMatch[2], 10);
|
||||
}
|
||||
|
||||
// Calculate scaleFactor
|
||||
let scaleFactor = 1;
|
||||
if (physicalWidth > 0 && logicalWidth > 0) {
|
||||
scaleFactor = Math.round(physicalWidth / logicalWidth);
|
||||
} else if (isRetina) {
|
||||
scaleFactor = 2;
|
||||
}
|
||||
|
||||
// Use logical resolution (or fall back to physical if no logical)
|
||||
const width = logicalWidth || physicalWidth;
|
||||
const height = logicalHeight || physicalHeight;
|
||||
|
||||
if (width > 0 && height > 0) {
|
||||
const isPrimary = disp.spdisplays_main === 'spdisplays_yes' ||
|
||||
disp._spdisplays_displayID === '1' ||
|
||||
displays.length === 0;
|
||||
|
||||
displays.push({
|
||||
index: displays.length,
|
||||
label: disp._name || `Display ${displays.length}`,
|
||||
width,
|
||||
height,
|
||||
scaleFactor,
|
||||
isPrimary,
|
||||
origin: { x: 0, y: 0 }, // macOS manages global offsets; nut.js uses logical
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (displays.length > 0) {
|
||||
logInfo(`Detected ${displays.length} display(s) via system_profiler`);
|
||||
for (const d of displays) {
|
||||
logDebug(`Display ${d.index}: ${d.width}x${d.height} @${d.scaleFactor}x "${d.label}" primary=${d.isPrimary}`);
|
||||
}
|
||||
return displays;
|
||||
}
|
||||
} catch (err) {
|
||||
logDebug(`system_profiler failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return await listDisplaysFallback();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback: use nut.js for basic display info with scaleFactor detection.
|
||||
*/
|
||||
async function listDisplaysFallback(): Promise<DisplayDescriptor[]> {
|
||||
const { screen } = await import('@nut-tree-fork/nut-js');
|
||||
const width = await screen.width();
|
||||
const height = await screen.height();
|
||||
|
||||
// Detect scaleFactor: capture a small region and compare physical vs logical
|
||||
let scaleFactor = 1;
|
||||
try {
|
||||
const { Region } = await import('@nut-tree-fork/nut-js');
|
||||
const testRegion = new Region(0, 0, 10, 10);
|
||||
const capture = await screen.grabRegion(testRegion);
|
||||
// If physical capture is larger than requested logical region, we have HiDPI
|
||||
if (capture.width > 10) {
|
||||
scaleFactor = Math.round(capture.width / 10);
|
||||
}
|
||||
} catch {
|
||||
logDebug('scaleFactor detection failed, defaulting to 1');
|
||||
}
|
||||
|
||||
const primary: DisplayDescriptor = {
|
||||
index: 0,
|
||||
label: 'Primary',
|
||||
width,
|
||||
height,
|
||||
scaleFactor,
|
||||
isPrimary: true,
|
||||
origin: { x: 0, y: 0 },
|
||||
};
|
||||
|
||||
logInfo(`Detected display (fallback): ${width}x${height} @${scaleFactor}x`);
|
||||
return [primary];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific display by index.
|
||||
*/
|
||||
export async function getDisplay(index: number): Promise<DisplayDescriptor> {
|
||||
const displays = await listDisplays();
|
||||
const display = displays.find(d => d.index === index);
|
||||
if (!display) {
|
||||
throw new DesktopError('getDisplay', new Error(`Display index ${index} not found (${displays.length} displays available)`));
|
||||
}
|
||||
return display;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the primary display.
|
||||
*/
|
||||
export async function getPrimaryDisplay(): Promise<DisplayDescriptor> {
|
||||
const displays = await listDisplays();
|
||||
return displays.find(d => d.isPrimary) ?? displays[0];
|
||||
}
|
||||
121
qimingclaw/crates/agent-gui-server/src/desktop/imageSearch.ts
Normal file
121
qimingclaw/crates/agent-gui-server/src/desktop/imageSearch.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Image search wrapper around nut.js template matcher.
|
||||
*
|
||||
* Uses nut.js screen.find() with configurable confidence threshold.
|
||||
* Temp file is written once and reused across poll iterations.
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { DesktopError } from '../utils/errors.js';
|
||||
|
||||
export interface ImageSearchResult {
|
||||
found: boolean;
|
||||
region?: { x: number; y: number; width: number; height: number };
|
||||
confidence?: number;
|
||||
}
|
||||
|
||||
export interface WaitForImageResult extends ImageSearchResult {
|
||||
elapsed: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an image on screen by template matching.
|
||||
*/
|
||||
export async function findImage(templateBase64: string, confidence: number = 0.9): Promise<ImageSearchResult> {
|
||||
try {
|
||||
const { screen, loadImage } = await import('@nut-tree-fork/nut-js');
|
||||
|
||||
// Write template to temp file (async)
|
||||
const tmpFile = path.join(os.tmpdir(), `gui-agent-template-${Date.now()}-${Math.random().toString(36).slice(2)}.png`);
|
||||
await fs.writeFile(tmpFile, Buffer.from(templateBase64, 'base64'));
|
||||
|
||||
try {
|
||||
const templateImage = await loadImage(tmpFile);
|
||||
|
||||
// Set confidence on the screen finder
|
||||
screen.config.confidence = confidence;
|
||||
|
||||
const result = await screen.find(templateImage);
|
||||
return {
|
||||
found: true,
|
||||
region: {
|
||||
x: result.left,
|
||||
y: result.top,
|
||||
width: result.width,
|
||||
height: result.height,
|
||||
},
|
||||
confidence,
|
||||
};
|
||||
} catch {
|
||||
return { found: false };
|
||||
} finally {
|
||||
try { await fs.unlink(tmpFile); } catch { /* ignore */ }
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof DesktopError) throw err;
|
||||
throw new DesktopError('imageSearch.findImage', err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an image using an already-written temp file path.
|
||||
* Used internally by waitForImage to avoid re-writing the file on each poll.
|
||||
*/
|
||||
async function findImageFromFile(tmpFile: string, confidence: number): Promise<ImageSearchResult> {
|
||||
const { screen, loadImage } = await import('@nut-tree-fork/nut-js');
|
||||
const templateImage = await loadImage(tmpFile);
|
||||
screen.config.confidence = confidence;
|
||||
|
||||
try {
|
||||
const result = await screen.find(templateImage);
|
||||
return {
|
||||
found: true,
|
||||
region: {
|
||||
x: result.left,
|
||||
y: result.top,
|
||||
width: result.width,
|
||||
height: result.height,
|
||||
},
|
||||
confidence,
|
||||
};
|
||||
} catch {
|
||||
return { found: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for an image to appear on screen with timeout.
|
||||
* Writes the template to a temp file once and reuses it across polls.
|
||||
*/
|
||||
export async function waitForImage(
|
||||
templateBase64: string,
|
||||
timeout: number = 10000,
|
||||
confidence: number = 0.9,
|
||||
): Promise<WaitForImageResult> {
|
||||
const start = Date.now();
|
||||
const pollInterval = 500;
|
||||
|
||||
// Write temp file once, reuse across polls
|
||||
const tmpFile = path.join(os.tmpdir(), `gui-agent-wait-${Date.now()}-${Math.random().toString(36).slice(2)}.png`);
|
||||
await fs.writeFile(tmpFile, Buffer.from(templateBase64, 'base64'));
|
||||
|
||||
try {
|
||||
while (Date.now() - start < timeout) {
|
||||
try {
|
||||
const result = await findImageFromFile(tmpFile, confidence);
|
||||
if (result.found) {
|
||||
return { ...result, elapsed: Date.now() - start };
|
||||
}
|
||||
} catch {
|
||||
// Individual poll failure is non-fatal
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
||||
}
|
||||
|
||||
return { found: false, elapsed: Date.now() - start };
|
||||
} finally {
|
||||
try { await fs.unlink(tmpFile); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
125
qimingclaw/crates/agent-gui-server/src/desktop/keyboard.ts
Normal file
125
qimingclaw/crates/agent-gui-server/src/desktop/keyboard.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Keyboard operations with CJK/long text routing.
|
||||
*
|
||||
* - Non-ASCII or text > 50 chars → clipboard paste
|
||||
* - Otherwise → nut.js keyboard.type()
|
||||
*/
|
||||
|
||||
import { DesktopError } from '../utils/errors.js';
|
||||
import { pasteText } from './clipboard.js';
|
||||
|
||||
const NON_ASCII_RE = /[^\x00-\x7F]/;
|
||||
const MAX_TYPE_LENGTH = 50;
|
||||
|
||||
/**
|
||||
* Key name aliases mapping to nut.js Key enum names.
|
||||
* LLMs may return various names like "Meta", "Command", "Cmd", "Win", "Super".
|
||||
* nut.js uses: LeftSuper, RightSuper, LeftControl, RightControl, etc.
|
||||
* Arrow keys in nut.js are: Up, Down, Left, Right (not ArrowUp, etc.)
|
||||
*/
|
||||
const KEY_ALIASES: Record<string, string> = {
|
||||
// macOS Command key aliases → Super
|
||||
Meta: 'LeftSuper',
|
||||
Command: 'LeftSuper',
|
||||
Cmd: 'LeftSuper',
|
||||
'⌘': 'LeftSuper',
|
||||
// Windows/Linux Super/Win key aliases
|
||||
Win: 'LeftSuper',
|
||||
Super: 'LeftSuper',
|
||||
// Control key aliases
|
||||
Control: 'LeftControl',
|
||||
Ctrl: 'LeftControl',
|
||||
'⌃': 'LeftControl',
|
||||
// Alt/Option key aliases
|
||||
Alt: 'LeftAlt',
|
||||
Option: 'LeftAlt',
|
||||
Opt: 'LeftAlt',
|
||||
'⌥': 'LeftAlt',
|
||||
// Shift key aliases
|
||||
Shift: 'LeftShift',
|
||||
'⇧': 'LeftShift',
|
||||
// Arrow key aliases (nut.js uses Up/Down/Left/Right, not ArrowUp/ArrowDown)
|
||||
ArrowUp: 'Up',
|
||||
ArrowDown: 'Down',
|
||||
ArrowLeft: 'Left',
|
||||
ArrowRight: 'Right',
|
||||
// Common alternative names
|
||||
Return: 'Enter',
|
||||
Esc: 'Escape',
|
||||
Del: 'Delete',
|
||||
Ins: 'Insert',
|
||||
};
|
||||
|
||||
/**
|
||||
* Type text with smart routing:
|
||||
* - CJK/non-ASCII or long text → clipboard paste
|
||||
* - Short ASCII → direct nut.js typing
|
||||
*/
|
||||
export async function typeText(text: string): Promise<void> {
|
||||
if (NON_ASCII_RE.test(text) || text.length > MAX_TYPE_LENGTH) {
|
||||
await pasteText(text);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { keyboard } = await import('@nut-tree-fork/nut-js');
|
||||
await keyboard.type(text);
|
||||
} catch (err) {
|
||||
throw new DesktopError('keyboard.typeText', err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize key name using aliases mapping.
|
||||
*/
|
||||
function normalizeKeyName(key: string): string {
|
||||
// First check aliases
|
||||
if (KEY_ALIASES[key]) {
|
||||
return KEY_ALIASES[key];
|
||||
}
|
||||
// Then return as-is (for F1-F24, ArrowUp, Escape, etc.)
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Press a single key by name.
|
||||
*/
|
||||
export async function pressKey(key: string): Promise<void> {
|
||||
try {
|
||||
const { keyboard, Key } = await import('@nut-tree-fork/nut-js');
|
||||
const normalizedKey = normalizeKeyName(key);
|
||||
const keyObj = Key[normalizedKey as keyof typeof Key];
|
||||
if (keyObj === undefined) {
|
||||
throw new Error(`Unknown key: ${key} (normalized: ${normalizedKey})`);
|
||||
}
|
||||
await keyboard.pressKey(keyObj);
|
||||
await keyboard.releaseKey(keyObj);
|
||||
} catch (err) {
|
||||
throw new DesktopError('keyboard.pressKey', err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Press a key combination (hotkey).
|
||||
*
|
||||
* Note: nut.js pressKey doesn't support multiple keys, but type() does.
|
||||
* For hotkeys, we use keyboard.type(Key1, Key2, ...) which simulates
|
||||
* pressing keys together as a combination.
|
||||
*/
|
||||
export async function hotkey(keys: string[]): Promise<void> {
|
||||
try {
|
||||
const { keyboard, Key } = await import('@nut-tree-fork/nut-js');
|
||||
const keyObjs = keys.map(k => {
|
||||
const normalizedKey = normalizeKeyName(k);
|
||||
const keyObj = Key[normalizedKey as keyof typeof Key];
|
||||
if (keyObj === undefined) {
|
||||
throw new Error(`Unknown key: ${k} (normalized: ${normalizedKey})`);
|
||||
}
|
||||
return keyObj;
|
||||
});
|
||||
// Use keyboard.type() for hotkeys - it handles key combinations correctly
|
||||
await keyboard.type(...keyObjs);
|
||||
} catch (err) {
|
||||
throw new DesktopError('keyboard.hotkey', err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
90
qimingclaw/crates/agent-gui-server/src/desktop/mouse.ts
Normal file
90
qimingclaw/crates/agent-gui-server/src/desktop/mouse.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Mouse operations wrapper around nut.js.
|
||||
*/
|
||||
|
||||
import { DesktopError } from '../utils/errors.js';
|
||||
|
||||
type ButtonType = 'left' | 'right' | 'middle';
|
||||
|
||||
async function getNutMouse() {
|
||||
const { mouse, Button, Point, straightTo } = await import('@nut-tree-fork/nut-js');
|
||||
return { mouse, Button, Point, straightTo };
|
||||
}
|
||||
|
||||
function mapButton(button: ButtonType | undefined, Button: any): any {
|
||||
switch (button) {
|
||||
case 'right': return Button.RIGHT;
|
||||
case 'middle': return Button.MIDDLE;
|
||||
default: return Button.LEFT;
|
||||
}
|
||||
}
|
||||
|
||||
export async function click(x: number, y: number, button?: ButtonType): Promise<void> {
|
||||
try {
|
||||
const { mouse, Button, Point, straightTo } = await getNutMouse();
|
||||
await mouse.move(straightTo(new Point(x, y)));
|
||||
await mouse.click(mapButton(button, Button));
|
||||
} catch (err) {
|
||||
throw new DesktopError('mouse.click', err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
|
||||
export async function doubleClick(x: number, y: number, button?: ButtonType): Promise<void> {
|
||||
try {
|
||||
const { mouse, Button, Point, straightTo } = await getNutMouse();
|
||||
await mouse.move(straightTo(new Point(x, y)));
|
||||
await mouse.doubleClick(mapButton(button, Button));
|
||||
} catch (err) {
|
||||
throw new DesktopError('mouse.doubleClick', err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
|
||||
export async function moveTo(x: number, y: number): Promise<void> {
|
||||
try {
|
||||
const { mouse, Point, straightTo } = await getNutMouse();
|
||||
await mouse.move(straightTo(new Point(x, y)));
|
||||
} catch (err) {
|
||||
throw new DesktopError('mouse.moveTo', err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
|
||||
export async function drag(startX: number, startY: number, endX: number, endY: number, button?: ButtonType): Promise<void> {
|
||||
try {
|
||||
const { mouse, Button, Point, straightTo } = await getNutMouse();
|
||||
await mouse.move(straightTo(new Point(startX, startY)));
|
||||
await mouse.pressButton(mapButton(button, Button));
|
||||
await mouse.move(straightTo(new Point(endX, endY)));
|
||||
await mouse.releaseButton(mapButton(button, Button));
|
||||
} catch (err) {
|
||||
throw new DesktopError('mouse.drag', err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
|
||||
export async function scroll(x: number, y: number, deltaY: number, deltaX?: number): Promise<void> {
|
||||
try {
|
||||
const { mouse, Point, straightTo } = await getNutMouse();
|
||||
await mouse.move(straightTo(new Point(x, y)));
|
||||
if (deltaY > 0) {
|
||||
await mouse.scrollDown(Math.abs(deltaY));
|
||||
} else if (deltaY < 0) {
|
||||
await mouse.scrollUp(Math.abs(deltaY));
|
||||
}
|
||||
if (deltaX && deltaX > 0) {
|
||||
await mouse.scrollRight(Math.abs(deltaX));
|
||||
} else if (deltaX && deltaX < 0) {
|
||||
await mouse.scrollLeft(Math.abs(deltaX));
|
||||
}
|
||||
} catch (err) {
|
||||
throw new DesktopError('mouse.scroll', err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPosition(): Promise<{ x: number; y: number }> {
|
||||
try {
|
||||
const { mouse } = await getNutMouse();
|
||||
const pos = await mouse.getPosition();
|
||||
return { x: pos.x, y: pos.y };
|
||||
} catch (err) {
|
||||
throw new DesktopError('mouse.getPosition', err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Screen analysis using vision model.
|
||||
*
|
||||
* Captures screenshot and sends to vision model for analysis.
|
||||
*/
|
||||
|
||||
import { complete } from '@mariozechner/pi-ai';
|
||||
import type { Model, Api } from '@mariozechner/pi-ai';
|
||||
import { captureScreenshot } from './screenshot.js';
|
||||
import { DesktopError } from '../utils/errors.js';
|
||||
|
||||
export interface AnalyzeResult {
|
||||
analysis: string;
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
}
|
||||
|
||||
const SCREEN_ANALYSIS_SYSTEM_PROMPT = `You are a GUI screen analysis assistant specialized in desktop automation.
|
||||
|
||||
Your task is to analyze screenshots and provide actionable information for automation tasks.
|
||||
|
||||
When analyzing the screen:
|
||||
1. Identify UI elements: buttons, menus, input fields, icons, windows, dialogs
|
||||
2. Provide approximate coordinates when asked (as percentages like "top-left quadrant" or pixel estimates)
|
||||
3. Describe element states: enabled/disabled, focused, selected, minimized, etc.
|
||||
4. Read visible text content accurately
|
||||
5. Identify the active application and window focus
|
||||
|
||||
Be concise but thorough. Format your response clearly with:
|
||||
- Element descriptions
|
||||
- Locations (when relevant)
|
||||
- States and any actionable details
|
||||
|
||||
If the user asks about specific elements, locate them precisely and describe their position relative to the screen or window.`;
|
||||
|
||||
/**
|
||||
* Capture screenshot and analyze with vision model.
|
||||
*
|
||||
* @param model - The vision model to use
|
||||
* @param apiKey - API key for the model
|
||||
* @param prompt - Analysis instruction (e.g., "What buttons are visible?")
|
||||
* @param displayIndex - Display to capture (default: 0)
|
||||
*/
|
||||
export async function analyzeScreen(
|
||||
model: Model<Api>,
|
||||
apiKey: string,
|
||||
prompt: string,
|
||||
displayIndex: number = 0,
|
||||
): Promise<AnalyzeResult> {
|
||||
try {
|
||||
// Capture screenshot
|
||||
const screenshot = await captureScreenshot(displayIndex);
|
||||
|
||||
// Build message with image
|
||||
const messages = [
|
||||
{
|
||||
role: 'user' as const,
|
||||
content: [
|
||||
{
|
||||
type: 'image' as const,
|
||||
data: screenshot.image,
|
||||
mimeType: screenshot.mimeType,
|
||||
},
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: prompt,
|
||||
},
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
// Call vision model using pi-ai complete function
|
||||
const response = await complete(model, {
|
||||
systemPrompt: SCREEN_ANALYSIS_SYSTEM_PROMPT,
|
||||
messages,
|
||||
}, {
|
||||
apiKey,
|
||||
});
|
||||
|
||||
// Extract text from response
|
||||
let analysis = '';
|
||||
if (response && response.content) {
|
||||
for (const part of response.content) {
|
||||
if (part.type === 'text') {
|
||||
analysis += part.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
analysis,
|
||||
imageWidth: screenshot.imageWidth,
|
||||
imageHeight: screenshot.imageHeight,
|
||||
};
|
||||
} catch (err) {
|
||||
throw new DesktopError('screenAnalyzer.analyze', err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
113
qimingclaw/crates/agent-gui-server/src/desktop/screenshot.ts
Normal file
113
qimingclaw/crates/agent-gui-server/src/desktop/screenshot.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Screenshot pipeline: capture → scale → JPEG → base64
|
||||
*
|
||||
* 1. nut.js screen.capture() at physical resolution
|
||||
* 2. sharp resize to logical resolution (absorb scaleFactor)
|
||||
* 3. If longest edge > 1920, proportionally scale to 1920
|
||||
* 4. JPEG encode with configurable quality
|
||||
* 5. Return base64 + metadata
|
||||
*/
|
||||
|
||||
import sharp from 'sharp';
|
||||
import { getDisplay } from './display.js';
|
||||
import { DesktopError } from '../utils/errors.js';
|
||||
import { logInfo, logDebug } from '../utils/logger.js';
|
||||
|
||||
const MAX_LONGEST_EDGE = 1920;
|
||||
const MAX_JPEG_BYTES = 1_500_000; // 1.5 MB hard limit for LLM input
|
||||
const QUALITY_RETRY_STEPS = [60, 45, 30]; // Fallback quality levels
|
||||
|
||||
export interface ScreenshotResult {
|
||||
/** Base64-encoded JPEG image */
|
||||
image: string;
|
||||
mimeType: 'image/jpeg';
|
||||
/** Byte size of the JPEG */
|
||||
imageBytes: number;
|
||||
/** Width of the final image sent to LLM */
|
||||
imageWidth: number;
|
||||
/** Height of the final image sent to LLM */
|
||||
imageHeight: number;
|
||||
/** Logical display width */
|
||||
logicalWidth: number;
|
||||
/** Logical display height */
|
||||
logicalHeight: number;
|
||||
/** Physical capture width */
|
||||
physicalWidth: number;
|
||||
/** Physical capture height */
|
||||
physicalHeight: number;
|
||||
/** Display scale factor */
|
||||
scaleFactor: number;
|
||||
/** Which display was captured */
|
||||
displayIndex: number;
|
||||
}
|
||||
|
||||
export async function captureScreenshot(displayIndex: number, jpegQuality: number = 75): Promise<ScreenshotResult> {
|
||||
try {
|
||||
const display = await getDisplay(displayIndex);
|
||||
const { screen } = await import('@nut-tree-fork/nut-js');
|
||||
|
||||
// Capture raw RGBA buffer at physical resolution
|
||||
const { Region } = await import('@nut-tree-fork/nut-js');
|
||||
const region = new Region(display.origin.x, display.origin.y, display.width, display.height);
|
||||
|
||||
const captured = await screen.grabRegion(region);
|
||||
const physicalWidth = captured.width;
|
||||
const physicalHeight = captured.height;
|
||||
const rawBuffer = captured.data;
|
||||
|
||||
// Logical resolution (absorb scaleFactor)
|
||||
const scaleFactor = display.scaleFactor;
|
||||
let logicalWidth = Math.round(physicalWidth / scaleFactor);
|
||||
let logicalHeight = Math.round(physicalHeight / scaleFactor);
|
||||
|
||||
// Target dimensions: start with logical
|
||||
let targetWidth = logicalWidth;
|
||||
let targetHeight = logicalHeight;
|
||||
|
||||
// If longest edge > MAX, proportionally scale down
|
||||
const longestEdge = Math.max(targetWidth, targetHeight);
|
||||
if (longestEdge > MAX_LONGEST_EDGE) {
|
||||
const scale = MAX_LONGEST_EDGE / longestEdge;
|
||||
targetWidth = Math.round(targetWidth * scale);
|
||||
targetHeight = Math.round(targetHeight * scale);
|
||||
}
|
||||
|
||||
// Resize + JPEG encode
|
||||
const sharpInstance = sharp(rawBuffer, {
|
||||
raw: { width: physicalWidth, height: physicalHeight, channels: 4 },
|
||||
}).resize(targetWidth, targetHeight, { kernel: 'lanczos3' });
|
||||
|
||||
let jpegBuffer = await sharpInstance.clone().jpeg({ quality: jpegQuality }).toBuffer();
|
||||
|
||||
// Quality degradation retry — if bytes exceed limit, retry with lower quality
|
||||
if (jpegBuffer.length > MAX_JPEG_BYTES) {
|
||||
for (const retryQuality of QUALITY_RETRY_STEPS) {
|
||||
if (retryQuality >= jpegQuality) continue; // Skip if not lower than current
|
||||
logDebug(`Screenshot ${jpegBuffer.length} bytes exceeds ${MAX_JPEG_BYTES}, retrying with quality=${retryQuality}`);
|
||||
jpegBuffer = await sharpInstance.clone().jpeg({ quality: retryQuality }).toBuffer();
|
||||
if (jpegBuffer.length <= MAX_JPEG_BYTES) break;
|
||||
}
|
||||
}
|
||||
|
||||
const base64 = jpegBuffer.toString('base64');
|
||||
|
||||
logDebug(`Screenshot: ${physicalWidth}x${physicalHeight} → ${targetWidth}x${targetHeight}, ${jpegBuffer.length} bytes`);
|
||||
|
||||
return {
|
||||
image: base64,
|
||||
mimeType: 'image/jpeg',
|
||||
imageBytes: jpegBuffer.length,
|
||||
imageWidth: targetWidth,
|
||||
imageHeight: targetHeight,
|
||||
logicalWidth,
|
||||
logicalHeight,
|
||||
physicalWidth,
|
||||
physicalHeight,
|
||||
scaleFactor,
|
||||
displayIndex,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof DesktopError) throw err;
|
||||
throw new DesktopError('captureScreenshot', err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user