alfred-ide/extension.js
Alfred fd7361e044 Initial commit: Alfred Commander v1.0.1 - VS Code extension
- AI chat sidebar with multi-provider support
- Session management, token tracking, usage stats
- Voice input, file attachments, code actions
- Integrates with Alfred IDE (code-server) auth system
2026-04-07 11:40:25 -04:00

2094 lines
93 KiB
JavaScript

const vscode = require('vscode');
const https = require('https');
const http = require('http');
const os = require('os');
const fs = require('fs');
const crypto = require('crypto');
let currentAgent = 'alfred';
let convId = null;
let csrfToken = null;
let sessionCookie = null;
let userProfile = null;
let hmacSecretCache = null;
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',
};
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;
}
}
});
if (userProfile) {
setTimeout(() => {
webviewView.webview.postMessage({ type: 'user-profile', profile: userProfile });
}, 500);
}
}
}
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() {
const editor = vscode.window.activeTextEditor;
if (!editor) return '';
const doc = editor.document;
const sel = editor.selection;
let ctx = `[IDE Context] File: ${doc.fileName}, Language: ${doc.languageId}, Lines: ${doc.lineCount}`;
if (!sel.isEmpty) {
const selected = doc.getText(sel).substring(0, 500);
ctx += `, Selected code:\n\`\`\`${doc.languageId}\n${selected}\n\`\`\``;
} else {
const line = doc.lineAt(sel.active.line);
ctx += `, Current line ${sel.active.line + 1}: ${line.text.substring(0, 200)}`;
}
return ctx;
}
async function queryAlfredAPI(prompt, agent, editorContext, selectedModel = 'sonnet', images = [], pdfFiles = [], attachmentTexts = [], zipFiles = [], multiplier = 30) {
const payload = {
message: prompt, agent: agent || 'alfred',
context: editorContext || '', channel: 'ide-chat',
conv_id: convId || '', model: selectedModel,
token_multiplier: multiplier || 30
};
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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; }
</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>
</div>
<div class="chat-area" id="chatArea">
<div class="message alfred">
<div class="sender">Alfred</div>
<div>Ready. Claude Sonnet 4 connected. Speak or type.</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) {
return text
.replace(/\`\`\`(\\w*)\\n([\\s\\S]*?)\`\`\`/g, '<pre><code>$2</code></pre>')
.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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.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 {
const payload = {
message: text, agent: agent || 'alfred', model: model || 'sonnet',
token_multiplier: multiplier || 30,
channel: 'ide-chat', context: '', 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 || {});
}
});
// ── 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']].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 (_) {}
});
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 (&gt;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 };