556 lines
22 KiB
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>
|