- Workspace Intelligence: project detection (20+ types), file tree, git info, diagnostics, open editors, terminal names - Commander System Prompt: knows Danny (client_id 33), ecosystem awareness, coding excellence directives - Rich Context: every API call now includes active file, cursor pos, selected code, project type, git branch, diagnostics - Code Block Actions: Copy, Insert at cursor, Run in terminal — buttons on every code block response - New IDE Quick Actions: Git, Problems, Search, Format (added to existing Terminal, Save, Commands, Split, New) - File Operations: read-file, search-files, grep-search, open-file, apply-edit, create-file handlers - Live Context Push: editor changes and file saves pushed to webview in real-time - Commander Greeting: personalized startup message when Danny logs in - GoForge Only: repository + homepage point to alfredlinux.com/forge, removed GitHub Copilot refs from extensions.json - 2093 → 2686 lines
2687 lines
121 KiB
JavaScript
2687 lines
121 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 getProjectLanguages() {
|
|
const root = getWorkspaceRoot();
|
|
const exts = safeExec('find . -maxdepth 4 -type f -not -path "./.git/*" -not -path "./node_modules/*" -not -path "./.venv/*" -not -path "./vendor/*" | sed "s/.*\\.//" | sort | uniq -c | sort -rn | head -20', root);
|
|
return exts || '';
|
|
}
|
|
|
|
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 || '';
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// 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
|
|
|
|
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
|
|
|
|
`;
|
|
|
|
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';
|
|
}
|
|
|
|
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 '';
|
|
}
|
|
}
|
|
|
|
|
|
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 });
|
|
}
|
|
});
|
|
|
|
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 };
|