g3 console initial cut + error doesnt kill auto

This commit is contained in:
Dhanji Prasanna
2025-11-04 11:39:26 +11:00
parent 6913c5f72e
commit aaf918828f
53 changed files with 6796 additions and 23 deletions

View File

@@ -0,0 +1,80 @@
// API client for G3 Console
const API_BASE = '/api';
const api = {
// Get all instances
async getInstances() {
const response = await fetch(`${API_BASE}/instances`);
if (!response.ok) throw new Error('Failed to fetch instances');
return response.json();
},
// Get single instance details
async getInstance(id) {
const response = await fetch(`${API_BASE}/instances/${id}`);
if (!response.ok) throw new Error('Failed to fetch instance');
return response.json();
},
// Get instance logs
async getInstanceLogs(id) {
const response = await fetch(`${API_BASE}/instances/${id}/logs`);
if (!response.ok) throw new Error('Failed to fetch logs');
return response.json();
},
// Launch new instance
async launchInstance(data) {
const response = await fetch(`${API_BASE}/instances/launch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) {
// Try to extract error message from response
try {
const errorData = await response.json();
throw new Error(errorData.message || errorData.error || 'Failed to launch instance');
} catch (e) {
throw new Error(`Failed to launch instance (${response.status})`);
}
}
return response.json();
},
// Kill instance
async killInstance(id) {
const response = await fetch(`${API_BASE}/instances/${id}/kill`, {
method: 'POST'
});
if (!response.ok) throw new Error('Failed to kill instance');
return response.json();
},
// Restart instance
async restartInstance(id) {
const response = await fetch(`${API_BASE}/instances/${id}/restart`, {
method: 'POST'
});
if (!response.ok) throw new Error('Failed to restart instance');
return response.json();
},
// Get console state
async getState() {
const response = await fetch(`${API_BASE}/state`);
if (!response.ok) throw new Error('Failed to fetch state');
return response.json();
},
// Save console state
async saveState(state) {
const response = await fetch(`${API_BASE}/state`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(state)
});
if (!response.ok) throw new Error('Failed to save state');
return response.json();
}
};

View File

@@ -0,0 +1,313 @@
// Main application logic
// Global action handlers
window.handleKill = async function(id) {
if (!confirm('Are you sure you want to kill this instance?')) return;
// Find the button and show loading state
const button = event.target;
const originalText = button.textContent;
button.disabled = true;
button.innerHTML = '<span class="spinner" style="width: 1rem; height: 1rem; border-width: 2px; display: inline-block; vertical-align: middle;"></span> Terminating...';
try {
await api.killInstance(id);
// Show success state
button.innerHTML = '✓ Terminated';
button.classList.remove('btn-danger');
button.classList.add('btn-secondary');
// Refresh after a short delay
setTimeout(() => {
router.handleRoute(router.currentRoute);
}, 1000);
} catch (error) {
// Restore button state on error
button.disabled = false;
button.textContent = originalText;
alert('Failed to kill instance: ' + error.message);
}
};
window.handleRestart = async function(id) {
// Find the button and show loading state
const button = event.target;
const originalText = button.textContent;
button.disabled = true;
button.innerHTML = '<span class="spinner" style="width: 1rem; height: 1rem; border-width: 2px; display: inline-block; vertical-align: middle;"></span> Restarting...';
try {
await api.restartInstance(id);
// Show intermediate states
button.innerHTML = '<span class="spinner" style="width: 1rem; height: 1rem; border-width: 2px; display: inline-block; vertical-align: middle;"></span> Starting...';
// Wait a bit then show success
setTimeout(() => {
button.innerHTML = '✓ Running';
button.classList.remove('btn-primary');
button.classList.add('btn-success');
}, 1500);
// Refresh current view
setTimeout(() => {
router.handleRoute(router.currentRoute);
}, 2500);
} catch (error) {
// Restore button state on error
button.disabled = false;
button.textContent = originalText;
alert('Failed to kill instance: ' + error.message);
}
};
// Modal management
const modal = {
element: null,
init() {
this.element = document.getElementById('new-run-modal');
// Close button
document.getElementById('modal-close').addEventListener('click', () => this.close());
document.getElementById('cancel-launch').addEventListener('click', () => this.close());
// Close on overlay click
this.element.querySelector('.modal-overlay').addEventListener('click', () => this.close());
// Form submission
document.getElementById('launch-form').addEventListener('submit', (e) => {
e.preventDefault();
this.handleLaunch();
});
// File browser buttons - use HTML5 file input
document.getElementById('browse-workspace').addEventListener('click', () => {
this.browseDirectory('workspace');
});
document.getElementById('browse-binary').addEventListener('click', () => {
this.browseFile('g3-binary-path');
});
// Provider change updates model options
document.getElementById('provider').addEventListener('change', (e) => {
this.updateModelOptions(e.target.value);
});
},
browseDirectory(inputId) {
// Create a hidden file input with directory picker
const input = document.createElement('input');
input.type = 'file';
input.webkitdirectory = true;
input.directory = true;
input.multiple = false;
input.onchange = (e) => {
const files = e.target.files;
if (files.length > 0) {
// Get the directory path from the first file
const path = files[0].webkitRelativePath.split('/')[0];
// In browser context, we get relative path, so we need to construct full path
// For now, just use the directory name and let user adjust
document.getElementById(inputId).value = path;
}
};
input.click();
},
browseFile(inputId) {
// Create a hidden file input
const input = document.createElement('input');
input.type = 'file';
input.accept = '*';
input.onchange = (e) => {
const files = e.target.files;
if (files.length > 0) {
// Get the file name
// Note: For security reasons, browsers don't give us the full path
// User will need to type the full path manually
document.getElementById(inputId).value = files[0].name;
}
};
input.click();
},
open() {
// Load saved state
const form = document.getElementById('launch-form');
if (state.lastWorkspace) {
form.workspace.value = state.lastWorkspace;
}
if (state.g3BinaryPath) {
form.g3_binary_path.value = state.g3BinaryPath;
}
form.provider.value = state.lastProvider;
this.updateModelOptions(state.lastProvider);
form.model.value = state.lastModel;
this.element.classList.remove('hidden');
},
close() {
this.element.classList.add('hidden');
},
updateModelOptions(provider) {
const modelSelect = document.getElementById('model');
const models = {
databricks: [
{ value: 'databricks-claude-sonnet-4-5', label: 'databricks-claude-sonnet-4-5' },
{ value: 'databricks-meta-llama-3-1-405b-instruct', label: 'databricks-meta-llama-3-1-405b-instruct' }
],
anthropic: [
{ value: 'claude-3-5-sonnet-20241022', label: 'claude-3-5-sonnet-20241022' },
{ value: 'claude-3-opus-20240229', label: 'claude-3-opus-20240229' }
],
local: [
{ value: 'local-model', label: 'Local Model' }
]
};
modelSelect.innerHTML = '';
for (const model of models[provider] || []) {
const option = document.createElement('option');
option.value = model.value;
option.textContent = model.label;
modelSelect.appendChild(option);
}
},
async handleLaunch() {
const form = document.getElementById('launch-form');
const formData = new FormData(form);
const data = {
prompt: formData.get('prompt'),
workspace: formData.get('workspace'),
provider: formData.get('provider'),
model: formData.get('model'),
mode: formData.get('mode'),
g3_binary_path: formData.get('g3_binary_path') || null
};
try {
// Show loading state
const submitBtn = form.querySelector('button[type="submit"]');
const modalBody = this.element.querySelector('.modal-body');
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner" style="width: 1rem; height: 1rem; border-width: 2px; display: inline-block; vertical-align: middle;"></span> Starting g3 instance...';
const response = await api.launchInstance(data);
// Show intermediate state
submitBtn.innerHTML = '<span class="spinner" style="width: 1rem; height: 1rem; border-width: 2px; display: inline-block; vertical-align: middle;"></span> Waiting for process...';
// Wait a bit to let the process start
await new Promise(resolve => setTimeout(resolve, 1500));
submitBtn.innerHTML = '✓ Instance started!';
// Save state
state.updateLaunchDefaults(
data.workspace,
data.provider,
data.model,
data.g3_binary_path
);
// Close modal and navigate home
this.close();
router.navigate('/');
// Reset form
form.reset();
submitBtn.disabled = false;
submitBtn.textContent = 'Start Instance';
} catch (error) {
// Display detailed error message in modal
const errorDiv = document.createElement('div');
errorDiv.className = 'error-message';
errorDiv.style.cssText = 'background: #fee; border: 1px solid #fcc; color: #c33; padding: 1rem; margin: 1rem 0; border-radius: 0.5rem;';
let errorMessage = 'Failed to launch instance';
if (error.message) {
errorMessage += ': ' + error.message;
}
// Check for specific error types
if (error.message && error.message.includes('400')) {
errorMessage = 'Invalid configuration. Please check that the g3 binary path exists and is executable, and that the workspace directory is valid.';
} else if (error.message && error.message.includes('500')) {
errorMessage = 'Server error while launching instance. Check console logs for details.';
}
errorDiv.textContent = errorMessage;
// Remove any existing error messages
const existingError = modalBody.querySelector('.error-message');
if (existingError) existingError.remove();
// Insert error message at the top of modal body
modalBody.insertBefore(errorDiv, modalBody.firstChild);
const submitBtn = form.querySelector('button[type="submit"]');
submitBtn.disabled = false;
submitBtn.textContent = 'Start Instance';
}
}
};
// Theme toggle
function initTheme() {
const themeToggle = document.getElementById('theme-toggle');
themeToggle.addEventListener('click', () => {
const newTheme = state.theme === 'dark' ? 'light' : 'dark';
state.setTheme(newTheme);
themeToggle.textContent = newTheme === 'dark' ? '🌙' : '☀️';
});
// Set initial theme
document.body.className = state.theme;
themeToggle.textContent = state.theme === 'dark' ? '🌙' : '☀️';
}
// Initialize app
async function init() {
// Prevent double initialization
if (window.g3Initialized) {
return;
}
window.g3Initialized = true;
// Load state
await state.load();
// Initialize theme
initTheme();
// Initialize modal
modal.init();
// New Run button
document.getElementById('new-run-btn').addEventListener('click', () => {
modal.open();
});
// Initialize router
router.init();
}
// Simplified initialization - call exactly once when DOM is ready
if (document.readyState === 'loading') {
// DOM still loading, wait for DOMContentLoaded
document.addEventListener('DOMContentLoaded', init, { once: true });
} else {
// DOM already loaded (interactive or complete), init immediately
init();
}

View File

@@ -0,0 +1,311 @@
// UI Components for G3 Console
const components = {
// Render status badge
statusBadge(status) {
const colors = {
running: 'badge-success',
completed: 'badge-success',
failed: 'badge-error',
idle: 'badge-warning',
terminated: 'badge-neutral'
};
return `<span class="badge ${colors[status] || 'badge-neutral'}">${status}</span>`;
},
// Render progress bar
progressBar(instance, stats) {
const duration = stats.duration_secs;
const estimated = duration * 1.5; // Simple estimation
const progress = Math.min((duration / estimated) * 100, 100);
// Check if this is ensemble mode with turn data
if (instance.instance_type === 'ensemble' && stats.turns && stats.turns.length > 0) {
return this.ensembleProgressBar(stats.turns, duration);
}
return `
<div class="progress-bar">
<div class="progress-fill" style="width: ${progress}%"></div>
<span class="progress-text">${Math.round(duration / 60)}m elapsed</span>
</div>
`;
},
// Render multi-segment progress bar for ensemble mode
ensembleProgressBar(turns, totalDuration) {
const colors = {
coach: '#3b82f6',
player: '#6b7280',
completed: '#10b981',
error: '#ef4444'
};
let segments = '';
for (const turn of turns) {
const percentage = (turn.duration_secs / totalDuration) * 100;
const color = colors[turn.agent] || colors.player;
const statusColor = turn.status === 'error' ? colors.error : color;
segments += `
<div class="progress-segment"
style="width: ${percentage}%; background-color: ${statusColor};"
title="${turn.agent}: ${turn.duration_secs}s - ${turn.status}">
</div>
`;
}
return `
<div class="progress-bar ensemble">
${segments}
<span class="progress-text">${Math.round(totalDuration / 60)}m elapsed</span>
</div>
`;
},
// Render instance panel
instancePanel(instance, stats, latestMessage) {
return `
<div class="instance-panel" data-id="${instance.id}" onclick="router.navigate('/instance/${instance.id}')">
<div class="panel-header">
<div class="panel-title">
<h3>${instance.workspace}</h3>
${this.statusBadge(instance.status)}
</div>
<div class="panel-meta">
<span class="meta-item">${instance.instance_type}</span>
<span class="meta-item">PID: ${instance.pid}</span>
<span class="meta-item">${new Date(instance.start_time).toLocaleString()}</span>
</div>
</div>
${this.progressBar(instance, stats)}
<div class="panel-stats">
<div class="stat-item">
<span class="stat-label">Tokens</span>
<span class="stat-value">${stats.total_tokens.toLocaleString()}</span>
</div>
<div class="stat-item">
<span class="stat-label">Tool Calls</span>
<span class="stat-value">${stats.tool_calls}</span>
</div>
<div class="stat-item">
<span class="stat-label">Errors</span>
<span class="stat-value">${stats.errors}</span>
</div>
<div class="stat-item">
<span class="stat-label">Duration</span>
<span class="stat-value">${Math.round(stats.duration_secs / 60)}m</span>
</div>
</div>
${latestMessage ? `
<div class="panel-message">
<strong>Latest:</strong> ${this.truncate(latestMessage, 100)}
</div>
` : ''}
<div class="panel-actions">
${instance.status === 'running' ? `
<button class="btn btn-danger btn-sm" onclick="event.stopPropagation(); handleKill('${instance.id}')">Kill</button>
` : ''}
${instance.status === 'terminated' ? `
<button class="btn btn-primary btn-sm" onclick="event.stopPropagation(); handleRestart('${instance.id}')">Restart</button>
` : ''}
<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); router.navigate('/instance/${instance.id}')">View Details</button>
</div>
</div>
`;
},
// Render loading spinner
spinner(message = 'Loading...') {
return `
<div class="spinner-container">
<div class="spinner"></div>
<p>${message}</p>
</div>
`;
},
// Render error message
error(message) {
return `
<div class="error-message">
<strong>Error:</strong> ${message}
</div>
`;
},
// Render empty state
emptyState(message) {
return `
<div class="empty-state">
<p>${message}</p>
</div>
`;
},
// Truncate text
truncate(text, length) {
if (text.length <= length) return text;
return text.substring(0, length) + '...';
},
// Render chat message
chatMessage(message, agent = null) {
const agentClass = agent === 'coach' ? 'message-coach' : agent === 'player' ? 'message-player' : '';
return `
<div class="chat-message ${agentClass}">
${agent ? `<div class="message-agent">${agent}</div>` : ''}
<div class="message-content">${marked.parse(message)}</div>
</div>
`;
},
// Render tool call
toolCall(toolCall) {
const statusIcon = toolCall.success ? '✓' : '✗';
const statusClass = toolCall.success ? 'success' : 'error';
return `
<div class="tool-call" data-tool-id="${toolCall.id}">
<div class="tool-header" onclick="this.parentElement.classList.toggle('expanded')">
<span class="tool-name">🔧 ${toolCall.tool_name}</span>
<div class="tool-header-right">
${toolCall.execution_time_ms ? `<span class="tool-time">${toolCall.execution_time_ms}ms</span>` : ''}
<span class="tool-status ${statusClass}">${statusIcon}</span>
</div>
</div>
<div class="tool-details">
<div class="tool-section">
<strong>Parameters:</strong>
<pre><code class="language-json">${JSON.stringify(toolCall.parameters, null, 2)}</code></pre>
</div>
${toolCall.result ? `
<div class="tool-section">
<strong>Result:</strong>
<pre><code class="language-json">${JSON.stringify(toolCall.result, null, 2)}</code></pre>
</div>
` : ''}
${toolCall.error ? `
<div class="tool-section">
<strong>Error:</strong>
<pre><code class="language-text">${this.escapeHtml(toolCall.error)}</code></pre>
</div>
` : ''}
<div class="tool-meta">
<span>Timestamp: ${new Date(toolCall.timestamp).toLocaleString()}</span>
${toolCall.execution_time_ms ? `<span> • Duration: ${toolCall.execution_time_ms}ms</span>` : ''}
<span> • Status: ${toolCall.success ? 'Success' : 'Failed'}</span>
</div>
</div>
</div>
`;
},
// Render git status section
gitStatus(gitStatus) {
if (!gitStatus) {
return '<p class="text-muted">No git repository detected</p>';
}
return `
<div class="git-status">
<div class="git-header">
<span class="git-branch">📍 ${gitStatus.branch}</span>
<span class="git-changes">${gitStatus.uncommitted_changes} uncommitted changes</span>
</div>
${gitStatus.uncommitted_changes > 0 ? `
<div class="git-files">
${gitStatus.modified_files.length > 0 ? `
<div class="git-file-group">
<strong class="file-status modified">Modified:</strong>
<ul>
${gitStatus.modified_files.map(f => `<li>${f}</li>`).join('')}
</ul>
</div>
` : ''}
${gitStatus.added_files.length > 0 ? `
<div class="git-file-group">
<strong class="file-status added">Added:</strong>
<ul>
${gitStatus.added_files.map(f => `<li>${f}</li>`).join('')}
</ul>
</div>
` : ''}
${gitStatus.deleted_files.length > 0 ? `
<div class="git-file-group">
<strong class="file-status deleted">Deleted:</strong>
<ul>
${gitStatus.deleted_files.map(f => `<li>${f}</li>`).join('')}
</ul>
</div>
` : ''}
</div>
` : ''}
</div>
`;
},
// Render project files section
projectFiles(projectFiles) {
if (!projectFiles || (!projectFiles.requirements && !projectFiles.readme && !projectFiles.agents)) {
return '<p class="text-muted">No project files found</p>';
}
let html = '<div class="project-files">';
if (projectFiles.requirements) {
html += `
<div class="project-file">
<div class="file-header" onclick="this.parentElement.classList.toggle('expanded')">
<span class="file-name">📄 requirements.md</span>
<span class="file-toggle">▼</span>
</div>
<div class="file-content">
<pre><code>${this.escapeHtml(projectFiles.requirements)}</code></pre>
</div>
</div>
`;
}
if (projectFiles.readme) {
html += `
<div class="project-file">
<div class="file-header" onclick="this.parentElement.classList.toggle('expanded')">
<span class="file-name">📄 README.md</span>
<span class="file-toggle">▼</span>
</div>
<div class="file-content">
<pre><code>${this.escapeHtml(projectFiles.readme)}</code></pre>
</div>
</div>
`;
}
if (projectFiles.agents) {
html += `
<div class="project-file">
<div class="file-header" onclick="this.parentElement.classList.toggle('expanded')">
<span class="file-name">📄 AGENTS.md</span>
<span class="file-toggle">▼</span>
</div>
<div class="file-content">
<pre><code>${this.escapeHtml(projectFiles.agents)}</code></pre>
</div>
</div>
`;
}
html += '</div>';
return html;
},
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
};

1213
crates/g3-console/web/js/highlight.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,167 @@
// Simple client-side router
const router = {
currentRoute: '/',
init() {
// Handle browser back/forward
window.addEventListener('popstate', () => {
this.handleRoute(window.location.pathname);
});
// Handle initial route
this.handleRoute(window.location.pathname);
},
navigate(path) {
window.history.pushState({}, '', path);
this.handleRoute(path);
},
async handleRoute(path) {
this.currentRoute = path;
const container = document.getElementById('page-container');
if (path === '/' || path === '') {
await this.renderHome(container);
} else if (path.startsWith('/instance/')) {
const id = path.split('/')[2];
await this.renderDetail(container, id);
} else {
container.innerHTML = components.error('Page not found');
}
},
async renderHome(container) {
container.innerHTML = components.spinner('Loading instances...');
try {
const instances = await api.getInstances();
if (instances.length === 0) {
container.innerHTML = components.emptyState(
'No running instances. Click "+ New Run" to start one.'
);
return;
}
let html = '<div class="instances-list">';
for (const instance of instances) {
// Use stats from API response
const stats = instance.stats || { total_tokens: 0, tool_calls: 0, errors: 0, duration_secs: 0 };
html += components.instancePanel(instance, stats, instance.latest_message);
}
html += '</div>';
container.innerHTML = html;
// Auto-refresh every 5 seconds
setTimeout(() => {
if (this.currentRoute === '/') {
this.renderHome(container);
}
}, 5000);
} catch (error) {
container.innerHTML = components.error(error.message);
}
},
async renderDetail(container, id) {
container.innerHTML = components.spinner('Loading instance details...');
try {
const instance = await api.getInstance(id);
const logs = await api.getInstanceLogs(id);
// Build detail view HTML
let html = `
<div class="detail-view">
<div class="detail-header">
<button class="btn btn-secondary" onclick="router.navigate('/')">&larr; Back</button>
<h2>${instance.workspace}</h2>
${components.statusBadge(instance.status)}
</div>
<div class="detail-stats">
<div class="stat-card">
<div class="stat-label">Tokens</div>
<div class="stat-value">${(instance.stats?.total_tokens || 0).toLocaleString()}</div>
</div>
<div class="stat-card">
<div class="stat-label">Tool Calls</div>
<div class="stat-value">${instance.stats?.tool_calls || 0}</div>
</div>
<div class="stat-card">
<div class="stat-label">Errors</div>
<div class="stat-value">${instance.stats?.errors || 0}</div>
</div>
<div class="stat-card">
<div class="stat-label">Duration</div>
<div class="stat-value">${Math.round((instance.stats?.duration_secs || 0) / 60)}m</div>
</div>
</div>
<div class="detail-section">
<h3>Git Status</h3>
${components.gitStatus(instance.git_status)}
</div>
<div class="detail-section">
<h3>Project Files</h3>
${components.projectFiles(instance.project_files)}
</div>
<div class="detail-content">
<h3>Tool Calls</h3>
<div class="tool-calls-section">
`;
// Render tool calls
if (logs && logs.tool_calls && logs.tool_calls.length > 0) {
for (const toolCall of logs.tool_calls) {
html += components.toolCall(toolCall);
}
} else {
html += '<p class="text-muted">No tool calls yet</p>';
}
html += `
</div>
<h3>Chat History</h3>
<div class="chat-messages">
`;
// Render messages from logs
if (logs && logs.messages && logs.messages.length > 0) {
for (const msg of logs.messages) {
html += components.chatMessage(msg.content, msg.agent);
}
} else {
html += '<p class="text-muted">No messages yet</p>';
}
html += `
</div>
</div>
</div>
</div>
`;
container.innerHTML = html;
// Apply syntax highlighting
document.querySelectorAll('pre code').forEach((block) => {
hljs.highlightElement(block);
});
// Auto-refresh every 3 seconds
setTimeout(() => {
if (this.currentRoute === `/instance/${id}`) {
this.renderDetail(container, id);
}
}, 3000);
} catch (error) {
container.innerHTML = components.error(error.message);
}
}
};

View File

@@ -0,0 +1,51 @@
// State management for G3 Console
const state = {
theme: 'dark',
lastWorkspace: null,
g3BinaryPath: null,
lastProvider: 'databricks',
lastModel: 'databricks-claude-sonnet-4-5',
async load() {
try {
const data = await api.getState();
this.theme = data.theme || 'dark';
this.lastWorkspace = data.last_workspace;
this.g3BinaryPath = data.g3_binary_path;
this.lastProvider = data.last_provider || 'databricks';
this.lastModel = data.last_model || 'databricks-claude-sonnet-4-5';
return data;
} catch (error) {
console.error('Failed to load state:', error);
return null;
}
},
async save() {
try {
await api.saveState({
theme: this.theme,
last_workspace: this.lastWorkspace,
g3_binary_path: this.g3BinaryPath,
last_provider: this.lastProvider,
last_model: this.lastModel
});
} catch (error) {
console.error('Failed to save state:', error);
}
},
setTheme(theme) {
this.theme = theme;
document.body.className = theme;
this.save();
},
updateLaunchDefaults(workspace, provider, model, binaryPath) {
this.lastWorkspace = workspace;
this.lastProvider = provider;
this.lastModel = model;
if (binaryPath) this.g3BinaryPath = binaryPath;
this.save();
}
};