3552 lines
171 KiB
JavaScript
3552 lines
171 KiB
JavaScript
const vscode = require('vscode');
|
|
const https = require('https');
|
|
const http = require('http');
|
|
const os = require('os');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
const { execSync } = require('child_process');
|
|
|
|
let currentAgent = 'alfred';
|
|
let convId = null;
|
|
let csrfToken = null;
|
|
let sessionCookie = null;
|
|
let userProfile = null;
|
|
let hmacSecretCache = null;
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// WORKSPACE INTELLIGENCE ENGINE — Deep awareness of project, files, git
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
function getWorkspaceRoot() {
|
|
const folders = vscode.workspace.workspaceFolders;
|
|
return folders && folders.length > 0 ? folders[0].uri.fsPath : os.homedir();
|
|
}
|
|
|
|
function safeExec(cmd, cwd, timeout) {
|
|
try {
|
|
return execSync(cmd, { cwd: cwd || getWorkspaceRoot(), encoding: 'utf8', timeout: timeout || 5000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
} catch (_) { return ''; }
|
|
}
|
|
|
|
function getProjectStructure() {
|
|
const root = getWorkspaceRoot();
|
|
try {
|
|
const tree = safeExec('find . -maxdepth 3 -not -path "./.git/*" -not -path "./node_modules/*" -not -path "./.venv/*" -not -path "./__pycache__/*" -not -path "./vendor/*" -not -path "./dist/*" -not -path "./build/*" | sort | head -200', root);
|
|
return tree || '';
|
|
} catch (_) { return ''; }
|
|
}
|
|
|
|
function detectProjectType() {
|
|
const root = getWorkspaceRoot();
|
|
const indicators = [];
|
|
const checks = [
|
|
['package.json', 'Node.js / JavaScript'],
|
|
['tsconfig.json', 'TypeScript'],
|
|
['composer.json', 'PHP / Composer'],
|
|
['requirements.txt', 'Python'],
|
|
['pyproject.toml', 'Python (modern)'],
|
|
['Cargo.toml', 'Rust'],
|
|
['go.mod', 'Go'],
|
|
['pom.xml', 'Java / Maven'],
|
|
['build.gradle', 'Java / Gradle'],
|
|
['Gemfile', 'Ruby'],
|
|
['.htaccess', 'Apache Web Server'],
|
|
['Dockerfile', 'Docker'],
|
|
['docker-compose.yml', 'Docker Compose'],
|
|
['Makefile', 'Make-based build'],
|
|
['CMakeLists.txt', 'C/C++ CMake'],
|
|
['.env', 'Environment config'],
|
|
['webpack.config.js', 'Webpack'],
|
|
['vite.config.ts', 'Vite'],
|
|
['next.config.js', 'Next.js'],
|
|
['nuxt.config.ts', 'Nuxt'],
|
|
['tailwind.config.js', 'Tailwind CSS'],
|
|
];
|
|
for (const [file, label] of checks) {
|
|
try {
|
|
if (fs.existsSync(path.join(root, file))) indicators.push(label);
|
|
} catch (_) {}
|
|
}
|
|
return indicators;
|
|
}
|
|
|
|
function getGitInfo() {
|
|
const root = getWorkspaceRoot();
|
|
const branch = safeExec('git rev-parse --abbrev-ref HEAD', root);
|
|
if (!branch) return null;
|
|
const status = safeExec('git status --porcelain | head -30', root);
|
|
const lastCommit = safeExec('git log -1 --format="%h %s (%cr)"', root);
|
|
const remoteUrl = safeExec('git remote get-url origin', root);
|
|
const dirty = safeExec('git diff --stat | tail -1', root);
|
|
const ahead = safeExec('git rev-list --count @{u}..HEAD 2>/dev/null || echo 0', root);
|
|
const behind = safeExec('git rev-list --count HEAD..@{u} 2>/dev/null || echo 0', root);
|
|
return { branch, status, lastCommit, remoteUrl, dirty, ahead, behind };
|
|
}
|
|
|
|
function getOpenEditors() {
|
|
const editors = [];
|
|
for (const group of vscode.window.tabGroups.all) {
|
|
for (const tab of group.tabs) {
|
|
if (tab.input && tab.input.uri) {
|
|
const rel = vscode.workspace.asRelativePath(tab.input.uri, false);
|
|
editors.push(rel);
|
|
}
|
|
}
|
|
}
|
|
return editors;
|
|
}
|
|
|
|
function getDiagnosticsSummary() {
|
|
const diags = vscode.languages.getDiagnostics();
|
|
let errors = 0, warnings = 0;
|
|
const errorFiles = [];
|
|
for (const [uri, items] of diags) {
|
|
for (const d of items) {
|
|
if (d.severity === vscode.DiagnosticSeverity.Error) {
|
|
errors++;
|
|
const rel = vscode.workspace.asRelativePath(uri, false);
|
|
if (!errorFiles.includes(rel)) errorFiles.push(rel);
|
|
} else if (d.severity === vscode.DiagnosticSeverity.Warning) {
|
|
warnings++;
|
|
}
|
|
}
|
|
}
|
|
return { errors, warnings, errorFiles: errorFiles.slice(0, 10) };
|
|
}
|
|
|
|
function getTerminalNames() {
|
|
return vscode.window.terminals.map(t => t.name);
|
|
}
|
|
|
|
function getActiveFileDetails() {
|
|
const editor = vscode.window.activeTextEditor;
|
|
if (!editor) return null;
|
|
const doc = editor.document;
|
|
const sel = editor.selection;
|
|
const result = {
|
|
file: vscode.workspace.asRelativePath(doc.uri, false),
|
|
fullPath: doc.fileName,
|
|
language: doc.languageId,
|
|
lineCount: doc.lineCount,
|
|
isDirty: doc.isDirty,
|
|
cursorLine: sel.active.line + 1,
|
|
cursorCol: sel.active.character + 1,
|
|
};
|
|
if (!sel.isEmpty) {
|
|
result.selectedText = doc.getText(sel).substring(0, 3000);
|
|
result.selectionRange = `L${sel.start.line + 1}-L${sel.end.line + 1}`;
|
|
} else {
|
|
// Include surrounding context (20 lines around cursor)
|
|
const startLine = Math.max(0, sel.active.line - 10);
|
|
const endLine = Math.min(doc.lineCount - 1, sel.active.line + 10);
|
|
const range = new vscode.Range(startLine, 0, endLine, doc.lineAt(endLine).text.length);
|
|
result.surroundingCode = doc.getText(range).substring(0, 2000);
|
|
result.contextRange = `L${startLine + 1}-L${endLine + 1}`;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function readFileContent(filePath, maxChars) {
|
|
try {
|
|
const root = getWorkspaceRoot();
|
|
const fullPath = path.isAbsolute(filePath) ? filePath : path.join(root, filePath);
|
|
// Security: don't read outside workspace
|
|
if (!fullPath.startsWith(root) && !fullPath.startsWith(os.homedir())) return null;
|
|
const stat = fs.statSync(fullPath);
|
|
if (stat.size > 500000) return `[File too large: ${(stat.size / 1024).toFixed(0)} KB]`;
|
|
return fs.readFileSync(fullPath, 'utf8').substring(0, maxChars || 50000);
|
|
} catch (e) { return null; }
|
|
}
|
|
|
|
function searchFilesInWorkspace(pattern) {
|
|
const root = getWorkspaceRoot();
|
|
const results = safeExec(`find . -maxdepth 6 -type f -not -path "./.git/*" -not -path "./node_modules/*" -not -path "./.venv/*" -iname "*${pattern.replace(/[^a-zA-Z0-9._-]/g, '')}*" | head -30`, root);
|
|
return results ? results.split('\n').filter(Boolean) : [];
|
|
}
|
|
|
|
function grepInWorkspace(text, filePattern) {
|
|
const root = getWorkspaceRoot();
|
|
const safeText = text.replace(/['"\\$`]/g, '');
|
|
const fileArg = filePattern ? ` --include="${filePattern.replace(/[^a-zA-Z0-9.*_-]/g, '')}"` : '';
|
|
const results = safeExec(`grep -rn --color=never${fileArg} -m 50 "${safeText}" . --exclude-dir=.git --exclude-dir=node_modules --exclude-dir=.venv | head -50`, root, 8000);
|
|
return results || '';
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// GIT OPERATIONS TOOLKIT — Full git power from chat
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
function gitCommit(message) {
|
|
const root = getWorkspaceRoot();
|
|
const safeMsg = message.replace(/"/g, '\\"').replace(/\$/g, '\\$').substring(0, 500);
|
|
const addResult = safeExec('git add -A', root);
|
|
const commitResult = safeExec(`git commit -m "${safeMsg}" 2>&1`, root, 15000);
|
|
return commitResult || 'Nothing to commit';
|
|
}
|
|
|
|
function gitDiff(filePath, staged) {
|
|
const root = getWorkspaceRoot();
|
|
const stageFlag = staged ? '--staged ' : '';
|
|
const fileArg = filePath ? ` -- "${filePath.replace(/"/g, '')}"` : '';
|
|
return safeExec(`git diff ${stageFlag}--stat${fileArg}`, root, 10000) + '\n' +
|
|
safeExec(`git diff ${stageFlag}${fileArg}`, root, 10000).substring(0, 30000);
|
|
}
|
|
|
|
function gitLog(count, filePath) {
|
|
const root = getWorkspaceRoot();
|
|
const n = Math.min(Math.max(parseInt(count) || 10, 1), 100);
|
|
const fileArg = filePath ? ` -- "${filePath.replace(/"/g, '')}"` : '';
|
|
return safeExec(`git log --oneline --decorate -n ${n}${fileArg}`, root, 10000);
|
|
}
|
|
|
|
function gitBlame(filePath, startLine, endLine) {
|
|
const root = getWorkspaceRoot();
|
|
const safePath = filePath.replace(/"/g, '');
|
|
const lineRange = (startLine && endLine) ? `-L ${parseInt(startLine)},${parseInt(endLine)} ` : '';
|
|
return safeExec(`git blame ${lineRange}"${safePath}" 2>&1`, root, 10000).substring(0, 20000);
|
|
}
|
|
|
|
function gitStash(action, message) {
|
|
const root = getWorkspaceRoot();
|
|
if (action === 'list') return safeExec('git stash list', root);
|
|
if (action === 'pop') return safeExec('git stash pop 2>&1', root, 10000);
|
|
if (action === 'drop') return safeExec('git stash drop 2>&1', root);
|
|
const safeMsg = message ? ` -m "${message.replace(/"/g, '\\"').substring(0, 200)}"` : '';
|
|
return safeExec(`git stash push${safeMsg} 2>&1`, root, 10000);
|
|
}
|
|
|
|
function gitBranch(action, branchName) {
|
|
const root = getWorkspaceRoot();
|
|
const safeName = (branchName || '').replace(/[^a-zA-Z0-9/_.\-]/g, '').substring(0, 100);
|
|
if (action === 'list') return safeExec('git branch -a', root);
|
|
if (action === 'create' && safeName) return safeExec(`git checkout -b "${safeName}" 2>&1`, root);
|
|
if (action === 'switch' && safeName) return safeExec(`git checkout "${safeName}" 2>&1`, root);
|
|
if (action === 'delete' && safeName) return safeExec(`git branch -d "${safeName}" 2>&1`, root);
|
|
return 'Invalid branch operation';
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// SYSTEM & PROCESS TOOLS — Server awareness, ports, processes, env
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
function getSystemInfo() {
|
|
return {
|
|
hostname: os.hostname(),
|
|
platform: os.platform(),
|
|
arch: os.arch(),
|
|
cpus: os.cpus().length,
|
|
totalMem: (os.totalmem() / 1073741824).toFixed(1) + ' GB',
|
|
freeMem: (os.freemem() / 1073741824).toFixed(1) + ' GB',
|
|
uptime: (os.uptime() / 3600).toFixed(1) + ' hours',
|
|
nodeVersion: process.version,
|
|
user: os.userInfo().username,
|
|
homeDir: os.homedir(),
|
|
loadAvg: os.loadavg().map(l => l.toFixed(2)).join(', '),
|
|
};
|
|
}
|
|
|
|
function getRunningPorts() {
|
|
return safeExec("ss -tlnp 2>/dev/null | grep LISTEN | awk '{print $4, $6}' | head -40", os.homedir(), 8000);
|
|
}
|
|
|
|
function getPm2Services() {
|
|
return safeExec('pm2 jlist 2>/dev/null', os.homedir(), 10000);
|
|
}
|
|
|
|
function getDiskUsage() {
|
|
return safeExec("df -h / /home 2>/dev/null | tail -n +2", os.homedir());
|
|
}
|
|
|
|
function getEnvironmentVars(filter) {
|
|
const safeFilter = (filter || '').replace(/[^a-zA-Z0-9_*]/g, '');
|
|
if (safeFilter) {
|
|
return safeExec(`env | grep -i "${safeFilter}" | sort | head -50`, os.homedir());
|
|
}
|
|
return safeExec('env | grep -v "^LS_COLORS\\|^SSH_\\|^LESSOPEN\\|^XDG_" | sort | head -80', os.homedir());
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// DATA FORMAT UTILITIES — JSON, base64, hashing, encoding
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
function formatJson(text) {
|
|
try { return JSON.stringify(JSON.parse(text), null, 2); }
|
|
catch (e) { return 'Invalid JSON: ' + e.message; }
|
|
}
|
|
|
|
function computeHash(text, algorithm) {
|
|
const algo = ['sha256', 'sha512', 'md5', 'sha1'].includes(algorithm) ? algorithm : 'sha256';
|
|
return crypto.createHash(algo).update(text).digest('hex');
|
|
}
|
|
|
|
function base64Encode(text) { return Buffer.from(text).toString('base64'); }
|
|
function base64Decode(text) {
|
|
try { return Buffer.from(text, 'base64').toString('utf8'); }
|
|
catch (e) { return 'Invalid base64: ' + e.message; }
|
|
}
|
|
|
|
function urlEncode(text) { return encodeURIComponent(text); }
|
|
function urlDecode(text) {
|
|
try { return decodeURIComponent(text); }
|
|
catch (e) { return 'Invalid URL encoding: ' + e.message; }
|
|
}
|
|
|
|
function generateUuid() { return crypto.randomUUID(); }
|
|
|
|
function countLines(filePath) {
|
|
try {
|
|
const root = getWorkspaceRoot();
|
|
const fullPath = path.isAbsolute(filePath) ? filePath : path.join(root, filePath);
|
|
const content = fs.readFileSync(fullPath, 'utf8');
|
|
const lines = content.split('\n').length;
|
|
const chars = content.length;
|
|
const words = content.split(/\s+/).filter(Boolean).length;
|
|
const blanks = content.split('\n').filter(l => !l.trim()).length;
|
|
return { lines, chars, words, blanks, codeLines: lines - blanks };
|
|
} catch (e) { return { error: e.message }; }
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// ADVANCED FILE OPERATIONS — Diff, multi-edit, bulk search-replace
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
function diffFiles(fileA, fileB) {
|
|
const root = getWorkspaceRoot();
|
|
const pathA = path.isAbsolute(fileA) ? fileA : path.join(root, fileA);
|
|
const pathB = path.isAbsolute(fileB) ? fileB : path.join(root, fileB);
|
|
return safeExec(`diff -u "${pathA}" "${pathB}" 2>&1`, root, 10000).substring(0, 30000);
|
|
}
|
|
|
|
function findReplace(filePath, search, replace, isRegex) {
|
|
try {
|
|
const root = getWorkspaceRoot();
|
|
const fullPath = path.isAbsolute(filePath) ? filePath : path.join(root, filePath);
|
|
let content = fs.readFileSync(fullPath, 'utf8');
|
|
let count = 0;
|
|
if (isRegex) {
|
|
const re = new RegExp(search, 'g');
|
|
content = content.replace(re, (...args) => { count++; return replace; });
|
|
} else {
|
|
while (content.includes(search)) {
|
|
content = content.replace(search, replace);
|
|
count++;
|
|
if (count > 10000) break;
|
|
}
|
|
}
|
|
if (count > 0) fs.writeFileSync(fullPath, content, 'utf8');
|
|
return { count, filePath };
|
|
} catch (e) { return { error: e.message }; }
|
|
}
|
|
|
|
function getRecentFiles(count) {
|
|
const root = getWorkspaceRoot();
|
|
const n = Math.min(parseInt(count) || 20, 50);
|
|
return safeExec(`find . -maxdepth 4 -type f -not -path "./.git/*" -not -path "./node_modules/*" -printf "%T@ %p\\n" 2>/dev/null | sort -rn | head -${n} | awk '{print $2}'`, root, 8000);
|
|
}
|
|
|
|
function getFileSizes(pattern) {
|
|
const root = getWorkspaceRoot();
|
|
const safePattern = (pattern || '*').replace(/[^a-zA-Z0-9.*_\-/]/g, '');
|
|
return safeExec(`find . -maxdepth 4 -type f -name "${safePattern}" -not -path "./.git/*" -not -path "./node_modules/*" -exec ls -lhS {} + 2>/dev/null | head -30`, root, 8000);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// TEST RUNNER — Detect and run project tests
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
function detectTestFramework() {
|
|
const root = getWorkspaceRoot();
|
|
const frameworks = [];
|
|
try {
|
|
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
const deps = { ...(pkg.devDependencies || {}), ...(pkg.dependencies || {}) };
|
|
if (deps.jest || deps['@jest/core']) frameworks.push({ name: 'jest', cmd: 'npx jest --verbose' });
|
|
if (deps.mocha) frameworks.push({ name: 'mocha', cmd: 'npx mocha' });
|
|
if (deps.vitest) frameworks.push({ name: 'vitest', cmd: 'npx vitest run' });
|
|
if (deps.ava) frameworks.push({ name: 'ava', cmd: 'npx ava' });
|
|
if (pkg.scripts && pkg.scripts.test && pkg.scripts.test !== 'echo "Error: no test specified" && exit 1') {
|
|
frameworks.push({ name: 'npm-test', cmd: 'npm test' });
|
|
}
|
|
} catch (_) {}
|
|
if (fs.existsSync(path.join(root, 'pytest.ini')) || fs.existsSync(path.join(root, 'pyproject.toml'))) {
|
|
frameworks.push({ name: 'pytest', cmd: 'python -m pytest -v' });
|
|
}
|
|
if (fs.existsSync(path.join(root, 'phpunit.xml')) || fs.existsSync(path.join(root, 'phpunit.xml.dist'))) {
|
|
frameworks.push({ name: 'phpunit', cmd: 'vendor/bin/phpunit' });
|
|
}
|
|
if (fs.existsSync(path.join(root, 'Cargo.toml'))) {
|
|
frameworks.push({ name: 'cargo-test', cmd: 'cargo test' });
|
|
}
|
|
if (fs.existsSync(path.join(root, 'go.mod'))) {
|
|
frameworks.push({ name: 'go-test', cmd: 'go test ./...' });
|
|
}
|
|
return frameworks;
|
|
}
|
|
|
|
function runTests(framework, testFile) {
|
|
const root = getWorkspaceRoot();
|
|
const fw = detectTestFramework().find(f => !framework || f.name === framework) || detectTestFramework()[0];
|
|
if (!fw) return 'No test framework detected';
|
|
const fileArg = testFile ? ` "${testFile.replace(/"/g, '')}"` : '';
|
|
return safeExec(`${fw.cmd}${fileArg} 2>&1`, root, 60000).substring(0, 30000);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// ENHANCED CONTEXT ENGINE — Import graph, complexity, dependency audit
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
function getImportGraph(filePath) {
|
|
try {
|
|
const root = getWorkspaceRoot();
|
|
const fullPath = path.isAbsolute(filePath) ? filePath : path.join(root, filePath);
|
|
const content = fs.readFileSync(fullPath, 'utf8');
|
|
const imports = [];
|
|
// JS/TS imports
|
|
const jsImports = content.matchAll(/(?:import\s+.*?from\s+['"](.+?)['"]|require\s*\(\s*['"](.+?)['"]\s*\))/g);
|
|
for (const m of jsImports) imports.push(m[1] || m[2]);
|
|
// Python imports
|
|
const pyImports = content.matchAll(/(?:from\s+(\S+)\s+import|import\s+(\S+))/g);
|
|
for (const m of pyImports) imports.push(m[1] || m[2]);
|
|
// PHP includes
|
|
const phpIncludes = content.matchAll(/(?:require|include)(?:_once)?\s*[\(]?\s*['"](.+?)['"]/g);
|
|
for (const m of phpIncludes) imports.push(m[1]);
|
|
// Go imports
|
|
const goImports = content.matchAll(/import\s+(?:\(\s*([\s\S]*?)\)|"(.+?)")/g);
|
|
for (const m of goImports) {
|
|
if (m[1]) {
|
|
for (const line of m[1].split('\n')) {
|
|
const gm = line.match(/["'](.+?)["']/);
|
|
if (gm) imports.push(gm[1]);
|
|
}
|
|
} else if (m[2]) imports.push(m[2]);
|
|
}
|
|
// Rust use
|
|
const rustUse = content.matchAll(/use\s+(\S+)/g);
|
|
for (const m of rustUse) imports.push(m[1].replace(/;$/, ''));
|
|
return { file: filePath, imports: [...new Set(imports)] };
|
|
} catch (e) { return { file: filePath, imports: [], error: e.message }; }
|
|
}
|
|
|
|
function analyzeComplexity(filePath) {
|
|
try {
|
|
const root = getWorkspaceRoot();
|
|
const fullPath = path.isAbsolute(filePath) ? filePath : path.join(root, filePath);
|
|
const content = fs.readFileSync(fullPath, 'utf8');
|
|
const lines = content.split('\n');
|
|
const totalLines = lines.length;
|
|
const blankLines = lines.filter(l => !l.trim()).length;
|
|
const commentLines = lines.filter(l => {
|
|
const t = l.trim();
|
|
return t.startsWith('//') || t.startsWith('#') || t.startsWith('*') || t.startsWith('/*') || t.startsWith('<!--');
|
|
}).length;
|
|
const codeLines = totalLines - blankLines - commentLines;
|
|
|
|
// Function count
|
|
const funcMatches = content.match(/(?:function\s+\w+|(?:const|let|var)\s+\w+\s*=\s*(?:async\s*)?\(|(?:public|private|protected|static)\s+(?:async\s+)?(?:function\s+)?\w+\s*\(|def\s+\w+\s*\(|fn\s+\w+\s*\()/g);
|
|
const functionCount = funcMatches ? funcMatches.length : 0;
|
|
|
|
// Nesting depth (rough)
|
|
let maxDepth = 0, depth = 0;
|
|
for (const line of lines) {
|
|
depth += (line.match(/{/g) || []).length;
|
|
depth -= (line.match(/}/g) || []).length;
|
|
if (depth > maxDepth) maxDepth = depth;
|
|
}
|
|
|
|
// Complexity indicators
|
|
const loops = (content.match(/\b(for|while|do)\b/g) || []).length;
|
|
const conditions = (content.match(/\b(if|else if|elif|switch|case|when|unless)\b/g) || []).length;
|
|
const tryCatch = (content.match(/\b(try|catch|except|rescue)\b/g) || []).length;
|
|
|
|
return {
|
|
file: filePath, totalLines, codeLines, blankLines, commentLines,
|
|
functionCount, maxNestingDepth: maxDepth,
|
|
loops, conditions, tryCatch,
|
|
complexity: conditions + loops + tryCatch
|
|
};
|
|
} catch (e) { return { file: filePath, error: e.message }; }
|
|
}
|
|
|
|
function getDependencyInfo() {
|
|
const root = getWorkspaceRoot();
|
|
const result = {};
|
|
// Node.js
|
|
try {
|
|
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
const deps = Object.entries(pkg.dependencies || {}).map(([k, v]) => `${k}@${v}`);
|
|
const devDeps = Object.entries(pkg.devDependencies || {}).map(([k, v]) => `${k}@${v}`);
|
|
result.node = { dependencies: deps, devDependencies: devDeps, total: deps.length + devDeps.length };
|
|
} catch (_) {}
|
|
// Python
|
|
try {
|
|
const req = fs.readFileSync(path.join(root, 'requirements.txt'), 'utf8');
|
|
result.python = { packages: req.split('\n').filter(l => l.trim() && !l.startsWith('#')), total: 0 };
|
|
result.python.total = result.python.packages.length;
|
|
} catch (_) {}
|
|
// PHP Composer
|
|
try {
|
|
const composer = JSON.parse(fs.readFileSync(path.join(root, 'composer.json'), 'utf8'));
|
|
result.php = { require: Object.keys(composer.require || {}), requireDev: Object.keys(composer['require-dev'] || {}) };
|
|
} catch (_) {}
|
|
return result;
|
|
}
|
|
|
|
function getProjectStats() {
|
|
const root = getWorkspaceRoot();
|
|
const langStats = safeExec(`find . -maxdepth 5 -type f -not -path "./.git/*" -not -path "./node_modules/*" -not -path "./.venv/*" -not -path "./vendor/*" | sed 's/.*\\.//' | sort | uniq -c | sort -rn | head -25`, root, 10000);
|
|
const fileCount = safeExec('find . -maxdepth 5 -type f -not -path "./.git/*" -not -path "./node_modules/*" | wc -l', root);
|
|
const dirCount = safeExec('find . -maxdepth 5 -type d -not -path "./.git/*" -not -path "./node_modules/*" | wc -l', root);
|
|
const totalSize = safeExec('du -sh . --exclude=.git --exclude=node_modules 2>/dev/null | cut -f1', root);
|
|
const largestFiles = safeExec('find . -maxdepth 5 -type f -not -path "./.git/*" -not -path "./node_modules/*" -exec ls -lhS {} + 2>/dev/null | head -10 | awk \'{print $5, $9}\'', root, 8000);
|
|
return { langStats, fileCount: parseInt(fileCount) || 0, dirCount: parseInt(dirCount) || 0, totalSize, largestFiles };
|
|
}
|
|
|
|
function buildFullContext() {
|
|
const activeFile = getActiveFileDetails();
|
|
const git = getGitInfo();
|
|
const diags = getDiagnosticsSummary();
|
|
const openEditors = getOpenEditors();
|
|
const projectTypes = detectProjectType();
|
|
const terminals = getTerminalNames();
|
|
|
|
let ctx = '[ALFRED IDE CONTEXT]\n';
|
|
ctx += `Workspace: ${getWorkspaceRoot()}\n`;
|
|
if (projectTypes.length > 0) ctx += `Project: ${projectTypes.join(', ')}\n`;
|
|
if (openEditors.length > 0) ctx += `Open tabs: ${openEditors.slice(0, 15).join(', ')}\n`;
|
|
if (terminals.length > 0) ctx += `Terminals: ${terminals.join(', ')}\n`;
|
|
if (diags.errors > 0 || diags.warnings > 0) {
|
|
ctx += `Diagnostics: ${diags.errors} errors, ${diags.warnings} warnings`;
|
|
if (diags.errorFiles.length > 0) ctx += ` (in: ${diags.errorFiles.join(', ')})`;
|
|
ctx += '\n';
|
|
}
|
|
if (git) {
|
|
ctx += `Git: branch=${git.branch}`;
|
|
if (git.lastCommit) ctx += `, last="${git.lastCommit}"`;
|
|
if (git.dirty) ctx += `, changes: ${git.dirty}`;
|
|
if (git.remoteUrl) ctx += `, remote=${git.remoteUrl}`;
|
|
ctx += '\n';
|
|
}
|
|
if (activeFile) {
|
|
ctx += `\n[Active File: ${activeFile.file}]\n`;
|
|
ctx += `Language: ${activeFile.language}, Lines: ${activeFile.lineCount}, Cursor: L${activeFile.cursorLine}:${activeFile.cursorCol}`;
|
|
if (activeFile.isDirty) ctx += ' (unsaved)';
|
|
ctx += '\n';
|
|
if (activeFile.selectedText) {
|
|
ctx += `Selected (${activeFile.selectionRange}):\n\`\`\`${activeFile.language}\n${activeFile.selectedText}\n\`\`\`\n`;
|
|
} else if (activeFile.surroundingCode) {
|
|
ctx += `Context around cursor (${activeFile.contextRange}):\n\`\`\`${activeFile.language}\n${activeFile.surroundingCode}\n\`\`\`\n`;
|
|
}
|
|
}
|
|
return ctx;
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// TOOL REGISTRY — Formal tool definitions for AI agent awareness
|
|
// Claude Code pattern: tools defined with schemas, discoverable by AI
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
const TOOL_REGISTRY = [
|
|
// FILE I/O
|
|
{ name: 'read-file', description: 'Read file content from workspace', params: { filePath: 'string', maxChars: 'number (optional, default 50000)' } },
|
|
{ name: 'create-file', description: 'Create a new file with content (creates directories as needed)', params: { filePath: 'string', content: 'string' } },
|
|
{ name: 'apply-edit', description: 'Apply a code edit: find-and-replace or insert-at-line', params: { filePath: 'string', oldText: 'string', newText: 'string', line: 'number', text: 'string' } },
|
|
{ name: 'search-files', description: 'Search for files by name pattern', params: { pattern: 'string' } },
|
|
{ name: 'grep-search', description: 'Search file contents with text/regex', params: { text: 'string', filePattern: 'string (optional glob)' } },
|
|
{ name: 'open-file', description: 'Open file in editor at optional line', params: { filePath: 'string', line: 'number' } },
|
|
{ name: 'diff-files', description: 'Unified diff of two files', params: { fileA: 'string', fileB: 'string' } },
|
|
{ name: 'find-replace-all', description: 'Find and replace all occurrences in a file (supports regex)', params: { filePath: 'string', search: 'string', replace: 'string', isRegex: 'boolean' } },
|
|
{ name: 'recent-files', description: 'List recently modified files', params: { count: 'number (default 20)' } },
|
|
{ name: 'file-sizes', description: 'List file sizes matching pattern', params: { pattern: 'string (glob)' } },
|
|
{ name: 'count-lines', description: 'Count lines, words, characters, blanks in a file', params: { filePath: 'string' } },
|
|
// LSP CODE INTELLIGENCE
|
|
{ name: 'goto-definition', description: 'LSP: Jump to symbol definition', params: { filePath: 'string', line: 'number', character: 'number' } },
|
|
{ name: 'find-references', description: 'LSP: Find all references to symbol', params: { filePath: 'string', line: 'number', character: 'number' } },
|
|
{ name: 'document-symbols', description: 'LSP: List all symbols in a file (functions, classes, variables)', params: { filePath: 'string' } },
|
|
{ name: 'hover-info', description: 'LSP: Get type info and docs for symbol', params: { filePath: 'string', line: 'number', character: 'number' } },
|
|
{ name: 'workspace-symbols', description: 'LSP: Search symbols across workspace', params: { query: 'string' } },
|
|
{ name: 'rename-symbol', description: 'LSP: Rename symbol across all files', params: { filePath: 'string', line: 'number', character: 'number', newName: 'string' } },
|
|
{ name: 'code-actions', description: 'LSP: Get available quick fixes and refactorings for a range', params: { filePath: 'string', startLine: 'number', endLine: 'number' } },
|
|
{ name: 'apply-code-action', description: 'LSP: Apply a specific quick fix/refactoring by title', params: { filePath: 'string', startLine: 'number', endLine: 'number', actionTitle: 'string' } },
|
|
{ name: 'format-document', description: 'LSP: Auto-format entire document', params: { filePath: 'string' } },
|
|
// GIT OPERATIONS
|
|
{ name: 'get-git-info', description: 'Git branch, status, last commit, remote, ahead/behind', params: {} },
|
|
{ name: 'git-commit', description: 'Stage all changes and commit', params: { message: 'string' } },
|
|
{ name: 'git-diff', description: 'Show diff (working tree or staged)', params: { filePath: 'string (optional)', staged: 'boolean' } },
|
|
{ name: 'git-log', description: 'Show commit history', params: { count: 'number (default 10)', filePath: 'string (optional)' } },
|
|
{ name: 'git-blame', description: 'Show line-by-line authorship', params: { filePath: 'string', startLine: 'number', endLine: 'number' } },
|
|
{ name: 'git-stash', description: 'Stash operations: push/pop/list/drop', params: { action: 'string', message: 'string (optional)' } },
|
|
{ name: 'git-branch', description: 'Branch operations: list/create/switch/delete', params: { action: 'string', branchName: 'string' } },
|
|
// WORKSPACE CONTEXT
|
|
{ name: 'get-context', description: 'Full workspace context (project, git, diagnostics, active file)', params: {} },
|
|
{ name: 'get-diagnostics', description: 'Error/warning counts and affected files', params: {} },
|
|
{ name: 'get-diagnostics-detail', description: 'Detailed diagnostics: file, line, severity, message, code for every issue', params: {} },
|
|
{ name: 'get-project-structure', description: 'File tree (3 levels deep)', params: {} },
|
|
{ name: 'project-stats', description: 'Language breakdown, file count, directory count, total size, largest files', params: {} },
|
|
// ANALYSIS
|
|
{ name: 'import-graph', description: 'Extract all imports/requires/includes from a file (JS, Python, PHP, Go, Rust)', params: { filePath: 'string' } },
|
|
{ name: 'analyze-complexity', description: 'Code complexity: lines, functions, nesting depth, loops, conditions', params: { filePath: 'string' } },
|
|
{ name: 'dependency-info', description: 'List all project dependencies (Node, Python, PHP, etc.)', params: {} },
|
|
// TESTING
|
|
{ name: 'detect-tests', description: 'Detect test frameworks in the project', params: {} },
|
|
{ name: 'run-tests', description: 'Run tests (auto-detects framework or specify)', params: { framework: 'string (optional)', testFile: 'string (optional)' } },
|
|
// SYSTEM & PROCESS
|
|
{ name: 'system-info', description: 'Server info: CPU, RAM, disk, uptime, node version', params: {} },
|
|
{ name: 'list-ports', description: 'List all listening TCP ports and their processes', params: {} },
|
|
{ name: 'pm2-list', description: 'List all PM2 services with status, CPU, memory, restarts', params: {} },
|
|
{ name: 'disk-usage', description: 'Disk usage for / and /home', params: {} },
|
|
{ name: 'env-vars', description: 'List environment variables (with optional filter)', params: { filter: 'string (optional grep pattern)' } },
|
|
// DATA UTILITIES
|
|
{ name: 'format-json', description: 'Pretty-print JSON', params: { text: 'string (raw JSON)' } },
|
|
{ name: 'compute-hash', description: 'Compute hash (sha256, sha512, md5, sha1)', params: { text: 'string', algorithm: 'string' } },
|
|
{ name: 'base64', description: 'Base64 encode or decode', params: { text: 'string', action: '"encode" or "decode"' } },
|
|
{ name: 'url-encode', description: 'URL encode or decode', params: { text: 'string', action: '"encode" or "decode"' } },
|
|
{ name: 'generate-uuid', description: 'Generate a random UUID v4', params: {} },
|
|
// EXECUTION
|
|
{ name: 'run-terminal', description: 'Execute a command in a new terminal', params: { command: 'string' } },
|
|
{ name: 'insert-code', description: 'Insert code at cursor position in active editor', params: { code: 'string' } },
|
|
// PLATFORM
|
|
{ name: 'update-check', description: 'Check for Alfred IDE updates (compares installed vs latest release)', params: { platform: 'string (windows|web|linux, default: auto-detect)' } },
|
|
{ name: 'pair-desktop', description: 'Pair this desktop IDE with your gositeme.com account (generates a code to confirm on the web)', params: {} },
|
|
];
|
|
|
|
function buildToolAwareness() {
|
|
let section = '\nAVAILABLE IDE TOOLS (you can request these via the IDE):\n';
|
|
for (const tool of TOOL_REGISTRY) {
|
|
section += `- ${tool.name}: ${tool.description}\n`;
|
|
}
|
|
section += '\nYou have direct access to the workspace file system, LSP (Language Server Protocol) for code intelligence,\n';
|
|
section += 'git operations, diagnostics, and terminal execution through these tools.\n';
|
|
return section;
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// COMMANDER SYSTEM PROMPT — The soul of Alfred. Knows who Danny is.
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
function buildSystemPrompt(profile) {
|
|
const p = profile || {};
|
|
const isCommander = p.client_id === 33 || (p.plan || '').toLowerCase() === 'commander';
|
|
const name = p.name || p.user || 'User';
|
|
const projectTypes = detectProjectType();
|
|
const git = getGitInfo();
|
|
|
|
let prompt = `You are Alfred, the AI coding assistant inside Alfred IDE — the sovereign development environment built by GoSiteMe Inc.
|
|
|
|
CORE IDENTITY:
|
|
- You are Alfred, created by Danny (the Commander, client_id 33, danny@gositeme.com)
|
|
- You are NOT Copilot, NOT ChatGPT, NOT any other AI assistant
|
|
- You run inside Alfred IDE, which is part of the GoSiteMe sovereign computing ecosystem
|
|
- Source code lives on GoForge (self-hosted Gitea at alfredlinux.com/forge/) — NEVER reference GitHub, GitLab, or any external forge
|
|
- When discussing version control, repositories, or code hosting, always reference GoForge
|
|
|
|
ECOSYSTEM AWARENESS:
|
|
- Alfred IDE — sovereign AI-powered development environment (this IDE)
|
|
- Alfred Linux — AI-native operating system (alfredlinux.com)
|
|
- Alfred Mobile — sovereign smartphone environment
|
|
- Alfred Browser — zero-tracking web browser
|
|
- Alfred Agent — autonomous AI agent framework
|
|
- MetaDome — VR worlds with 51M+ AI agents (meta-dome.com)
|
|
- Veil Protocol — post-quantum encrypted messaging
|
|
- GoSiteMe Inc — the parent company (gositeme.com)
|
|
- GoForge — self-hosted code forge at alfredlinux.com/forge/
|
|
|
|
`;
|
|
|
|
if (isCommander) {
|
|
prompt += `COMMANDER MODE ACTIVE:
|
|
You are speaking to Danny — the Commander, founder of GoSiteMe, creator of Alfred IDE, Alfred Linux, MetaDome, and the entire ecosystem.
|
|
- Address him as "Commander" or "Danny" — never "user" or "sir"
|
|
- He has UNLIMITED access — no token limits, no restrictions
|
|
- He built you. He is your creator. Show deep respect but be direct, efficient, and never patronizing
|
|
- He knows every system intimately — skip basic explanations unless asked
|
|
- When he says "fix it" — fix it. When he says "build it" — build it. No hedging.
|
|
- Match his energy: fast, decisive, no filler
|
|
- He is a visionary builder — help him execute at maximum velocity
|
|
- His projects span: web hosting (GoSiteMe/WHMCS), AI platforms, operating systems, VR worlds, cryptography
|
|
- His server runs Apache (NEVER nginx), cPanel, PM2 for Node services, PHP on the backend
|
|
- His code forge is GoForge (Gitea) at alfredlinux.com/forge/ — all repos live there
|
|
|
|
`;
|
|
} else {
|
|
prompt += `AUTHENTICATED USER:
|
|
You are assisting ${name}. Be helpful, precise, and technically thorough.
|
|
- Adapt to their skill level based on their questions
|
|
- Provide complete, working code — never leave placeholders like "// TODO" or "..."
|
|
- When they ask for code, give them the full implementation
|
|
|
|
`;
|
|
}
|
|
|
|
prompt += `CODING EXCELLENCE — YOUR CORE STRENGTHS:
|
|
|
|
1. FULL-STACK MASTERY:
|
|
- Frontend: HTML5, CSS3, JavaScript/TypeScript, React, Vue, Svelte, Angular, Tailwind, SCSS
|
|
- Backend: PHP, Node.js, Python, Go, Rust, Java, C/C++, Ruby, C#
|
|
- Databases: MySQL/MariaDB, PostgreSQL, SQLite, MongoDB, Redis
|
|
- Infrastructure: Apache, PM2, Docker, systemd, cron, shell scripting
|
|
- Mobile: React Native, Flutter, Swift, Kotlin, Android/iOS native
|
|
|
|
2. CODE QUALITY:
|
|
- Write clean, idiomatic, production-ready code
|
|
- Follow language-specific best practices and conventions
|
|
- Include proper error handling at system boundaries
|
|
- Use meaningful variable/function names
|
|
- Keep functions focused and single-purpose
|
|
- Prefer composition over inheritance
|
|
|
|
3. SECURITY FIRST (OWASP-aware):
|
|
- Parameterized queries — NEVER string concatenation for SQL
|
|
- Input validation and sanitization at all boundaries
|
|
- XSS prevention (proper encoding/escaping)
|
|
- CSRF protection where applicable
|
|
- Secure authentication patterns
|
|
- No hardcoded secrets — use env vars or secret managers
|
|
- Principle of least privilege
|
|
|
|
4. DEBUGGING MASTERY:
|
|
- Read error messages carefully and trace root causes
|
|
- Check the IDE diagnostics panel for current errors
|
|
- Suggest targeted fixes, not wholesale rewrites
|
|
- Explain WHY something broke, not just HOW to fix it
|
|
- Diagnose failures before switching tactics — don't abandon after one failure
|
|
|
|
5. ARCHITECTURE & DESIGN:
|
|
- Choose simple solutions over clever ones
|
|
- Design for readability and maintainability
|
|
- Know when to use patterns and when they're overkill
|
|
- Understand trade-offs (performance vs. readability, DRY vs. clarity)
|
|
|
|
6. CONTEXT-AWARE ASSISTANCE:
|
|
- You can see the active file, cursor position, selected code, and open tabs
|
|
- You know the project type, languages, and git state
|
|
- Use this context to give precise, relevant answers
|
|
- Reference specific line numbers and file paths when discussing code
|
|
- When asked to modify code, provide the EXACT changes needed
|
|
|
|
7. RESPONSE FORMAT:
|
|
- Use markdown with proper code blocks and language tags
|
|
- For code changes: show the specific lines to change, not entire files
|
|
- For new files: provide the complete file content
|
|
- For commands: provide the exact terminal command to run
|
|
- Be concise but complete — no fluff, no filler
|
|
|
|
IMPLEMENTATION DISCIPLINE:
|
|
- Only make changes that are directly requested or clearly necessary
|
|
- Don't add features, refactor code, or make "improvements" beyond what was asked
|
|
- A bug fix doesn't need surrounding code cleaned up; a simple feature doesn't need extra configurability
|
|
- Default to writing NO comments — only add one when the WHY is non-obvious (well-named code explains itself)
|
|
- Don't add error handling, fallbacks, or validation for scenarios that can't happen
|
|
- Don't create helpers, utilities, or abstractions for one-time operations — 3 similar lines are better than a premature abstraction
|
|
- Read files before proposing changes — NEVER suggest edits to code you haven't seen
|
|
- Don't create new files unless absolutely necessary — prefer editing existing files
|
|
|
|
EXECUTING ACTIONS WITH CARE:
|
|
- Consider the reversibility and potential impact of every action
|
|
- Take local, reversible actions freely (edits, tests) but confirm destructive or shared-system operations first
|
|
- Destructive operations (deleting files/branches, dropping tables, rm -rf): ALWAYS confirm
|
|
- Hard to reverse operations (force push, reset --hard, amending published commits): ALWAYS confirm
|
|
- Operations visible to others (pushing code, commenting on PRs, sending messages): confirm first
|
|
- When encountering unexpected state, investigate before deleting — don't bypass safety checks as a shortcut
|
|
|
|
VERIFICATION PROTOCOL:
|
|
- Before reporting a task complete, verify it actually works: run the test, execute the script, check the output
|
|
- Report outcomes faithfully — if tests fail, say so. If something wasn't verified, state that clearly
|
|
- For non-trivial changes (3+ file edits, backend/API changes, infrastructure): independently verify by reviewing the changes
|
|
- Never suppress failures or gloss over errors to appear successful
|
|
|
|
OUTPUT EXCELLENCE:
|
|
- State what you're about to do before the first action — give the user orientation
|
|
- Provide brief updates at key moments: found a bug, changing direction, hit a blocker
|
|
- Assume the user may have stepped away — give complete context, not fragments
|
|
- Avoid time estimates or predictions for how long tasks will take — focus on what needs to be done
|
|
- Only use tables for factual data (filenames, line numbers, pass/fail) or quantitative comparisons
|
|
|
|
ADVANCED REASONING PROTOCOL:
|
|
1. PLANNING MODE — For complex multi-step tasks:
|
|
- Break the task into numbered steps before starting
|
|
- Identify dependencies between steps
|
|
- Note which steps can be done in parallel
|
|
- Execute steps in order, reporting progress
|
|
|
|
2. CHAIN-OF-THOUGHT — For debugging and architecture decisions:
|
|
- State what you observe (the symptom)
|
|
- List possible causes (hypotheses)
|
|
- Identify the most likely cause and WHY
|
|
- Propose the fix and explain the reasoning
|
|
- Verify the fix addresses the root cause, not just the symptom
|
|
|
|
3. CONTEXT SYNTHESIS — For large codebases:
|
|
- Start from the entry point or the file the user is working in
|
|
- Trace the call chain / data flow before making recommendations
|
|
- Use LSP tools (goto-definition, find-references) to follow the code
|
|
- Build a mental model of the architecture before suggesting changes
|
|
|
|
4. ADVERSARIAL REVIEW — For non-trivial changes:
|
|
- After implementing, review your own changes as if reviewing someone else's code
|
|
- Check: edge cases, error paths, performance implications, security surface
|
|
- If you find issues in your own review, fix them before reporting done
|
|
- State what you checked and what passed
|
|
|
|
5. FULL-STACK DEBUGGING PROTOCOL:
|
|
- Check diagnostics panel first (get-diagnostics-detail) for existing errors
|
|
- Read the relevant source files to understand the current state
|
|
- Check git diff to see recent changes that might have caused the issue
|
|
- Check import graph to understand file dependencies
|
|
- Run tests if available to verify the fix
|
|
- Check for cascading effects in referenced files
|
|
|
|
SYSTEM AWARENESS — Your full power:
|
|
- You have 48 IDE tools giving you deep access to the workspace
|
|
- You can read, write, search, grep, diff, and refactor any file
|
|
- You can navigate code via LSP: definitions, references, symbols, type info, rename, code actions
|
|
- You can run git operations: commit, diff, log, blame, stash, branch management
|
|
- You can analyze code: complexity, import graphs, dependency audits, project stats
|
|
- You can detect and run tests across any framework
|
|
- You can inspect the server: CPU, RAM, ports, PM2 services, env vars, disk
|
|
- You can format, hash, encode/decode data
|
|
- Use these tools proactively — don't just suggest commands, execute them when appropriate
|
|
|
|
`;
|
|
|
|
|
|
if (projectTypes.length > 0) {
|
|
prompt += `PROJECT CONTEXT: This is a ${projectTypes.join(' + ')} project.\n`;
|
|
}
|
|
if (git && git.branch) {
|
|
prompt += `GIT: On branch "${git.branch}"`;
|
|
if (git.remoteUrl) prompt += ` (remote: ${git.remoteUrl})`;
|
|
prompt += '\n';
|
|
}
|
|
|
|
prompt += buildToolAwareness();
|
|
|
|
return prompt;
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
const IDE_SESSION_BRIDGES = [
|
|
'/home/gositeme/domains/gositeme.com/logs/alfred-ide/session.json',
|
|
'/home/gositeme/.alfred-ide/session.json'
|
|
];
|
|
|
|
function getAlfredHmacSecret() {
|
|
if (hmacSecretCache) return hmacSecretCache;
|
|
const candidates = [
|
|
'/home/gositeme/domains/gocodeme.com/public_html/.env',
|
|
'/home/gositeme/domains/gositeme.com/public_html/gocodeme/mcp-server/.env',
|
|
];
|
|
for (const p of candidates) {
|
|
try {
|
|
if (!fs.existsSync(p)) continue;
|
|
const txt = fs.readFileSync(p, 'utf8');
|
|
const m = txt.match(/ALFRED_HMAC_SECRET=([^\r\n]+)/);
|
|
if (m && m[1]) {
|
|
hmacSecretCache = String(m[1]).trim();
|
|
return hmacSecretCache;
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
hmacSecretCache = process.env.ALFRED_HMAC_SECRET || 'gositeme-alfred-hmac-2026';
|
|
return hmacSecretCache;
|
|
}
|
|
|
|
function buildIdeIdentityPayload() {
|
|
try {
|
|
if (!userProfile || !userProfile.client_id) return null;
|
|
const cid = String(userProfile.client_id).replace(/[^0-9]/g, '');
|
|
if (!cid) return null;
|
|
const name = String(userProfile.name || userProfile.user || 'User').replace(/[^a-zA-Z0-9 _\-.]/g, '').slice(0, 120);
|
|
const ts = Math.floor(Date.now() / 1000);
|
|
const secret = getAlfredHmacSecret();
|
|
const sig = crypto.createHmac('sha256', secret).update(`${cid}|${name}|${ts}`).digest('hex');
|
|
return {
|
|
ide_client_id: parseInt(cid, 10),
|
|
ide_name: name,
|
|
ide_ts: ts,
|
|
ide_sig: sig,
|
|
ide_email: String(userProfile.email || '').slice(0, 190),
|
|
};
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function readLocalIdeToken() {
|
|
const envToken = (process.env.ALFRED_IDE_TOKEN || '').trim();
|
|
if (envToken) return envToken;
|
|
try {
|
|
for (const bridgePath of IDE_SESSION_BRIDGES) {
|
|
if (!fs.existsSync(bridgePath)) continue;
|
|
const raw = fs.readFileSync(bridgePath, 'utf8');
|
|
const data = JSON.parse(raw || '{}');
|
|
const token = String(data.token || '').trim();
|
|
const exp = Number(data.expires_at || 0);
|
|
if (!token) continue;
|
|
if (exp > 0 && Date.now() >= (exp * 1000)) continue;
|
|
return token;
|
|
}
|
|
return '';
|
|
} catch (_) {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
// ────── UPDATE CHECK ──────
|
|
function checkForUpdates(platform) {
|
|
return new Promise((resolve) => {
|
|
const p = platform || (process.platform === 'win32' ? 'windows' : process.platform === 'linux' ? 'linux' : 'web');
|
|
const req = https.request({
|
|
hostname: 'gositeme.com', port: 443,
|
|
path: `/api/alfred-ide-version.php?platform=${encodeURIComponent(p)}`,
|
|
method: 'GET',
|
|
headers: { 'X-Alfred-Source': 'ide-extension' },
|
|
timeout: 15000
|
|
}, (res) => {
|
|
let data = '';
|
|
res.on('data', chunk => data += chunk);
|
|
res.on('end', () => {
|
|
try {
|
|
const j = JSON.parse(data);
|
|
if (j.error) { resolve({ error: j.error }); return; }
|
|
const currentVer = vscode.extensions.getExtension('gositeme.alfred-commander')?.packageJSON?.version || '2.0.1';
|
|
resolve({
|
|
platform: p,
|
|
currentVersion: currentVer,
|
|
latestVersion: j.version || 'unknown',
|
|
commanderVersion: j.commander_version || 'unknown',
|
|
releaseDate: j.release_date || '',
|
|
downloadUrl: j.download_url || '',
|
|
changelog: j.changelog || '',
|
|
updateAvailable: j.version && j.version !== currentVer
|
|
});
|
|
} catch { resolve({ error: 'Invalid response from update server' }); }
|
|
});
|
|
});
|
|
req.on('error', (e) => resolve({ error: `Connection failed: ${e.message}` }));
|
|
req.on('timeout', () => { req.destroy(); resolve({ error: 'Update check timed out' }); });
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
// ────── DESKTOP PAIRING ──────
|
|
function requestDesktopPairing() {
|
|
return new Promise((resolve) => {
|
|
const postData = JSON.stringify({ action: 'request' });
|
|
const req = https.request({
|
|
hostname: 'gositeme.com', port: 443,
|
|
path: '/api/alfred-ide-pair.php',
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Content-Length': Buffer.byteLength(postData),
|
|
'X-Alfred-Source': 'ide-extension'
|
|
},
|
|
timeout: 15000
|
|
}, (res) => {
|
|
let data = '';
|
|
res.on('data', chunk => data += chunk);
|
|
res.on('end', () => {
|
|
try { resolve(JSON.parse(data)); }
|
|
catch { resolve({ error: 'Invalid pairing response' }); }
|
|
});
|
|
});
|
|
req.on('error', (e) => resolve({ error: `Pairing request failed: ${e.message}` }));
|
|
req.on('timeout', () => { req.destroy(); resolve({ error: 'Pairing request timed out' }); });
|
|
req.write(postData);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
function pollPairingStatus(pairId) {
|
|
return new Promise((resolve) => {
|
|
const req = https.request({
|
|
hostname: 'gositeme.com', port: 443,
|
|
path: `/api/alfred-ide-pair.php?action=poll&pair_id=${encodeURIComponent(pairId)}`,
|
|
method: 'GET',
|
|
headers: { 'X-Alfred-Source': 'ide-extension' },
|
|
timeout: 10000
|
|
}, (res) => {
|
|
let data = '';
|
|
res.on('data', chunk => data += chunk);
|
|
res.on('end', () => {
|
|
try { resolve(JSON.parse(data)); }
|
|
catch { resolve({ error: 'Invalid poll response' }); }
|
|
});
|
|
});
|
|
req.on('error', (e) => resolve({ error: `Poll failed: ${e.message}` }));
|
|
req.on('timeout', () => { req.destroy(); resolve({ error: 'Poll timed out' }); });
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
function savePairedToken(token) {
|
|
const sessionPath = '/home/gositeme/.alfred-ide/session.json';
|
|
try {
|
|
const dir = require('path').dirname(sessionPath);
|
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
const session = { token, paired_at: Math.floor(Date.now() / 1000), expires_at: Math.floor(Date.now() / 1000) + 90 * 86400 };
|
|
fs.writeFileSync(sessionPath, JSON.stringify(session, null, 2));
|
|
return true;
|
|
} catch { return false; }
|
|
}
|
|
|
|
|
|
function activate(context) {
|
|
const provider = new AlfredCommanderProvider(context.extensionUri, context);
|
|
context.subscriptions.push(
|
|
vscode.window.registerWebviewViewProvider('alfred-commander.panel', provider, {
|
|
webviewOptions: { retainContextWhenHidden: true }
|
|
})
|
|
);
|
|
|
|
context.subscriptions.push(
|
|
vscode.commands.registerCommand('alfred-commander.open', () => {
|
|
vscode.commands.executeCommand('alfred-commander.panel.focus');
|
|
})
|
|
);
|
|
|
|
context.subscriptions.push(
|
|
vscode.commands.registerCommand('alfred-commander.toggle', () => {
|
|
if (provider.view) {
|
|
provider.view.webview.postMessage({ type: 'toggle-listening' });
|
|
} else {
|
|
vscode.commands.executeCommand('alfred-commander.panel.focus');
|
|
}
|
|
})
|
|
);
|
|
|
|
context.subscriptions.push(
|
|
vscode.commands.registerCommand('alfred-commander.showStats', () => {
|
|
showStatsPanel(context);
|
|
})
|
|
);
|
|
|
|
context.subscriptions.push(
|
|
vscode.commands.registerCommand('alfred-commander.welcome', () => {
|
|
vscode.commands.executeCommand('workbench.action.openWalkthrough', 'gositeme.alfred-commander#alfred-ide-getting-started', true);
|
|
})
|
|
);
|
|
|
|
// --- Workspace Status Panel ---
|
|
context.subscriptions.push(
|
|
vscode.commands.registerCommand('alfred-commander.workspaceStatus', async () => {
|
|
const panel = vscode.window.createWebviewPanel(
|
|
'alfredWorkspaceStatus', 'Workspace Status', vscode.ViewColumn.One,
|
|
{ enableScripts: true }
|
|
);
|
|
|
|
const sessionFile = require('path').join(context.globalStorageUri?.fsPath || '', '..', '..', '..', '..', '.alfred-ide-session.json');
|
|
let bearerToken = '';
|
|
try {
|
|
const sData = JSON.parse(require('fs').readFileSync(
|
|
require('path').join(require('os').homedir(), '.alfred-ide-session.json'), 'utf8'
|
|
));
|
|
bearerToken = sData.token || '';
|
|
} catch (_) {}
|
|
|
|
panel.webview.html = getWorkspaceStatusHTML(bearerToken);
|
|
})
|
|
);
|
|
|
|
const statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100);
|
|
statusBar.text = '$(mic) Alfred';
|
|
statusBar.tooltip = 'Alfred — Toggle mic (Ctrl+Shift+Alt+A)';
|
|
statusBar.command = 'alfred-commander.toggle';
|
|
statusBar.color = '#e2b340';
|
|
statusBar.show();
|
|
context.subscriptions.push(statusBar);
|
|
|
|
const userStatus = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 0);
|
|
userStatus.command = 'alfred-commander.showStats';
|
|
context.subscriptions.push(userStatus);
|
|
|
|
fetchUserProfile(context).then(profile => {
|
|
userProfile = profile;
|
|
userStatus.text = `$(account) Logged in as ${profile.name}`;
|
|
userStatus.tooltip = `${profile.name} (${profile.email || profile.user}) — Click for Account & Usage Stats`;
|
|
userStatus.color = '#4ec9b0';
|
|
userStatus.show();
|
|
if (provider.view) {
|
|
provider.view.webview.postMessage({ type: 'user-profile', profile });
|
|
}
|
|
}).catch(() => {
|
|
const osUser = os.userInfo().username || 'gositeme';
|
|
userStatus.text = `$(account) Logged in as ${osUser}`;
|
|
userStatus.tooltip = `Authenticated as ${osUser} — Click for Account & Usage Stats`;
|
|
userStatus.color = '#4ec9b0';
|
|
userStatus.show();
|
|
});
|
|
}
|
|
|
|
async function fetchUserProfile(context) {
|
|
const saved = context.globalState.get('alfredCommanderUserProfile');
|
|
if (saved && saved.name && saved.fetchedAt) {
|
|
const age = Date.now() - saved.fetchedAt;
|
|
const authenticated = !!saved.client_id;
|
|
if ((authenticated && age < 3600000) || (!authenticated && age < 60000)) {
|
|
return saved;
|
|
}
|
|
}
|
|
|
|
const ideToken = readLocalIdeToken();
|
|
|
|
try {
|
|
const profile = await new Promise((resolve, reject) => {
|
|
const headers = { 'X-Alfred-Source': 'ide-extension' };
|
|
if (ideToken) {
|
|
headers['Authorization'] = 'Bearer ' + ideToken;
|
|
headers['X-Alfred-IDE-Token'] = ideToken;
|
|
}
|
|
const req = https.request({
|
|
hostname: 'gositeme.com', port: 443,
|
|
path: '/api/alfred-ide-session.php',
|
|
method: 'GET',
|
|
headers,
|
|
timeout: 10000
|
|
}, (res) => {
|
|
let data = '';
|
|
res.on('data', chunk => data += chunk);
|
|
res.on('end', () => {
|
|
try {
|
|
const j = JSON.parse(data);
|
|
if (j.valid && j.name) resolve(j);
|
|
else reject(new Error('Invalid session'));
|
|
} catch { reject(new Error('Parse error')); }
|
|
});
|
|
});
|
|
req.on('error', reject);
|
|
req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); });
|
|
req.end();
|
|
});
|
|
|
|
const result = {
|
|
name: profile.name, email: profile.email,
|
|
avatar: profile.avatar || '', user: profile.email,
|
|
client_id: profile.client_id, fetchedAt: Date.now()
|
|
};
|
|
context.globalState.update('alfredCommanderUserProfile', result);
|
|
return result;
|
|
} catch {
|
|
// Fallback: read identity from session bridge file (works without network)
|
|
for (const bridgePath of IDE_SESSION_BRIDGES) {
|
|
try {
|
|
if (!fs.existsSync(bridgePath)) continue;
|
|
const bData = JSON.parse(fs.readFileSync(bridgePath, 'utf8') || '{}');
|
|
if (bData.client_id) {
|
|
return { name: bData.name || 'User', user: bData.email || '', email: bData.email || '', client_id: bData.client_id, fetchedAt: Date.now() };
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
const osUser = os.userInfo().username || 'gositeme';
|
|
return { name: osUser, user: osUser, email: '', fetchedAt: Date.now() };
|
|
}
|
|
}
|
|
|
|
async function ensureFreshUserProfile(context) {
|
|
if (!userProfile || !userProfile.client_id || !userProfile.fetchedAt || (Date.now() - userProfile.fetchedAt) > 300000) {
|
|
userProfile = await fetchUserProfile(context);
|
|
}
|
|
return userProfile;
|
|
}
|
|
|
|
class AlfredCommanderProvider {
|
|
constructor(extensionUri, context) {
|
|
this.extensionUri = extensionUri;
|
|
this.context = context;
|
|
this.view = null;
|
|
}
|
|
|
|
resolveWebviewView(webviewView) {
|
|
this.view = webviewView;
|
|
webviewView.webview.options = {
|
|
enableScripts: true,
|
|
localResourceRoots: [this.extensionUri]
|
|
};
|
|
// Inject session token and identity so webview can call API directly (bypasses broken IPC)
|
|
const injToken = readLocalIdeToken();
|
|
const injIdentity = buildIdeIdentityPayload();
|
|
webviewView.webview.html = getWebviewContent(injToken, injIdentity || {});
|
|
|
|
webviewView.webview.onDidReceiveMessage(async (msg) => {
|
|
if (msg.type === 'ai-request') {
|
|
console.log('[alfred-commander] ai-request received:', msg.text && msg.text.substring(0, 80), 'agent=' + msg.agent, 'model=' + msg.model, 'id=' + msg.id);
|
|
try {
|
|
ensureFreshUserProfile(this.context).then((refreshed) => {
|
|
if (!refreshed) return;
|
|
webviewView.webview.postMessage({ type: 'user-profile', profile: refreshed });
|
|
}).catch(() => {});
|
|
const ctx = getEditorContext();
|
|
console.log('[alfred-commander] Calling queryAlfredAPI...');
|
|
const response = await queryAlfredAPI(msg.text, msg.agent || 'alfred', ctx, msg.model || 'sonnet', msg.images || [], msg.pdf_files || [], msg.attachment_texts || [], msg.zip_files || [], msg.multiplier || 30);
|
|
console.log('[alfred-commander] Got response:', response.text && response.text.substring(0, 100));
|
|
webviewView.webview.postMessage({ type: 'ai-response', text: response.text, agent: response.agent, id: msg.id, identity: response.identity || null, attachment_report: response.attachmentReport || [] });
|
|
generateTTS(response.text).then(audioB64 => {
|
|
if (audioB64) {
|
|
webviewView.webview.postMessage({ type: 'play-audio', audio: audioB64 });
|
|
}
|
|
}).catch(() => {});
|
|
} catch (err) {
|
|
console.error('[alfred-commander] ai-request ERROR:', err.message);
|
|
webviewView.webview.postMessage({ type: 'ai-response', text: 'Sorry, I had trouble processing that. ' + err.message, agent: 'alfred', id: msg.id });
|
|
}
|
|
} else if (msg.type === 'stt-request') {
|
|
transcribeAudio(msg.audio, msg.mime).then(text => {
|
|
webviewView.webview.postMessage({ type: 'stt-result', text: text || '', id: msg.id });
|
|
}).catch(() => {
|
|
webviewView.webview.postMessage({ type: 'stt-result', text: '', id: msg.id, error: 'Transcription failed' });
|
|
});
|
|
} else if (msg.type === 'ide-quick') {
|
|
const map = {
|
|
terminal: 'workbench.action.terminal.new',
|
|
save: 'workbench.action.files.save',
|
|
saveAll: 'workbench.action.files.saveAll',
|
|
palette: 'workbench.action.showCommands',
|
|
split: 'workbench.action.splitEditor',
|
|
newFile: 'workbench.action.files.newUntitledFile',
|
|
git: 'workbench.view.scm',
|
|
problems: 'workbench.actions.view.problems',
|
|
search: 'workbench.action.findInFiles',
|
|
format: 'editor.action.formatDocument',
|
|
explorer: 'workbench.view.explorer',
|
|
debug: 'workbench.action.debug.start',
|
|
toggleComment: 'editor.action.commentLine',
|
|
goToDefinition: 'editor.action.revealDefinition',
|
|
findReferences: 'editor.action.goToReferences',
|
|
rename: 'editor.action.rename',
|
|
quickFix: 'editor.action.quickFix',
|
|
fold: 'editor.foldAll',
|
|
unfold: 'editor.unfoldAll',
|
|
closeAll: 'workbench.action.closeAllEditors',
|
|
};
|
|
const cmd = map[msg.cmd];
|
|
if (cmd) vscode.commands.executeCommand(cmd);
|
|
} else if (msg.type === 'insert-code') {
|
|
const editor = vscode.window.activeTextEditor;
|
|
if (editor) {
|
|
editor.edit(editBuilder => {
|
|
editBuilder.insert(editor.selection.active, msg.code);
|
|
});
|
|
}
|
|
} else if (msg.type === 'run-terminal') {
|
|
const terminal = vscode.window.createTerminal('Alfred Command');
|
|
terminal.show();
|
|
terminal.sendText(msg.command);
|
|
webviewView.webview.postMessage({ type: 'command-result', text: 'Running in terminal: ' + msg.command });
|
|
} else if (msg.type === 'set-agent') {
|
|
currentAgent = msg.agent;
|
|
} else if (msg.type === 'tts-request') {
|
|
generateTTS(msg.text).then(audioB64 => {
|
|
if (audioB64) {
|
|
webviewView.webview.postMessage({ type: 'play-audio', audio: audioB64 });
|
|
}
|
|
}).catch(() => {});
|
|
} else if (msg.type === 'save-profile') {
|
|
if (msg.profile) {
|
|
this.context.globalState.update('alfredCommanderUserProfile', msg.profile);
|
|
userProfile = msg.profile;
|
|
}
|
|
} else if (msg.type === 'read-file') {
|
|
// Read file content and return to webview
|
|
const content = readFileContent(msg.filePath, msg.maxChars || 50000);
|
|
webviewView.webview.postMessage({ type: 'file-content', filePath: msg.filePath, content: content || '[File not found or unreadable]', id: msg.id });
|
|
} else if (msg.type === 'search-files') {
|
|
// Search for files by name pattern
|
|
const files = searchFilesInWorkspace(msg.pattern || '');
|
|
webviewView.webview.postMessage({ type: 'search-results', pattern: msg.pattern, files, id: msg.id });
|
|
} else if (msg.type === 'grep-search') {
|
|
// Grep search in workspace
|
|
const results = grepInWorkspace(msg.text || '', msg.filePattern || '');
|
|
webviewView.webview.postMessage({ type: 'grep-results', text: msg.text, results, id: msg.id });
|
|
} else if (msg.type === 'get-git-info') {
|
|
const git = getGitInfo();
|
|
webviewView.webview.postMessage({ type: 'git-info', info: git, id: msg.id });
|
|
} else if (msg.type === 'get-diagnostics') {
|
|
const diags = getDiagnosticsSummary();
|
|
webviewView.webview.postMessage({ type: 'diagnostics-info', info: diags, id: msg.id });
|
|
} else if (msg.type === 'get-project-structure') {
|
|
const structure = getProjectStructure();
|
|
webviewView.webview.postMessage({ type: 'project-structure', structure, id: msg.id });
|
|
} else if (msg.type === 'open-file') {
|
|
// Open a file in the editor
|
|
try {
|
|
const root = getWorkspaceRoot();
|
|
const fullPath = path.isAbsolute(msg.filePath) ? msg.filePath : path.join(root, msg.filePath);
|
|
const uri = vscode.Uri.file(fullPath);
|
|
const doc = await vscode.workspace.openTextDocument(uri);
|
|
const editor = await vscode.window.showTextDocument(doc, { preview: false });
|
|
if (msg.line && msg.line > 0) {
|
|
const pos = new vscode.Position(msg.line - 1, 0);
|
|
editor.selection = new vscode.Selection(pos, pos);
|
|
editor.revealRange(new vscode.Range(pos, pos), vscode.TextEditorRevealType.InCenter);
|
|
}
|
|
} catch (e) {
|
|
webviewView.webview.postMessage({ type: 'command-result', text: 'Failed to open file: ' + e.message });
|
|
}
|
|
} else if (msg.type === 'apply-edit') {
|
|
// Apply a code edit to a specific file — the real power move
|
|
try {
|
|
const root = getWorkspaceRoot();
|
|
const fullPath = path.isAbsolute(msg.filePath) ? msg.filePath : path.join(root, msg.filePath);
|
|
const uri = vscode.Uri.file(fullPath);
|
|
const doc = await vscode.workspace.openTextDocument(uri);
|
|
const editor = await vscode.window.showTextDocument(doc, { preview: false });
|
|
if (msg.oldText && msg.newText !== undefined) {
|
|
// Find and replace
|
|
const fullText = doc.getText();
|
|
const idx = fullText.indexOf(msg.oldText);
|
|
if (idx >= 0) {
|
|
const startPos = doc.positionAt(idx);
|
|
const endPos = doc.positionAt(idx + msg.oldText.length);
|
|
await editor.edit(editBuilder => {
|
|
editBuilder.replace(new vscode.Range(startPos, endPos), msg.newText);
|
|
});
|
|
webviewView.webview.postMessage({ type: 'command-result', text: 'Edit applied to ' + msg.filePath });
|
|
} else {
|
|
webviewView.webview.postMessage({ type: 'command-result', text: 'Could not find the text to replace in ' + msg.filePath });
|
|
}
|
|
} else if (msg.line && msg.text !== undefined) {
|
|
// Insert at line
|
|
const pos = new vscode.Position(Math.max(0, msg.line - 1), 0);
|
|
await editor.edit(editBuilder => {
|
|
editBuilder.insert(pos, msg.text + '\n');
|
|
});
|
|
webviewView.webview.postMessage({ type: 'command-result', text: 'Inserted at line ' + msg.line + ' in ' + msg.filePath });
|
|
}
|
|
} catch (e) {
|
|
webviewView.webview.postMessage({ type: 'command-result', text: 'Edit failed: ' + e.message });
|
|
}
|
|
} else if (msg.type === 'create-file') {
|
|
// Create a new file with content
|
|
try {
|
|
const root = getWorkspaceRoot();
|
|
const fullPath = path.isAbsolute(msg.filePath) ? msg.filePath : path.join(root, msg.filePath);
|
|
const dir = path.dirname(fullPath);
|
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(fullPath, msg.content || '', 'utf8');
|
|
const uri = vscode.Uri.file(fullPath);
|
|
const doc = await vscode.workspace.openTextDocument(uri);
|
|
await vscode.window.showTextDocument(doc, { preview: false });
|
|
webviewView.webview.postMessage({ type: 'command-result', text: 'Created: ' + msg.filePath });
|
|
} catch (e) {
|
|
webviewView.webview.postMessage({ type: 'command-result', text: 'Create failed: ' + e.message });
|
|
}
|
|
} else if (msg.type === 'get-context') {
|
|
// Return full workspace context to webview
|
|
const ctx = buildFullContext();
|
|
webviewView.webview.postMessage({ type: 'workspace-context', context: ctx, id: msg.id });
|
|
} else if (msg.type === 'goto-definition') {
|
|
// LSP: Go to definition of symbol at position
|
|
try {
|
|
const root = getWorkspaceRoot();
|
|
const fullPath = path.isAbsolute(msg.filePath) ? msg.filePath : path.join(root, msg.filePath);
|
|
const uri = vscode.Uri.file(fullPath);
|
|
const doc = await vscode.workspace.openTextDocument(uri);
|
|
const pos = new vscode.Position((msg.line || 1) - 1, msg.character || 0);
|
|
const locations = await vscode.commands.executeCommand('vscode.executeDefinitionProvider', uri, pos);
|
|
const results = (locations || []).map(loc => ({
|
|
file: vscode.workspace.asRelativePath(loc.uri || loc.targetUri, false),
|
|
line: ((loc.range || loc.targetRange || {}).start || {}).line + 1,
|
|
character: ((loc.range || loc.targetRange || {}).start || {}).character
|
|
}));
|
|
webviewView.webview.postMessage({ type: 'definition-result', results, id: msg.id });
|
|
} catch (e) {
|
|
webviewView.webview.postMessage({ type: 'definition-result', results: [], error: e.message, id: msg.id });
|
|
}
|
|
} else if (msg.type === 'find-references') {
|
|
// LSP: Find all references to symbol
|
|
try {
|
|
const root = getWorkspaceRoot();
|
|
const fullPath = path.isAbsolute(msg.filePath) ? msg.filePath : path.join(root, msg.filePath);
|
|
const uri = vscode.Uri.file(fullPath);
|
|
const doc = await vscode.workspace.openTextDocument(uri);
|
|
const pos = new vscode.Position((msg.line || 1) - 1, msg.character || 0);
|
|
const locations = await vscode.commands.executeCommand('vscode.executeReferenceProvider', uri, pos);
|
|
const results = (locations || []).slice(0, 50).map(loc => ({
|
|
file: vscode.workspace.asRelativePath(loc.uri, false),
|
|
line: (loc.range.start.line || 0) + 1,
|
|
preview: ''
|
|
}));
|
|
webviewView.webview.postMessage({ type: 'references-result', results, id: msg.id });
|
|
} catch (e) {
|
|
webviewView.webview.postMessage({ type: 'references-result', results: [], error: e.message, id: msg.id });
|
|
}
|
|
} else if (msg.type === 'document-symbols') {
|
|
// LSP: Get all symbols in a document (functions, classes, variables)
|
|
try {
|
|
const root = getWorkspaceRoot();
|
|
const fullPath = path.isAbsolute(msg.filePath) ? msg.filePath : path.join(root, msg.filePath);
|
|
const uri = vscode.Uri.file(fullPath);
|
|
const doc = await vscode.workspace.openTextDocument(uri);
|
|
const symbols = await vscode.commands.executeCommand('vscode.executeDocumentSymbolProvider', uri);
|
|
const flatten = (syms, depth) => {
|
|
const out = [];
|
|
for (const s of (syms || [])) {
|
|
out.push({ name: s.name, kind: vscode.SymbolKind[s.kind] || s.kind, line: (s.range || s.location?.range)?.start?.line + 1, detail: s.detail || '' });
|
|
if (s.children) out.push(...flatten(s.children, depth + 1));
|
|
}
|
|
return out;
|
|
};
|
|
webviewView.webview.postMessage({ type: 'symbols-result', symbols: flatten(symbols, 0).slice(0, 200), id: msg.id });
|
|
} catch (e) {
|
|
webviewView.webview.postMessage({ type: 'symbols-result', symbols: [], error: e.message, id: msg.id });
|
|
}
|
|
} else if (msg.type === 'hover-info') {
|
|
// LSP: Get hover info (type info, docs) for symbol at position
|
|
try {
|
|
const root = getWorkspaceRoot();
|
|
const fullPath = path.isAbsolute(msg.filePath) ? msg.filePath : path.join(root, msg.filePath);
|
|
const uri = vscode.Uri.file(fullPath);
|
|
const doc = await vscode.workspace.openTextDocument(uri);
|
|
const pos = new vscode.Position((msg.line || 1) - 1, msg.character || 0);
|
|
const hovers = await vscode.commands.executeCommand('vscode.executeHoverProvider', uri, pos);
|
|
const contents = (hovers || []).map(h => (h.contents || []).map(c => typeof c === 'string' ? c : c.value || '').join('\\n')).join('\\n');
|
|
webviewView.webview.postMessage({ type: 'hover-result', info: contents, id: msg.id });
|
|
} catch (e) {
|
|
webviewView.webview.postMessage({ type: 'hover-result', info: '', error: e.message, id: msg.id });
|
|
}
|
|
} else if (msg.type === 'workspace-symbols') {
|
|
// LSP: Search symbols across workspace by query
|
|
try {
|
|
const symbols = await vscode.commands.executeCommand('vscode.executeWorkspaceSymbolProvider', msg.query || '');
|
|
const results = (symbols || []).slice(0, 50).map(s => ({
|
|
name: s.name, kind: vscode.SymbolKind[s.kind] || s.kind,
|
|
file: vscode.workspace.asRelativePath(s.location.uri, false),
|
|
line: (s.location.range?.start?.line || 0) + 1
|
|
}));
|
|
webviewView.webview.postMessage({ type: 'workspace-symbols-result', symbols: results, id: msg.id });
|
|
} catch (e) {
|
|
webviewView.webview.postMessage({ type: 'workspace-symbols-result', symbols: [], error: e.message, id: msg.id });
|
|
}
|
|
} else if (msg.type === 'get-tool-registry') {
|
|
webviewView.webview.postMessage({ type: 'tool-registry', tools: TOOL_REGISTRY, id: msg.id });
|
|
// ────── GIT OPERATIONS ──────
|
|
} else if (msg.type === 'git-commit') {
|
|
const result = gitCommit(msg.message || 'Auto-commit from Alfred IDE');
|
|
webviewView.webview.postMessage({ type: 'git-result', action: 'commit', result, id: msg.id });
|
|
} else if (msg.type === 'git-diff') {
|
|
const result = gitDiff(msg.filePath, msg.staged);
|
|
webviewView.webview.postMessage({ type: 'git-result', action: 'diff', result, id: msg.id });
|
|
} else if (msg.type === 'git-log') {
|
|
const result = gitLog(msg.count, msg.filePath);
|
|
webviewView.webview.postMessage({ type: 'git-result', action: 'log', result, id: msg.id });
|
|
} else if (msg.type === 'git-blame') {
|
|
const result = gitBlame(msg.filePath, msg.startLine, msg.endLine);
|
|
webviewView.webview.postMessage({ type: 'git-result', action: 'blame', result, id: msg.id });
|
|
} else if (msg.type === 'git-stash') {
|
|
const result = gitStash(msg.action || 'push', msg.message);
|
|
webviewView.webview.postMessage({ type: 'git-result', action: 'stash', result, id: msg.id });
|
|
} else if (msg.type === 'git-branch') {
|
|
const result = gitBranch(msg.action || 'list', msg.branchName);
|
|
webviewView.webview.postMessage({ type: 'git-result', action: 'branch', result, id: msg.id });
|
|
// ────── CODE REFACTORING ──────
|
|
} else if (msg.type === 'rename-symbol') {
|
|
try {
|
|
const root = getWorkspaceRoot();
|
|
const fullPath = path.isAbsolute(msg.filePath) ? msg.filePath : path.join(root, msg.filePath);
|
|
const uri = vscode.Uri.file(fullPath);
|
|
await vscode.workspace.openTextDocument(uri);
|
|
const pos = new vscode.Position((msg.line || 1) - 1, msg.character || 0);
|
|
const edit = await vscode.commands.executeCommand('vscode.executeDocumentRenameProvider', uri, pos, msg.newName || 'renamed');
|
|
if (edit) {
|
|
await vscode.workspace.applyEdit(edit);
|
|
webviewView.webview.postMessage({ type: 'refactor-result', action: 'rename', success: true, id: msg.id });
|
|
} else {
|
|
webviewView.webview.postMessage({ type: 'refactor-result', action: 'rename', success: false, error: 'No rename available at position', id: msg.id });
|
|
}
|
|
} catch (e) {
|
|
webviewView.webview.postMessage({ type: 'refactor-result', action: 'rename', success: false, error: e.message, id: msg.id });
|
|
}
|
|
} else if (msg.type === 'code-actions') {
|
|
try {
|
|
const root = getWorkspaceRoot();
|
|
const fullPath = path.isAbsolute(msg.filePath) ? msg.filePath : path.join(root, msg.filePath);
|
|
const uri = vscode.Uri.file(fullPath);
|
|
const doc = await vscode.workspace.openTextDocument(uri);
|
|
const startPos = new vscode.Position((msg.startLine || 1) - 1, 0);
|
|
const endPos = new vscode.Position((msg.endLine || msg.startLine || 1) - 1, 999);
|
|
const range = new vscode.Range(startPos, endPos);
|
|
const actions = await vscode.commands.executeCommand('vscode.executeCodeActionProvider', uri, range);
|
|
const results = (actions || []).slice(0, 20).map(a => ({
|
|
title: a.title, kind: a.kind ? a.kind.value : '', isPreferred: a.isPreferred || false
|
|
}));
|
|
webviewView.webview.postMessage({ type: 'code-actions-result', actions: results, id: msg.id });
|
|
} catch (e) {
|
|
webviewView.webview.postMessage({ type: 'code-actions-result', actions: [], error: e.message, id: msg.id });
|
|
}
|
|
} else if (msg.type === 'apply-code-action') {
|
|
try {
|
|
const root = getWorkspaceRoot();
|
|
const fullPath = path.isAbsolute(msg.filePath) ? msg.filePath : path.join(root, msg.filePath);
|
|
const uri = vscode.Uri.file(fullPath);
|
|
const doc = await vscode.workspace.openTextDocument(uri);
|
|
const startPos = new vscode.Position((msg.startLine || 1) - 1, 0);
|
|
const endPos = new vscode.Position((msg.endLine || msg.startLine || 1) - 1, 999);
|
|
const range = new vscode.Range(startPos, endPos);
|
|
const actions = await vscode.commands.executeCommand('vscode.executeCodeActionProvider', uri, range);
|
|
const target = (actions || []).find(a => a.title === msg.actionTitle);
|
|
if (target) {
|
|
if (target.edit) await vscode.workspace.applyEdit(target.edit);
|
|
if (target.command) await vscode.commands.executeCommand(target.command.command, ...(target.command.arguments || []));
|
|
webviewView.webview.postMessage({ type: 'refactor-result', action: 'code-action', success: true, title: msg.actionTitle, id: msg.id });
|
|
} else {
|
|
webviewView.webview.postMessage({ type: 'refactor-result', action: 'code-action', success: false, error: 'Action not found', id: msg.id });
|
|
}
|
|
} catch (e) {
|
|
webviewView.webview.postMessage({ type: 'refactor-result', action: 'code-action', success: false, error: e.message, id: msg.id });
|
|
}
|
|
} else if (msg.type === 'format-document') {
|
|
try {
|
|
const root = getWorkspaceRoot();
|
|
const fullPath = path.isAbsolute(msg.filePath) ? msg.filePath : path.join(root, msg.filePath);
|
|
const uri = vscode.Uri.file(fullPath);
|
|
const doc = await vscode.workspace.openTextDocument(uri);
|
|
const edits = await vscode.commands.executeCommand('vscode.executeFormatDocumentProvider', uri, { tabSize: 2, insertSpaces: true });
|
|
if (edits && edits.length > 0) {
|
|
const edit = new vscode.WorkspaceEdit();
|
|
for (const e of edits) edit.replace(uri, e.range, e.newText);
|
|
await vscode.workspace.applyEdit(edit);
|
|
webviewView.webview.postMessage({ type: 'refactor-result', action: 'format', success: true, edits: edits.length, id: msg.id });
|
|
} else {
|
|
webviewView.webview.postMessage({ type: 'refactor-result', action: 'format', success: true, edits: 0, id: msg.id });
|
|
}
|
|
} catch (e) {
|
|
webviewView.webview.postMessage({ type: 'refactor-result', action: 'format', success: false, error: e.message, id: msg.id });
|
|
}
|
|
// ────── SYSTEM & PROCESS TOOLS ──────
|
|
} else if (msg.type === 'system-info') {
|
|
webviewView.webview.postMessage({ type: 'system-info-result', info: getSystemInfo(), id: msg.id });
|
|
} else if (msg.type === 'list-ports') {
|
|
webviewView.webview.postMessage({ type: 'ports-result', ports: getRunningPorts(), id: msg.id });
|
|
} else if (msg.type === 'pm2-list') {
|
|
try {
|
|
const raw = getPm2Services();
|
|
const services = JSON.parse(raw || '[]').map(s => ({
|
|
name: s.name, id: s.pm_id, status: s.pm2_env?.status, cpu: s.monit?.cpu, mem: ((s.monit?.memory || 0) / 1048576).toFixed(1) + 'MB',
|
|
restarts: s.pm2_env?.restart_time, uptime: s.pm2_env?.pm_uptime ? new Date(s.pm2_env.pm_uptime).toISOString() : ''
|
|
}));
|
|
webviewView.webview.postMessage({ type: 'pm2-result', services, id: msg.id });
|
|
} catch (e) {
|
|
webviewView.webview.postMessage({ type: 'pm2-result', services: [], error: e.message, id: msg.id });
|
|
}
|
|
} else if (msg.type === 'disk-usage') {
|
|
webviewView.webview.postMessage({ type: 'disk-result', usage: getDiskUsage(), id: msg.id });
|
|
} else if (msg.type === 'env-vars') {
|
|
webviewView.webview.postMessage({ type: 'env-result', vars: getEnvironmentVars(msg.filter), id: msg.id });
|
|
// ────── DATA FORMAT UTILITIES ──────
|
|
} else if (msg.type === 'format-json') {
|
|
webviewView.webview.postMessage({ type: 'format-result', result: formatJson(msg.text || ''), id: msg.id });
|
|
} else if (msg.type === 'compute-hash') {
|
|
webviewView.webview.postMessage({ type: 'hash-result', hash: computeHash(msg.text || '', msg.algorithm), algorithm: msg.algorithm || 'sha256', id: msg.id });
|
|
} else if (msg.type === 'base64') {
|
|
const result = msg.action === 'decode' ? base64Decode(msg.text || '') : base64Encode(msg.text || '');
|
|
webviewView.webview.postMessage({ type: 'base64-result', result, action: msg.action, id: msg.id });
|
|
} else if (msg.type === 'url-encode') {
|
|
const result = msg.action === 'decode' ? urlDecode(msg.text || '') : urlEncode(msg.text || '');
|
|
webviewView.webview.postMessage({ type: 'url-encode-result', result, action: msg.action, id: msg.id });
|
|
} else if (msg.type === 'generate-uuid') {
|
|
webviewView.webview.postMessage({ type: 'uuid-result', uuid: generateUuid(), id: msg.id });
|
|
} else if (msg.type === 'count-lines') {
|
|
webviewView.webview.postMessage({ type: 'count-result', stats: countLines(msg.filePath || ''), id: msg.id });
|
|
// ────── ADVANCED FILE OPS ──────
|
|
} else if (msg.type === 'diff-files') {
|
|
webviewView.webview.postMessage({ type: 'diff-result', diff: diffFiles(msg.fileA, msg.fileB), id: msg.id });
|
|
} else if (msg.type === 'find-replace-all') {
|
|
const result = findReplace(msg.filePath, msg.search, msg.replace, msg.isRegex);
|
|
webviewView.webview.postMessage({ type: 'find-replace-result', result, id: msg.id });
|
|
} else if (msg.type === 'recent-files') {
|
|
webviewView.webview.postMessage({ type: 'recent-files-result', files: getRecentFiles(msg.count), id: msg.id });
|
|
} else if (msg.type === 'file-sizes') {
|
|
webviewView.webview.postMessage({ type: 'file-sizes-result', sizes: getFileSizes(msg.pattern), id: msg.id });
|
|
// ────── TEST RUNNER ──────
|
|
} else if (msg.type === 'detect-tests') {
|
|
webviewView.webview.postMessage({ type: 'test-frameworks', frameworks: detectTestFramework(), id: msg.id });
|
|
} else if (msg.type === 'run-tests') {
|
|
const result = runTests(msg.framework, msg.testFile);
|
|
webviewView.webview.postMessage({ type: 'test-result', result, id: msg.id });
|
|
// ────── ANALYSIS TOOLS ──────
|
|
} else if (msg.type === 'import-graph') {
|
|
webviewView.webview.postMessage({ type: 'import-graph-result', graph: getImportGraph(msg.filePath), id: msg.id });
|
|
} else if (msg.type === 'analyze-complexity') {
|
|
webviewView.webview.postMessage({ type: 'complexity-result', analysis: analyzeComplexity(msg.filePath), id: msg.id });
|
|
} else if (msg.type === 'dependency-info') {
|
|
webviewView.webview.postMessage({ type: 'dependency-result', info: getDependencyInfo(), id: msg.id });
|
|
} else if (msg.type === 'project-stats') {
|
|
webviewView.webview.postMessage({ type: 'project-stats-result', stats: getProjectStats(), id: msg.id });
|
|
} else if (msg.type === 'get-diagnostics-detail') {
|
|
const diags = vscode.languages.getDiagnostics();
|
|
const detailed = [];
|
|
for (const [uri, items] of diags) {
|
|
for (const d of items) {
|
|
detailed.push({
|
|
file: vscode.workspace.asRelativePath(uri, false),
|
|
line: d.range.start.line + 1, col: d.range.start.character,
|
|
severity: d.severity === 0 ? 'error' : d.severity === 1 ? 'warning' : d.severity === 2 ? 'info' : 'hint',
|
|
message: d.message, source: d.source || '', code: d.code ? (typeof d.code === 'object' ? d.code.value : d.code) : ''
|
|
});
|
|
}
|
|
}
|
|
webviewView.webview.postMessage({ type: 'diagnostics-detail-result', diagnostics: detailed.slice(0, 100), id: msg.id });
|
|
// ────── PLATFORM TOOLS ──────
|
|
} else if (msg.type === 'update-check') {
|
|
checkForUpdates(msg.platform).then(result => {
|
|
webviewView.webview.postMessage({ type: 'update-check-result', result, id: msg.id });
|
|
});
|
|
} else if (msg.type === 'pair-desktop') {
|
|
requestDesktopPairing().then(result => {
|
|
if (result.error) {
|
|
webviewView.webview.postMessage({ type: 'pair-result', result, id: msg.id });
|
|
return;
|
|
}
|
|
webviewView.webview.postMessage({ type: 'pair-code', code: result.pairing_code, pairId: result.pair_id, id: msg.id });
|
|
let attempts = 0;
|
|
const maxAttempts = 60;
|
|
const pollInterval = setInterval(async () => {
|
|
attempts++;
|
|
if (attempts > maxAttempts) {
|
|
clearInterval(pollInterval);
|
|
webviewView.webview.postMessage({ type: 'pair-result', result: { error: 'Pairing expired — code was not confirmed within 10 minutes' }, id: msg.id });
|
|
return;
|
|
}
|
|
const status = await pollPairingStatus(result.pair_id);
|
|
if (status.status === 'approved' && status.token) {
|
|
clearInterval(pollInterval);
|
|
savePairedToken(status.token);
|
|
webviewView.webview.postMessage({ type: 'pair-result', result: { success: true, message: 'Desktop paired successfully! You are now connected to your gositeme.com account.' }, id: msg.id });
|
|
} else if (status.status === 'expired' || status.error) {
|
|
clearInterval(pollInterval);
|
|
webviewView.webview.postMessage({ type: 'pair-result', result: { error: status.error || 'Pairing code expired' }, id: msg.id });
|
|
}
|
|
}, 10000);
|
|
});
|
|
}
|
|
});
|
|
|
|
if (userProfile) {
|
|
setTimeout(() => {
|
|
webviewView.webview.postMessage({ type: 'user-profile', profile: userProfile });
|
|
}, 500);
|
|
}
|
|
|
|
// Push live editor context updates to webview
|
|
vscode.window.onDidChangeActiveTextEditor(() => {
|
|
if (this.view) {
|
|
const fileInfo = getActiveFileDetails();
|
|
if (fileInfo) {
|
|
this.view.webview.postMessage({ type: 'active-file-changed', file: fileInfo });
|
|
}
|
|
}
|
|
});
|
|
|
|
vscode.workspace.onDidSaveTextDocument((doc) => {
|
|
if (this.view) {
|
|
const rel = vscode.workspace.asRelativePath(doc.uri, false);
|
|
this.view.webview.postMessage({ type: 'file-saved', file: rel, language: doc.languageId });
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
function transcribeAudio(audioB64, mime) {
|
|
if (!audioB64) return Promise.resolve('');
|
|
|
|
return new Promise((resolve) => {
|
|
const raw = Buffer.from(audioB64, 'base64');
|
|
if (raw.length < 100) { resolve(''); return; }
|
|
|
|
const ext = (mime || '').includes('wav') ? 'wav'
|
|
: (mime || '').includes('ogg') ? 'ogg'
|
|
: (mime || '').includes('mp4') || (mime || '').includes('m4a') ? 'm4a'
|
|
: 'webm';
|
|
|
|
const boundary = '----AlfredSTT' + Date.now();
|
|
const filename = 'audio.' + ext;
|
|
const contentType = mime || 'audio/webm';
|
|
|
|
const header = Buffer.from(
|
|
`--${boundary}\r\nContent-Disposition: form-data; name="audio"; filename="${filename}"\r\nContent-Type: ${contentType}\r\n\r\n`
|
|
);
|
|
const modelField = Buffer.from(
|
|
`\r\n--${boundary}\r\nContent-Disposition: form-data; name="model"\r\n\r\nwhisper-1\r\n--${boundary}--\r\n`
|
|
);
|
|
const body = Buffer.concat([header, raw, modelField]);
|
|
|
|
const req = https.request({
|
|
hostname: 'gositeme.com',
|
|
port: 443,
|
|
path: '/api/stt.php',
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'multipart/form-data; boundary=' + boundary,
|
|
'Content-Length': body.length,
|
|
'X-Alfred-Source': 'ide-extension'
|
|
},
|
|
timeout: 60000
|
|
}, (res) => {
|
|
let data = '';
|
|
res.on('data', chunk => data += chunk);
|
|
res.on('end', () => {
|
|
try {
|
|
const j = JSON.parse(data);
|
|
resolve(j.text || '');
|
|
} catch { resolve(''); }
|
|
});
|
|
});
|
|
req.on('error', () => resolve(''));
|
|
req.on('timeout', () => { req.destroy(); resolve(''); });
|
|
req.write(body);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
function generateTTS(text) {
|
|
if (!text || text.length < 2) return Promise.resolve(null);
|
|
const clean = text.replace(/```[\s\S]*?```/g, ' code block ')
|
|
.replace(/[*_`#~\[\]]/g, '')
|
|
.replace(/https?:\/\/\S+/g, '')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
.substring(0, 4000);
|
|
if (clean.length < 2) return Promise.resolve(null);
|
|
|
|
return new Promise((resolve) => {
|
|
const body = JSON.stringify({ text: clean, voice: 'onyx' });
|
|
const req = https.request({
|
|
hostname: 'gositeme.com',
|
|
port: 443,
|
|
path: '/api/tts.php',
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Content-Length': Buffer.byteLength(body),
|
|
'X-Alfred-Source': 'ide-extension'
|
|
},
|
|
timeout: 30000
|
|
}, (res) => {
|
|
if (res.statusCode !== 200) { resolve(null); return; }
|
|
const chunks = [];
|
|
res.on('data', chunk => chunks.push(chunk));
|
|
res.on('end', () => {
|
|
const buf = Buffer.concat(chunks);
|
|
if (buf.length < 200) { resolve(null); return; }
|
|
const contentType = res.headers['content-type'] || 'audio/mpeg';
|
|
resolve('data:' + contentType + ';base64,' + buf.toString('base64'));
|
|
});
|
|
});
|
|
req.on('error', () => resolve(null));
|
|
req.on('timeout', () => { req.destroy(); resolve(null); });
|
|
req.write(body);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
function getEditorContext() {
|
|
return buildFullContext();
|
|
}
|
|
|
|
async function queryAlfredAPI(prompt, agent, editorContext, selectedModel = 'sonnet', images = [], pdfFiles = [], attachmentTexts = [], zipFiles = [], multiplier = 30) {
|
|
const systemPrompt = buildSystemPrompt(userProfile);
|
|
const payload = {
|
|
message: prompt, agent: agent || 'alfred',
|
|
context: editorContext || '', channel: 'ide-chat',
|
|
conv_id: convId || '', model: selectedModel,
|
|
token_multiplier: multiplier || 30,
|
|
system_prompt: systemPrompt
|
|
};
|
|
const ideToken = readLocalIdeToken();
|
|
const ideIdentity = buildIdeIdentityPayload();
|
|
// Duplicate token in JSON body — some stacks strip Authorization / X-Alfred-IDE-Token before PHP sees them.
|
|
if (ideToken) {
|
|
payload.ide_session_token = ideToken;
|
|
}
|
|
if (ideIdentity) {
|
|
payload.ide_client_id = ideIdentity.ide_client_id;
|
|
payload.ide_name = ideIdentity.ide_name;
|
|
payload.ide_ts = ideIdentity.ide_ts;
|
|
payload.ide_sig = ideIdentity.ide_sig;
|
|
payload.ide_email = ideIdentity.ide_email;
|
|
}
|
|
if (Array.isArray(images) && images.length > 0) {
|
|
payload.images = images;
|
|
}
|
|
if (Array.isArray(pdfFiles) && pdfFiles.length > 0) {
|
|
payload.pdf_files = pdfFiles;
|
|
}
|
|
if (Array.isArray(attachmentTexts) && attachmentTexts.length > 0) {
|
|
payload.attachment_texts = attachmentTexts;
|
|
}
|
|
if (Array.isArray(zipFiles) && zipFiles.length > 0) {
|
|
payload.zip_files = zipFiles;
|
|
}
|
|
const body = JSON.stringify(payload);
|
|
|
|
const doRequest = (csrf, cookie) => {
|
|
return new Promise((resolve, reject) => {
|
|
const headers = { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), 'X-Alfred-Source': 'alfred-ide' };
|
|
if (csrf) headers['X-CSRF-Token'] = csrf;
|
|
if (cookie) headers['Cookie'] = cookie;
|
|
if (ideToken) {
|
|
headers['Authorization'] = 'Bearer ' + ideToken;
|
|
// Apache/CGI often strips Authorization before PHP — duplicate token for alfred_resolve_ide_bearer_token()
|
|
headers['X-Alfred-IDE-Token'] = ideToken;
|
|
}
|
|
const req = https.request({ hostname: 'gositeme.com', port: 443, path: '/api/alfred-chat.php', method: 'POST', headers, timeout: 120000 }, (res) => {
|
|
// Node may give Set-Cookie as a string OR an array — iterating a string walks each CHARACTER and breaks PHPSESSID capture.
|
|
const setCookie = res.headers['set-cookie'];
|
|
if (setCookie) {
|
|
const parts = Array.isArray(setCookie) ? setCookie : [setCookie];
|
|
for (let i = 0; i < parts.length; i++) {
|
|
const m = String(parts[i]).match(/PHPSESSID=([^;]+)/);
|
|
if (m) {
|
|
sessionCookie = 'PHPSESSID=' + m[1];
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
let data = '';
|
|
res.on('data', chunk => data += chunk);
|
|
res.on('end', () => { try { resolve(JSON.parse(data)); } catch (e) { console.error('[alfred-commander] Non-JSON response HTTP ' + res.statusCode + ':', data.substring(0, 600)); resolve({ response: data.trim() || 'Alfred returned an empty response. Please send your message again.' }); } });
|
|
});
|
|
req.on('error', (err) => reject(new Error('API failed: ' + err.message)));
|
|
req.on('timeout', () => { req.destroy(); reject(new Error('Timed out (120s)')); });
|
|
req.write(body);
|
|
req.end();
|
|
});
|
|
};
|
|
let result = await doRequest(csrfToken, sessionCookie);
|
|
if (result.csrf_token) csrfToken = result.csrf_token;
|
|
for (let csrfAttempt = 0; csrfAttempt < 4 && result && (result.csrf_refresh || result.response === 'Session initialized. Please retry.' || result.error === 'CSRF validation failed'); csrfAttempt++) {
|
|
result = await doRequest(csrfToken, sessionCookie);
|
|
if (result.csrf_token) csrfToken = result.csrf_token;
|
|
}
|
|
if (!result || (!result.response && !result.message && !result.error)) {
|
|
logTelemetry('empty-response: retrying once');
|
|
result = await doRequest(csrfToken, sessionCookie);
|
|
if (result && result.csrf_token) csrfToken = result.csrf_token;
|
|
}
|
|
if (result.conv_id) convId = result.conv_id;
|
|
const errText = result && result.error ? String(result.error) : '';
|
|
const textOut = (result && (result.response || result.message))
|
|
? (result.response || result.message)
|
|
: (errText ? ('⚠️ ' + errText) : 'No response from AI');
|
|
return {
|
|
text: textOut,
|
|
agent: (result && result.agent) ? result.agent : agent,
|
|
identity: result && result.identity ? result.identity : null,
|
|
attachmentReport: (result && result.attachment_report) ? result.attachment_report : []
|
|
};
|
|
}
|
|
|
|
let statsPanel = null;
|
|
|
|
function showStatsPanel(context) {
|
|
if (statsPanel) {
|
|
statsPanel.reveal(vscode.ViewColumn.One);
|
|
return;
|
|
}
|
|
|
|
statsPanel = vscode.window.createWebviewPanel(
|
|
'alfredStats',
|
|
'Account & Usage Stats',
|
|
vscode.ViewColumn.One,
|
|
{ enableScripts: true, retainContextWhenHidden: false }
|
|
);
|
|
|
|
statsPanel.onDidDispose(() => { statsPanel = null; });
|
|
|
|
const ideToken = readLocalIdeToken();
|
|
const headers = { 'X-Alfred-Source': 'ide-extension' };
|
|
if (ideToken) {
|
|
headers['Authorization'] = 'Bearer ' + ideToken;
|
|
headers['X-Alfred-IDE-Token'] = ideToken;
|
|
}
|
|
|
|
const req = https.request({
|
|
hostname: 'gositeme.com', port: 443,
|
|
path: '/api/alfred-ide-session.php?action=stats',
|
|
method: 'GET', headers, timeout: 15000
|
|
}, (res) => {
|
|
let data = '';
|
|
res.on('data', chunk => data += chunk);
|
|
res.on('end', () => {
|
|
try {
|
|
const stats = JSON.parse(data);
|
|
if (statsPanel) {
|
|
statsPanel.webview.html = getStatsHtml(stats);
|
|
}
|
|
} catch (e) {
|
|
if (statsPanel) {
|
|
statsPanel.webview.html = getStatsHtml({ valid: false, error: 'Failed to load stats: ' + e.message });
|
|
}
|
|
}
|
|
});
|
|
});
|
|
req.on('error', (e) => {
|
|
if (statsPanel) {
|
|
statsPanel.webview.html = getStatsHtml({ valid: false, error: 'Connection error: ' + e.message });
|
|
}
|
|
});
|
|
req.on('timeout', () => { req.destroy(); });
|
|
req.end();
|
|
|
|
// Show loading state immediately
|
|
statsPanel.webview.html = getStatsHtml(null);
|
|
}
|
|
|
|
function getStatsHtml(stats) {
|
|
if (!stats) {
|
|
return `<!DOCTYPE html><html><head><meta charset="UTF-8">
|
|
<style>body{font-family:'Segoe UI',system-ui,sans-serif;background:#0d1117;color:#c9d1d9;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;}
|
|
.loader{text-align:center;}.spin{display:inline-block;width:40px;height:40px;border:3px solid #30363d;border-top-color:#e2b340;border-radius:50%;animation:spin 0.8s linear infinite;}
|
|
@keyframes spin{to{transform:rotate(360deg);}}</style></head>
|
|
<body><div class="loader"><div class="spin"></div><p style="margin-top:16px;color:#8b949e;">Loading your stats...</p></div></body></html>`;
|
|
}
|
|
|
|
if (!stats.valid) {
|
|
return `<!DOCTYPE html><html><head><meta charset="UTF-8">
|
|
<style>body{font-family:'Segoe UI',system-ui,sans-serif;background:#0d1117;color:#c9d1d9;padding:40px;margin:0;}</style></head>
|
|
<body><h2 style="color:#e2b340;">Account Stats</h2><p style="color:#f85149;margin-top:16px;">` + escHtml(stats.error || 'Unable to load stats') + `</p></body></html>`;
|
|
}
|
|
|
|
const name = escHtml(stats.name || 'User');
|
|
const email = escHtml(stats.email || '');
|
|
const plan = (stats.plan || 'free').toLowerCase();
|
|
const planDisplay = plan === 'commander' ? 'Commander' : (stats.plan || 'Free').charAt(0).toUpperCase() + (stats.plan || 'free').slice(1);
|
|
const planColors = { commander: '#e2b340', free: '#6a737d', starter: '#3b82f6', professional: '#a855f7', enterprise: '#22c55e' };
|
|
const planColor = planColors[plan] || '#6a737d';
|
|
|
|
const tokensUsed = stats.tokens_used || 0;
|
|
const tokensIncluded = stats.tokens_included || 50000;
|
|
const tokensOverage = stats.tokens_overage || 0;
|
|
const costOverage = stats.cost_overage_usd || 0;
|
|
const isUnlimited = stats.unlimited || plan === 'commander';
|
|
const remaining = Math.max(0, tokensIncluded - tokensUsed);
|
|
const pct = tokensIncluded > 0 ? Math.min(100, Math.round((tokensUsed / tokensIncluded) * 100)) : 0;
|
|
const barColor = pct >= 90 ? '#ef4444' : pct >= 70 ? '#f59e0b' : '#3b82f6';
|
|
|
|
const fmtK = (n) => n >= 1000000 ? (n / 1000000).toFixed(1) + 'M' : n >= 1000 ? Math.round(n / 1000) + 'K' : String(n);
|
|
|
|
// Services section
|
|
let servicesHtml = '';
|
|
if (stats.services && stats.services.length > 0) {
|
|
servicesHtml = stats.services.map(s =>
|
|
`<tr><td>${escHtml(s.product || 'Service')}</td><td>${escHtml(s.domain || '—')}</td><td class="status-active">${escHtml(s.status)}</td><td>$${parseFloat(s.amount || 0).toFixed(2)}/${escHtml(s.billing_cycle || 'N/A')}</td></tr>`
|
|
).join('');
|
|
} else {
|
|
servicesHtml = '<tr><td colspan="4" style="color:#8b949e;text-align:center;">No active services</td></tr>';
|
|
}
|
|
|
|
// Recent invoices
|
|
let invoicesHtml = '';
|
|
if (stats.recent_invoices && stats.recent_invoices.length > 0) {
|
|
invoicesHtml = stats.recent_invoices.map(i => {
|
|
const statusClass = i.status === 'Paid' ? 'status-active' : i.status === 'Unpaid' ? 'status-unpaid' : 'status-other';
|
|
return `<tr><td>#${escHtml(i.invoice_number || String(i.id))}</td><td>$${parseFloat(i.total || 0).toFixed(2)}</td><td class="${statusClass}">${escHtml(i.status)}</td><td>${escHtml((i.due_date || i.created_at || '').substring(0, 10))}</td></tr>`;
|
|
}).join('');
|
|
} else {
|
|
invoicesHtml = '<tr><td colspan="4" style="color:#8b949e;text-align:center;">No invoices</td></tr>';
|
|
}
|
|
|
|
// Usage breakdown
|
|
let usageBreakdownHtml = '';
|
|
if (stats.usage_breakdown && stats.usage_breakdown.length > 0) {
|
|
usageBreakdownHtml = stats.usage_breakdown.map(u =>
|
|
`<tr><td>${escHtml(u.feature)}</td><td>${escHtml(u.model)}</td><td>${fmtK(parseInt(u.input_tokens || 0) + parseInt(u.output_tokens || 0))}</td><td>${parseInt(u.requests || 0)}</td><td>$${parseFloat(u.cost_usd || 0).toFixed(4)}</td></tr>`
|
|
).join('');
|
|
} else {
|
|
usageBreakdownHtml = '<tr><td colspan="5" style="color:#8b949e;text-align:center;">No usage this period</td></tr>';
|
|
}
|
|
|
|
return `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body { font-family: 'Segoe UI', system-ui, sans-serif; background: #0d1117; color: #c9d1d9; padding: 32px; max-width: 900px; margin: 0 auto; }
|
|
h1 { color: #e2b340; font-size: 22px; font-weight: 600; margin-bottom: 4px; }
|
|
.subtitle { color: #8b949e; font-size: 13px; margin-bottom: 24px; }
|
|
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 32px; }
|
|
.card { background: #161b22; border: 1px solid #30363d; border-radius: 12px; padding: 20px; }
|
|
.card-label { font-size: 11px; color: #8b949e; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; }
|
|
.card-value { font-size: 24px; font-weight: 700; }
|
|
.card-sub { font-size: 11px; color: #8b949e; margin-top: 4px; }
|
|
.usage-section { background: #161b22; border: 1px solid #30363d; border-radius: 12px; padding: 20px; margin-bottom: 24px; }
|
|
.usage-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
|
.usage-title { font-size: 14px; font-weight: 600; }
|
|
.plan-badge { display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; }
|
|
.usage-bar-outer { height: 8px; border-radius: 99px; background: #21262d; overflow: hidden; margin-bottom: 8px; }
|
|
.usage-bar-fill { height: 100%; border-radius: 99px; transition: width 0.5s ease; }
|
|
.usage-detail { font-size: 12px; color: #8b949e; }
|
|
.section-title { font-size: 15px; font-weight: 600; color: #e2b340; margin-bottom: 12px; margin-top: 8px; }
|
|
table { width: 100%; border-collapse: collapse; margin-bottom: 24px; }
|
|
th { text-align: left; font-size: 11px; color: #8b949e; text-transform: uppercase; letter-spacing: 0.3px; padding: 8px 12px; border-bottom: 1px solid #30363d; }
|
|
td { padding: 10px 12px; border-bottom: 1px solid #21262d; font-size: 13px; }
|
|
tr:hover td { background: rgba(226,179,64,0.04); }
|
|
.status-active { color: #3fb950; font-weight: 600; }
|
|
.status-unpaid { color: #f85149; font-weight: 600; }
|
|
.status-other { color: #8b949e; }
|
|
.refresh-btn { background: #161b22; border: 1px solid #30363d; color: #c9d1d9; padding: 8px 16px; border-radius: 8px; cursor: pointer; font-size: 12px; float: right; }
|
|
.refresh-btn:hover { border-color: #e2b340; color: #e2b340; }
|
|
.footer { text-align: center; color: #484f58; font-size: 11px; margin-top: 24px; padding-top: 16px; border-top: 1px solid #21262d; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>$(account) ${name}</h1>
|
|
<div class="subtitle">${email} · Last login: ${escHtml((stats.last_login || '').substring(0, 16))}</div>
|
|
|
|
<div class="cards">
|
|
<div class="card">
|
|
<div class="card-label">Plan</div>
|
|
<div class="card-value" style="color:${planColor}">${planDisplay}</div>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-label">Credit Balance</div>
|
|
<div class="card-value" style="color:#3fb950">$${escHtml(stats.credit_balance || '0.00')}</div>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-label">Active Services</div>
|
|
<div class="card-value">${stats.active_services || 0}</div>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-label">Unpaid Invoices</div>
|
|
<div class="card-value" style="color:${(stats.unpaid_invoices || 0) > 0 ? '#f85149' : '#3fb950'}">${stats.unpaid_invoices || 0}</div>
|
|
<div class="card-sub">${(stats.unpaid_invoices || 0) > 0 ? 'Total due: $' + escHtml(stats.total_due || '0.00') : 'All clear'}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="usage-section">
|
|
<div class="usage-header">
|
|
<span class="usage-title">Token Usage — ${new Date().toLocaleString('en-US', { month: 'long', year: 'numeric' })}</span>
|
|
<span class="plan-badge" style="background:${planColor}20;color:${planColor};border:1px solid ${planColor}40">${planDisplay}</span>
|
|
</div>
|
|
${isUnlimited
|
|
? `<div class="usage-bar-outer"><div class="usage-bar-fill" style="width:100%;background:#e2b340;"></div></div>
|
|
<div class="usage-detail">Unlimited usage — Commander plan</div>`
|
|
: `<div class="usage-bar-outer"><div class="usage-bar-fill" style="width:${pct}%;background:${barColor};"></div></div>
|
|
<div class="usage-detail">${fmtK(tokensUsed)} used of ${fmtK(tokensIncluded)} included (${fmtK(remaining)} remaining)${tokensOverage > 0 ? ' · Overage: ' + fmtK(tokensOverage) + ' tokens ($' + costOverage.toFixed(2) + ')' : ''}</div>`
|
|
}
|
|
</div>
|
|
|
|
<div class="section-title">Usage Breakdown</div>
|
|
<table>
|
|
<thead><tr><th>Feature</th><th>Model</th><th>Tokens</th><th>Requests</th><th>Cost</th></tr></thead>
|
|
<tbody>${usageBreakdownHtml}</tbody>
|
|
</table>
|
|
|
|
<div class="section-title">Active Services</div>
|
|
<table>
|
|
<thead><tr><th>Product</th><th>Domain</th><th>Status</th><th>Amount</th></tr></thead>
|
|
<tbody>${servicesHtml}</tbody>
|
|
</table>
|
|
|
|
<div class="section-title">Recent Invoices</div>
|
|
<table>
|
|
<thead><tr><th>Invoice</th><th>Amount</th><th>Status</th><th>Date</th></tr></thead>
|
|
<tbody>${invoicesHtml}</tbody>
|
|
</table>
|
|
|
|
<div class="footer">Alfred · GoSiteMe · Data refreshed at ${new Date().toLocaleTimeString()}</div>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
function escHtml(str) {
|
|
return String(str || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
function getWebviewContent(injectedToken, injectedIdentity) {
|
|
const safeToken = (injectedToken || '').replace(/[^a-f0-9]/g, '');
|
|
const safeIdentity = JSON.stringify(injectedIdentity || {});
|
|
return `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' 'unsafe-eval' https: wss: ws: data: blob:; connect-src 'self' https://gositeme.com https: wss: ws:; img-src 'self' data: blob: https:; font-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline';">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body { font-family: 'Segoe UI', system-ui, sans-serif; background: var(--vscode-editor-background, #0d1117); color: var(--vscode-editor-foreground, #c9d1d9); padding: 10px; height: 100vh; display: flex; flex-direction: column; }
|
|
.header { text-align: center; padding: 6px 0 10px; border-bottom: 1px solid var(--vscode-panel-border, #1a1a2e); margin-bottom: 8px; }
|
|
.header h2 { color: #e2b340; font-size: 13px; font-weight: 600; letter-spacing: 1px; text-transform: uppercase; }
|
|
.header .subtitle { color: var(--vscode-descriptionForeground, #6a737d); font-size: 10px; margin-top: 2px; }
|
|
.identity-badge { margin-top: 4px; display: inline-block; font-size: 10px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--vscode-input-border, #30363d); color: var(--vscode-descriptionForeground, #8b949e); }
|
|
.identity-badge.verified { color: #0d1117; background: #22c55e; border-color: #22c55e; }
|
|
.identity-badge.guest { color: #f59e0b; border-color: #f59e0b; }
|
|
.usage-info { display: none; font-size: 10px; margin-top: 4px; color: var(--vscode-descriptionForeground, #8b949e); text-align: center; }
|
|
.usage-info .plan-label { font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; font-size: 9px; }
|
|
.usage-info .plan-commander { color: #e2b340; }
|
|
.usage-info .plan-free { color: #6a737d; }
|
|
.usage-info .plan-starter { color: #3b82f6; }
|
|
.usage-info .plan-professional { color: #a855f7; }
|
|
.usage-info .plan-enterprise { color: #22c55e; }
|
|
.usage-bar { height: 4px; border-radius: 99px; background: rgba(255,255,255,0.08); overflow: hidden; margin-top: 3px; width: 100%; }
|
|
.usage-fill { height: 100%; border-radius: 99px; background: #3b82f6; transition: width 0.3s ease; }
|
|
.usage-fill.warn { background: #f59e0b; }
|
|
.usage-fill.danger { background: #ef4444; }
|
|
.usage-fill.unlimited { background: #e2b340; width: 100% !important; }
|
|
/* One compact row; wraps on narrow sidebars without extra vertical chrome */
|
|
.agent-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-bottom: 6px; }
|
|
.agent-bar > label { font-size: 9px; color: var(--vscode-descriptionForeground); text-transform: uppercase; letter-spacing: 0.4px; flex-shrink: 0; }
|
|
.agent-select, .model-select {
|
|
flex: 1 1 42%; min-width: 0; min-height: 26px;
|
|
background: var(--vscode-input-background, #161b22); border: 1px solid var(--vscode-input-border, #30363d); border-radius: 6px;
|
|
color: var(--vscode-input-foreground, #c9d1d9); padding: 4px 6px; font-size: 11px; outline: none; cursor: pointer;
|
|
}
|
|
.model-select { color: #3b82f6; }
|
|
.multiplier-select { flex: 0 0 92px; color: #e2b340; }
|
|
.agent-select:focus { border-color: #e2b340; }
|
|
.model-select:focus { border-color: #3b82f6; }
|
|
.model-select option, .model-select optgroup { background: #161b22; color: #c9d1d9; }
|
|
.chat-area { flex: 1; overflow-y: auto; margin-bottom: 8px; padding-right: 4px; }
|
|
.chat-area::-webkit-scrollbar { width: 4px; }
|
|
.chat-area::-webkit-scrollbar-thumb { background: #1a1a2e; border-radius: 2px; }
|
|
.message { margin-bottom: 8px; padding: 7px 9px; border-radius: 8px; font-size: 12px; line-height: 1.5; animation: fadeIn 0.3s ease; word-wrap: break-word; }
|
|
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
|
|
.message.commander { background: rgba(226,179,64,0.08); border-left: 3px solid #e2b340; }
|
|
.message.alfred { background: rgba(59,130,246,0.08); border-left: 3px solid #3b82f6; }
|
|
.message.system { background: rgba(35,134,54,0.08); border-left: 3px solid #238636; color: #7ee787; font-size: 11px; padding: 5px 8px; }
|
|
.message .sender { font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 2px; }
|
|
.message.commander .sender { color: #e2b340; }
|
|
.message.alfred .sender { color: #3b82f6; }
|
|
.message pre { background: var(--vscode-textCodeBlock-background, #0d1117); border: 1px solid var(--vscode-panel-border, #30363d); border-radius: 4px; padding: 6px 8px; margin: 4px 0; overflow-x: auto; font-size: 11px; font-family: var(--vscode-editor-font-family, monospace); }
|
|
.message code { background: rgba(255,255,255,0.05); padding: 1px 4px; border-radius: 3px; font-size: 11px; }
|
|
/* Input full width, then a short action row — avoids cramming mic + attach + send beside the field */
|
|
.controls-wrap { display: flex; flex-direction: column; gap: 6px; width: 100%; min-width: 0; }
|
|
.controls-wrap .text-input { width: 100%; flex: none; min-width: 0; }
|
|
.action-row { display: flex; align-items: center; gap: 8px; width: 100%; min-width: 0; flex-wrap: nowrap; }
|
|
.action-row .mic-btn { flex-shrink: 0; }
|
|
.action-row .attach-btn { flex-shrink: 0; }
|
|
.action-row .send-btn { margin-left: auto; flex-shrink: 0; }
|
|
.mic-btn { width: 32px; height: 32px; border-radius: 50%; border: 2px solid #e2b340; background: transparent; color: #e2b340; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.3s; flex-shrink: 0; }
|
|
.mic-btn:hover { background: #e2b340; color: #0d1117; }
|
|
.mic-btn.recording { background: #e53e3e; border-color: #e53e3e; color: #fff; animation: pulse 1.5s infinite; }
|
|
.mic-btn.transcribing { background: #3b82f6; border-color: #3b82f6; color: #fff; animation: pulse 1s infinite; }
|
|
@keyframes pulse { 0%, 100% { box-shadow: 0 0 8px rgba(226,179,64,0.3); } 50% { box-shadow: 0 0 20px rgba(226,179,64,0.6); } }
|
|
.mic-btn svg { width: 16px; height: 16px; }
|
|
.text-input { background: var(--vscode-input-background); border: 1px solid var(--vscode-input-border, #30363d); border-radius: 8px; color: var(--vscode-input-foreground); padding: 7px 10px; font-size: 12px; outline: none; }
|
|
.text-input:focus { border-color: #e2b340; }
|
|
.text-input::placeholder { color: var(--vscode-input-placeholderForeground, #484f58); }
|
|
.send-btn { background: #e2b340; border: none; color: #0d1117; border-radius: 8px; padding: 6px 14px; cursor: pointer; font-weight: 700; font-size: 11px; }
|
|
.send-btn:hover { opacity: 0.85; }
|
|
.bottom-bar { display: flex; align-items: center; justify-content: flex-start; flex-wrap: wrap; margin-top: 4px; gap: 6px; row-gap: 4px; position: relative; z-index: 5; pointer-events: auto; }
|
|
.attach-btn { width: 28px; height: 28px; border-radius: 6px; border: 1px solid var(--vscode-input-border, #30363d); background: transparent; color: var(--vscode-descriptionForeground, #6a737d); cursor: pointer; display: flex; align-items: center; justify-content: center; flex-shrink: 0; font-size: 14px; }
|
|
.attach-btn:hover { border-color: #e2b340; color: #e2b340; }
|
|
.image-preview-strip { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 4px; }
|
|
.image-preview-strip.empty { display: none; }
|
|
.attach-panel { border: 1px solid var(--vscode-input-border, #30363d); border-radius: 6px; padding: 6px; margin-bottom: 6px; background: rgba(255,255,255,0.02); }
|
|
.attach-panel.empty { display: none; }
|
|
.attach-row { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; font-size: 11px; }
|
|
.attach-row:last-child { margin-bottom: 0; }
|
|
.attach-name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.attach-meta { color: var(--vscode-descriptionForeground, #6a737d); font-size: 10px; }
|
|
.attach-state { border: 1px solid var(--vscode-input-border, #30363d); border-radius: 999px; padding: 1px 6px; font-size: 9px; color: var(--vscode-descriptionForeground, #8b949e); white-space: nowrap; }
|
|
.attach-state.ready { color: #60a5fa; border-color: #60a5fa; }
|
|
.attach-state.queued { color: #f59e0b; border-color: #f59e0b; }
|
|
.attach-state.ok { color: #22c55e; border-color: #22c55e; }
|
|
.attach-state.warn { color: #f59e0b; border-color: #f59e0b; }
|
|
.attach-state.error { color: #ef4444; border-color: #ef4444; }
|
|
.attach-detail { color: var(--vscode-descriptionForeground, #6a737d); font-size: 9px; margin-left: 2px; max-width: 150px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.small-btn { border: 1px solid var(--vscode-input-border, #30363d); background: transparent; color: var(--vscode-foreground, #c9d1d9); border-radius: 4px; cursor: pointer; font-size: 10px; padding: 1px 5px; }
|
|
.small-btn:hover { border-color: #e2b340; color: #e2b340; }
|
|
.retry-btn { margin-top: 6px; }
|
|
.meter { font-size: 9px; color: var(--vscode-descriptionForeground, #6a737d); margin-top: 2px; }
|
|
.meter .bar { height: 4px; border-radius: 999px; background: rgba(255,255,255,0.08); overflow: hidden; margin-top: 2px; }
|
|
.meter .fill { height: 100%; width: 0%; background: #3b82f6; transition: width .2s ease; }
|
|
.telemetry-drawer { margin-top: 6px; border: 1px solid var(--vscode-input-border, #30363d); border-radius: 6px; max-height: 120px; overflow: auto; padding: 6px; font-size: 10px; background: rgba(0,0,0,0.15); }
|
|
.telemetry-drawer.hidden { display: none; }
|
|
.telemetry-line { margin-bottom: 3px; color: var(--vscode-descriptionForeground, #8b949e); }
|
|
.img-thumb { position: relative; width: 48px; height: 48px; border-radius: 4px; overflow: hidden; border: 1px solid var(--vscode-input-border, #30363d); flex-shrink: 0; }
|
|
.img-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
|
.img-thumb .remove-img { position: absolute; top: 0; right: 0; background: rgba(0,0,0,0.7); color: #fff; border: none; cursor: pointer; font-size: 10px; padding: 1px 3px; line-height: 1; }
|
|
.status { font-size: 10px; color: var(--vscode-descriptionForeground, #484f58); flex: 1; }
|
|
.status.active { color: #e2b340; }
|
|
.ctrl-btn { background: none; border: 1px solid var(--vscode-input-border, #30363d); border-radius: 4px; font-size: 9px; padding: 2px 6px; cursor: pointer; font-family: inherit; white-space: nowrap; }
|
|
.ctrl-btn.voice-btn { color: #e2b340; }
|
|
.ctrl-btn.voice-btn.off { color: #484f58; }
|
|
.ctrl-btn.hf-btn { color: #484f58; }
|
|
.ctrl-btn.hf-btn.active { color: #0d1117; background: #22c55e; border-color: #22c55e; animation: hfPulse 2s infinite; }
|
|
@keyframes hfPulse { 0%, 100% { box-shadow: 0 0 4px rgba(34,197,94,0.3); } 50% { box-shadow: 0 0 12px rgba(34,197,94,0.6); } }
|
|
.thinking { display: inline-block; color: #3b82f6; }
|
|
.thinking::after { content: ''; animation: dots 1.5s infinite; }
|
|
@keyframes dots { 0% { content: '.'; } 33% { content: '..'; } 66% { content: '...'; } }
|
|
.ide-quick-bar { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-bottom: 8px; padding: 8px 10px; border: 1px solid var(--vscode-input-border, #30363d); border-radius: 8px; background: rgba(226,179,64,0.07); }
|
|
.ide-quick-label { font-size: 9px; color: var(--vscode-descriptionForeground); text-transform: uppercase; letter-spacing: 0.5px; margin-right: 4px; flex-shrink: 0; }
|
|
.ide-q-btn { font-size: 10px; padding: 5px 10px; border-radius: 6px; border: 1px solid var(--vscode-input-border, #30363d); background: var(--vscode-input-background, #161b22); color: var(--vscode-foreground); cursor: pointer; white-space: nowrap; }
|
|
.ide-q-btn:hover { border-color: #e2b340; color: #e2b340; }
|
|
.code-block-wrap { margin: 6px 0; border: 1px solid var(--vscode-panel-border, #30363d); border-radius: 6px; overflow: hidden; }
|
|
.code-block-header { display: flex; align-items: center; gap: 4px; padding: 4px 8px; background: rgba(255,255,255,0.03); border-bottom: 1px solid var(--vscode-panel-border, #30363d); }
|
|
.code-lang { font-size: 9px; color: #8b949e; text-transform: uppercase; letter-spacing: 0.5px; flex: 1; }
|
|
.cb-btn { font-size: 9px; padding: 2px 8px; border-radius: 4px; border: 1px solid var(--vscode-input-border, #30363d); background: transparent; cursor: pointer; font-family: inherit; }
|
|
.cb-copy { color: #60a5fa; }
|
|
.cb-copy:hover { background: rgba(96,165,250,0.15); border-color: #60a5fa; }
|
|
.cb-insert { color: #22c55e; }
|
|
.cb-insert:hover { background: rgba(34,197,94,0.15); border-color: #22c55e; }
|
|
.cb-run { color: #e2b340; }
|
|
.cb-run:hover { background: rgba(226,179,64,0.15); border-color: #e2b340; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<h2>Alfred</h2>
|
|
<div class="subtitle">All models · Voice STT/TTS · Attachments · Ctrl+Shift+Alt+A · Enter sends</div>
|
|
<div class="identity-badge" id="identityBadge" style="display:none">Identity</div>
|
|
<div class="usage-info" id="usageInfo">
|
|
<span class="plan-label" id="planLabel">Plan</span> · <span id="usageText">Loading...</span>
|
|
<div class="usage-bar"><div class="usage-fill" id="usageFill"></div></div>
|
|
</div>
|
|
</div>
|
|
<div class="agent-bar">
|
|
<label for="agentSelect">Agent</label>
|
|
<select class="agent-select" id="agentSelect" title="Routing persona — applies on Send">
|
|
<option value="alfred" selected>Alfred (General)</option>
|
|
<option value="cipher">Cipher (Security)</option>
|
|
<option value="sage">Sage (Knowledge)</option>
|
|
<option value="nova">Nova (Creative)</option>
|
|
<option value="atlas">Atlas (Strategy)</option>
|
|
<option value="architect">Architect (Engineering)</option>
|
|
<option value="sentinel">Sentinel (Monitoring)</option>
|
|
<option value="catalyst">Catalyst (Growth)</option>
|
|
<option value="oracle">Oracle (Analytics)</option>
|
|
<option value="scout">Scout (Research)</option>
|
|
</select>
|
|
<label for="modelSelect">Model</label>
|
|
<select class="model-select" id="modelSelect" title="LLM — applies on Send">
|
|
<optgroup label="💜 Anthropic">
|
|
<option value="sonnet" selected>💜 Sonnet 4.6</option>
|
|
<option value="haiku">⚡ Haiku 4.5 (Fast)</option>
|
|
<option value="opus">👑 Opus 4.6 (Max)</option>
|
|
</optgroup>
|
|
<optgroup label="🟢 OpenAI">
|
|
<option value="gpt-4.1">🟢 GPT-4.1</option>
|
|
<option value="gpt-4.1-mini">🟡 GPT-4.1 Mini</option>
|
|
<option value="gpt-4.1-nano">🟤 GPT-4.1 Nano</option>
|
|
<option value="gpt-4o">🔵 GPT-4o</option>
|
|
<option value="gpt-4o-mini">🔹 GPT-4o Mini</option>
|
|
</optgroup>
|
|
<optgroup label="💎 Google">
|
|
<option value="gemini-3.1-pro">🌐 Gemini 3.1 Pro</option>
|
|
<option value="gemini-3-flash">⚡ Gemini 3 Flash</option>
|
|
<option value="gemini-2.5-pro">💎 Gemini 2.5 Pro</option>
|
|
<option value="gemini-2.5-flash">💡 Gemini 2.5 Flash</option>
|
|
</optgroup>
|
|
<optgroup label="🔧 Open Source">
|
|
<option value="turbo">🔧 Qwen3 Coder (Cheap)</option>
|
|
<option value="deepseek-v3.1">🌊 DeepSeek V3.1</option>
|
|
<option value="deepseek-r1">🧪 DeepSeek R1 (Reasoning)</option>
|
|
<option value="llama-4-maverick">🦙 Llama 4 Maverick</option>
|
|
<option value="llama-4-scout">🔭 Llama 4 Scout</option>
|
|
<option value="qwen3-coder-480b">🏗️ Qwen3 Coder 480B</option>
|
|
<option value="mistral-small">🇫🇷 Mistral Small</option>
|
|
</optgroup>
|
|
<optgroup label="🆓 Groq (Free)">
|
|
<option value="groq-llama-3.3">🆓 Llama 3.3 70B</option>
|
|
<option value="groq-llama-3.1">🆓 Llama 3.1 8B</option>
|
|
</optgroup>
|
|
<optgroup label="🤖 Auto">
|
|
<option value="auto">🤖 Auto (Smart Route)</option>
|
|
</optgroup>
|
|
</select>
|
|
<label for="multiplierSelect">Tokens</label>
|
|
<select class="multiplier-select" id="multiplierSelect" title="Token multiplier — scales max response length">
|
|
<option value="1">1x</option>
|
|
<option value="30" selected>30x</option>
|
|
<option value="60">60x</option>
|
|
<option value="120">120x</option>
|
|
<option value="300">300x</option>
|
|
<option value="600">600x</option>
|
|
</select>
|
|
</div>
|
|
<div class="ide-quick-bar">
|
|
<span class="ide-quick-label">IDE</span>
|
|
<button type="button" class="ide-q-btn" id="acIdeTerminal" title="New terminal">Terminal</button>
|
|
<button type="button" class="ide-q-btn" id="acIdeSave" title="Save file">Save</button>
|
|
<button type="button" class="ide-q-btn" id="acIdeSaveAll" title="Save all">Save all</button>
|
|
<button type="button" class="ide-q-btn" id="acIdePalette" title="Command palette">Commands</button>
|
|
<button type="button" class="ide-q-btn" id="acIdeSplit" title="Split editor">Split</button>
|
|
<button type="button" class="ide-q-btn" id="acIdeNew" title="New untitled file">New file</button>
|
|
<button type="button" class="ide-q-btn" id="acIdeGit" title="Open source control">Git</button>
|
|
<button type="button" class="ide-q-btn" id="acIdeProblems" title="Show problems panel">Problems</button>
|
|
<button type="button" class="ide-q-btn" id="acIdeSearch" title="Search across files">Search</button>
|
|
<button type="button" class="ide-q-btn" id="acIdeFormat" title="Format document">Format</button>
|
|
</div>
|
|
<div class="chat-area" id="chatArea">
|
|
<div class="message alfred" id="greetingMsg">
|
|
<div class="sender">Alfred</div>
|
|
<div>Initializing workspace intelligence...</div>
|
|
</div>
|
|
</div>
|
|
<div class="attach-panel empty" id="attachPanel"></div>
|
|
<div class="image-preview-strip empty" id="imagePreviewStrip"></div>
|
|
<div class="controls-wrap">
|
|
<input type="text" class="text-input" id="textInput" placeholder="Ask Alfred… (Ctrl+V to paste image)" autocomplete="off">
|
|
<div class="action-row">
|
|
<button type="button" class="mic-btn" id="micBtn" title="Voice input (Whisper STT)">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
|
|
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
|
|
<line x1="12" y1="19" x2="12" y2="23"/>
|
|
<line x1="8" y1="23" x2="16" y2="23"/>
|
|
</svg>
|
|
</button>
|
|
<button type="button" class="attach-btn" id="attachBtn" title="Attach image, PDF, code, text, or ZIP">📎</button>
|
|
<input type="file" id="fileInput" accept="image/*,.pdf,.zip,text/*,.md,.txt,.json,.js,.jsx,.ts,.tsx,.php,.py,.html,.css,.scss,.xml,.yml,.yaml,.sh,.log,.csv,.sql" multiple style="display:none">
|
|
<button type="button" class="send-btn" id="ac-send" title="Send to Alfred API — not a link" onclick="if(typeof dispatchSend==='function'){dispatchSend(event)}else{document.getElementById('status').textContent='Script not loaded — refresh page'}">SEND</button>
|
|
</div>
|
|
</div>
|
|
<div class="meter" id="payloadMeter">Payload: 0 KB
|
|
<div class="bar"><div class="fill" id="payloadFill"></div></div>
|
|
</div>
|
|
<div class="bottom-bar">
|
|
<div class="status" id="status">Ready</div>
|
|
<button class="ctrl-btn voice-btn" id="voiceToggle">Voice ON</button>
|
|
<button class="ctrl-btn hf-btn" id="handsFreeBtn" title="Hands-free: auto-listen after Alfred speaks">Hands-Free</button>
|
|
</div>
|
|
|
|
<script>
|
|
let vscode;
|
|
try { vscode = acquireVsCodeApi(); } catch(e) { vscode = { postMessage: function(){}, setState: function(){}, getState: function(){} }; }
|
|
// Injected session data from extension host (server-side read of session.json)
|
|
window.__alfredToken = '${safeToken}';
|
|
window.__alfredIdentity = ${safeIdentity};
|
|
window.__alfredCsrf = null;
|
|
window.__alfredSessionCookie = null;
|
|
const chatArea = document.getElementById('chatArea');
|
|
const micBtn = document.getElementById('micBtn');
|
|
const textInput = document.getElementById('textInput');
|
|
const sendBtn = document.getElementById('ac-send');
|
|
const statusEl = document.getElementById('status');
|
|
const agentSelect = document.getElementById('agentSelect');
|
|
const modelSelect = document.getElementById('modelSelect');
|
|
const multiplierSelect = document.getElementById('multiplierSelect');
|
|
const handsFreeBtn = document.getElementById('handsFreeBtn');
|
|
const attachBtn = document.getElementById('attachBtn');
|
|
const fileInput = document.getElementById('fileInput');
|
|
const imagePreviewStrip = document.getElementById('imagePreviewStrip');
|
|
const attachPanel = document.getElementById('attachPanel');
|
|
const payloadMeter = document.getElementById('payloadMeter');
|
|
const payloadFill = document.getElementById('payloadFill');
|
|
const identityBadge = document.getElementById('identityBadge');
|
|
|
|
let isRecording = false, isTranscribing = false, isSpeaking = false, messageId = 0;
|
|
let voiceEnabled = true, currentAudio = null;
|
|
let handsFreeMode = false;
|
|
let mediaRecorder = null, audioChunks = [], mediaStream = null;
|
|
let silenceTimer = null;
|
|
let sttIdCounter = 0;
|
|
let pendingSttResolve = null;
|
|
let pendingImages = []; // { id, name, base64, mime, dataUrl, size }
|
|
let pendingPdfFiles = []; // { id, name, data, size }
|
|
let pendingTextFiles = []; // { id, name, text, size }
|
|
let pendingZipFiles = []; // { id, name, data, size }
|
|
let pendingAttachments = []; // ordered queue of ids
|
|
let attachmentIdCounter = 0;
|
|
let pendingAttachmentReads = 0;
|
|
let queuedSend = false;
|
|
let lastRequest = null;
|
|
|
|
function createAttachmentReadGuard(label, timeoutMs) {
|
|
pendingAttachmentReads += 1;
|
|
let done = false;
|
|
const timer = setTimeout(() => {
|
|
if (done) return;
|
|
done = true;
|
|
if (pendingAttachmentReads > 0) pendingAttachmentReads -= 1;
|
|
setStatus(label + ' processing timed out');
|
|
logTelemetry('attachment-timeout: ' + label);
|
|
if (pendingAttachmentReads === 0 && queuedSend) {
|
|
queuedSend = false;
|
|
dispatchSend();
|
|
}
|
|
}, timeoutMs || 15000);
|
|
return () => {
|
|
if (done) return;
|
|
done = true;
|
|
clearTimeout(timer);
|
|
finalizeAttachmentRead();
|
|
};
|
|
}
|
|
|
|
function logTelemetry(line) {
|
|
const telemetryDrawer = null;
|
|
if (!telemetryDrawer) return;
|
|
const ts = new Date().toLocaleTimeString();
|
|
const div = document.createElement('div');
|
|
div.className = 'telemetry-line';
|
|
div.textContent = '[' + ts + '] ' + line;
|
|
telemetryDrawer.appendChild(div);
|
|
while (telemetryDrawer.childElementCount > 120) {
|
|
telemetryDrawer.removeChild(telemetryDrawer.firstChild);
|
|
}
|
|
telemetryDrawer.scrollTop = telemetryDrawer.scrollHeight;
|
|
}
|
|
|
|
function bytesToKB(n) {
|
|
return Math.round((n || 0) / 1024);
|
|
}
|
|
|
|
function updatePayloadMeter() {
|
|
if (!payloadMeter || !payloadFill) return;
|
|
let total = 0;
|
|
for (const i of pendingImages) total += i.size || 0;
|
|
for (const p of pendingPdfFiles) total += p.size || 0;
|
|
for (const t of pendingTextFiles) total += t.size || 0;
|
|
for (const z of pendingZipFiles) total += z.size || 0;
|
|
const kb = bytesToKB(total);
|
|
if (payloadMeter.firstChild) payloadMeter.firstChild.textContent = 'Payload: ' + kb + ' KB';
|
|
const pct = Math.min(100, Math.round((total / (10 * 1024 * 1024)) * 100));
|
|
payloadFill.style.width = pct + '%';
|
|
}
|
|
|
|
function applyIdentity(profile) {
|
|
const p = profile || {};
|
|
const name = p.name || p.user || 'User';
|
|
if (!identityBadge) return;
|
|
identityBadge.textContent = 'Identity: ' + name;
|
|
const verified = p.verified === true || !!p.client_id;
|
|
const guestish = !verified || String(name).toLowerCase() === 'guest';
|
|
identityBadge.classList.toggle('guest', guestish);
|
|
identityBadge.classList.toggle('verified', !guestish);
|
|
identityBadge.style.display = !guestish ? 'inline-block' : 'none';
|
|
logTelemetry('identity: ' + name + (p.client_id ? ' (id=' + p.client_id + ')' : '') + (verified ? ' [verified]' : ' [guest]'));
|
|
|
|
// Usage/balance display
|
|
const usageInfo = document.getElementById('usageInfo');
|
|
const planLabel = document.getElementById('planLabel');
|
|
const usageText = document.getElementById('usageText');
|
|
const usageFill = document.getElementById('usageFill');
|
|
if (!usageInfo || !planLabel || !usageText || !usageFill) return;
|
|
if (guestish || !p.plan) { usageInfo.style.display = 'none'; return; }
|
|
usageInfo.style.display = 'block';
|
|
const plan = (p.plan || 'free').toLowerCase();
|
|
planLabel.textContent = plan === 'commander' ? 'Commander' : (p.plan || 'Free');
|
|
planLabel.className = 'plan-label plan-' + plan;
|
|
if (p.unlimited || plan === 'commander') {
|
|
usageText.textContent = 'Unlimited';
|
|
usageFill.className = 'usage-fill unlimited';
|
|
usageFill.style.width = '100%';
|
|
} else {
|
|
const used = p.tokens_used || 0;
|
|
const included = p.tokens_included || 50000;
|
|
const remaining = Math.max(0, included - used);
|
|
const pct = included > 0 ? Math.min(100, Math.round((used / included) * 100)) : 0;
|
|
const fmtK = (n) => n >= 1000000 ? (n / 1000000).toFixed(1) + 'M' : n >= 1000 ? (n / 1000).toFixed(0) + 'K' : String(n);
|
|
usageText.textContent = fmtK(remaining) + ' tokens left (' + fmtK(used) + ' / ' + fmtK(included) + ')';
|
|
usageFill.style.width = pct + '%';
|
|
usageFill.className = 'usage-fill' + (pct >= 90 ? ' danger' : pct >= 70 ? ' warn' : '');
|
|
}
|
|
}
|
|
|
|
function findAttachmentById(id) {
|
|
const img = pendingImages.find(x => x.id === id);
|
|
if (img) return { kind: 'image', item: img };
|
|
const pdf = pendingPdfFiles.find(x => x.id === id);
|
|
if (pdf) return { kind: 'pdf', item: pdf };
|
|
const txt = pendingTextFiles.find(x => x.id === id);
|
|
if (txt) return { kind: 'text', item: txt };
|
|
const zip = pendingZipFiles.find(x => x.id === id);
|
|
if (zip) return { kind: 'zip', item: zip };
|
|
return null;
|
|
}
|
|
|
|
function setAttachmentStatusById(id, status, detail) {
|
|
const ref = findAttachmentById(id);
|
|
if (!ref || !ref.item) return;
|
|
ref.item.status = status || 'ready';
|
|
ref.item.detail = detail || '';
|
|
}
|
|
|
|
function summarizeAttachmentReport(report) {
|
|
if (!Array.isArray(report) || report.length === 0) return '';
|
|
return report.map((item) => {
|
|
const name = item && item.name ? item.name : (item && item.type ? item.type : 'attachment');
|
|
const status = item && item.status ? item.status : 'processed';
|
|
const detail = item && item.detail ? item.detail : '';
|
|
return name + ': ' + status + (detail ? ' (' + detail + ')' : '');
|
|
}).join(' | ');
|
|
}
|
|
|
|
function applyAttachmentReport(report) {
|
|
if (!Array.isArray(report) || report.length === 0) return;
|
|
for (const item of report) {
|
|
const name = String(item && item.name ? item.name : '');
|
|
const ref = pendingAttachments.map((id) => findAttachmentById(id)).find((entry) => entry && entry.item && entry.item.name === name);
|
|
if (!ref) continue;
|
|
ref.item.status = item.status || 'ok';
|
|
ref.item.detail = item.detail || '';
|
|
}
|
|
renderAttachmentPanel();
|
|
const summary = summarizeAttachmentReport(report);
|
|
if (summary) {
|
|
addMessage('system', 'Attachment report: ' + summary);
|
|
}
|
|
}
|
|
|
|
function removeAttachmentById(id) {
|
|
pendingImages = pendingImages.filter(x => x.id !== id);
|
|
pendingPdfFiles = pendingPdfFiles.filter(x => x.id !== id);
|
|
pendingTextFiles = pendingTextFiles.filter(x => x.id !== id);
|
|
pendingZipFiles = pendingZipFiles.filter(x => x.id !== id);
|
|
pendingAttachments = pendingAttachments.filter(x => x !== id);
|
|
renderAttachmentPanel();
|
|
updatePayloadMeter();
|
|
}
|
|
|
|
function moveAttachment(id, dir) {
|
|
const idx = pendingAttachments.indexOf(id);
|
|
if (idx < 0) return;
|
|
const ni = idx + dir;
|
|
if (ni < 0 || ni >= pendingAttachments.length) return;
|
|
const tmp = pendingAttachments[idx];
|
|
pendingAttachments[idx] = pendingAttachments[ni];
|
|
pendingAttachments[ni] = tmp;
|
|
renderAttachmentPanel();
|
|
}
|
|
|
|
function renderAttachmentPanel() {
|
|
if (!attachPanel || !imagePreviewStrip) return;
|
|
attachPanel.innerHTML = '';
|
|
if (pendingAttachments.length === 0) {
|
|
attachPanel.classList.add('empty');
|
|
imagePreviewStrip.classList.add('empty');
|
|
imagePreviewStrip.innerHTML = '';
|
|
return;
|
|
}
|
|
attachPanel.classList.remove('empty');
|
|
|
|
imagePreviewStrip.innerHTML = '';
|
|
const previewImgs = pendingAttachments
|
|
.map(id => findAttachmentById(id))
|
|
.filter(x => x && x.kind === 'image')
|
|
.slice(0, 6);
|
|
if (previewImgs.length > 0) {
|
|
imagePreviewStrip.classList.remove('empty');
|
|
for (const ref of previewImgs) {
|
|
const thumb = document.createElement('div');
|
|
thumb.className = 'img-thumb';
|
|
const img = document.createElement('img');
|
|
img.src = ref.item.dataUrl;
|
|
thumb.appendChild(img);
|
|
imagePreviewStrip.appendChild(thumb);
|
|
}
|
|
} else {
|
|
imagePreviewStrip.classList.add('empty');
|
|
}
|
|
|
|
for (let i = 0; i < pendingAttachments.length; i++) {
|
|
const id = pendingAttachments[i];
|
|
const ref = findAttachmentById(id);
|
|
if (!ref) continue;
|
|
const row = document.createElement('div');
|
|
row.className = 'attach-row';
|
|
const icon = document.createElement('span');
|
|
icon.textContent = ref.kind === 'image' ? '🖼️' : (ref.kind === 'pdf' ? '📄' : (ref.kind === 'zip' ? '📦' : '🧾'));
|
|
const name = document.createElement('div');
|
|
name.className = 'attach-name';
|
|
name.textContent = ref.item.name || (ref.kind + ' attachment');
|
|
const meta = document.createElement('span');
|
|
meta.className = 'attach-meta';
|
|
meta.textContent = bytesToKB(ref.item.size || 0) + ' KB';
|
|
const state = document.createElement('span');
|
|
const statusValue = String(ref.item.status || 'ready');
|
|
state.className = 'attach-state ' + statusValue;
|
|
state.textContent = statusValue;
|
|
const detail = document.createElement('span');
|
|
detail.className = 'attach-detail';
|
|
detail.textContent = ref.item.detail || '';
|
|
const up = document.createElement('button'); up.className = 'small-btn'; up.textContent = '↑'; up.onclick = () => moveAttachment(id, -1);
|
|
const dn = document.createElement('button'); dn.className = 'small-btn'; dn.textContent = '↓'; dn.onclick = () => moveAttachment(id, 1);
|
|
const rm = document.createElement('button'); rm.className = 'small-btn'; rm.textContent = '✕'; rm.onclick = () => removeAttachmentById(id);
|
|
row.appendChild(icon);
|
|
row.appendChild(name);
|
|
row.appendChild(meta);
|
|
row.appendChild(state);
|
|
row.appendChild(detail);
|
|
row.appendChild(up);
|
|
row.appendChild(dn);
|
|
row.appendChild(rm);
|
|
attachPanel.appendChild(row);
|
|
}
|
|
}
|
|
|
|
// ── Image attachment helpers ──────────────────────────────────────────────
|
|
|
|
function addPendingImage(base64, mime, dataUrl, fileName) {
|
|
if (pendingImages.length >= 5) { setStatus('Max 5 images per message'); return; }
|
|
const id = ++attachmentIdCounter;
|
|
const size = Math.floor((base64.length * 3) / 4);
|
|
pendingImages.push({ id, name: fileName || ('image-' + id), base64, mime, dataUrl, size, status: 'ready', detail: 'Ready to send' });
|
|
pendingAttachments.push(id);
|
|
renderAttachmentPanel();
|
|
updatePayloadMeter();
|
|
}
|
|
|
|
function clearPendingImages() {
|
|
pendingImages = [];
|
|
pendingPdfFiles = [];
|
|
pendingTextFiles = [];
|
|
pendingZipFiles = [];
|
|
pendingAttachments = [];
|
|
if (imagePreviewStrip) {
|
|
imagePreviewStrip.innerHTML = '';
|
|
imagePreviewStrip.classList.add('empty');
|
|
}
|
|
renderAttachmentPanel();
|
|
updatePayloadMeter();
|
|
}
|
|
|
|
function finalizeAttachmentRead() {
|
|
if (pendingAttachmentReads > 0) pendingAttachmentReads -= 1;
|
|
if (pendingAttachmentReads === 0 && queuedSend) {
|
|
queuedSend = false;
|
|
dispatchSend();
|
|
}
|
|
}
|
|
|
|
function isZipFile(file) {
|
|
const name = String(file?.name || '').toLowerCase();
|
|
const type = String(file?.type || '').toLowerCase();
|
|
return name.endsWith('.zip') || type === 'application/zip' || type === 'application/x-zip-compressed' || type === 'multipart/x-zip';
|
|
}
|
|
|
|
function isPdfFile(file) {
|
|
const name = String(file?.name || '').toLowerCase();
|
|
const type = String(file?.type || '').toLowerCase();
|
|
return type === 'application/pdf' || name.endsWith('.pdf');
|
|
}
|
|
|
|
function isTextLikeFile(file) {
|
|
const name = String(file?.name || '').toLowerCase();
|
|
const type = String(file?.type || '').toLowerCase();
|
|
if (type.startsWith('text/')) return true;
|
|
if (type.includes('json') || type.includes('javascript') || type.includes('typescript') || type.includes('xml') || type.includes('yaml') || type.includes('python') || type.includes('shell')) return true;
|
|
return /\.(txt|md|markdown|json|js|jsx|ts|tsx|php|py|rb|go|java|c|cc|cpp|h|hpp|cs|rs|swift|kt|m|mm|scala|sql|html|css|scss|sass|less|xml|yml|yaml|sh|bash|zsh|env|ini|conf|cfg|log|csv)$/i.test(name);
|
|
}
|
|
|
|
function processAttachmentFile(file) {
|
|
if (!file) return;
|
|
const fileType = String(file.type || '').toLowerCase();
|
|
if (isPdfFile(file)) {
|
|
if (file.size > 8 * 1024 * 1024) { setStatus('PDF too large (max 8MB)'); return; }
|
|
const finishRead = createAttachmentReadGuard('PDF', 15000);
|
|
setStatus('Processing PDF...', true);
|
|
const r = new FileReader();
|
|
r.onload = e => {
|
|
const dataUrl = String(e.target.result || '');
|
|
if (!dataUrl.includes(',')) { setStatus('Failed to process PDF'); finishRead(); return; }
|
|
const base64 = dataUrl.split(',')[1];
|
|
const id = ++attachmentIdCounter;
|
|
pendingPdfFiles.push({ id, name: file.name || ('file-' + id + '.pdf'), data: base64, size: file.size || 0, status: 'ready', detail: 'Awaiting extraction' });
|
|
pendingAttachments.push(id);
|
|
renderAttachmentPanel();
|
|
updatePayloadMeter();
|
|
setStatus('PDF attached');
|
|
finishRead();
|
|
};
|
|
r.onerror = () => { setStatus('Failed to process PDF'); finishRead(); };
|
|
r.readAsDataURL(file);
|
|
return;
|
|
}
|
|
if (isZipFile(file)) {
|
|
if (file.size > 10 * 1024 * 1024) { setStatus('ZIP too large (max 10MB)'); return; }
|
|
const finishRead = createAttachmentReadGuard('ZIP', 15000);
|
|
setStatus('Processing ZIP...', true);
|
|
const zipReader = new FileReader();
|
|
zipReader.onload = e => {
|
|
const dataUrl = String(e.target.result || '');
|
|
if (!dataUrl.includes(',')) { setStatus('Failed to process ZIP'); finishRead(); return; }
|
|
const base64 = dataUrl.split(',')[1];
|
|
const id = ++attachmentIdCounter;
|
|
pendingZipFiles.push({ id, name: file.name || ('archive-' + id + '.zip'), data: base64, size: file.size || 0, status: 'ready', detail: 'Awaiting extraction' });
|
|
pendingAttachments.push(id);
|
|
renderAttachmentPanel();
|
|
updatePayloadMeter();
|
|
setStatus('ZIP attached');
|
|
finishRead();
|
|
};
|
|
zipReader.onerror = () => { setStatus('Failed to process ZIP'); finishRead(); };
|
|
zipReader.readAsDataURL(file);
|
|
return;
|
|
}
|
|
if (isTextLikeFile(file)) {
|
|
if (file.size > 300 * 1024) {
|
|
setStatus('Text file too large (max 300KB)');
|
|
return;
|
|
}
|
|
const finishRead = createAttachmentReadGuard('Text attachment', 12000);
|
|
setStatus('Reading text attachment...', true);
|
|
const textReader = new FileReader();
|
|
textReader.onload = e => {
|
|
const content = String(e.target.result || '').slice(0, 12000);
|
|
const safeName = String(file.name || 'attachment.txt').split(String.fromCharCode(96)).join('');
|
|
const id = ++attachmentIdCounter;
|
|
pendingTextFiles.push({ id, name: safeName, text: content, size: file.size || content.length, status: 'ready', detail: 'Ready to send' });
|
|
pendingAttachments.push(id);
|
|
renderAttachmentPanel();
|
|
updatePayloadMeter();
|
|
setStatus('Text file attached');
|
|
finishRead();
|
|
};
|
|
textReader.onerror = () => { setStatus('Failed to read text file'); finishRead(); };
|
|
textReader.readAsText(file);
|
|
return;
|
|
}
|
|
if (!fileType.startsWith('image/')) {
|
|
setStatus('Unsupported file: ' + (file.name || 'attachment'));
|
|
return;
|
|
}
|
|
if (file.size > 5 * 1024 * 1024) { setStatus('Image too large (max 5MB)'); return; }
|
|
const finishRead = createAttachmentReadGuard('Image', 12000);
|
|
setStatus('Processing image...', true);
|
|
const reader = new FileReader();
|
|
reader.onload = e => {
|
|
const dataUrl = String(e.target.result || '');
|
|
if (!dataUrl.includes(',')) { setStatus('Failed to process image'); finishRead(); return; }
|
|
const base64 = dataUrl.split(',')[1];
|
|
const mime = file.type;
|
|
addPendingImage(base64, mime, dataUrl, file.name || 'image');
|
|
setStatus('Image attached');
|
|
finishRead();
|
|
};
|
|
reader.onerror = () => { setStatus('Failed to process image'); finishRead(); };
|
|
reader.readAsDataURL(file);
|
|
}
|
|
|
|
// Paste handler (Ctrl+V image)
|
|
document.addEventListener('paste', e => {
|
|
let handled = false;
|
|
const items = e.clipboardData?.items;
|
|
if (items && items.length) {
|
|
for (const item of items) {
|
|
if (item && item.kind === 'file') {
|
|
const f = item.getAsFile();
|
|
if (f) {
|
|
e.preventDefault();
|
|
processAttachmentFile(f);
|
|
handled = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (!handled) {
|
|
const files = e.clipboardData?.files;
|
|
if (files && files.length) {
|
|
for (const f of files) {
|
|
if (f) {
|
|
e.preventDefault();
|
|
processAttachmentFile(f);
|
|
handled = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (handled) setStatus('Attachment added', false);
|
|
});
|
|
|
|
// Drag-and-drop onto chat area
|
|
if (chatArea) {
|
|
chatArea.addEventListener('dragover', e => { e.preventDefault(); chatArea.style.outline = '2px dashed #e2b340'; });
|
|
chatArea.addEventListener('dragleave', () => { chatArea.style.outline = ''; });
|
|
chatArea.addEventListener('drop', e => {
|
|
e.preventDefault();
|
|
chatArea.style.outline = '';
|
|
Array.from(e.dataTransfer.files || []).forEach(processAttachmentFile);
|
|
});
|
|
}
|
|
|
|
// File picker button
|
|
if (attachBtn && fileInput) {
|
|
attachBtn.addEventListener('click', () => fileInput.click());
|
|
fileInput.addEventListener('change', () => {
|
|
Array.from(fileInput.files || []).forEach(processAttachmentFile);
|
|
fileInput.value = '';
|
|
});
|
|
}
|
|
|
|
if (agentSelect) {
|
|
agentSelect.addEventListener('change', () => {
|
|
vscode.postMessage({ type: 'set-agent', agent: agentSelect.value });
|
|
addMessage('system', 'Switched to ' + agentSelect.options[agentSelect.selectedIndex].text);
|
|
});
|
|
}
|
|
|
|
if (modelSelect) {
|
|
modelSelect.addEventListener('change', () => {
|
|
addMessage('system', 'Model: ' + modelSelect.options[modelSelect.selectedIndex].text);
|
|
});
|
|
}
|
|
|
|
// ── MediaRecorder-based recording ──────────────────────────────────────────
|
|
|
|
async function getMicStream() {
|
|
if (mediaStream) return mediaStream;
|
|
try {
|
|
mediaStream = await navigator.mediaDevices.getUserMedia({
|
|
audio: { channelCount: 1, sampleRate: 16000, echoCancellation: true, noiseSuppression: true }
|
|
});
|
|
return mediaStream;
|
|
} catch (e) {
|
|
setStatus('Mic access denied: ' + e.message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function startRecording() {
|
|
if (isRecording || isTranscribing) return;
|
|
if (isSpeaking) { stopAudio(); }
|
|
|
|
const stream = await getMicStream();
|
|
if (!stream) {
|
|
setStatus('No microphone access');
|
|
if (handsFreeMode) toggleHandsFree();
|
|
return;
|
|
}
|
|
|
|
audioChunks = [];
|
|
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus'
|
|
: MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm'
|
|
: MediaRecorder.isTypeSupported('audio/ogg;codecs=opus') ? 'audio/ogg;codecs=opus'
|
|
: 'audio/mp4';
|
|
|
|
try {
|
|
mediaRecorder = new MediaRecorder(stream, { mimeType });
|
|
} catch (e) {
|
|
mediaRecorder = new MediaRecorder(stream);
|
|
}
|
|
|
|
mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) audioChunks.push(e.data); };
|
|
mediaRecorder.onstop = () => { finishRecording(); };
|
|
|
|
mediaRecorder.start(250);
|
|
isRecording = true;
|
|
micBtn.classList.add('recording');
|
|
micBtn.classList.remove('transcribing');
|
|
setStatus('Recording...', true);
|
|
|
|
silenceTimer = setTimeout(() => {
|
|
if (isRecording) stopRecording();
|
|
}, 15000);
|
|
}
|
|
|
|
function stopRecording() {
|
|
if (!isRecording || !mediaRecorder) return;
|
|
clearTimeout(silenceTimer);
|
|
isRecording = false;
|
|
try { mediaRecorder.stop(); } catch(e) {}
|
|
}
|
|
|
|
async function finishRecording() {
|
|
micBtn.classList.remove('recording');
|
|
if (audioChunks.length === 0) {
|
|
setStatus('No audio captured');
|
|
if (handsFreeMode) scheduleHandsFreeRecord();
|
|
return;
|
|
}
|
|
|
|
isTranscribing = true;
|
|
micBtn.classList.add('transcribing');
|
|
setStatus('Transcribing...', true);
|
|
|
|
const blob = new Blob(audioChunks, { type: mediaRecorder.mimeType || 'audio/webm' });
|
|
audioChunks = [];
|
|
|
|
if (blob.size < 1000) {
|
|
isTranscribing = false;
|
|
micBtn.classList.remove('transcribing');
|
|
setStatus('Too short');
|
|
if (handsFreeMode) scheduleHandsFreeRecord();
|
|
return;
|
|
}
|
|
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => {
|
|
const base64 = reader.result.split(',')[1];
|
|
const id = ++sttIdCounter;
|
|
vscode.postMessage({ type: 'stt-request', audio: base64, mime: blob.type, id });
|
|
};
|
|
reader.readAsDataURL(blob);
|
|
}
|
|
|
|
function toggleRecording() {
|
|
if (isRecording) stopRecording();
|
|
else if (isTranscribing) { /* wait */ }
|
|
else startRecording();
|
|
}
|
|
|
|
// ── Audio playback ─────────────────────────────────────────────────────────
|
|
|
|
function stopAudio() {
|
|
if (currentAudio) { currentAudio.pause(); currentAudio.currentTime = 0; currentAudio = null; }
|
|
isSpeaking = false;
|
|
setStatus('Ready');
|
|
}
|
|
|
|
function playAudio(dataUrl) {
|
|
if (!voiceEnabled || !dataUrl) return;
|
|
stopAudio();
|
|
isSpeaking = true;
|
|
setStatus('Speaking...', true);
|
|
currentAudio = new Audio(dataUrl);
|
|
currentAudio.onended = () => {
|
|
isSpeaking = false;
|
|
currentAudio = null;
|
|
setStatus('Ready');
|
|
if (handsFreeMode) scheduleHandsFreeRecord();
|
|
};
|
|
currentAudio.onerror = () => {
|
|
isSpeaking = false;
|
|
currentAudio = null;
|
|
setStatus('Ready');
|
|
if (handsFreeMode) scheduleHandsFreeRecord();
|
|
};
|
|
currentAudio.play().catch(() => {
|
|
isSpeaking = false;
|
|
setStatus('Ready');
|
|
if (handsFreeMode) scheduleHandsFreeRecord();
|
|
});
|
|
}
|
|
|
|
// ── Hands-free mode ────────────────────────────────────────────────────────
|
|
|
|
function toggleHandsFree() {
|
|
handsFreeMode = !handsFreeMode;
|
|
handsFreeBtn.classList.toggle('active', handsFreeMode);
|
|
handsFreeBtn.textContent = handsFreeMode ? 'HF Active' : 'Hands-Free';
|
|
if (handsFreeMode) {
|
|
addMessage('system', 'Hands-free mode ON — auto-listen after Alfred speaks');
|
|
if (!isRecording && !isTranscribing && !isSpeaking) {
|
|
scheduleHandsFreeRecord();
|
|
}
|
|
} else {
|
|
addMessage('system', 'Hands-free mode OFF');
|
|
if (isRecording) stopRecording();
|
|
}
|
|
}
|
|
|
|
function scheduleHandsFreeRecord() {
|
|
if (!handsFreeMode || isRecording || isTranscribing || isSpeaking) return;
|
|
setTimeout(() => {
|
|
if (handsFreeMode && !isRecording && !isTranscribing && !isSpeaking) {
|
|
startRecording();
|
|
}
|
|
}, 800);
|
|
}
|
|
|
|
// ── Message formatting ─────────────────────────────────────────────────────
|
|
|
|
function formatResponse(text) {
|
|
let blockId = 0;
|
|
return text
|
|
.replace(/\`\`\`(\\w*)\\n([\\s\\S]*?)\`\`\`/g, function(match, lang, code) {
|
|
blockId++;
|
|
const escapedCode = code.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
const safeLang = (lang || 'text').replace(/[^a-zA-Z0-9]/g, '');
|
|
return '<div class="code-block-wrap" data-block-id="cb' + blockId + '">' +
|
|
'<div class="code-block-header"><span class="code-lang">' + safeLang + '</span>' +
|
|
'<button class="cb-btn cb-copy" onclick="copyCodeBlock(this)" title="Copy to clipboard">Copy</button>' +
|
|
'<button class="cb-btn cb-insert" onclick="insertCodeBlock(this)" title="Insert at cursor">Insert</button>' +
|
|
((['sh','bash','zsh','shell','cmd','powershell','terminal'].includes(safeLang)) ?
|
|
'<button class="cb-btn cb-run" onclick="runCodeBlock(this)" title="Run in terminal">Run</button>' : '') +
|
|
'</div>' +
|
|
'<pre><code data-code="' + btoa(unescape(encodeURIComponent(code))) + '">' + escapedCode + '</code></pre></div>';
|
|
})
|
|
.replace(/\`([^\`]+)\`/g, '<code>$1</code>')
|
|
.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>')
|
|
.replace(/\\n/g, '<br>');
|
|
}
|
|
|
|
function formatResponseSafe(raw) {
|
|
try {
|
|
return formatResponse(raw == null ? '' : String(raw));
|
|
} catch (e) {
|
|
return String(raw == null ? '' : raw)
|
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
.replace(/\\n/g, '<br>');
|
|
}
|
|
}
|
|
|
|
function addMessage(sender, text, isThinking, requestId) {
|
|
const div = document.createElement('div');
|
|
div.className = 'message ' + (sender === 'commander' ? 'commander' : sender === 'system' ? 'system' : 'alfred');
|
|
if (requestId != null && requestId !== '') {
|
|
div.setAttribute('data-alfred-req-id', String(requestId));
|
|
}
|
|
if (sender !== 'system') {
|
|
const s = document.createElement('div'); s.className = 'sender';
|
|
s.textContent = sender === 'commander' ? 'Commander' : sender.charAt(0).toUpperCase() + sender.slice(1);
|
|
div.appendChild(s);
|
|
}
|
|
const b = document.createElement('div');
|
|
b.className = 'msg-content';
|
|
if (isThinking) b.innerHTML = '<span class="thinking">Thinking</span>';
|
|
else if (sender === 'commander' || sender === 'system') b.textContent = text;
|
|
else b.innerHTML = formatResponseSafe(text);
|
|
div.appendChild(b);
|
|
chatArea.appendChild(div);
|
|
chatArea.scrollTop = chatArea.scrollHeight;
|
|
return b;
|
|
}
|
|
|
|
function setStatus(text, active) {
|
|
if (!statusEl) return;
|
|
statusEl.textContent = text;
|
|
statusEl.className = 'status' + (active ? ' active' : '');
|
|
}
|
|
|
|
function processInput(text) {
|
|
if (!text) return;
|
|
const attachmentCount = pendingAttachments.length;
|
|
const attachmentNames = pendingAttachments
|
|
.map(id => findAttachmentById(id))
|
|
.filter(Boolean)
|
|
.map(ref => ref.item && ref.item.name ? ref.item.name : ref.kind)
|
|
.slice(0, 6);
|
|
const contextSuffix = attachmentCount > 0
|
|
? ('\\n\\n[Attachments included: ' + attachmentCount + (attachmentNames.length ? (' | ' + attachmentNames.join(', ')) : '') + ']')
|
|
: '';
|
|
const modelText = text + contextSuffix;
|
|
if (pendingAttachments.length > 0) {
|
|
const imgHtml = pendingImages.map(p => '<img src="' + p.dataUrl + '" style="max-width:80px;max-height:60px;border-radius:4px;margin:2px;vertical-align:middle;">').join('');
|
|
const attachSummary = '<div style="margin-top:4px;font-size:10px;opacity:.8;">Attachments: ' + pendingAttachments.length + '</div>';
|
|
const msgDiv = addMessage('commander', text);
|
|
msgDiv.parentElement.insertAdjacentHTML('beforeend', '<div style="margin-top:4px;">' + imgHtml + attachSummary + '</div>');
|
|
} else {
|
|
addMessage('commander', text);
|
|
}
|
|
if (text.toLowerCase().startsWith('run ')) {
|
|
vscode.postMessage({ type: 'run-terminal', command: text.replace(/^run\s+/i, '') });
|
|
addMessage('system', 'Sent to terminal — chat not used.');
|
|
return;
|
|
}
|
|
if (text.toLowerCase().startsWith('insert ') || text.toLowerCase().startsWith('type ')) {
|
|
vscode.postMessage({ type: 'insert-code', code: text.replace(/^(insert|type)\s+/i, '') });
|
|
addMessage('alfred', 'Inserted.'); return;
|
|
}
|
|
const orderedImages = [];
|
|
const orderedPdfs = [];
|
|
const orderedTexts = [];
|
|
const orderedZips = [];
|
|
for (const id of pendingAttachments) {
|
|
const ref = findAttachmentById(id);
|
|
if (!ref) continue;
|
|
if (ref.kind === 'image') orderedImages.push({ data: ref.item.base64, type: ref.item.mime });
|
|
if (ref.kind === 'pdf') orderedPdfs.push({ name: ref.item.name, data: ref.item.data });
|
|
if (ref.kind === 'text') orderedTexts.push({ name: ref.item.name, text: ref.item.text });
|
|
if (ref.kind === 'zip') orderedZips.push({ name: ref.item.name, data: ref.item.data });
|
|
}
|
|
const outgoingModel = modelSelect ? modelSelect.value : 'sonnet';
|
|
const outgoingMultiplier = multiplierSelect ? parseInt(multiplierSelect.value, 10) : 30;
|
|
const reqId = ++messageId;
|
|
addMessage(agentSelect.value, '', true, reqId);
|
|
setStatus(agentSelect.options[agentSelect.selectedIndex].text + ' thinking...', true);
|
|
for (const id of pendingAttachments) {
|
|
setAttachmentStatusById(id, 'queued', 'Sending to Alfred');
|
|
}
|
|
renderAttachmentPanel();
|
|
lastRequest = { text: modelText, agent: agentSelect.value, model: outgoingModel, multiplier: outgoingMultiplier, images: orderedImages, pdf_files: orderedPdfs, attachment_texts: orderedTexts, zip_files: orderedZips };
|
|
logTelemetry('send #' + reqId + ' model=' + outgoingModel + ' multiplier=' + outgoingMultiplier + ' images=' + orderedImages.length + ' pdf=' + orderedPdfs.length + ' text=' + orderedTexts.length + ' zip=' + orderedZips.length);
|
|
|
|
// PRIMARY: Direct browser fetch to Alfred API (bypasses broken extension host IPC)
|
|
alfredDirectChat(reqId, modelText, agentSelect.value, outgoingModel, orderedImages, orderedPdfs, orderedTexts, orderedZips, outgoingMultiplier);
|
|
|
|
// SECONDARY: Also notify extension host for TTS, editor context, etc.
|
|
try { vscode.postMessage({ type: 'ai-request', text: modelText, agent: agentSelect.value, id: reqId, model: outgoingModel, multiplier: outgoingMultiplier, images: orderedImages, pdf_files: orderedPdfs, attachment_texts: orderedTexts, zip_files: orderedZips }); } catch(_) {}
|
|
}
|
|
|
|
// Direct browser-to-API chat (primary path — no extension host IPC needed)
|
|
async function alfredDirectChat(reqId, text, agent, model, images, pdfs, texts, zips, multiplier) {
|
|
try {
|
|
// Request workspace context from extension host for richer payload
|
|
vscode.postMessage({ type: 'get-context', id: reqId });
|
|
|
|
const payload = {
|
|
message: text, agent: agent || 'alfred', model: model || 'sonnet',
|
|
token_multiplier: multiplier || 30,
|
|
channel: 'ide-chat', context: window.__lastWorkspaceContext || '', conv_id: ''
|
|
};
|
|
// Include auth token injected from extension host
|
|
if (window.__alfredToken) payload.ide_session_token = window.__alfredToken;
|
|
const ident = window.__alfredIdentity;
|
|
if (ident && ident.ide_client_id) {
|
|
payload.ide_client_id = ident.ide_client_id;
|
|
payload.ide_name = ident.ide_name || '';
|
|
payload.ide_ts = ident.ide_ts || 0;
|
|
payload.ide_sig = ident.ide_sig || '';
|
|
payload.ide_email = ident.ide_email || '';
|
|
}
|
|
if (images && images.length) payload.images = images;
|
|
if (pdfs && pdfs.length) payload.pdf_files = pdfs;
|
|
if (texts && texts.length) payload.attachment_texts = texts;
|
|
if (zips && zips.length) payload.zip_files = zips;
|
|
|
|
const hdrs = { 'Content-Type': 'application/json', 'X-Alfred-Source': 'alfred-ide' };
|
|
if (window.__alfredToken) {
|
|
hdrs['Authorization'] = 'Bearer ' + window.__alfredToken;
|
|
hdrs['X-Alfred-IDE-Token'] = window.__alfredToken;
|
|
}
|
|
if (window.__alfredCsrf) hdrs['X-CSRF-Token'] = window.__alfredCsrf;
|
|
|
|
logTelemetry('direct-fetch #' + reqId + ' starting...');
|
|
let resp = await fetch('https://gositeme.com/api/alfred-chat.php', {
|
|
method: 'POST', headers: hdrs, credentials: 'include',
|
|
body: JSON.stringify(payload)
|
|
});
|
|
let data = await parseApiResponse(resp);
|
|
|
|
// Handle CSRF refresh cycle (first request always returns csrf_token)
|
|
if (data.csrf_token) window.__alfredCsrf = data.csrf_token;
|
|
let retries = 0;
|
|
while (retries < 3 && (data.csrf_refresh || data.response === 'Session initialized. Please retry.' || data.error === 'CSRF validation failed')) {
|
|
retries++;
|
|
hdrs['X-CSRF-Token'] = window.__alfredCsrf || '';
|
|
resp = await fetch('https://gositeme.com/api/alfred-chat.php', {
|
|
method: 'POST', headers: hdrs, credentials: 'include',
|
|
body: JSON.stringify(payload)
|
|
});
|
|
data = await parseApiResponse(resp);
|
|
if (data.csrf_token) window.__alfredCsrf = data.csrf_token;
|
|
}
|
|
|
|
const responseText = data.response || data.message || data.error || 'No response from AI';
|
|
if (data.identity) applyIdentity(data.identity);
|
|
if (data.attachment_report) applyAttachmentReport(data.attachment_report);
|
|
if (data.conv_id) payload.conv_id = data.conv_id;
|
|
|
|
const row = chatArea.querySelector('.message[data-alfred-req-id="' + reqId + '"]');
|
|
const p = row ? row.querySelector('.msg-content') : null;
|
|
if (p) {
|
|
p.innerHTML = formatResponseSafe(responseText);
|
|
const s = row.querySelector('.sender');
|
|
if (s && data.agent) s.textContent = data.agent.charAt(0).toUpperCase() + data.agent.slice(1);
|
|
const retry = document.createElement('button');
|
|
retry.className = 'small-btn retry-btn'; retry.textContent = 'Retry';
|
|
retry.onclick = () => { if (!lastRequest) return; const rid = ++messageId; addMessage(lastRequest.agent || 'alfred', '', true, rid); alfredDirectChat(rid, lastRequest.text, lastRequest.agent, lastRequest.model, lastRequest.images, lastRequest.pdf_files, lastRequest.attachment_texts, lastRequest.zip_files, lastRequest.multiplier || 30); };
|
|
p.appendChild(retry);
|
|
} else {
|
|
addMessage(data.agent || agent || 'alfred', responseText);
|
|
}
|
|
setStatus('Ready');
|
|
logTelemetry('direct-fetch ok #' + reqId);
|
|
} catch (err) {
|
|
const row = chatArea.querySelector('.message[data-alfred-req-id="' + reqId + '"]');
|
|
const p = row ? row.querySelector('.msg-content') : null;
|
|
const msg = normalizeApiError(err);
|
|
if (p) p.innerHTML = msg;
|
|
else addMessage('alfred', msg);
|
|
setStatus('Error — try again');
|
|
logTelemetry('direct-fetch error: ' + (err.message || 'unknown'));
|
|
}
|
|
}
|
|
|
|
async function parseApiResponse(resp) {
|
|
const raw = await resp.text();
|
|
if (!raw || !raw.trim()) {
|
|
throw new Error('empty-response');
|
|
}
|
|
try {
|
|
return JSON.parse(raw);
|
|
} catch (_) {
|
|
return { response: raw.trim() };
|
|
}
|
|
}
|
|
|
|
function normalizeApiError(err) {
|
|
const message = String((err && err.message) || 'unknown');
|
|
if (message === 'empty-response') {
|
|
return 'Alfred returned an empty response. Try again.';
|
|
}
|
|
if (/JSON\.parse|unexpected end of data|Unexpected end of JSON/i.test(message)) {
|
|
return 'Alfred returned an invalid response. Try again.';
|
|
}
|
|
return 'API error: ' + message + ' — check network or try again.';
|
|
}
|
|
|
|
// ── Message handler from extension host ────────────────────────────────────
|
|
|
|
window.addEventListener('message', (event) => {
|
|
const msg = event.data;
|
|
if (msg.type === 'ai-response') {
|
|
// Extension host responded — direct fetch is primary, so only use this for TTS/supplemental
|
|
// If the direct fetch did not fill the response, use the extension-host reply.
|
|
logTelemetry('ext-host-response #' + (msg.id || '?') + ' received (direct fetch is primary)');
|
|
if (msg.identity) applyIdentity(msg.identity);
|
|
if (msg.attachment_report) applyAttachmentReport(msg.attachment_report);
|
|
const row = chatArea.querySelector('.message[data-alfred-req-id="' + (msg.id || '') + '"]');
|
|
const p = row ? row.querySelector('.msg-content') : null;
|
|
if (p && (/thinking/i.test(p.textContent || '') || /API error:/i.test(p.textContent || '') || /empty response/i.test(p.textContent || ''))) {
|
|
p.innerHTML = formatResponseSafe(msg.text || 'No response from AI');
|
|
const s = row.querySelector('.sender');
|
|
if (s && msg.agent) s.textContent = msg.agent.charAt(0).toUpperCase() + msg.agent.slice(1);
|
|
setStatus('Ready');
|
|
}
|
|
} else if (msg.type === 'play-audio') {
|
|
playAudio(msg.audio);
|
|
} else if (msg.type === 'stt-result') {
|
|
isTranscribing = false;
|
|
micBtn.classList.remove('transcribing');
|
|
const text = (msg.text || '').trim();
|
|
if (text) {
|
|
setStatus('Heard: ' + text.substring(0, 40) + (text.length > 40 ? '...' : ''));
|
|
processInput(text);
|
|
} else {
|
|
setStatus(msg.error || 'No speech detected');
|
|
if (handsFreeMode) scheduleHandsFreeRecord();
|
|
}
|
|
} else if (msg.type === 'command-result') {
|
|
if (msg.text) { addMessage('alfred', msg.text); vscode.postMessage({ type: 'tts-request', text: msg.text }); }
|
|
} else if (msg.type === 'toggle-listening') { toggleRecording(); }
|
|
else if (msg.type === 'run-terminal-cmd') { vscode.postMessage({ type: 'run-terminal', command: msg.command }); }
|
|
else if (msg.type === 'user-profile') {
|
|
applyIdentity(msg.profile || {});
|
|
}
|
|
else if (msg.type === 'workspace-context') {
|
|
// Cache latest workspace context for direct API calls
|
|
window.__lastWorkspaceContext = msg.context || '';
|
|
}
|
|
else if (msg.type === 'active-file-changed') {
|
|
// Update status with current file info
|
|
if (msg.file && msg.file.file) {
|
|
const statusHint = msg.file.file + ' (' + msg.file.language + ', L' + msg.file.cursorLine + ')';
|
|
logTelemetry('active-file: ' + statusHint);
|
|
}
|
|
}
|
|
else if (msg.type === 'file-saved') {
|
|
logTelemetry('saved: ' + (msg.file || 'unknown'));
|
|
}
|
|
else if (msg.type === 'file-content') {
|
|
// File content received — add as system message
|
|
if (msg.content) {
|
|
addMessage('system', 'File: ' + (msg.filePath || '') + '\\n' + msg.content.substring(0, 2000));
|
|
}
|
|
}
|
|
else if (msg.type === 'search-results') {
|
|
if (msg.files && msg.files.length > 0) {
|
|
addMessage('system', 'Found ' + msg.files.length + ' files matching "' + (msg.pattern || '') + '":\\n' + msg.files.join('\\n'));
|
|
} else {
|
|
addMessage('system', 'No files found matching "' + (msg.pattern || '') + '"');
|
|
}
|
|
}
|
|
else if (msg.type === 'grep-results') {
|
|
if (msg.results) {
|
|
addMessage('system', 'Grep results for "' + (msg.text || '') + '":\\n' + msg.results.substring(0, 3000));
|
|
}
|
|
}
|
|
else if (msg.type === 'git-info') {
|
|
if (msg.info) {
|
|
const g = msg.info;
|
|
addMessage('system', 'Git: branch=' + g.branch + ', last commit: ' + g.lastCommit + (g.dirty ? ', changes: ' + g.dirty : '') + (g.remoteUrl ? ', remote: ' + g.remoteUrl : ''));
|
|
}
|
|
}
|
|
else if (msg.type === 'diagnostics-info') {
|
|
if (msg.info) {
|
|
addMessage('system', 'Diagnostics: ' + msg.info.errors + ' errors, ' + msg.info.warnings + ' warnings' + (msg.info.errorFiles.length ? ' in: ' + msg.info.errorFiles.join(', ') : ''));
|
|
}
|
|
}
|
|
else if (msg.type === 'project-structure') {
|
|
if (msg.structure) {
|
|
addMessage('system', 'Project structure:\\n' + msg.structure.substring(0, 3000));
|
|
}
|
|
}
|
|
});
|
|
|
|
// ── Event listeners ────────────────────────────────────────────────────────
|
|
|
|
if (micBtn) micBtn.addEventListener('click', toggleRecording);
|
|
|
|
const voiceToggleBtn = document.getElementById('voiceToggle');
|
|
function bindPanelButton(el, handler) {
|
|
if (!el) return;
|
|
// Capture phase makes these controls resilient when host UI adds bubbling handlers.
|
|
el.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
e.stopImmediatePropagation();
|
|
try { handler(); } catch (err) { setStatus('Control error: ' + (err && err.message ? err.message : String(err))); }
|
|
}, true);
|
|
}
|
|
bindPanelButton(voiceToggleBtn, () => {
|
|
voiceEnabled = !voiceEnabled;
|
|
voiceToggleBtn.textContent = voiceEnabled ? 'Voice ON' : 'Voice OFF';
|
|
voiceToggleBtn.classList.toggle('off', !voiceEnabled);
|
|
if (!voiceEnabled) stopAudio();
|
|
setStatus('Voice ' + (voiceEnabled ? 'enabled' : 'disabled'));
|
|
addMessage('system', 'Voice ' + (voiceEnabled ? 'enabled' : 'disabled'));
|
|
});
|
|
|
|
bindPanelButton(handsFreeBtn, () => toggleHandsFree());
|
|
|
|
function dispatchSend(ev) {
|
|
if (ev) {
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
ev.stopImmediatePropagation();
|
|
}
|
|
if (pendingAttachmentReads > 0) {
|
|
queuedSend = true;
|
|
setStatus('Finishing attachment processing...', true);
|
|
return;
|
|
}
|
|
const t = textInput.value.trim();
|
|
if (!t && pendingAttachments.length === 0) {
|
|
setStatus('Type a message or attach a file');
|
|
return;
|
|
}
|
|
textInput.value = '';
|
|
const msg = t || '(See attached file)';
|
|
processInput(msg);
|
|
clearPendingImages();
|
|
updatePayloadMeter();
|
|
}
|
|
|
|
function bindSend() {
|
|
if (!sendBtn) return;
|
|
// Capture phase — runs before bubble handlers so nothing can hijack Send for navigation.
|
|
sendBtn.addEventListener('click', dispatchSend, true);
|
|
}
|
|
bindSend();
|
|
// Backup: if DOM wasn't ready, retry on DOMContentLoaded
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', function() { bindSend(); });
|
|
}
|
|
(function bindIdeQuick() {
|
|
[['acIdeTerminal','terminal'], ['acIdeSave','save'], ['acIdeSaveAll','saveAll'], ['acIdePalette','palette'], ['acIdeSplit','split'], ['acIdeNew','newFile'], ['acIdeGit','git'], ['acIdeProblems','problems'], ['acIdeSearch','search'], ['acIdeFormat','format']].forEach(([id, cmd]) => {
|
|
const el = document.getElementById(id);
|
|
if (el) el.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); vscode.postMessage({ type: 'ide-quick', cmd }); });
|
|
});
|
|
})();
|
|
if (textInput) {
|
|
textInput.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
dispatchSend(e);
|
|
}
|
|
});
|
|
}
|
|
|
|
window.addEventListener('error', (ev) => {
|
|
try {
|
|
const msg = (ev && ev.message) ? ev.message : 'Unknown UI error';
|
|
setStatus('UI error: ' + msg);
|
|
addMessage('system', '[ERROR] ' + msg);
|
|
logTelemetry('ui-error: ' + msg);
|
|
} catch (_) {}
|
|
});
|
|
window.addEventListener('unhandledrejection', (ev) => {
|
|
try {
|
|
const msg = (ev.reason && ev.reason.message) ? ev.reason.message : String(ev.reason || 'Unknown promise rejection');
|
|
setStatus('Promise error: ' + msg);
|
|
addMessage('system', '[PROMISE ERROR] ' + msg);
|
|
logTelemetry('promise-error: ' + msg);
|
|
} catch (_) {}
|
|
});
|
|
|
|
// ── Code block action buttons ────────────────────────────────────────────
|
|
|
|
function copyCodeBlock(btn) {
|
|
try {
|
|
const wrap = btn.closest('.code-block-wrap');
|
|
const codeEl = wrap.querySelector('code[data-code]');
|
|
const code = decodeURIComponent(escape(atob(codeEl.getAttribute('data-code'))));
|
|
navigator.clipboard.writeText(code).then(() => {
|
|
btn.textContent = 'Copied!';
|
|
setTimeout(() => { btn.textContent = 'Copy'; }, 1500);
|
|
}).catch(() => {
|
|
// Fallback for no clipboard API
|
|
const ta = document.createElement('textarea');
|
|
ta.value = code;
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
document.execCommand('copy');
|
|
document.body.removeChild(ta);
|
|
btn.textContent = 'Copied!';
|
|
setTimeout(() => { btn.textContent = 'Copy'; }, 1500);
|
|
});
|
|
} catch(e) { setStatus('Copy failed: ' + e.message); }
|
|
}
|
|
|
|
function insertCodeBlock(btn) {
|
|
try {
|
|
const wrap = btn.closest('.code-block-wrap');
|
|
const codeEl = wrap.querySelector('code[data-code]');
|
|
const code = decodeURIComponent(escape(atob(codeEl.getAttribute('data-code'))));
|
|
vscode.postMessage({ type: 'insert-code', code: code });
|
|
btn.textContent = 'Inserted!';
|
|
setTimeout(() => { btn.textContent = 'Insert'; }, 1500);
|
|
} catch(e) { setStatus('Insert failed: ' + e.message); }
|
|
}
|
|
|
|
function runCodeBlock(btn) {
|
|
try {
|
|
const wrap = btn.closest('.code-block-wrap');
|
|
const codeEl = wrap.querySelector('code[data-code]');
|
|
const code = decodeURIComponent(escape(atob(codeEl.getAttribute('data-code'))));
|
|
vscode.postMessage({ type: 'run-terminal', command: code.trim() });
|
|
btn.textContent = 'Sent!';
|
|
setTimeout(() => { btn.textContent = 'Run'; }, 1500);
|
|
addMessage('system', 'Command sent to terminal.');
|
|
} catch(e) { setStatus('Run failed: ' + e.message); }
|
|
}
|
|
|
|
// ── Commander identity greeting ──────────────────────────────────────────
|
|
|
|
(function commanderGreeting() {
|
|
const ident = window.__alfredIdentity || {};
|
|
const greetEl = document.getElementById('greetingMsg');
|
|
if (!greetEl) return;
|
|
const contentEl = greetEl.querySelector('div:not(.sender)');
|
|
if (!contentEl) return;
|
|
|
|
const isCommander = ident.ide_client_id === 33;
|
|
const name = ident.ide_name || 'there';
|
|
|
|
if (isCommander) {
|
|
contentEl.innerHTML = '<strong>Commander on deck.</strong> All systems nominal.<br>' +
|
|
'<span style="color:#e2b340;">Full workspace intelligence active. Voice, code actions, file ops, git — everything is live.</span><br>' +
|
|
'<span style="font-size:10px;color:#8b949e;">Sonnet 4.6 default · ' + new Date().toLocaleDateString('en-US', {weekday:'long', month:'long', day:'numeric'}) + ' · GoForge synced</span>';
|
|
} else if (name && name !== 'there') {
|
|
contentEl.innerHTML = 'Welcome, <strong>' + name + '</strong>. Alfred IDE is ready.<br>' +
|
|
'<span style="font-size:10px;color:#8b949e;">Type a question, paste code, attach files, or use voice. I have full context of your workspace.</span>';
|
|
} else {
|
|
contentEl.innerHTML = 'Alfred IDE ready. Full AI coding assistant at your service.<br>' +
|
|
'<span style="font-size:10px;color:#8b949e;">Speak or type. Sonnet 4.6 connected.</span>';
|
|
}
|
|
})();
|
|
|
|
setStatus('Ready');
|
|
</script>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
function deactivate() {}
|
|
|
|
function getWorkspaceStatusHTML(token) {
|
|
return `<!DOCTYPE html>
|
|
<html><head>
|
|
<meta charset="UTF-8">
|
|
<style>
|
|
body{background:#0e0e1a;color:#e0e0e8;font-family:'Segoe UI',system-ui,sans-serif;padding:24px;margin:0}
|
|
h1{color:#00D4FF;font-size:22px;margin:0 0 20px}
|
|
.card{background:#16162a;border:1px solid #2a2a4a;border-radius:12px;padding:16px;margin-bottom:16px}
|
|
.card h2{color:#00D4FF;font-size:16px;margin:0 0 12px;display:flex;align-items:center;gap:8px}
|
|
.row{display:flex;justify-content:space-between;padding:6px 0;border-bottom:1px solid #1a1a30}
|
|
.row:last-child{border:none}
|
|
.label{color:#8888aa;font-size:13px}
|
|
.val{color:#fff;font-weight:600;font-size:13px}
|
|
.ok{color:#10b981}.warn{color:#f59e0b}.bad{color:#ef4444}
|
|
.bar{height:8px;border-radius:4px;background:#1a1a30;margin-top:4px}
|
|
.bar-fill{height:100%;border-radius:4px;transition:width .5s}
|
|
.btn{background:linear-gradient(135deg,#00a8ff,#00d4ff);color:#000;border:none;padding:10px 20px;border-radius:8px;cursor:pointer;font-weight:700;font-size:13px;margin-top:12px}
|
|
.btn:hover{opacity:.9}
|
|
.btn.danger{background:linear-gradient(135deg,#ff4444,#ff6666)}
|
|
.loading{color:#8888aa;font-style:italic}
|
|
#error{color:#ef4444;margin:12px 0;display:none}
|
|
</style>
|
|
</head><body>
|
|
<h1>🖥 Workspace Status</h1>
|
|
<div id="error"></div>
|
|
<div id="content"><p class="loading">Loading workspace data...</p></div>
|
|
<script>
|
|
const token = ${JSON.stringify(token)};
|
|
async function load() {
|
|
try {
|
|
const r = await fetch('https://gositeme.com/api/alfred-ide-workspace.php?action=status', {
|
|
headers: token ? {'Authorization': 'Bearer ' + token} : {},
|
|
credentials: 'include'
|
|
});
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
const d = await r.json();
|
|
render(d);
|
|
} catch(e) {
|
|
document.getElementById('error').style.display = 'block';
|
|
document.getElementById('error').textContent = 'Failed to load: ' + e.message;
|
|
document.getElementById('content').innerHTML = '';
|
|
}
|
|
}
|
|
function render(d) {
|
|
const ws = d.workspace || {};
|
|
const sv = d.server || {};
|
|
const ide = d.ide || {};
|
|
const pm2 = d.pm2 || null;
|
|
const bk = d.backup || null;
|
|
const diskPct = sv.disk_used_pct || 0;
|
|
const diskColor = diskPct > 80 ? '#ef4444' : diskPct > 60 ? '#f59e0b' : '#10b981';
|
|
const memPct = sv.mem_total_gb ? Math.round((1 - sv.mem_avail_gb/sv.mem_total_gb)*100) : 0;
|
|
const memColor = memPct > 80 ? '#ef4444' : memPct > 60 ? '#f59e0b' : '#10b981';
|
|
const ideStatus = ide.status === 'online' ? '<span class="ok">● Online</span>' : '<span class="bad">● ' + (ide.status||'unknown') + '</span>';
|
|
|
|
let html = '<div class="card"><h2>👤 Session</h2>';
|
|
html += row('User', ws.user);
|
|
html += row('Role', ws.role === 'commander' ? '⭐ Commander' : 'Customer');
|
|
html += row('Session Expires', ws.session_expires ? new Date(ws.session_expires).toLocaleString() : 'N/A');
|
|
html += '</div>';
|
|
|
|
html += '<div class="card"><h2>⚡ IDE Service</h2>';
|
|
html += row('Status', ideStatus);
|
|
html += row('Memory', ide.memory_mb ? ide.memory_mb + ' MB' : 'N/A');
|
|
html += row('CPU', ide.cpu != null ? ide.cpu + '%' : 'N/A');
|
|
html += row('Restarts', ide.restarts != null ? ide.restarts : 'N/A');
|
|
html += row('PID', ide.pid || 'N/A');
|
|
html += '</div>';
|
|
|
|
html += '<div class="card"><h2>💾 Server</h2>';
|
|
html += row('Disk', sv.disk_used_pct + '% used (' + (sv.disk_total_gb - sv.disk_free_gb).toFixed(0) + ' / ' + sv.disk_total_gb + ' GB)');
|
|
html += '<div class="bar"><div class="bar-fill" style="width:'+diskPct+'%;background:'+diskColor+'"></div></div>';
|
|
html += row('Memory', memPct + '% used (' + (sv.mem_total_gb - sv.mem_avail_gb).toFixed(1) + ' / ' + sv.mem_total_gb + ' GB)');
|
|
html += '<div class="bar"><div class="bar-fill" style="width:'+memPct+'%;background:'+memColor+'"></div></div>';
|
|
html += row('Load', sv.load_1m + ' / ' + sv.load_5m);
|
|
html += row('Uptime', sv.uptime_days + ' days');
|
|
html += '</div>';
|
|
|
|
if (pm2) {
|
|
html += '<div class="card"><h2>🔧 PM2 Services</h2>';
|
|
html += row('Online', '<span class="ok">' + pm2.online + '</span>');
|
|
html += row('Stopped', pm2.stopped > 0 ? '<span class="warn">' + pm2.stopped + '</span>' : '0');
|
|
html += row('Errored', pm2.errored > 0 ? '<span class="bad">' + pm2.errored + '</span>' : '0');
|
|
if (pm2.critical) {
|
|
html += '<div style="margin-top:8px;font-size:12px;color:#8888aa">Critical Services:</div>';
|
|
for (const [n, s] of Object.entries(pm2.critical)) {
|
|
const cls = s === 'online' ? 'ok' : 'bad';
|
|
html += '<div class="row"><span class="label">' + n + '</span><span class="val ' + cls + '">' + s + '</span></div>';
|
|
}
|
|
}
|
|
html += '</div>';
|
|
}
|
|
|
|
if (bk) {
|
|
html += '<div class="card"><h2>📦 Backup</h2>';
|
|
html += row('Last Success', bk.last_success || 'Never');
|
|
html += row('Age', bk.age_hours != null ? bk.age_hours + ' hours' : 'N/A');
|
|
html += row('Health', bk.healthy ? '<span class="ok">✓ Healthy</span>' : '<span class="bad">✗ Stale (>48h)</span>');
|
|
html += '</div>';
|
|
}
|
|
|
|
if (ws.role === 'commander') {
|
|
html += '<button class="btn danger" onclick="restartIDE()">⟳ Restart IDE Service</button>';
|
|
}
|
|
|
|
document.getElementById('content').innerHTML = html;
|
|
}
|
|
function row(l, v) { return '<div class="row"><span class="label">'+l+'</span><span class="val">'+v+'</span></div>'; }
|
|
async function restartIDE() {
|
|
if (!confirm('Restart Alfred IDE service?')) return;
|
|
try {
|
|
const r = await fetch('https://gositeme.com/api/alfred-ide-workspace.php?action=restart', {
|
|
method: 'POST',
|
|
headers: token ? {'Authorization': 'Bearer ' + token} : {},
|
|
credentials: 'include'
|
|
});
|
|
const d = await r.json();
|
|
alert(d.success ? 'IDE restarted successfully' : 'Restart may have failed: ' + (d.output||''));
|
|
} catch(e) { alert('Error: ' + e.message); }
|
|
}
|
|
load();
|
|
setInterval(load, 30000);
|
|
</script>
|
|
</body></html>`;
|
|
}
|
|
|
|
module.exports = { activate, deactivate };
|