diff --git a/package-lock.json b/package-lock.json index 9f99d2f..6fdfaf9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4524,6 +4524,16 @@ "alien-signals": "^2.0.6" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==" + }, + "node_modules/@xterm/addon-web-links": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", + "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==" + }, "node_modules/@xterm/headless": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-5.5.0.tgz", @@ -8012,6 +8022,47 @@ "pathe": "^2.0.1" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/points-on-curve": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", @@ -9950,15 +10001,24 @@ "@jaeswift/jae-web-ui": "file:../", "@mariozechner/mini-lit": "^0.2.0", "@tailwindcss/vite": "^4.1.17", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/xterm": "^6.0.0", "diff2html": "^3.4.56", "lit": "^3.3.1", "lucide": "^0.544.0", - "mermaid": "^11.13.0" + "mermaid": "^11.13.0", + "playwright": "^1.58.2" }, "devDependencies": { "typescript": "^5.7.3", "vite": "^7.1.6" } + }, + "packages/web-ui/node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==" } } } diff --git a/packages/web-ui/example/package.json b/packages/web-ui/example/package.json index 30fdaca..5ca4e2b 100644 --- a/packages/web-ui/example/package.json +++ b/packages/web-ui/example/package.json @@ -8,20 +8,27 @@ "build": "vite build", "preview": "vite preview", "check": "tsgo --noEmit", - "clean": "shx rm -rf dist" + "clean": "shx rm -rf dist", + "terminal-server": "node server/terminal-server.mjs", + "browser-server": "node server/browser-server.mjs", + "dev:all": "concurrently -k -n VITE,TERM,BROWSER -c cyan,green,magenta \"npm run dev\" \"npm run terminal-server\" \"npm run browser-server\"" }, "dependencies": { "@jaeswift/jae-ai": "file:../../ai", "@jaeswift/jae-web-ui": "file:../", "@mariozechner/mini-lit": "^0.2.0", "@tailwindcss/vite": "^4.1.17", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/xterm": "^6.0.0", "diff2html": "^3.4.56", "lit": "^3.3.1", "lucide": "^0.544.0", - "mermaid": "^11.13.0" + "mermaid": "^11.13.0", + "playwright": "^1.58.2" }, "devDependencies": { "typescript": "^5.7.3", "vite": "^7.1.6" } -} +} \ No newline at end of file diff --git a/packages/web-ui/example/server/browser-server.mjs b/packages/web-ui/example/server/browser-server.mjs new file mode 100644 index 0000000..5d96f98 --- /dev/null +++ b/packages/web-ui/example/server/browser-server.mjs @@ -0,0 +1,65 @@ +import { WebSocketServer } from 'ws'; +import { chromium } from 'playwright'; + +const PORT = 7702; +const wss = new WebSocketServer({ port: PORT }); +console.log(`Browser WS server on ws://localhost:${PORT}`); + +let browser = null; + +async function getBrowser() { + if (!browser) browser = await chromium.launch({ headless: true, args: ['--no-sandbox','--disable-setuid-sandbox'] }); + return browser; +} + +wss.on('connection', async (ws) => { + let context = null; + let page = null; + + async function screenshot() { + if (!page) return; + try { + const buf = await page.screenshot({ type: 'jpeg', quality: 70, fullPage: false }); + ws.send(JSON.stringify({ type: 'screenshot', data: buf.toString('base64'), url: page.url() })); + } catch(e) { ws.send(JSON.stringify({ type: 'error', msg: String(e) })); } + } + + async function navigate(url) { + try { + if (!context) { + const b = await getBrowser(); + context = await b.newContext({ viewport: { width: 1280, height: 800 } }); + page = await context.newPage(); + } + if (!url.startsWith('http')) url = 'https://' + url; + ws.send(JSON.stringify({ type: 'loading' })); + await page.goto(url, { timeout: 30000, waitUntil: 'domcontentloaded' }); + await screenshot(); + } catch(e) { ws.send(JSON.stringify({ type: 'error', msg: String(e) })); } + } + + ws.on('message', async (msg) => { + try { + const m = JSON.parse(msg.toString()); + if (m.type === 'navigate') await navigate(m.url); + if (m.type === 'screenshot') await screenshot(); + if (m.type === 'click') { + if (page) { await page.mouse.click(m.x, m.y); await screenshot(); } + } + if (m.type === 'scroll') { + if (page) { await page.mouse.wheel(0, m.dy); await screenshot(); } + } + if (m.type === 'type') { + if (page) { await page.keyboard.type(m.text); await screenshot(); } + } + if (m.type === 'back') { if (page) { await page.goBack(); await screenshot(); } } + if (m.type === 'fwd') { if (page) { await page.goForward(); await screenshot(); } } + if (m.type === 'reload'){ if (page) { await page.reload(); await screenshot(); } } + } catch(e) { ws.send(JSON.stringify({ type: 'error', msg: String(e) })); } + }); + + ws.on('close', async () => { if (context) await context.close().catch(()=>{}); context = null; page = null; }); + + // Send welcome screenshot placeholder + ws.send(JSON.stringify({ type: 'ready' })); +}); diff --git a/packages/web-ui/example/server/terminal-server.mjs b/packages/web-ui/example/server/terminal-server.mjs new file mode 100644 index 0000000..95dda1e --- /dev/null +++ b/packages/web-ui/example/server/terminal-server.mjs @@ -0,0 +1,27 @@ +import { WebSocketServer } from 'ws'; +import { spawn } from 'child_process'; + +const PORT = 7701; +const wss = new WebSocketServer({ port: PORT }); +console.log(`Terminal WS server on ws://localhost:${PORT}`); + +wss.on('connection', (ws) => { + const shell = spawn('/bin/bash', [], { + env: { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor' }, + cwd: process.env.HOME || '/root', + }); + + shell.stdout.on('data', (d) => { try { ws.send(JSON.stringify({ type:'data', data: d.toString('binary') })); } catch{} }); + shell.stderr.on('data', (d) => { try { ws.send(JSON.stringify({ type:'data', data: d.toString('binary') })); } catch{} }); + shell.on('close', (code) => { try { ws.send(JSON.stringify({ type:'exit', code })); ws.close(); } catch{} }); + + ws.on('message', (msg) => { + try { + const m = JSON.parse(msg.toString()); + if (m.type === 'input') shell.stdin.write(m.data); + if (m.type === 'resize') { /* no node-pty resize without pty, best effort */ } + } catch{} + }); + + ws.on('close', () => { shell.kill(); }); +}); diff --git a/packages/web-ui/example/src/components/browser-panel.ts b/packages/web-ui/example/src/components/browser-panel.ts new file mode 100644 index 0000000..f789157 --- /dev/null +++ b/packages/web-ui/example/src/components/browser-panel.ts @@ -0,0 +1,126 @@ +import { LitElement, html } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; + +@customElement('jae-browser-panel') +export class JaeBrowserPanel extends LitElement { + @state() private url = ''; + @state() private inputUrl = ''; + @state() private screenshot = ''; + @state() private loading = false; + @state() private connected = false; + @state() private error = ''; + + private ws: WebSocket | null = null; + private imgEl: HTMLImageElement | null = null; + + createRenderRoot() { return this; } + + override connectedCallback() { + super.connectedCallback(); + this.style.display = 'flex'; + this.style.flexDirection = 'column'; + this.style.height = '100%'; + this.style.minHeight = '0'; + this.connect(); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this.ws?.close(); + } + + connect() { + this.ws = new WebSocket('ws://localhost:7702'); + this.ws.onopen = () => { this.connected = true; this.requestUpdate(); }; + this.ws.onclose = () => { this.connected = false; this.requestUpdate(); }; + this.ws.onerror = () => { this.connected = false; this.error = 'Browser server not running.'; this.requestUpdate(); }; + this.ws.onmessage = (e) => { + const m = JSON.parse(e.data); + if (m.type === 'screenshot') { + this.loading = false; + this.screenshot = `data:image/jpeg;base64,${m.data}`; + this.url = m.url; + this.inputUrl = m.url; + this.error = ''; + this.requestUpdate(); + } + if (m.type === 'loading') { this.loading = true; this.requestUpdate(); } + if (m.type === 'error') { this.loading = false; this.error = m.msg; this.requestUpdate(); } + }; + } + + navigate(url?: string) { + const target = url || this.inputUrl; + if (!target) return; + this.ws?.send(JSON.stringify({ type: 'navigate', url: target })); + this.loading = true; + this.requestUpdate(); + } + + private handleImgClick(e: MouseEvent) { + const img = e.currentTarget as HTMLImageElement; + const rect = img.getBoundingClientRect(); + const scaleX = 1280 / rect.width; + const scaleY = 800 / rect.height; + const x = (e.clientX - rect.left) * scaleX; + const y = (e.clientY - rect.top) * scaleY; + this.ws?.send(JSON.stringify({ type: 'click', x, y })); + this.loading = true; + this.requestUpdate(); + } + + private handleScroll(e: WheelEvent) { + e.preventDefault(); + this.ws?.send(JSON.stringify({ type: 'scroll', dy: e.deltaY })); + } + + override render() { + return html` +
Enter a URL above to browse
+