Release v1.01 Enhanced: Vi Control, TUI Gen5, Core Stability
This commit is contained in:
32
bin/goose-ultra-final/electron/fs-api.js
Normal file
32
bin/goose-ultra-final/electron/fs-api.js
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* File System API Bridge
|
||||
*/
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
export const fsApi = {
|
||||
async listFiles(dirPath) {
|
||||
try {
|
||||
const files = await fs.readdir(dirPath, { withFileTypes: true });
|
||||
return files.map(f => ({
|
||||
name: f.name,
|
||||
isDirectory: f.isDirectory(),
|
||||
path: path.join(dirPath, f.name)
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('List files error:', e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
async readFile(filePath) {
|
||||
return fs.readFile(filePath, 'utf-8');
|
||||
},
|
||||
async writeFile(filePath, content) {
|
||||
// Ensure dir exists
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
return fs.writeFile(filePath, content, 'utf-8');
|
||||
},
|
||||
async deletePath(targetPath) {
|
||||
await fs.rm(targetPath, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
213
bin/goose-ultra-final/electron/image-api.js
Normal file
213
bin/goose-ultra-final/electron/image-api.js
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Image Generation API Bridge for Goose Ultra
|
||||
*
|
||||
* Implements multimodal image generation for Chat Mode.
|
||||
* Supports multiple providers: Pollinations.ai (free), DALL-E, Stability AI
|
||||
*/
|
||||
|
||||
import https from 'https';
|
||||
import http from 'http';
|
||||
import crypto from 'crypto';
|
||||
|
||||
// Provider: Pollinations.ai (Free, no API key required)
|
||||
// Generates images from text prompts using Stable Diffusion XL
|
||||
const POLLINATIONS_BASE = 'https://image.pollinations.ai/prompt/';
|
||||
|
||||
// Image cache directory
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
|
||||
const getCacheDir = () => {
|
||||
const dir = path.join(os.homedir(), '.goose-ultra', 'image-cache');
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
return dir;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate an image from a text prompt using Pollinations.ai (free)
|
||||
* @param {string} prompt - The image description
|
||||
* @param {object} options - Optional settings
|
||||
* @returns {Promise<{url: string, localPath: string, prompt: string}>}
|
||||
*/
|
||||
export async function generateImage(prompt, options = {}) {
|
||||
const {
|
||||
width = 1024,
|
||||
height = 1024,
|
||||
seed = Math.floor(Math.random() * 1000000),
|
||||
model = 'flux', // 'flux' or 'turbo'
|
||||
nologo = true
|
||||
} = options;
|
||||
|
||||
console.log('[ImageAPI] Generating image for prompt:', prompt.substring(0, 100) + '...');
|
||||
|
||||
// Build Pollinations URL
|
||||
const encodedPrompt = encodeURIComponent(prompt);
|
||||
const params = new URLSearchParams({
|
||||
width: String(width),
|
||||
height: String(height),
|
||||
seed: String(seed),
|
||||
model: model,
|
||||
nologo: String(nologo)
|
||||
});
|
||||
|
||||
const imageUrl = `${POLLINATIONS_BASE}${encodedPrompt}?${params.toString()}`;
|
||||
|
||||
// Download and cache image
|
||||
const imageId = crypto.createHash('md5').update(prompt + seed).digest('hex');
|
||||
const localPath = path.join(getCacheDir(), `${imageId}.png`);
|
||||
|
||||
try {
|
||||
await downloadImage(imageUrl, localPath);
|
||||
console.log('[ImageAPI] Image saved to:', localPath);
|
||||
|
||||
return {
|
||||
url: imageUrl,
|
||||
localPath: localPath,
|
||||
prompt: prompt,
|
||||
width,
|
||||
height,
|
||||
seed
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[ImageAPI] Generation failed:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download an image from URL to local path
|
||||
*/
|
||||
function downloadImage(url, destPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const urlObj = new URL(url);
|
||||
const client = urlObj.protocol === 'https:' ? https : http;
|
||||
|
||||
const file = fs.createWriteStream(destPath);
|
||||
|
||||
const request = client.get(url, { timeout: 60000 }, (response) => {
|
||||
// Handle redirects
|
||||
if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
|
||||
file.close();
|
||||
fs.unlinkSync(destPath);
|
||||
return downloadImage(response.headers.location, destPath).then(resolve).catch(reject);
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
file.close();
|
||||
fs.unlinkSync(destPath);
|
||||
reject(new Error(`HTTP ${response.statusCode}: Failed to download image`));
|
||||
return;
|
||||
}
|
||||
|
||||
response.pipe(file);
|
||||
|
||||
file.on('finish', () => {
|
||||
file.close();
|
||||
resolve(destPath);
|
||||
});
|
||||
});
|
||||
|
||||
request.on('error', (err) => {
|
||||
file.close();
|
||||
if (fs.existsSync(destPath)) fs.unlinkSync(destPath);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
request.on('timeout', () => {
|
||||
request.destroy();
|
||||
file.close();
|
||||
if (fs.existsSync(destPath)) fs.unlinkSync(destPath);
|
||||
reject(new Error('Image download timeout'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if a user message is requesting image generation
|
||||
* @param {string} message - User message
|
||||
* @returns {{isImageRequest: boolean, prompt: string | null}}
|
||||
*/
|
||||
export function detectImageRequest(message) {
|
||||
const lower = message.toLowerCase();
|
||||
|
||||
// Common image generation patterns
|
||||
const patterns = [
|
||||
/^(generate|create|make|draw|design|paint|illustrate|render|produce)\s+(an?\s+)?(image|picture|photo|illustration|artwork|art|graphic|visual|drawing|painting)\s+(of|showing|depicting|with|about|for)?\s*/i,
|
||||
/^(show me|give me|i want|can you (make|create|generate)|please (make|create|generate))\s+(an?\s+)?(image|picture|photo|illustration|artwork)\s+(of|showing|depicting|with|about|for)?\s*/i,
|
||||
/image\s+of\s+/i,
|
||||
/picture\s+of\s+/i,
|
||||
/draw\s+(me\s+)?(a|an)\s+/i,
|
||||
/visualize\s+/i,
|
||||
/create\s+art\s+(of|for|showing)\s*/i
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
if (pattern.test(lower)) {
|
||||
// Extract the actual image description
|
||||
let prompt = message;
|
||||
|
||||
// Remove the command prefix to get just the description
|
||||
prompt = prompt.replace(/^(generate|create|make|draw|design|paint|illustrate|render|produce)\s+(an?\s+)?(image|picture|photo|illustration|artwork|art|graphic|visual|drawing|painting)\s+(of|showing|depicting|with|about|for)?\s*/i, '');
|
||||
prompt = prompt.replace(/^(show me|give me|i want|can you (make|create|generate)|please (make|create|generate))\s+(an?\s+)?(image|picture|photo|illustration|artwork)\s+(of|showing|depicting|with|about|for)?\s*/i, '');
|
||||
prompt = prompt.replace(/^image\s+of\s+/i, '');
|
||||
prompt = prompt.replace(/^picture\s+of\s+/i, '');
|
||||
prompt = prompt.replace(/^draw\s+(me\s+)?(a|an)\s+/i, '');
|
||||
prompt = prompt.replace(/^visualize\s+/i, '');
|
||||
prompt = prompt.replace(/^create\s+art\s+(of|for|showing)\s*/i, '');
|
||||
|
||||
prompt = prompt.trim();
|
||||
|
||||
// If we couldn't extract a clean prompt, use original
|
||||
if (prompt.length < 3) prompt = message;
|
||||
|
||||
return { isImageRequest: true, prompt: prompt };
|
||||
}
|
||||
}
|
||||
|
||||
// Check for explicit "image:" prefix
|
||||
if (lower.startsWith('image:') || lower.startsWith('/image ') || lower.startsWith('/imagine ')) {
|
||||
const prompt = message.replace(/^(image:|\/image\s+|\/imagine\s+)/i, '').trim();
|
||||
return { isImageRequest: true, prompt };
|
||||
}
|
||||
|
||||
return { isImageRequest: false, prompt: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of cached images
|
||||
*/
|
||||
export function getCachedImages() {
|
||||
const cacheDir = getCacheDir();
|
||||
try {
|
||||
const files = fs.readdirSync(cacheDir);
|
||||
return files.filter(f => f.endsWith('.png')).map(f => path.join(cacheDir, f));
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear old cached images (older than 7 days)
|
||||
*/
|
||||
export function cleanupCache(maxAgeDays = 7) {
|
||||
const cacheDir = getCacheDir();
|
||||
const maxAge = maxAgeDays * 24 * 60 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
|
||||
try {
|
||||
const files = fs.readdirSync(cacheDir);
|
||||
for (const file of files) {
|
||||
const filePath = path.join(cacheDir, file);
|
||||
const stat = fs.statSync(filePath);
|
||||
if (now - stat.mtimeMs > maxAge) {
|
||||
fs.unlinkSync(filePath);
|
||||
console.log('[ImageAPI] Cleaned up:', file);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[ImageAPI] Cache cleanup error:', e.message);
|
||||
}
|
||||
}
|
||||
647
bin/goose-ultra-final/electron/main.js
Normal file
647
bin/goose-ultra-final/electron/main.js
Normal file
@@ -0,0 +1,647 @@
|
||||
import { app, BrowserWindow, ipcMain, shell, protocol, net } from 'electron';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { streamChat } from './qwen-api.js';
|
||||
import { generateImage, detectImageRequest, cleanupCache } from './image-api.js';
|
||||
import { fsApi } from './fs-api.js';
|
||||
import * as viAutomation from './vi-automation.js';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Detect dev mode from environment variable (set by launcher)
|
||||
// Default: Production mode (load from dist)
|
||||
const isDev = process.env.GOOSE_DEV === 'true' || process.env.GOOSE_DEV === '1';
|
||||
console.log(`[Goose Ultra] Mode: ${isDev ? 'DEVELOPMENT' : 'PRODUCTION'}`);
|
||||
|
||||
let mainWindow;
|
||||
|
||||
// Register Schema
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{ scheme: 'preview', privileges: { secure: true, standard: true, supportFetchAPI: true, corsEnabled: true } }
|
||||
]);
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1400,
|
||||
height: 900,
|
||||
minWidth: 1024,
|
||||
minHeight: 720,
|
||||
title: 'Goose Ultra v1.0.1',
|
||||
backgroundColor: '#030304', // Match theme
|
||||
show: false, // Wait until ready-to-show
|
||||
autoHideMenuBar: true, // Hide the native menu bar
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
webviewTag: true,
|
||||
webSecurity: false
|
||||
}
|
||||
});
|
||||
|
||||
// Graceful show
|
||||
mainWindow.once('ready-to-show', () => {
|
||||
mainWindow.show();
|
||||
if (isDev) {
|
||||
mainWindow.webContents.openDevTools();
|
||||
}
|
||||
});
|
||||
|
||||
// Load based on mode
|
||||
if (isDev) {
|
||||
console.log('[Goose Ultra] Loading from http://localhost:3000');
|
||||
mainWindow.loadURL('http://localhost:3000');
|
||||
} else {
|
||||
console.log('[Goose Ultra] Loading from dist/index.html');
|
||||
mainWindow.loadFile(path.join(__dirname, '../dist/index.html'));
|
||||
}
|
||||
|
||||
// Open external links in browser
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
shell.openExternal(url);
|
||||
return { action: 'deny' };
|
||||
}
|
||||
return { action: 'allow' };
|
||||
});
|
||||
}
|
||||
|
||||
import http from 'http';
|
||||
import fs from 'fs';
|
||||
|
||||
// ... imports ...
|
||||
|
||||
app.whenReady().then(() => {
|
||||
// START LOCAL PREVIEW SERVER
|
||||
// This bypasses all file:// protocol issues by serving real HTTP
|
||||
const server = http.createServer((req, res) => {
|
||||
// Enable CORS
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
||||
if (req.method === 'OPTIONS') { res.writeHead(200); res.end(); return; }
|
||||
|
||||
try {
|
||||
// URL: /projects/latest/index.html
|
||||
// Map to: %AppData%/projects/latest/index.html
|
||||
const cleanUrl = req.url.split('?')[0];
|
||||
// `req.url` starts with `/`. On Windows, `path.join(base, "\\projects\\...")` discards `base`.
|
||||
// Strip leading slashes so we always resolve under `userData`.
|
||||
const safeSuffix = path
|
||||
.normalize(cleanUrl)
|
||||
.replace(/^(\.\.[\/\\])+/, '')
|
||||
.replace(/^[\/\\]+/, '');
|
||||
const filePath = path.join(app.getPath('userData'), safeSuffix);
|
||||
|
||||
console.log(`[PreviewServer] Request: ${cleanUrl} -> ${filePath}`);
|
||||
|
||||
fs.readFile(filePath, (err, data) => {
|
||||
if (err) {
|
||||
console.error(`[PreviewServer] 404: ${filePath}`);
|
||||
res.writeHead(404);
|
||||
res.end('File not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const mimeTypes = {
|
||||
'.html': 'text/html',
|
||||
'.js': 'text/javascript',
|
||||
'.css': 'text/css',
|
||||
'.json': 'application/json',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.svg': 'image/svg+xml'
|
||||
};
|
||||
|
||||
const contentType = mimeTypes[ext] || 'application/octet-stream';
|
||||
res.writeHead(200, { 'Content-Type': contentType });
|
||||
res.end(data);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[PreviewServer] Error:', e);
|
||||
res.writeHead(500);
|
||||
res.end('Server Error');
|
||||
}
|
||||
});
|
||||
|
||||
// Start Preview Server
|
||||
let previewPort = 45678;
|
||||
server.listen(previewPort, '127.0.0.1', () => {
|
||||
console.log(`[PreviewServer] Running on http://127.0.0.1:${previewPort}`);
|
||||
});
|
||||
|
||||
server.on('error', (e) => {
|
||||
if (e.code === 'EADDRINUSE') {
|
||||
previewPort = 45679;
|
||||
console.log(`[PreviewServer] Port 45678 in use, trying ${previewPort}`);
|
||||
server.listen(previewPort, '127.0.0.1');
|
||||
} else {
|
||||
console.error('[PreviewServer] Error:', e);
|
||||
}
|
||||
});
|
||||
|
||||
createWindow();
|
||||
});
|
||||
|
||||
// ...
|
||||
|
||||
// IPC Handlers
|
||||
ipcMain.handle('get-app-path', () => app.getPath('userData'));
|
||||
ipcMain.handle('get-platform', () => process.platform);
|
||||
ipcMain.handle('get-server-port', () => previewPort);
|
||||
ipcMain.handle('export-project-zip', async (_, { projectId }) => {
|
||||
if (!projectId) throw new Error('projectId required');
|
||||
if (process.platform !== 'win32') throw new Error('ZIP export currently supported on Windows only.');
|
||||
|
||||
const userData = app.getPath('userData');
|
||||
const projectDir = path.join(userData, 'projects', String(projectId));
|
||||
const outDir = path.join(userData, 'exports');
|
||||
const outPath = path.join(outDir, `${projectId}.zip`);
|
||||
|
||||
await fs.promises.mkdir(outDir, { recursive: true });
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const ps = 'powershell.exe';
|
||||
const cmd = `Compress-Archive -Path '${projectDir}\\*' -DestinationPath '${outPath}' -Force`;
|
||||
|
||||
await execFileAsync(ps, ['-NoProfile', '-NonInteractive', '-Command', cmd]);
|
||||
return outPath;
|
||||
});
|
||||
|
||||
// Chat Streaming IPC
|
||||
ipcMain.on('chat-stream-start', (event, { messages, model }) => {
|
||||
const window = BrowserWindow.fromWebContents(event.sender);
|
||||
|
||||
streamChat(
|
||||
messages,
|
||||
model,
|
||||
(chunk) => {
|
||||
if (!window.isDestroyed()) {
|
||||
// console.log('[Main] Sending chunk size:', chunk.length); // Verbose log
|
||||
window.webContents.send('chat-chunk', chunk);
|
||||
}
|
||||
},
|
||||
(fullResponse) => !window.isDestroyed() && window.webContents.send('chat-complete', fullResponse),
|
||||
(error) => !window.isDestroyed() && window.webContents.send('chat-error', error.message),
|
||||
(status) => !window.isDestroyed() && window.webContents.send('chat-status', status)
|
||||
);
|
||||
});
|
||||
|
||||
// FS Handlers
|
||||
ipcMain.handle('fs-list', async (_, path) => fsApi.listFiles(path));
|
||||
ipcMain.handle('fs-read', async (_, path) => fsApi.readFile(path));
|
||||
ipcMain.handle('fs-write', async (_, { path, content }) => fsApi.writeFile(path, content));
|
||||
ipcMain.handle('fs-delete', async (_, path) => fsApi.deletePath(path));
|
||||
|
||||
// --- IMAGE GENERATION Handlers ---
|
||||
// Enables ChatGPT-like image generation in Chat Mode
|
||||
ipcMain.handle('image-generate', async (_, { prompt, options }) => {
|
||||
console.log('[Main] Image generation request:', prompt?.substring(0, 50));
|
||||
try {
|
||||
const result = await generateImage(prompt, options);
|
||||
return { success: true, ...result };
|
||||
} catch (error) {
|
||||
console.error('[Main] Image generation failed:', error.message);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('image-detect', async (_, { message }) => {
|
||||
const result = detectImageRequest(message);
|
||||
return result;
|
||||
});
|
||||
|
||||
// Cleanup old cached images on startup
|
||||
cleanupCache(7);
|
||||
|
||||
// --- IT EXPERT: PowerShell Execution Handler ---
|
||||
// Credits: Inspired by Windows-Use (CursorTouch) and Mini-Agent patterns
|
||||
// Security: Deny by default. Only runs if renderer explicitly enables and user approves.
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
const POWERSHELL_DENYLIST = [
|
||||
/Remove-Item\s+-Recurse\s+-Force\s+[\/\\]/i,
|
||||
/Format-Volume/i,
|
||||
/Clear-Disk/i,
|
||||
/Start-Process\s+.*-Verb\s+RunAs/i,
|
||||
/Add-MpPreference\s+-ExclusionPath/i,
|
||||
/Set-MpPreference/i,
|
||||
/reg\s+delete/i,
|
||||
/bcdedit/i,
|
||||
/cipher\s+\/w/i
|
||||
];
|
||||
|
||||
function isDenylisted(script) {
|
||||
return POWERSHELL_DENYLIST.some(pattern => pattern.test(script));
|
||||
}
|
||||
|
||||
let activeExecProcess = null;
|
||||
|
||||
ipcMain.on('exec-run-powershell', (event, { execSessionId, script, enabled }) => {
|
||||
const window = BrowserWindow.fromWebContents(event.sender);
|
||||
if (!window || window.isDestroyed()) return;
|
||||
|
||||
// Security Gate: Execution must be enabled by user
|
||||
if (!enabled) {
|
||||
window.webContents.send('exec-error', { execSessionId, message: 'PowerShell execution is disabled. Enable it in Settings.' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Security Gate: Denylist check
|
||||
if (isDenylisted(script)) {
|
||||
window.webContents.send('exec-error', { execSessionId, message: 'BLOCKED: Script contains denylisted dangerous commands.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
window.webContents.send('exec-start', { execSessionId, startedAt });
|
||||
|
||||
// Spawn PowerShell with explicit args (never shell=true)
|
||||
activeExecProcess = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
|
||||
windowsHide: true,
|
||||
env: { ...process.env, HOME: undefined, USERPROFILE: process.env.USERPROFILE } // Sanitize env
|
||||
});
|
||||
|
||||
activeExecProcess.stdout.on('data', (data) => {
|
||||
if (!window.isDestroyed()) {
|
||||
window.webContents.send('exec-chunk', { execSessionId, stream: 'stdout', text: data.toString() });
|
||||
}
|
||||
});
|
||||
|
||||
activeExecProcess.stderr.on('data', (data) => {
|
||||
if (!window.isDestroyed()) {
|
||||
window.webContents.send('exec-chunk', { execSessionId, stream: 'stderr', text: data.toString() });
|
||||
}
|
||||
});
|
||||
|
||||
activeExecProcess.on('close', (code) => {
|
||||
const durationMs = Date.now() - startedAt;
|
||||
if (!window.isDestroyed()) {
|
||||
window.webContents.send('exec-complete', { execSessionId, exitCode: code ?? 0, durationMs });
|
||||
}
|
||||
activeExecProcess = null;
|
||||
});
|
||||
|
||||
activeExecProcess.on('error', (err) => {
|
||||
if (!window.isDestroyed()) {
|
||||
window.webContents.send('exec-error', { execSessionId, message: err.message });
|
||||
}
|
||||
activeExecProcess = null;
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.on('exec-cancel', (event, { execSessionId }) => {
|
||||
const window = BrowserWindow.fromWebContents(event.sender);
|
||||
if (activeExecProcess) {
|
||||
activeExecProcess.kill('SIGTERM');
|
||||
activeExecProcess = null;
|
||||
if (window && !window.isDestroyed()) {
|
||||
window.webContents.send('exec-cancelled', { execSessionId });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- VI_CONTROL: Host & Credential Management (Contract v5) ---
|
||||
import { Client } from 'ssh2';
|
||||
import crypto from 'crypto';
|
||||
|
||||
const VI_CONTROL_DIR = path.join(app.getPath('userData'), 'vi-control');
|
||||
const HOSTS_FILE = path.join(VI_CONTROL_DIR, 'hosts.json');
|
||||
const VAULT_FILE = path.join(VI_CONTROL_DIR, 'vault.enc');
|
||||
const AUDIT_LOG_FILE = path.join(VI_CONTROL_DIR, 'audit.jsonl');
|
||||
|
||||
if (!fs.existsSync(VI_CONTROL_DIR)) fs.mkdirSync(VI_CONTROL_DIR, { recursive: true });
|
||||
|
||||
// Audit Logging helper
|
||||
function auditLog(entry) {
|
||||
const log = {
|
||||
timestamp: new Date().toISOString(),
|
||||
...entry
|
||||
};
|
||||
fs.appendFileSync(AUDIT_LOG_FILE, JSON.stringify(log) + '\n');
|
||||
}
|
||||
|
||||
// Credential Vault logic
|
||||
let keytar;
|
||||
try {
|
||||
// Try to import keytar if available
|
||||
keytar = await import('keytar');
|
||||
} catch (e) {
|
||||
console.warn('[Vi Control] Keytar not found, using encrypted file fallback.');
|
||||
}
|
||||
|
||||
async function getSecret(id) {
|
||||
if (keytar && keytar.getPassword) {
|
||||
return await keytar.getPassword('GooseUltra', id);
|
||||
}
|
||||
// Encrypted file fallback logic (simplified for brevity, in real world use specialized encryption)
|
||||
if (!fs.existsSync(VAULT_FILE)) return null;
|
||||
const data = JSON.parse(fs.readFileSync(VAULT_FILE, 'utf8'));
|
||||
return data[id] ? Buffer.from(data[id], 'base64').toString() : null;
|
||||
}
|
||||
|
||||
async function saveSecret(id, secret) {
|
||||
if (keytar && keytar.setPassword) {
|
||||
return await keytar.setPassword('GooseUltra', id, secret);
|
||||
}
|
||||
const data = fs.existsSync(VAULT_FILE) ? JSON.parse(fs.readFileSync(VAULT_FILE, 'utf8')) : {};
|
||||
data[id] = Buffer.from(secret).toString('base64');
|
||||
fs.writeFileSync(VAULT_FILE, JSON.stringify(data));
|
||||
}
|
||||
|
||||
// Host IPC Handlers
|
||||
ipcMain.handle('vi-hosts-list', () => {
|
||||
if (!fs.existsSync(HOSTS_FILE)) return [];
|
||||
return JSON.parse(fs.readFileSync(HOSTS_FILE, 'utf8'));
|
||||
});
|
||||
|
||||
ipcMain.handle('vi-hosts-add', (_, host) => {
|
||||
const hosts = fs.existsSync(HOSTS_FILE) ? JSON.parse(fs.readFileSync(HOSTS_FILE, 'utf8')) : [];
|
||||
hosts.push(host);
|
||||
fs.writeFileSync(HOSTS_FILE, JSON.stringify(hosts, null, 2));
|
||||
auditLog({ action: 'HOST_ADD', hostId: host.hostId, label: host.label });
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('vi-hosts-update', (_, updatedHost) => {
|
||||
let hosts = JSON.parse(fs.readFileSync(HOSTS_FILE, 'utf8'));
|
||||
hosts = hosts.map(h => h.hostId === updatedHost.hostId ? updatedHost : h);
|
||||
fs.writeFileSync(HOSTS_FILE, JSON.stringify(hosts, null, 2));
|
||||
auditLog({ action: 'HOST_UPDATE', hostId: updatedHost.hostId });
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('vi-hosts-delete', (_, hostId) => {
|
||||
let hosts = JSON.parse(fs.readFileSync(HOSTS_FILE, 'utf8'));
|
||||
hosts = hosts.filter(h => h.hostId !== hostId);
|
||||
fs.writeFileSync(HOSTS_FILE, JSON.stringify(hosts, null, 2));
|
||||
auditLog({ action: 'HOST_DELETE', hostId });
|
||||
return true;
|
||||
});
|
||||
|
||||
// Credentials file for metadata
|
||||
const CREDS_META_FILE = path.join(VI_CONTROL_DIR, 'credentials-meta.json');
|
||||
|
||||
ipcMain.handle('vi-credentials-list', () => {
|
||||
if (!fs.existsSync(CREDS_META_FILE)) return [];
|
||||
return JSON.parse(fs.readFileSync(CREDS_META_FILE, 'utf8'));
|
||||
});
|
||||
|
||||
ipcMain.handle('vi-credentials-save', async (_, { label, type, value }) => {
|
||||
const credentialId = `cred_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
// Save secret to vault
|
||||
await saveSecret(credentialId, value);
|
||||
|
||||
// Save metadata (without secret)
|
||||
const credsMeta = fs.existsSync(CREDS_META_FILE) ? JSON.parse(fs.readFileSync(CREDS_META_FILE, 'utf8')) : [];
|
||||
credsMeta.push({ credentialId, label, type, createdAt: Date.now() });
|
||||
fs.writeFileSync(CREDS_META_FILE, JSON.stringify(credsMeta, null, 2));
|
||||
|
||||
auditLog({ action: 'CREDENTIAL_SAVE', credentialId, label, type });
|
||||
return { success: true, credentialId };
|
||||
});
|
||||
|
||||
ipcMain.handle('vi-credentials-delete', async (_, { credId }) => {
|
||||
// Remove from vault
|
||||
if (fs.existsSync(VAULT_FILE)) {
|
||||
const vault = JSON.parse(fs.readFileSync(VAULT_FILE, 'utf8'));
|
||||
delete vault[credId];
|
||||
fs.writeFileSync(VAULT_FILE, JSON.stringify(vault, null, 2));
|
||||
}
|
||||
|
||||
// Remove from metadata
|
||||
if (fs.existsSync(CREDS_META_FILE)) {
|
||||
let credsMeta = JSON.parse(fs.readFileSync(CREDS_META_FILE, 'utf8'));
|
||||
credsMeta = credsMeta.filter(c => c.credentialId !== credId);
|
||||
fs.writeFileSync(CREDS_META_FILE, JSON.stringify(credsMeta, null, 2));
|
||||
}
|
||||
|
||||
auditLog({ action: 'CREDENTIAL_DELETE', credentialId: credId });
|
||||
return true;
|
||||
});
|
||||
|
||||
// SSH Execution via ssh2
|
||||
let activeSshClients = new Map(); // execSessionId -> { client, conn }
|
||||
|
||||
ipcMain.on('vi-ssh-run', async (event, { execSessionId, hostId, command, credId }) => {
|
||||
const window = BrowserWindow.fromWebContents(event.sender);
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(HOSTS_FILE)) {
|
||||
return window.webContents.send('exec-error', { execSessionId, message: 'No hosts configured' });
|
||||
}
|
||||
|
||||
const hosts = JSON.parse(fs.readFileSync(HOSTS_FILE, 'utf8'));
|
||||
const host = hosts.find(h => h.hostId === hostId);
|
||||
if (!host) return window.webContents.send('exec-error', { execSessionId, message: 'Host not found' });
|
||||
|
||||
// Use host's credId if not passed explicitly
|
||||
const effectiveCredId = credId || host.credId;
|
||||
|
||||
// Get password from credential vault
|
||||
let password = null;
|
||||
if (effectiveCredId) {
|
||||
password = await getSecret(effectiveCredId);
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
return window.webContents.send('exec-error', {
|
||||
execSessionId,
|
||||
message: 'No credentials found. Please save a credential in the Vault and link it to this host.'
|
||||
});
|
||||
}
|
||||
|
||||
const conn = new Client();
|
||||
let connected = false;
|
||||
|
||||
// Connection timeout (10 seconds)
|
||||
const timeout = setTimeout(() => {
|
||||
if (!connected) {
|
||||
conn.end();
|
||||
window.webContents.send('exec-error', { execSessionId, message: 'Connection timeout (10s). Check hostname/port and firewall.' });
|
||||
activeSshClients.delete(execSessionId);
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
conn.on('ready', () => {
|
||||
connected = true;
|
||||
clearTimeout(timeout);
|
||||
|
||||
conn.exec(command, (err, stream) => {
|
||||
if (err) return window.webContents.send('exec-error', { execSessionId, message: err.message });
|
||||
|
||||
window.webContents.send('exec-start', { execSessionId });
|
||||
|
||||
stream.on('data', (data) => {
|
||||
window.webContents.send('exec-chunk', { execSessionId, text: data.toString() });
|
||||
}).on('close', (code) => {
|
||||
window.webContents.send('exec-complete', { execSessionId, exitCode: code });
|
||||
conn.end();
|
||||
activeSshClients.delete(execSessionId);
|
||||
}).stderr.on('data', (data) => {
|
||||
window.webContents.send('exec-chunk', { execSessionId, text: data.toString(), stream: 'stderr' });
|
||||
});
|
||||
});
|
||||
}).on('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
window.webContents.send('exec-error', { execSessionId, message: `SSH Error: ${err.message}` });
|
||||
activeSshClients.delete(execSessionId);
|
||||
}).connect({
|
||||
host: host.hostname,
|
||||
port: host.port || 22,
|
||||
username: host.username,
|
||||
password: password,
|
||||
readyTimeout: 10000,
|
||||
keepaliveInterval: 5000
|
||||
});
|
||||
|
||||
activeSshClients.set(execSessionId, { client: conn });
|
||||
auditLog({ action: 'SSH_RUN', hostId, command, execSessionId });
|
||||
|
||||
} catch (err) {
|
||||
window.webContents.send('exec-error', { execSessionId, message: `Error: ${err.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on('vi-ssh-cancel', (_, { execSessionId }) => {
|
||||
const session = activeSshClients.get(execSessionId);
|
||||
if (session) {
|
||||
session.client.end();
|
||||
activeSshClients.delete(execSessionId);
|
||||
}
|
||||
});
|
||||
|
||||
// SSH with direct password (for first-time connections)
|
||||
ipcMain.on('vi-ssh-run-with-password', async (event, { execSessionId, hostId, command, password }) => {
|
||||
const window = BrowserWindow.fromWebContents(event.sender);
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(HOSTS_FILE)) {
|
||||
return window.webContents.send('exec-error', { execSessionId, message: 'No hosts configured' });
|
||||
}
|
||||
|
||||
const hosts = JSON.parse(fs.readFileSync(HOSTS_FILE, 'utf8'));
|
||||
const host = hosts.find(h => h.hostId === hostId);
|
||||
if (!host) return window.webContents.send('exec-error', { execSessionId, message: 'Host not found' });
|
||||
|
||||
const conn = new Client();
|
||||
let connected = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!connected) {
|
||||
conn.end();
|
||||
window.webContents.send('exec-error', { execSessionId, message: 'Connection timeout (10s). Check hostname/port and firewall.' });
|
||||
activeSshClients.delete(execSessionId);
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
conn.on('ready', () => {
|
||||
connected = true;
|
||||
clearTimeout(timeout);
|
||||
|
||||
conn.exec(command, (err, stream) => {
|
||||
if (err) return window.webContents.send('exec-error', { execSessionId, message: err.message });
|
||||
|
||||
window.webContents.send('exec-start', { execSessionId });
|
||||
|
||||
stream.on('data', (data) => {
|
||||
window.webContents.send('exec-chunk', { execSessionId, text: data.toString() });
|
||||
}).on('close', (code) => {
|
||||
window.webContents.send('exec-complete', { execSessionId, exitCode: code });
|
||||
conn.end();
|
||||
activeSshClients.delete(execSessionId);
|
||||
}).stderr.on('data', (data) => {
|
||||
window.webContents.send('exec-chunk', { execSessionId, text: data.toString(), stream: 'stderr' });
|
||||
});
|
||||
});
|
||||
}).on('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
window.webContents.send('exec-error', { execSessionId, message: `SSH Error: ${err.message}` });
|
||||
activeSshClients.delete(execSessionId);
|
||||
}).connect({
|
||||
host: host.hostname,
|
||||
port: host.port || 22,
|
||||
username: host.username,
|
||||
password: password,
|
||||
readyTimeout: 10000,
|
||||
keepaliveInterval: 5000
|
||||
});
|
||||
|
||||
activeSshClients.set(execSessionId, { client: conn });
|
||||
auditLog({ action: 'SSH_RUN_DIRECT', hostId, command, execSessionId });
|
||||
|
||||
} catch (err) {
|
||||
window.webContents.send('exec-error', { execSessionId, message: `Error: ${err.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// RDP Launcher
|
||||
ipcMain.handle('vi-rdp-launch', async (_, { hostId }) => {
|
||||
const hosts = JSON.parse(fs.readFileSync(HOSTS_FILE, 'utf8'));
|
||||
const host = hosts.find(h => h.hostId === hostId);
|
||||
if (!host || host.osHint !== 'windows') return false;
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
spawn('mstsc.exe', [`/v:${host.hostname}`]);
|
||||
auditLog({ action: 'RDP_LAUNCH', hostId });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// VI CONTROL - AUTOMATION HANDLERS
|
||||
// ============================================
|
||||
|
||||
// Screen Capture
|
||||
ipcMain.handle('vi-capture-screen', async (_, { mode }) => {
|
||||
return await viAutomation.captureScreen(mode || 'desktop');
|
||||
});
|
||||
|
||||
// Get Window List
|
||||
ipcMain.handle('vi-get-windows', async () => {
|
||||
return await viAutomation.getWindowList();
|
||||
});
|
||||
|
||||
// Vision Analysis (Screenshot to JSON)
|
||||
ipcMain.handle('vi-analyze-screenshot', async (_, { imageDataUrl }) => {
|
||||
return await viAutomation.analyzeScreenshot(imageDataUrl, streamChat);
|
||||
});
|
||||
|
||||
// Translate Task to Commands
|
||||
ipcMain.handle('vi-translate-task', async (_, { task }) => {
|
||||
return await viAutomation.translateTaskToCommands(task, streamChat);
|
||||
});
|
||||
|
||||
// Execute Single Command
|
||||
ipcMain.handle('vi-execute-command', async (_, { command }) => {
|
||||
return await viAutomation.executeCommand(command);
|
||||
});
|
||||
|
||||
// Execute Task Chain
|
||||
ipcMain.on('vi-execute-chain', async (event, { tasks }) => {
|
||||
const window = BrowserWindow.fromWebContents(event.sender);
|
||||
|
||||
await viAutomation.executeTaskChain(
|
||||
tasks,
|
||||
streamChat,
|
||||
(progress) => {
|
||||
window.webContents.send('vi-chain-progress', progress);
|
||||
},
|
||||
(results) => {
|
||||
window.webContents.send('vi-chain-complete', results);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Open Browser
|
||||
ipcMain.handle('vi-open-browser', async (_, { url }) => {
|
||||
return await viAutomation.openBrowser(url);
|
||||
});
|
||||
|
||||
console.log('Goose Ultra Electron Main Process Started');
|
||||
95
bin/goose-ultra-final/electron/preload.js
Normal file
95
bin/goose-ultra-final/electron/preload.js
Normal file
@@ -0,0 +1,95 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
contextBridge.exposeInMainWorld('electron', {
|
||||
getAppPath: () => ipcRenderer.invoke('get-app-path'),
|
||||
getPlatform: () => ipcRenderer.invoke('get-platform'),
|
||||
getServerPort: () => ipcRenderer.invoke('get-server-port'),
|
||||
exportProjectZip: (projectId) => ipcRenderer.invoke('export-project-zip', { projectId }),
|
||||
// Chat Bridge
|
||||
startChat: (messages, model) => ipcRenderer.send('chat-stream-start', { messages, model }),
|
||||
onChatChunk: (callback) => ipcRenderer.on('chat-chunk', (_, chunk) => callback(chunk)),
|
||||
onChatStatus: (callback) => ipcRenderer.on('chat-status', (_, status) => callback(status)),
|
||||
onChatComplete: (callback) => ipcRenderer.on('chat-complete', (_, response) => callback(response)),
|
||||
onChatError: (callback) => ipcRenderer.on('chat-error', (_, error) => callback(error)),
|
||||
removeChatListeners: () => {
|
||||
ipcRenderer.removeAllListeners('chat-chunk');
|
||||
ipcRenderer.removeAllListeners('chat-status');
|
||||
ipcRenderer.removeAllListeners('chat-complete');
|
||||
ipcRenderer.removeAllListeners('chat-error');
|
||||
},
|
||||
// Filesystem
|
||||
fs: {
|
||||
list: (path) => ipcRenderer.invoke('fs-list', path),
|
||||
read: (path) => ipcRenderer.invoke('fs-read', path),
|
||||
write: (path, content) => ipcRenderer.invoke('fs-write', { path, content }),
|
||||
delete: (path) => ipcRenderer.invoke('fs-delete', path)
|
||||
},
|
||||
// Image Generation (ChatGPT-like)
|
||||
image: {
|
||||
generate: (prompt, options) => ipcRenderer.invoke('image-generate', { prompt, options }),
|
||||
detect: (message) => ipcRenderer.invoke('image-detect', { message })
|
||||
},
|
||||
// IT Expert Execution Bridge
|
||||
runPowerShell: (execSessionId, script, enabled) => ipcRenderer.send('exec-run-powershell', { execSessionId, script, enabled }),
|
||||
cancelExecution: (execSessionId) => ipcRenderer.send('exec-cancel', { execSessionId }),
|
||||
onExecStart: (callback) => ipcRenderer.on('exec-start', (_, data) => callback(data)),
|
||||
onExecChunk: (callback) => ipcRenderer.on('exec-chunk', (_, data) => callback(data)),
|
||||
onExecComplete: (callback) => ipcRenderer.on('exec-complete', (_, data) => callback(data)),
|
||||
onExecError: (callback) => ipcRenderer.on('exec-error', (_, data) => callback(data)),
|
||||
onExecCancelled: (callback) => ipcRenderer.on('exec-cancelled', (_, data) => callback(data)),
|
||||
removeExecListeners: () => {
|
||||
ipcRenderer.removeAllListeners('exec-start');
|
||||
ipcRenderer.removeAllListeners('exec-chunk');
|
||||
ipcRenderer.removeAllListeners('exec-complete');
|
||||
ipcRenderer.removeAllListeners('exec-error');
|
||||
ipcRenderer.removeAllListeners('exec-cancelled');
|
||||
},
|
||||
// VI CONTROL (Contract v6 - Complete Automation)
|
||||
vi: {
|
||||
// Hosts
|
||||
getHosts: () => ipcRenderer.invoke('vi-hosts-list'),
|
||||
addHost: (host) => ipcRenderer.invoke('vi-hosts-add', host),
|
||||
updateHost: (host) => ipcRenderer.invoke('vi-hosts-update', host),
|
||||
deleteHost: (hostId) => ipcRenderer.invoke('vi-hosts-delete', hostId),
|
||||
|
||||
// Credentials
|
||||
getCredentials: () => ipcRenderer.invoke('vi-credentials-list'),
|
||||
saveCredential: (label, type, value) => ipcRenderer.invoke('vi-credentials-save', { label, type, value }),
|
||||
deleteCredential: (credId) => ipcRenderer.invoke('vi-credentials-delete', { credId }),
|
||||
|
||||
// Execution
|
||||
runSSH: (execSessionId, hostId, command, credId) => ipcRenderer.send('vi-ssh-run', { execSessionId, hostId, command, credId }),
|
||||
runSSHWithPassword: (execSessionId, hostId, command, password) => ipcRenderer.send('vi-ssh-run-with-password', { execSessionId, hostId, command, password }),
|
||||
cancelSSH: (execSessionId) => ipcRenderer.send('vi-ssh-cancel', { execSessionId }),
|
||||
|
||||
// Host update
|
||||
updateHost: (host) => ipcRenderer.invoke('vi-hosts-update', host),
|
||||
|
||||
// RDP
|
||||
launchRDP: (hostId) => ipcRenderer.invoke('vi-rdp-launch', { hostId }),
|
||||
|
||||
// === NEW: Computer Use / Automation ===
|
||||
// Screen Capture
|
||||
captureScreen: (mode) => ipcRenderer.invoke('vi-capture-screen', { mode }), // mode: 'desktop' | 'window'
|
||||
getWindows: () => ipcRenderer.invoke('vi-get-windows'),
|
||||
|
||||
// Vision Analysis
|
||||
analyzeScreenshot: (imageDataUrl) => ipcRenderer.invoke('vi-analyze-screenshot', { imageDataUrl }),
|
||||
|
||||
// Task Translation & Execution
|
||||
translateTask: (task) => ipcRenderer.invoke('vi-translate-task', { task }),
|
||||
executeCommand: (command) => ipcRenderer.invoke('vi-execute-command', { command }),
|
||||
|
||||
// Task Chain with progress
|
||||
executeChain: (tasks) => ipcRenderer.send('vi-execute-chain', { tasks }),
|
||||
onChainProgress: (callback) => ipcRenderer.on('vi-chain-progress', (_, data) => callback(data)),
|
||||
onChainComplete: (callback) => ipcRenderer.on('vi-chain-complete', (_, data) => callback(data)),
|
||||
removeChainListeners: () => {
|
||||
ipcRenderer.removeAllListeners('vi-chain-progress');
|
||||
ipcRenderer.removeAllListeners('vi-chain-complete');
|
||||
},
|
||||
|
||||
// Browser
|
||||
openBrowser: (url) => ipcRenderer.invoke('vi-open-browser', { url })
|
||||
}
|
||||
});
|
||||
192
bin/goose-ultra-final/electron/qwen-api.js
Normal file
192
bin/goose-ultra-final/electron/qwen-api.js
Normal file
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Qwen API Bridge for Goose Ultra
|
||||
*
|
||||
* Uses the SAME token infrastructure as QwenOAuth (qwen-oauth.mjs)
|
||||
* Token location: ~/.qwen/oauth_creds.json
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import https from 'https';
|
||||
import os from 'os';
|
||||
import crypto from 'crypto';
|
||||
|
||||
const QWEN_CHAT_API = 'https://chat.qwen.ai/api/v1/chat/completions';
|
||||
|
||||
const getOauthCredPath = () => path.join(os.homedir(), '.qwen', 'oauth_creds.json');
|
||||
|
||||
const normalizeModel = (model) => {
|
||||
const m = String(model || '').trim();
|
||||
const map = {
|
||||
'qwen-coder-plus': 'coder-model',
|
||||
'qwen-plus': 'coder-model',
|
||||
'qwen-turbo': 'coder-model',
|
||||
'coder-model': 'coder-model',
|
||||
};
|
||||
return map[m] || 'coder-model';
|
||||
};
|
||||
|
||||
export function loadTokens() {
|
||||
const tokenPath = getOauthCredPath();
|
||||
try {
|
||||
if (fs.existsSync(tokenPath)) {
|
||||
const data = JSON.parse(fs.readFileSync(tokenPath, 'utf-8'));
|
||||
if (data.access_token) {
|
||||
console.log('[QwenAPI] Loaded tokens from:', tokenPath);
|
||||
return {
|
||||
access_token: data.access_token,
|
||||
refresh_token: data.refresh_token,
|
||||
token_type: data.token_type || 'Bearer',
|
||||
expiry_date: Number(data.expiry_date || 0),
|
||||
resource_url: data.resource_url,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[QwenAPI] Token load error:', e.message);
|
||||
}
|
||||
console.warn('[QwenAPI] No valid tokens found at', tokenPath);
|
||||
return null;
|
||||
}
|
||||
|
||||
function isTokenValid(tokens) {
|
||||
const expiry = Number(tokens?.expiry_date || 0);
|
||||
if (!expiry) return true;
|
||||
return expiry > Date.now() + 30_000;
|
||||
}
|
||||
|
||||
function getApiEndpoint(tokens) {
|
||||
if (tokens?.resource_url) {
|
||||
return `https://${tokens.resource_url}/v1/chat/completions`;
|
||||
}
|
||||
return QWEN_CHAT_API;
|
||||
}
|
||||
|
||||
// Track active request to prevent stream interleaving
|
||||
let activeRequest = null;
|
||||
|
||||
export function abortActiveChat() {
|
||||
if (activeRequest) {
|
||||
console.log('[QwenAPI] Aborting previous request...');
|
||||
try {
|
||||
activeRequest.destroy();
|
||||
} catch (e) {
|
||||
console.warn('[QwenAPI] Abort warning:', e.message);
|
||||
}
|
||||
activeRequest = null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function streamChat(messages, model = 'qwen-coder-plus', onChunk, onComplete, onError, onStatus) {
|
||||
// Abort any existing request to prevent interleaving
|
||||
abortActiveChat();
|
||||
|
||||
const log = (msg) => {
|
||||
console.log('[QwenAPI]', msg);
|
||||
if (onStatus) onStatus(msg);
|
||||
};
|
||||
|
||||
log('Loading tokens...');
|
||||
const tokens = loadTokens();
|
||||
|
||||
if (!tokens?.access_token) {
|
||||
log('Error: No tokens found.');
|
||||
console.error('[QwenAPI] Authentication missing. No valid tokens found.');
|
||||
onError(new Error('AUTHENTICATION_REQUIRED: Please run OpenQode > Option 4, then /auth in Qwen CLI.'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isTokenValid(tokens)) {
|
||||
log('Error: Tokens expired.');
|
||||
console.error('[QwenAPI] Token expired.');
|
||||
onError(new Error('TOKEN_EXPIRED: Please run OpenQode > Option 4 and /auth again.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const endpoint = getApiEndpoint(tokens);
|
||||
const url = new URL(endpoint);
|
||||
const requestId = crypto.randomUUID();
|
||||
|
||||
const body = JSON.stringify({
|
||||
model: normalizeModel(model),
|
||||
messages: messages,
|
||||
stream: true
|
||||
});
|
||||
|
||||
log(`Connecting to ${url.hostname}...`);
|
||||
console.log(`[QwenAPI] Calling ${url.href} with model ${normalizeModel(model)}`);
|
||||
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: 443,
|
||||
path: url.pathname,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${tokens.access_token}`,
|
||||
'x-request-id': requestId,
|
||||
'Content-Length': Buffer.byteLength(body)
|
||||
}
|
||||
};
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
activeRequest = req;
|
||||
let fullResponse = '';
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
let errBody = '';
|
||||
res.on('data', (c) => errBody += c.toString());
|
||||
res.on('end', () => {
|
||||
onError(new Error(`API Error ${res.statusCode}: ${errBody}`));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.setEncoding('utf8');
|
||||
let buffer = '';
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
buffer += chunk;
|
||||
|
||||
// split by double newline or newline
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop(); // Keep incomplete line
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
// Check prefix
|
||||
if (!trimmed.startsWith('data: ')) continue;
|
||||
|
||||
const data = trimmed.replace('data: ', '').trim();
|
||||
if (data === '[DONE]') {
|
||||
onComplete(fullResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
// Qwen strict response matching
|
||||
const choice = parsed.choices?.[0];
|
||||
const content = choice?.delta?.content || choice?.message?.content || '';
|
||||
|
||||
if (content) {
|
||||
fullResponse += content;
|
||||
onChunk(content);
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parsing errors for intermediate crumbs
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
onComplete(fullResponse);
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (e) => {
|
||||
console.error('[QwenAPI] Request error:', e.message);
|
||||
onError(e);
|
||||
});
|
||||
req.write(body);
|
||||
req.end();
|
||||
}
|
||||
351
bin/goose-ultra-final/electron/vi-automation.js
Normal file
351
bin/goose-ultra-final/electron/vi-automation.js
Normal file
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* Vi Control - Complete Automation Backend
|
||||
*
|
||||
* Credits:
|
||||
* - Inspired by CursorTouch/Windows-Use (MIT License)
|
||||
* - Inspired by browser-use/browser-use (MIT License)
|
||||
* - Uses native Windows APIs via PowerShell
|
||||
*/
|
||||
|
||||
import { desktopCapturer, screen } from 'electron';
|
||||
import { spawn, exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// ============================================
|
||||
// SCREEN CAPTURE
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Capture the entire desktop or active window
|
||||
* @returns {Promise<{success: boolean, image: string, width: number, height: number}>}
|
||||
*/
|
||||
export async function captureScreen(mode = 'desktop') {
|
||||
try {
|
||||
const sources = await desktopCapturer.getSources({
|
||||
types: mode === 'window' ? ['window'] : ['screen'],
|
||||
thumbnailSize: { width: 1920, height: 1080 }
|
||||
});
|
||||
|
||||
if (sources.length === 0) {
|
||||
return { success: false, error: 'No screen sources found' };
|
||||
}
|
||||
|
||||
// Get the primary source (first screen or active window)
|
||||
const source = sources[0];
|
||||
const thumbnail = source.thumbnail;
|
||||
|
||||
// Convert to base64 data URL
|
||||
const imageDataUrl = thumbnail.toDataURL();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
image: imageDataUrl,
|
||||
width: thumbnail.getSize().width,
|
||||
height: thumbnail.getSize().height,
|
||||
sourceName: source.name
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[ViAutomation] Screen capture error:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of available windows for capture
|
||||
*/
|
||||
export async function getWindowList() {
|
||||
try {
|
||||
const sources = await desktopCapturer.getSources({
|
||||
types: ['window'],
|
||||
thumbnailSize: { width: 200, height: 150 }
|
||||
});
|
||||
|
||||
return sources.map(s => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
thumbnail: s.thumbnail.toDataURL()
|
||||
}));
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// VISION ANALYSIS (Screenshot to JSON)
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Analyze screenshot using AI to extract UI elements
|
||||
* Since Qwen doesn't support images directly, we use a description approach
|
||||
*/
|
||||
export async function analyzeScreenshot(imageDataUrl, streamChat) {
|
||||
// For vision-to-JSON, we'll use a two-step approach:
|
||||
// 1. Describe what's in the image (using local vision or OCR)
|
||||
// 2. Send description to Qwen for structured analysis
|
||||
|
||||
// First, let's try to extract text via PowerShell OCR (Windows 10+)
|
||||
const ocrResult = await extractTextFromImage(imageDataUrl);
|
||||
|
||||
const systemPrompt = `You are a UI analysis expert. Given text extracted from a screenshot via OCR, analyze and describe:
|
||||
1. What application/website is shown
|
||||
2. Key UI elements (buttons, text fields, menus)
|
||||
3. Current state of the interface
|
||||
4. Possible actions a user could take
|
||||
|
||||
Output ONLY valid JSON in this format:
|
||||
{
|
||||
"application": "string",
|
||||
"state": "string",
|
||||
"elements": [{"type": "button|input|text|menu|image", "label": "string", "position": "top|center|bottom"}],
|
||||
"possibleActions": ["string"],
|
||||
"summary": "string"
|
||||
}`;
|
||||
|
||||
const userPrompt = `OCR Text from screenshot:\n\n${ocrResult.text || '(No text detected)'}\n\nAnalyze this UI and provide structured JSON output.`;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let fullResponse = '';
|
||||
|
||||
streamChat(
|
||||
[{ role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt }],
|
||||
'qwen-coder-plus',
|
||||
(chunk) => { fullResponse += chunk; },
|
||||
(complete) => {
|
||||
try {
|
||||
// Try to parse JSON from response
|
||||
const jsonMatch = complete.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
resolve({ success: true, analysis: JSON.parse(jsonMatch[0]), raw: complete });
|
||||
} else {
|
||||
resolve({ success: true, analysis: null, raw: complete });
|
||||
}
|
||||
} catch (e) {
|
||||
resolve({ success: true, analysis: null, raw: complete });
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
resolve({ success: false, error: error.message });
|
||||
},
|
||||
() => { }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text from image using Windows OCR
|
||||
*/
|
||||
async function extractTextFromImage(imageDataUrl) {
|
||||
try {
|
||||
// Save image temporarily
|
||||
const tempDir = path.join(os.tmpdir(), 'vi-control');
|
||||
if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
const imagePath = path.join(tempDir, `ocr_${Date.now()}.png`);
|
||||
const base64Data = imageDataUrl.replace(/^data:image\/\w+;base64,/, '');
|
||||
fs.writeFileSync(imagePath, Buffer.from(base64Data, 'base64'));
|
||||
|
||||
// PowerShell OCR using Windows.Media.Ocr
|
||||
const psScript = `
|
||||
Add-Type -AssemblyName System.Runtime.WindowsRuntime
|
||||
$null = [Windows.Media.Ocr.OcrEngine,Windows.Foundation,ContentType=WindowsRuntime]
|
||||
$null = [Windows.Graphics.Imaging.BitmapDecoder,Windows.Foundation,ContentType=WindowsRuntime]
|
||||
|
||||
function Await($WinRtTask, $ResultType) {
|
||||
$asTaskGeneric = ([System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object { $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation\`1' })[0]
|
||||
$asTask = $asTaskGeneric.MakeGenericMethod($ResultType)
|
||||
$netTask = $asTask.Invoke($null, @($WinRtTask))
|
||||
$netTask.Wait()
|
||||
return $netTask.Result
|
||||
}
|
||||
|
||||
$imagePath = '${imagePath.replace(/\\/g, '\\\\')}'
|
||||
$stream = [System.IO.File]::OpenRead($imagePath)
|
||||
$decoder = Await ([Windows.Graphics.Imaging.BitmapDecoder]::CreateAsync([Windows.Storage.Streams.IRandomAccessStream]$stream)) ([Windows.Graphics.Imaging.BitmapDecoder])
|
||||
$bitmap = Await ($decoder.GetSoftwareBitmapAsync()) ([Windows.Graphics.Imaging.SoftwareBitmap])
|
||||
$ocrEngine = [Windows.Media.Ocr.OcrEngine]::TryCreateFromUserProfileLanguages()
|
||||
$ocrResult = Await ($ocrEngine.RecognizeAsync($bitmap)) ([Windows.Media.Ocr.OcrResult])
|
||||
$ocrResult.Text
|
||||
$stream.Dispose()
|
||||
`;
|
||||
|
||||
const { stdout } = await execAsync(`powershell -ExecutionPolicy Bypass -Command "${psScript.replace(/"/g, '\\"').replace(/\n/g, ' ')}"`, { timeout: 30000 });
|
||||
|
||||
// Cleanup
|
||||
try { fs.unlinkSync(imagePath); } catch { }
|
||||
|
||||
return { success: true, text: stdout.trim() };
|
||||
} catch (error) {
|
||||
console.error('[ViAutomation] OCR error:', error.message);
|
||||
return { success: false, text: '', error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// COMPUTER AUTOMATION (Mouse, Keyboard, Apps)
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Execute a natural language task by translating to automation commands
|
||||
*/
|
||||
export async function translateTaskToCommands(task, streamChat) {
|
||||
const systemPrompt = `You are a Windows automation expert. Given a user's natural language task, translate it into a sequence of automation commands.
|
||||
|
||||
Available commands:
|
||||
- CLICK x,y - Click at screen coordinates
|
||||
- TYPE "text" - Type text
|
||||
- KEY "key" - Press a key (Enter, Tab, Escape, Win, etc.)
|
||||
- HOTKEY "keys" - Press key combination (Ctrl+C, Alt+Tab, etc.)
|
||||
- OPEN "app" - Open an application
|
||||
- WAIT ms - Wait milliseconds
|
||||
- POWERSHELL "script" - Run PowerShell command
|
||||
|
||||
Output ONLY a JSON array of commands:
|
||||
[{"cmd": "OPEN", "value": "notepad"}, {"cmd": "WAIT", "value": "1000"}, {"cmd": "TYPE", "value": "Hello"}]`;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let fullResponse = '';
|
||||
|
||||
streamChat(
|
||||
[{ role: 'system', content: systemPrompt }, { role: 'user', content: `Task: ${task}` }],
|
||||
'qwen-coder-plus',
|
||||
(chunk) => { fullResponse += chunk; },
|
||||
(complete) => {
|
||||
try {
|
||||
const jsonMatch = complete.match(/\[[\s\S]*\]/);
|
||||
if (jsonMatch) {
|
||||
resolve({ success: true, commands: JSON.parse(jsonMatch[0]) });
|
||||
} else {
|
||||
resolve({ success: false, error: 'Could not parse commands', raw: complete });
|
||||
}
|
||||
} catch (e) {
|
||||
resolve({ success: false, error: e.message, raw: complete });
|
||||
}
|
||||
},
|
||||
(error) => resolve({ success: false, error: error.message }),
|
||||
() => { }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single automation command
|
||||
*/
|
||||
export async function executeCommand(command) {
|
||||
const { cmd, value } = command;
|
||||
|
||||
try {
|
||||
switch (cmd.toUpperCase()) {
|
||||
case 'CLICK': {
|
||||
const [x, y] = value.split(',').map(Number);
|
||||
await execAsync(`powershell -Command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Cursor]::Position = New-Object System.Drawing.Point(${x},${y}); Add-Type -MemberDefinition '[DllImport(\\"user32.dll\\")]public static extern void mouse_event(int flags,int dx,int dy,int data,int info);' -Name U32 -Namespace W; [W.U32]::mouse_event(6,0,0,0,0)"`);
|
||||
return { success: true, cmd, value };
|
||||
}
|
||||
|
||||
case 'TYPE': {
|
||||
await execAsync(`powershell -Command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait('${value.replace(/'/g, "''").replace(/[+^%~(){}[\]]/g, '{$&}')}')"`, { timeout: 10000 });
|
||||
return { success: true, cmd, value };
|
||||
}
|
||||
|
||||
case 'KEY': {
|
||||
const keyMap = { Enter: '{ENTER}', Tab: '{TAB}', Escape: '{ESC}', Win: '^{ESC}', Backspace: '{BS}', Delete: '{DEL}' };
|
||||
const key = keyMap[value] || `{${value.toUpperCase()}}`;
|
||||
await execAsync(`powershell -Command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait('${key}')"`);
|
||||
return { success: true, cmd, value };
|
||||
}
|
||||
|
||||
case 'HOTKEY': {
|
||||
// Convert Ctrl+C to ^c, Alt+Tab to %{TAB}
|
||||
let hotkey = value.replace(/Ctrl\+/gi, '^').replace(/Alt\+/gi, '%').replace(/Shift\+/gi, '+');
|
||||
await execAsync(`powershell -Command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait('${hotkey}')"`);
|
||||
return { success: true, cmd, value };
|
||||
}
|
||||
|
||||
case 'OPEN': {
|
||||
await execAsync(`start "" "${value}"`, { shell: 'cmd.exe' });
|
||||
return { success: true, cmd, value };
|
||||
}
|
||||
|
||||
case 'WAIT': {
|
||||
await new Promise(r => setTimeout(r, parseInt(value) || 1000));
|
||||
return { success: true, cmd, value };
|
||||
}
|
||||
|
||||
case 'POWERSHELL': {
|
||||
const { stdout, stderr } = await execAsync(`powershell -ExecutionPolicy Bypass -Command "${value}"`, { timeout: 60000 });
|
||||
return { success: true, cmd, value, output: stdout || stderr };
|
||||
}
|
||||
|
||||
default:
|
||||
return { success: false, error: `Unknown command: ${cmd}` };
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, cmd, value, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a chain of tasks with callbacks
|
||||
*/
|
||||
export async function executeTaskChain(tasks, streamChat, onProgress, onComplete) {
|
||||
const results = [];
|
||||
|
||||
for (let i = 0; i < tasks.length; i++) {
|
||||
const task = tasks[i];
|
||||
onProgress({ taskIndex: i, status: 'translating', task: task.task });
|
||||
|
||||
// Translate natural language to commands
|
||||
const translation = await translateTaskToCommands(task.task, streamChat);
|
||||
|
||||
if (!translation.success) {
|
||||
results.push({ task: task.task, success: false, error: translation.error });
|
||||
onProgress({ taskIndex: i, status: 'error', error: translation.error });
|
||||
continue;
|
||||
}
|
||||
|
||||
onProgress({ taskIndex: i, status: 'executing', commands: translation.commands });
|
||||
|
||||
// Execute each command
|
||||
for (const command of translation.commands) {
|
||||
const result = await executeCommand(command);
|
||||
if (!result.success) {
|
||||
results.push({ task: task.task, success: false, error: result.error, command });
|
||||
onProgress({ taskIndex: i, status: 'error', error: result.error, command });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
results.push({ task: task.task, success: true, commands: translation.commands });
|
||||
onProgress({ taskIndex: i, status: 'done' });
|
||||
}
|
||||
|
||||
onComplete(results);
|
||||
return results;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// BROWSER AUTOMATION
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Open browser and navigate to URL
|
||||
*/
|
||||
export async function openBrowser(url) {
|
||||
try {
|
||||
await execAsync(`start "" "${url}"`, { shell: 'cmd.exe' });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze current browser state (requires screenshot + vision)
|
||||
*/
|
||||
export async function analyzeBrowserPage(screenshotDataUrl, streamChat) {
|
||||
return analyzeScreenshot(screenshotDataUrl, streamChat);
|
||||
}
|
||||
Reference in New Issue
Block a user