This repository has been archived on 2026-06-13. You can view files and clone it, but cannot push or open issues or pull requests.
anvillm/cmd/anvilweb/static/index.html

556 lines
22 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AnviLLM Web</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<style>
body { padding: 0; }
header {
background: var(--pico-primary-background);
padding: 0.75rem 2rem;
margin-bottom: 1rem;
}
header h1 {
margin: 0;
font-size: 1.25rem;
color: white;
}
main { padding: 0 2rem; }
.state-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.875rem;
font-weight: 600;
}
.state-idle { background: #22c55e; color: white; }
.state-running { background: #3b82f6; color: white; }
.state-stopped { background: #6b7280; color: white; }
.state-error { background: #ef4444; color: white; }
.state-starting { background: #f97316; color: white; }
.state-exited { background: #9ca3af; color: white; }
table { margin-top: 1rem; }
table button { margin: 0 0.25rem; padding: 0.25rem 0.5rem; font-size: 0.875rem; }
table td { vertical-align: middle; }
.actions-cell { white-space: nowrap; }
.session-id { font-family: monospace; font-size: 0.875rem; color: var(--pico-muted-color); }
#prompt-area { min-height: 100px; }
.hidden { display: none; }
#log-pane {
margin-top: 2rem;
border: 1px solid var(--pico-muted-border-color);
border-radius: 0.5rem;
overflow: hidden;
}
#log-header {
background: var(--pico-card-background-color);
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--pico-muted-border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
#log-header h3 {
margin: 0;
font-size: 1rem;
}
#log-content {
background: #000;
color: #fff;
font-family: 'Courier New', monospace;
font-size: 0.875rem;
padding: 1rem;
height: 400px;
overflow-y: auto;
white-space: pre-wrap;
word-wrap: break-word;
}
#log-content:empty::before {
content: 'No log data yet...';
color: #666;
}
th.sortable { cursor: pointer; user-select: none; }
th.sortable:hover { background: var(--pico-secondary-background); }
th.sortable::after { content: ' ⇅'; opacity: 0.3; }
th.sortable.asc::after { content: ' ↑'; opacity: 1; }
th.sortable.desc::after { content: ' ↓'; opacity: 1; }
</style>
</head>
<body>
<header>
<h1>AnviLLM</h1>
</header>
<main>
<section>
<div style="display: flex; justify-content: space-between; align-items: center;">
<h2>Sessions</h2>
<div>
<button onclick="openNewSessionDialog()">New Session</button>
<button onclick="loadSessions()" class="secondary">Refresh</button>
</div>
</div>
<input type="search" id="search-box" placeholder="Search sessions..." style="margin-top: 1rem;">
<div id="sessions-container"></div>
</section>
<section id="log-pane" class="hidden">
<div id="log-header">
<h3 id="log-title">Log</h3>
<button onclick="closeLogPane()" class="secondary" style="margin: 0;">Close</button>
</div>
<div id="log-content"></div>
</section>
<dialog id="new-session-dialog">
<article>
<header>
<button aria-label="Close" rel="prev" onclick="closeNewSessionDialog()"></button>
<h3>New Session</h3>
</header>
<form id="new-session-form">
<label>
Backend
<select name="backend" required>
<option value="kiro-cli">Kiro</option>
<option value="claude">Claude</option>
</select>
</label>
<label>
Working Directory
<input type="text" name="cwd" placeholder="/path/to/project" required>
</label>
<footer style="display: flex; justify-content: flex-end; gap: 0.5rem;">
<button type="submit">Start Session</button>
<button type="button" onclick="closeNewSessionDialog()" class="secondary">Cancel</button>
</footer>
</form>
</article>
</dialog>
<dialog id="prompt-dialog">
<article>
<header>
<button aria-label="Close" rel="prev" onclick="closePromptDialog()"></button>
<h3>Send Prompt</h3>
</header>
<form id="prompt-form">
<label>
Prompt
<textarea id="prompt-area" name="prompt" required></textarea>
</label>
<footer style="display: flex; justify-content: flex-end; gap: 0.5rem;">
<button type="submit">Send</button>
<button type="button" onclick="closePromptDialog()" class="secondary">Cancel</button>
</footer>
</form>
</article>
</dialog>
<dialog id="context-dialog">
<article>
<header>
<button aria-label="Close" rel="prev" onclick="closeContextDialog()"></button>
<h3>Edit Context</h3>
</header>
<form id="context-form">
<label>
Context (prepended to all prompts)
<textarea id="context-area" name="context" rows="10"></textarea>
</label>
<footer style="display: flex; justify-content: flex-end; gap: 0.5rem;">
<button type="submit">Save</button>
<button type="button" onclick="closeContextDialog()" class="secondary">Cancel</button>
</footer>
</form>
</article>
</dialog>
<dialog id="alias-dialog">
<article>
<header>
<button aria-label="Close" rel="prev" onclick="closeAliasDialog()"></button>
<h3>Set Alias</h3>
</header>
<form id="alias-form">
<label>
Alias
<input type="text" id="alias-input" name="alias" required>
</label>
<footer style="display: flex; justify-content: flex-end; gap: 0.5rem;">
<button type="submit">Save</button>
<button type="button" onclick="closeAliasDialog()" class="secondary">Cancel</button>
</footer>
</form>
</article>
</dialog>
</main>
<script>
let currentSessionId = null;
let allSessions = [];
let logEventSource = null;
let sortColumn = 'alias';
let sortAsc = true;
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function filterSessions() {
const query = document.getElementById('search-box').value.toLowerCase();
const filtered = allSessions.filter(s => {
const searchText = [
s.alias || '',
s.id || '',
s.state || '',
s.backend || '',
s.cwd || '',
s.pid || ''
].join(' ').toLowerCase();
return searchText.includes(query);
});
renderSessions(filtered);
}
function renderSessions(sessions) {
const container = document.getElementById('sessions-container');
if (sessions.length === 0) {
container.innerHTML = '<p>No sessions found</p>';
return;
}
sessions.sort((a, b) => {
let valA, valB;
switch (sortColumn) {
case 'alias': valA = (a.alias || a.id).toLowerCase(); valB = (b.alias || b.id).toLowerCase(); break;
case 'id': valA = a.id; valB = b.id; break;
case 'state': valA = a.state; valB = b.state; break;
case 'backend': valA = a.backend || ''; valB = b.backend || ''; break;
case 'cwd': valA = a.cwd; valB = b.cwd; break;
case 'pid': valA = parseInt(a.pid) || 0; valB = parseInt(b.pid) || 0; break;
default: valA = ''; valB = '';
}
const cmp = typeof valA === 'number' ? valA - valB : String(valA).localeCompare(String(valB));
return sortAsc ? cmp : -cmp;
});
const sortClass = col => `sortable${sortColumn === col ? (sortAsc ? ' asc' : ' desc') : ''}`;
const table = `
<table>
<thead>
<tr>
<th class="${sortClass('alias')}" data-sort="alias">Alias</th>
<th class="${sortClass('id')}" data-sort="id">ID</th>
<th class="${sortClass('state')}" data-sort="state">State</th>
<th class="${sortClass('backend')}" data-sort="backend">Backend</th>
<th class="${sortClass('cwd')}" data-sort="cwd">Working Directory</th>
<th class="${sortClass('pid')}" data-sort="pid">PID</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${sessions.map(s => {
const alias = escapeHtml(s.alias || s.id);
const backend = escapeHtml(s.backend || 'unknown');
const cwd = escapeHtml(s.cwd);
const pid = escapeHtml(s.pid);
const state = escapeHtml(s.state);
const id = escapeHtml(s.id);
return `
<tr>
<td><strong>${alias}</strong></td>
<td><span class="session-id">${id}</span></td>
<td><span class="state-badge state-${state}">${state}</span></td>
<td>${backend}</td>
<td><small>${cwd}</small></td>
<td><small>${pid}</small></td>
<td class="actions-cell">
<button data-session-id="${id}" data-action="log">Log</button>
<button data-session-id="${id}" data-action="prompt" ${s.state !== 'idle' ? 'disabled' : ''}>Prompt</button>
<button data-session-id="${id}" data-action="context" class="secondary">Context</button>
<button data-session-id="${id}" data-action="alias" class="secondary">Alias</button>
<button data-session-id="${id}" data-action="stop" ${s.state === 'stopped' ? 'disabled' : ''} class="secondary">Stop</button>
<button data-session-id="${id}" data-action="restart" class="secondary">Restart</button>
<button data-session-id="${id}" data-action="kill" class="contrast">Kill</button>
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
`;
container.innerHTML = table;
}
async function loadSessions() {
try {
const res = await fetch('/api/sessions');
const sessions = await res.json();
allSessions = sessions;
filterSessions();
} catch (err) {
console.error('Failed to load sessions:', err);
document.getElementById('sessions-container').innerHTML = '<p>Failed to connect to server</p>';
}
}
function openNewSessionDialog() {
document.getElementById('new-session-dialog').showModal();
}
function closeNewSessionDialog() {
document.getElementById('new-session-dialog').close();
document.getElementById('new-session-form').reset();
}
document.getElementById('new-session-form').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const data = new FormData(form);
try {
const res = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
backend: data.get('backend'),
cwd: data.get('cwd')
})
});
if (res.ok) {
form.reset();
closeNewSessionDialog();
setTimeout(loadSessions, 500);
} else {
alert('Failed to create session');
}
} catch (err) {
alert('Error: ' + err.message);
}
});
function openPromptDialog(id) {
currentSessionId = id;
document.getElementById('prompt-dialog').showModal();
document.getElementById('prompt-area').focus();
}
function closePromptDialog() {
document.getElementById('prompt-dialog').close();
document.getElementById('prompt-form').reset();
}
document.getElementById('prompt-form').addEventListener('submit', async (e) => {
e.preventDefault();
const prompt = document.getElementById('prompt-area').value;
try {
const res = await fetch(`/api/session/${currentSessionId}/prompt`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt })
});
if (res.ok) {
closePromptDialog();
loadSessions();
} else {
alert('Failed to send prompt');
}
} catch (err) {
alert('Error: ' + err.message);
}
});
async function openContextDialog(id) {
currentSessionId = id;
try {
const res = await fetch(`/api/session/${id}/context`);
const data = await res.json();
document.getElementById('context-area').value = data.content || '';
document.getElementById('context-dialog').showModal();
} catch (err) {
alert('Error loading context: ' + err.message);
}
}
function closeContextDialog() {
document.getElementById('context-dialog').close();
}
document.getElementById('context-form').addEventListener('submit', async (e) => {
e.preventDefault();
const content = document.getElementById('context-area').value;
try {
const res = await fetch(`/api/session/${currentSessionId}/context`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content })
});
if (res.ok) {
closeContextDialog();
} else {
alert('Failed to save context');
}
} catch (err) {
alert('Error: ' + err.message);
}
});
async function openAliasDialog(id) {
currentSessionId = id;
try {
const res = await fetch(`/api/session/${id}/alias`);
const data = await res.json();
document.getElementById('alias-input').value = data.content || '';
document.getElementById('alias-dialog').showModal();
} catch (err) {
alert('Error loading alias: ' + err.message);
}
}
function closeAliasDialog() {
document.getElementById('alias-dialog').close();
}
document.getElementById('alias-form').addEventListener('submit', async (e) => {
e.preventDefault();
const content = document.getElementById('alias-input').value;
try {
const res = await fetch(`/api/session/${currentSessionId}/alias`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content })
});
if (res.ok) {
closeAliasDialog();
loadSessions();
} else {
alert('Failed to save alias');
}
} catch (err) {
alert('Error: ' + err.message);
}
});
async function controlSession(id, cmd) {
try {
const res = await fetch(`/api/session/${id}/ctl`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: cmd })
});
if (res.ok) {
setTimeout(loadSessions, 500);
} else {
alert(`Failed to ${cmd} session`);
}
} catch (err) {
alert('Error: ' + err.message);
}
}
// Load sessions on page load
loadSessions();
// Auto-refresh every 5 seconds
setInterval(loadSessions, 5000);
// Search box filtering
document.getElementById('search-box').addEventListener('input', filterSessions);
// Column sorting
document.addEventListener('click', (e) => {
const th = e.target.closest('th[data-sort]');
if (th) {
const col = th.dataset.sort;
if (sortColumn === col) sortAsc = !sortAsc;
else { sortColumn = col; sortAsc = true; }
filterSessions();
}
});
// Event delegation for session actions
document.addEventListener('click', (e) => {
const btn = e.target.closest('button[data-session-id]');
if (!btn || btn.disabled) return;
const id = btn.dataset.sessionId;
const action = btn.dataset.action;
switch (action) {
case 'log': openLogPane(id); break;
case 'prompt': openPromptDialog(id); break;
case 'context': openContextDialog(id); break;
case 'alias': openAliasDialog(id); break;
case 'stop': controlSession(id, 'stop'); break;
case 'restart': controlSession(id, 'restart'); break;
case 'kill': controlSession(id, 'kill'); break;
}
});
// Log streaming functions
function openLogPane(id) {
// Close existing stream if any
closeLogPane();
// Find session info for title
const session = allSessions.find(s => s.id === id);
const displayName = session ? (session.alias || session.id.substring(0, 8)) : id.substring(0, 8);
// Update UI
document.getElementById('log-title').textContent = `Log: ${displayName}`;
document.getElementById('log-content').textContent = '';
document.getElementById('log-pane').classList.remove('hidden');
// Start streaming
logEventSource = new EventSource(`/api/log/${id}`);
logEventSource.onmessage = (event) => {
const logContent = document.getElementById('log-content');
// Each event.data is one line from the log
logContent.textContent += event.data + '\n';
// Auto-scroll to bottom
logContent.scrollTop = logContent.scrollHeight;
};
logEventSource.onerror = (error) => {
console.error('Log stream error:', error);
if (logEventSource) {
logEventSource.close();
logEventSource = null;
}
};
}
function closeLogPane() {
// Close event source
if (logEventSource) {
logEventSource.close();
logEventSource = null;
}
// Hide pane
document.getElementById('log-pane').classList.add('hidden');
document.getElementById('log-content').textContent = '';
}
</script>
</body>
</html>