Restore to commit 74e578279624c6045ca440a3459ebfa1f8d54191

This commit is contained in:
southseact-3d
2026-02-07 20:32:41 +00:00
commit ed67b7741b
252 changed files with 99814 additions and 0 deletions

792
chat_v2/public/app.js Normal file
View File

@@ -0,0 +1,792 @@
// Reuse the existing app.js logic - this was copied over from chat/public/app.js
// In a production step, you'd avoid duplication: use a symlink or keep a single source
const state = {
sessions: [],
currentSessionId: null,
models: [],
pollingTimer: null,
mode: 'agent', // agent or plan
showingHome: false,
cliOptions: ['opencode', 'gemini-cli'],
currentCli: 'opencode',
activeStreams: new Map(), // Track active SSE connections
opencodeStatus: null,
};
const el = {
sessionList: document.getElementById('session-list'),
chatArea: document.getElementById('chat-area'),
chatTitle: document.getElementById('chat-title'),
sessionId: document.getElementById('session-id'),
sessionModel: document.getElementById('session-model'),
sessionPending: document.getElementById('session-pending'),
queueIndicator: document.getElementById('queue-indicator'),
cliSelect: document.getElementById('cli-select'),
modelSelect: document.getElementById('model-select'),
modeSelect: document.getElementById('mode-select'),
customModelLabel: document.getElementById('custom-model-label'),
customModelInput: document.getElementById('custom-model-input'),
newChat: document.getElementById('new-chat'),
homeBtn: document.getElementById('home-btn'),
homeView: document.getElementById('home-view'),
homeSessionList: document.getElementById('home-session-list'),
sessionMeta: document.getElementById('session-meta'),
messageInput: document.getElementById('message-input'),
sendBtn: document.getElementById('send-btn'),
statusLine: document.getElementById('status-line'),
quickButtons: document.querySelectorAll('[data-quick]'),
gitButtons: document.querySelectorAll('[data-git]'),
gitOutput: document.getElementById('git-output'),
diagnosticsButton: document.getElementById('diagnostics-button'),
commitMessage: document.getElementById('commit-message'),
githubButton: document.getElementById('github-button'),
githubModal: document.getElementById('github-modal'),
githubClose: document.getElementById('github-close'),
modalCommitMessage: document.getElementById('modal-commit-message'),
};
function setStatus(msg) { el.statusLine.textContent = msg || ''; }
async function api(path, options = {}) {
const res = await fetch(path, { headers: { 'Content-Type': 'application/json' }, ...options });
const text = await res.text();
const json = text ? JSON.parse(text) : {};
if (!res.ok) {
const err = new Error(json.error || res.statusText);
// include stdout/stderr if returned by the server
if (json.stdout) err.stdout = json.stdout;
if (json.stderr) err.stderr = json.stderr;
throw err;
}
return json;
}
function populateCliSelect() {
if (!el.cliSelect) return;
el.cliSelect.innerHTML = '';
state.cliOptions.forEach((cli) => {
const opt = document.createElement('option');
opt.value = cli;
opt.textContent = cli.toUpperCase();
el.cliSelect.appendChild(opt);
});
el.cliSelect.value = state.currentCli;
}
function renderSessions() {
el.sessionList.innerHTML = '';
if (!state.sessions.length) {
el.sessionList.innerHTML = '<div class="muted">No sessions yet.</div>';
return;
}
state.sessions.forEach((session) => {
const div = document.createElement('div');
div.className = `session-item ${session.id === state.currentSessionId ? 'active' : ''}`;
div.innerHTML = `
<div class="session-title">${session.title || 'Chat'}</div>
<div class="session-meta">
<span class="badge">${session.cli || 'opencode'}</span>
<span class="badge">${session.model}</span>
<span class="badge ${session.pending ? 'accent' : ''}">${session.pending || 0} queued</span>
</div>
<div class="session-delete">
<button title="Delete" class="delete-btn">🗑</button>
<div class="delete-menu" role="dialog" aria-label="Confirm delete chat">
<div class="title">Delete this chat?</div>
<div class="actions">
<button class="cancel-delete ghost">Cancel</button>
<button class="confirm-delete">Delete</button>
</div>
</div>
</div>
`;
div.addEventListener('click', () => selectSession(session.id));
const deleteBtn = div.querySelector('.delete-btn');
const cancelBtn = div.querySelector('.cancel-delete');
const confirmBtn = div.querySelector('.confirm-delete');
if (deleteBtn) {
deleteBtn.addEventListener('click', (e) => {
e.stopPropagation();
const isOpen = div.classList.toggle('show-delete-menu');
if (isOpen) {
document.querySelectorAll('.session-item.show-delete-menu').forEach((item) => { if (item !== div) item.classList.remove('show-delete-menu'); });
const onDocClick = (ev) => { if (!div.contains(ev.target)) div.classList.remove('show-delete-menu'); };
setTimeout(() => document.addEventListener('click', onDocClick, { once: true }), 0);
}
});
}
if (cancelBtn) cancelBtn.addEventListener('click', (e) => { e.stopPropagation(); div.classList.remove('show-delete-menu'); });
if (confirmBtn) confirmBtn.addEventListener('click', async (e) => {
e.stopPropagation();
try {
await api(`/api/sessions/${session.id}`, { method: 'DELETE' });
state.sessions = state.sessions.filter((s) => s.id !== session.id);
if (state.currentSessionId === session.id) {
if (state.sessions.length) await selectSession(state.sessions[0].id);
else { state.currentSessionId = null; el.sessionList.innerHTML = ''; await createSession(); }
} else {
renderSessions();
}
setStatus('Chat deleted');
} catch (error) { setStatus(`Failed to delete chat: ${error.message}`); }
});
el.sessionList.appendChild(div);
});
}
function renderSessionMeta(session) {
el.sessionId.textContent = session.id;
el.sessionModel.textContent = `${session.cli || 'opencode'} / ${session.model}`;
el.sessionPending.textContent = session.pending || 0;
el.queueIndicator.textContent = session.pending ? `${session.pending} queued` : 'Idle';
el.queueIndicator.style.borderColor = session.pending ? 'var(--accent)' : 'var(--border)';
el.chatTitle.textContent = session.title || 'Chat';
if (session.cli && el.cliSelect && el.cliSelect.value !== session.cli) {
el.cliSelect.value = session.cli;
state.currentCli = session.cli;
}
if (session.model && el.modelSelect.value !== session.model) el.modelSelect.value = session.model;
}
function renderMessages(session) {
el.chatArea.innerHTML = '';
if (!session.messages || !session.messages.length) {
el.chatArea.innerHTML = '<div class="muted empty-message">Send a message to start the conversation.</div>';
return;
}
session.messages.forEach((msg) => {
const status = msg.status || 'done';
const userCard = document.createElement('div');
userCard.className = 'message user';
const userMeta = document.createElement('div');
userMeta.className = 'meta';
userMeta.innerHTML = `
<span>You</span>
<span class="badge">${msg.model || session.model}</span>
<span class="status-chip ${status}">${status}</span>
`;
const userBody = document.createElement('div');
userBody.className = 'body';
userBody.appendChild(renderContentWithTodos(msg.content || ''));
userCard.appendChild(userMeta);
userCard.appendChild(userBody);
// Attachments
if (Array.isArray(msg.attachments) && msg.attachments.length) {
const attachWrap = document.createElement('div');
attachWrap.className = 'attachments';
msg.attachments.forEach((a) => {
if (a && a.url && (a.type || '').startsWith('image/')) {
const img = document.createElement('img');
img.className = 'attachment-image';
img.src = a.url;
img.alt = a.name || 'image';
img.style.maxWidth = '400px';
img.style.display = 'block';
img.style.marginTop = '8px';
attachWrap.appendChild(img);
}
});
userCard.appendChild(attachWrap);
}
el.chatArea.appendChild(userCard);
if (msg.reply || msg.error || (status === 'running' && msg.partialOutput)) {
const assistantCard = document.createElement('div');
assistantCard.className = 'message assistant';
const assistantMeta = document.createElement('div');
assistantMeta.className = 'meta';
assistantMeta.innerHTML = `<span>${(session.cli || 'opencode').toUpperCase()}</span>`;
// Add a small raw toggle button for diagnostics
const rawBtn = document.createElement('button');
rawBtn.className = 'ghost';
rawBtn.style.marginLeft = '8px';
rawBtn.textContent = 'Raw';
assistantMeta.appendChild(rawBtn);
const assistantBody = document.createElement('div');
assistantBody.className = 'body';
assistantBody.appendChild(renderContentWithTodos(msg.reply || msg.partialOutput || msg.opencodeSummary || ''));
assistantCard.appendChild(assistantMeta);
assistantCard.appendChild(assistantBody);
if (Array.isArray(msg.attachments) && msg.attachments.length) {
const attachWrap = document.createElement('div');
attachWrap.className = 'attachments';
msg.attachments.forEach((a) => {
if (a && a.url && (a.type || '').startsWith('image/')) {
const img = document.createElement('img');
img.className = 'attachment-image';
img.src = a.url;
img.alt = a.name || 'image';
img.style.maxWidth = '400px';
img.style.display = 'block';
img.style.marginTop = '8px';
attachWrap.appendChild(img);
}
});
assistantCard.appendChild(attachWrap);
}
if (msg.error) {
const err = document.createElement('div');
err.className = 'body';
err.style.color = 'var(--danger)';
err.textContent = msg.error;
assistantCard.appendChild(err);
}
if ((!msg.reply || !msg.reply.length) && (!msg.partialOutput || !msg.partialOutput.length) && msg.opencodeSummary) {
const summary = document.createElement('div');
summary.className = 'body';
summary.style.color = 'var(--muted)';
summary.textContent = `Opencode output: ${msg.opencodeSummary}`;
assistantCard.appendChild(summary);
}
// Raw output container
const rawPre = document.createElement('pre');
rawPre.className = 'raw-output muted';
rawPre.style.display = 'none';
rawPre.textContent = [(msg.partialOutput || ''), (msg.opencodeSummary || '')].filter(Boolean).join('\n\n');
assistantCard.appendChild(rawPre);
rawBtn.addEventListener('click', () => { rawPre.style.display = rawPre.style.display === 'none' ? 'block' : 'none'; });
el.chatArea.appendChild(assistantCard);
}
});
el.chatArea.scrollTop = el.chatArea.scrollHeight;
}
// Helper: render text content and convert markdown task-list lines to actual checkboxes
function renderContentWithTodos(text) {
const wrapper = document.createElement('div');
if (!text) return document.createTextNode('');
const processedText = String(text).replace(/\.\s+/g, '.\n');
const lines = processedText.split(/\r?\n/);
let currentList = null;
for (const line of lines) {
const taskMatch = line.match(/^\s*[-*]\s*\[( |x|X)\]\s*(.*)$/);
if (taskMatch) {
if (!currentList) { currentList = document.createElement('ul'); wrapper.appendChild(currentList); }
const li = document.createElement('li');
const label = document.createElement('label');
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.disabled = true; // non-interactive for now
checkbox.checked = !!taskMatch[1].trim();
label.appendChild(checkbox);
label.appendChild(document.createTextNode(' ' + taskMatch[2]));
li.appendChild(label);
currentList.appendChild(li);
} else {
if (currentList) currentList = null;
const p = document.createElement('div');
p.textContent = line;
wrapper.appendChild(p);
}
}
return wrapper;
}
async function loadModels(cli = state.currentCli || 'opencode') {
try {
state.currentCli = cli;
if (el.cliSelect && el.cliSelect.value !== cli) el.cliSelect.value = cli;
const data = await api(`/api/models?cli=${encodeURIComponent(cli)}`);
state.models = data.models || [];
el.modelSelect.innerHTML = '';
state.models.forEach((m) => {
const option = document.createElement('option');
option.value = m.name || m.id || m;
option.textContent = m.label || m.name || m.id || m;
el.modelSelect.appendChild(option);
});
const customOpt = document.createElement('option');
customOpt.value = 'custom';
customOpt.textContent = 'Custom model...';
el.modelSelect.appendChild(customOpt);
} catch (error) { setStatus(`Model load failed: ${error.message}`); }
}
async function loadSessions() {
const data = await api('/api/sessions');
state.sessions = data.sessions || [];
if (state.sessions.length && !state.currentCli) {
state.currentCli = state.sessions[0].cli || 'opencode';
if (el.cliSelect) el.cliSelect.value = state.currentCli;
}
renderSessions();
if (!state.currentSessionId && state.sessions.length && !state.showingHome) {
await selectSession(state.sessions[0].id);
}
}
async function selectSession(id) {
state.currentSessionId = id;
const session = state.sessions.find((s) => s.id === id);
if (session) {
state.currentCli = session.cli || 'opencode';
if (el.cliSelect) el.cliSelect.value = state.currentCli;
await loadModels(state.currentCli);
}
state.showingHome = false;
showChatView();
renderSessions();
await refreshCurrentSession();
setPollingInterval(2500);
}
function showHomeView() {
state.showingHome = true;
state.currentSessionId = null;
el.homeView.style.display = 'block';
el.chatArea.style.display = 'none';
el.sessionMeta.style.display = 'none';
document.querySelector('.composer').style.display = 'none';
// Render all sessions in home view
el.homeSessionList.innerHTML = '';
state.sessions.forEach((session) => {
const card = document.createElement('div');
card.style.cssText = 'padding:16px; border:1px solid var(--border); border-radius:8px; cursor:pointer; transition:all 0.2s;';
card.innerHTML = `
<div style="font-weight:600; margin-bottom:8px;">${session.title || 'Chat'}</div>
<div style="font-size:12px; color:var(--muted);">
<span>Model: ${session.model}</span> •
<span>${session.messages.length} messages</span> •
<span>Updated: ${new Date(session.updatedAt).toLocaleString()}</span>
</div>
`;
card.onmouseenter = () => card.style.borderColor = 'var(--accent)';
card.onmouseleave = () => card.style.borderColor = 'var(--border)';
card.onclick = () => selectSession(session.id);
el.homeSessionList.appendChild(card);
});
}
function showChatView() {
state.showingHome = false;
el.homeView.style.display = 'none';
el.chatArea.style.display = 'block';
el.sessionMeta.style.display = 'flex';
document.querySelector('.composer').style.display = 'block';
}
// Set up SSE stream for a message
function streamMessage(sessionId, messageId) {
// Close existing stream if any
if (state.activeStreams.has(messageId)) {
const existing = state.activeStreams.get(messageId);
existing.close();
state.activeStreams.delete(messageId);
}
const url = `/api/sessions/${sessionId}/messages/${messageId}/stream`;
const eventSource = new EventSource(url);
eventSource.onopen = () => {
console.log('SSE stream opened for message', messageId);
};
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
// Update the session with streaming data
const session = state.sessions.find(s => s.id === sessionId);
if (!session) return;
const message = session.messages.find(m => m.id === messageId);
if (!message) return;
if (data.type === 'start') {
message.status = 'running';
setStatus('OpenCode is responding...');
} else if (data.type === 'chunk') {
// Update partial output immediately
message.partialOutput = data.filtered || data.partialOutput || data.content;
message.outputType = data.outputType;
message.partialUpdatedAt = data.timestamp;
message.status = 'running';
// Re-render messages to show new content immediately
renderMessages(session);
setStatus('Streaming response...');
} else if (data.type === 'complete') {
message.reply = data.content;
message.status = 'done';
message.finishedAt = data.timestamp;
message.outputType = data.outputType;
message.opencodeExitCode = data.exitCode;
eventSource.close();
state.activeStreams.delete(messageId);
renderMessages(session);
setStatus('Complete');
// Update session list
renderSessions();
} else if (data.type === 'error') {
message.error = data.error;
message.status = 'error';
message.finishedAt = data.timestamp;
message.opencodeExitCode = data.code;
eventSource.close();
state.activeStreams.delete(messageId);
renderMessages(session);
setStatus('Error: ' + data.error);
// Update session list
renderSessions();
}
} catch (err) {
console.error('Failed to parse SSE message', err);
}
};
eventSource.onerror = (err) => {
console.error('SSE error', err);
eventSource.close();
state.activeStreams.delete(messageId);
// Fall back to polling for this message
setTimeout(() => refreshCurrentSession(), 1000);
};
state.activeStreams.set(messageId, eventSource);
}
async function refreshCurrentSession() {
if (!state.currentSessionId) return;
try {
const { session } = await api(`/api/sessions/${state.currentSessionId}`);
state.sessions = state.sessions.map((s) => (s.id === session.id ? session : s));
renderSessions();
renderSessionMeta(session);
renderMessages(session);
// Set up streaming for any running messages that don't have streams yet
const running = (session.messages || []).filter((m) => m.status === 'running' || m.status === 'queued');
running.forEach(msg => {
if (!state.activeStreams.has(msg.id)) {
streamMessage(session.id, msg.id);
}
});
// Adjust polling - slower when using SSE
if (running.length > 0) setPollingInterval(2000);
else setPollingInterval(5000);
} catch (error) { setStatus(error.message); }
}
function setPollingInterval(intervalMs) {
if (!intervalMs) return;
if (state.pollingInterval === intervalMs) return;
if (state.pollingTimer) clearInterval(state.pollingTimer);
state.pollingInterval = intervalMs;
state.pollingTimer = setInterval(refreshCurrentSession, state.pollingInterval);
}
async function createSession(options = {}) {
const cli = el.cliSelect ? el.cliSelect.value : state.currentCli || 'opencode';
state.currentCli = cli;
const model = el.modelSelect.value || 'default';
const payload = { model, cli };
if (options.appId) payload.appId = options.appId;
const { session } = await api('/api/sessions', { method: 'POST', body: JSON.stringify(payload) });
state.sessions.unshift(session);
renderSessions();
await selectSession(session.id);
}
async function checkOpencodeStatus() {
try {
const status = await api('/api/opencode/status');
state.opencodeStatus = status;
if (!status.available) {
setStatus(`Warning: OpenCode CLI not available - ${status.error || 'unknown error'}`);
}
return status;
} catch (error) {
console.error('Failed to check opencode status', error);
return null;
}
}
async function sendMessage() {
const content = el.messageInput.value.trim();
if (!content) return;
// Create session if needed
if (!state.currentSessionId) await createSession();
const cli = el.cliSelect ? el.cliSelect.value : state.currentCli || 'opencode';
state.currentCli = cli;
let model = el.modelSelect.value;
if (model === 'custom') {
const txt = el.customModelInput.value.trim();
if (txt) model = txt;
else {
setStatus('Please enter a custom model name');
el.sendBtn.disabled = false;
return;
}
}
const mode = state.mode || 'agent';
el.sendBtn.disabled = true;
setStatus('Sending...');
try {
// Auto-generate title from first message if it's "New Chat"
const session = state.sessions.find(s => s.id === state.currentSessionId);
const isFirstMessage = session && session.title === 'New Chat' && session.messages.length === 0;
const response = await api(`/api/sessions/${state.currentSessionId}/messages`, {
method: 'POST',
body: JSON.stringify({ content, model, mode, cli }),
});
if (isFirstMessage) {
const title = content.slice(0, 50) + (content.length > 50 ? '...' : '');
await api(`/api/sessions/${state.currentSessionId}`, {
method: 'PATCH',
body: JSON.stringify({ title }),
});
}
el.messageInput.value = '';
// Start streaming immediately for the new message
if (response.message && response.message.id) {
streamMessage(state.currentSessionId, response.message.id);
}
await refreshCurrentSession();
} catch (error) {
setStatus(error.message);
} finally {
el.sendBtn.disabled = false;
}
}
function hookEvents() {
el.newChat.addEventListener('click', () => {
const currentSession = state.sessions.find(s => s.id === state.currentSessionId);
const currentAppId = currentSession?.appId;
createSession(currentAppId ? { appId: currentAppId } : {});
});
el.homeBtn.addEventListener('click', showHomeView);
el.sendBtn.addEventListener('click', sendMessage);
if (el.cliSelect) {
el.cliSelect.addEventListener('change', async () => {
state.currentCli = el.cliSelect.value;
await loadModels(state.currentCli);
if (state.currentSessionId) {
try {
await api(`/api/sessions/${state.currentSessionId}`, { method: 'PATCH', body: JSON.stringify({ cli: state.currentCli }) });
await refreshCurrentSession();
} catch (err) { setStatus(`Failed to update CLI: ${err.message}`); }
}
});
}
el.messageInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) sendMessage(); });
// Support paste image handling: if user pastes an image, convert to base64 and send
el.messageInput.addEventListener('paste', async (e) => {
try {
// Check clipboard items for an image
const items = e.clipboardData && e.clipboardData.items ? Array.from(e.clipboardData.items) : [];
const imageItem = items.find(it => it.type && it.type.startsWith('image/'));
if (!imageItem) return; // no image in clipboard
e.preventDefault();
const blob = imageItem.getAsFile();
if (!blob) return;
const reader = new FileReader();
reader.onload = async () => {
const dataUrl = reader.result; // data:image/png;base64,...
// Build a message with an attachment
// Create session if needed
if (!state.currentSessionId) await createSession();
const cli = el.cliSelect ? el.cliSelect.value : state.currentCli || 'opencode';
state.currentCli = cli;
const model = el.modelSelect.value === 'custom' ? el.customModelInput.value.trim() || 'default' : el.modelSelect.value || 'default';
const payload = { content: '', model, cli, mode: state.mode || 'agent', attachments: [{ name: blob.name || 'pasted-image', type: blob.type, data: dataUrl.split(',')[1] }] };
setStatus('Uploading image...');
el.sendBtn.disabled = true;
try {
const response = await api(`/api/sessions/${state.currentSessionId}/messages`, { method: 'POST', body: JSON.stringify(payload) });
setStatus('Image sent');
el.messageInput.value = '';
// Start streaming for the new message
if (response.message && response.message.id) {
streamMessage(state.currentSessionId, response.message.id);
}
await refreshCurrentSession();
} catch (err) { setStatus(`Failed to upload image: ${err.message}`); }
el.sendBtn.disabled = false;
};
reader.readAsDataURL(blob);
} catch (err) { console.error('Paste handler error', err); }
});
// Mode select handler
el.modeSelect.addEventListener('change', () => {
state.mode = el.modeSelect.value;
});
el.quickButtons.forEach((btn) => { btn.addEventListener('click', () => { const tag = btn.dataset.quick; const map = { shorter: 'Please condense the last answer.', more: 'Tell me more about this topic.', }; el.messageInput.value = map[tag] || ''; el.messageInput.focus(); }); });
el.gitButtons.forEach((btn) => { btn.addEventListener('click', async () => {
const action = btn.dataset.git;
el.gitOutput.textContent = `Running ${action}...`;
try {
const payload = {};
// Disable all git buttons while running
el.gitButtons.forEach((b) => b.disabled = true);
if (action === 'push' || action === 'sync') payload.message = (el.commitMessage && el.commitMessage.value) ? el.commitMessage.value : (el.modalCommitMessage && el.modalCommitMessage.value) || 'Update from web UI';
const data = await api(`/api/git/${action}`, { method: 'POST', body: JSON.stringify(payload) });
const out = data.output || data.stdout || data.stderr || 'Done';
el.gitOutput.textContent = out;
} catch (error) {
// Show detailed output if available
const lines = [];
lines.push(error.message);
if (error.stdout) lines.push('\nSTDOUT:\n' + error.stdout.trim());
if (error.stderr) lines.push('\nSTDERR:\n' + error.stderr.trim());
el.gitOutput.textContent = lines.join('\n');
}
// Re-enable the buttons
el.gitButtons.forEach((b) => b.disabled = false);
}); });
// Hook up GitHub modal and its buttons
if (el.githubButton) {
el.githubButton.addEventListener('click', () => {
console.log('GitHub button clicked, showing modal');
el.githubModal.style.display = 'flex';
});
} else {
console.error('GitHub button element not found');
}
if (el.githubClose) {
el.githubClose.addEventListener('click', () => {
console.log('GitHub close button clicked');
el.githubModal.style.display = 'none';
});
}
const modalButtons = document.querySelectorAll('#github-modal [data-git]');
modalButtons.forEach((btn) => {
btn.addEventListener('click', async () => {
const action = btn.dataset.git;
el.gitOutput.textContent = `Running ${action}...`;
try {
const payload = {};
// Disable all git buttons while running
el.gitButtons.forEach((b) => b.disabled = true);
if (action === 'push' || action === 'sync') payload.message = el.modalCommitMessage && el.modalCommitMessage.value ? el.modalCommitMessage.value : 'Update from web UI';
const data = await api(`/api/git/${action}`, { method: 'POST', body: JSON.stringify(payload) });
const out = data.output || data.stdout || data.stderr || 'Done';
el.gitOutput.textContent = out;
} catch (error) {
const lines = [];
lines.push(error.message);
if (error.stdout) lines.push('\nSTDOUT:\n' + error.stdout.trim());
if (error.stderr) lines.push('\nSTDERR:\n' + error.stderr.trim());
el.gitOutput.textContent = lines.join('\n');
}
el.gitButtons.forEach((b) => b.disabled = false);
});
});
if (el.diagnosticsButton) {
el.diagnosticsButton.addEventListener('click', async () => {
el.gitOutput.textContent = 'Running diagnostics...';
try {
const data = await api('/api/diagnostics');
const out = `Version:\n${data.version || ''}\n\nModels Output:\n${data.modelsOutput || ''}`;
el.gitOutput.textContent = out;
} catch (error) {
el.gitOutput.textContent = `Diagnostics failed: ${error.message}`;
}
});
}
el.modelSelect.addEventListener('change', async () => {
const selected = el.modelSelect.value;
if (selected === 'custom') {
el.customModelLabel.style.display = 'inline-flex';
el.customModelInput.focus();
return;
}
el.customModelLabel.style.display = 'none';
// If a session is selected, update the session's active model on the server
if (state.currentSessionId) {
try {
await api(`/api/sessions/${state.currentSessionId}`, { method: 'PATCH', body: JSON.stringify({ model: selected }) });
await refreshCurrentSession();
} catch (e) { setStatus(`Failed to update model: ${e.message}`); }
}
});
el.customModelInput.addEventListener('keydown', async (e) => {
if (e.key === 'Enter') {
const txt = el.customModelInput.value.trim();
if (!txt) return setStatus('Please enter a custom model name');
if (state.currentSessionId) {
try {
await api(`/api/sessions/${state.currentSessionId}`, { method: 'PATCH', body: JSON.stringify({ model: txt }) });
await refreshCurrentSession();
} catch (err) { setStatus(`Failed to update custom model: ${err.message}`); }
}
}
});
}
// Handle page visibility changes to maintain polling
document.addEventListener('visibilitychange', () => {
const isVisible = document.visibilityState === 'visible';
if (isVisible) {
// User came back to the page, refresh immediately
console.log('Page became visible, refreshing...');
refreshCurrentSession().catch(err => console.error('Refresh failed', err));
// Ensure polling interval is set
if (!state.pollingTimer) {
setPollingInterval(2500);
}
} else {
// User left the page, but keep polling in the background at a slower rate
console.log('Page became hidden, maintaining background polling...');
if (state.pollingTimer) {
setPollingInterval(5000); // Slower polling in background
}
}
});
// Handle page unload gracefully
window.addEventListener('beforeunload', (e) => {
// Check if there are running processes
const running = state.sessions.flatMap(s => s.messages || []).filter(m => m.status === 'running' || m.status === 'queued');
if (running.length > 0) {
console.log('Page unloading with running processes. They will continue on the server.');
// Don't prevent unload, just log it
}
});
// When user comes back to the page after a long time, ensure we reconnect to running processes
window.addEventListener('focus', () => {
console.log('Window focused, checking for running processes to reconnect...');
if (state.currentSessionId) {
refreshCurrentSession().catch(err => console.error('Refresh on focus failed', err));
}
});
(async function init() {
populateCliSelect();
hookEvents();
// Check opencode status on startup
checkOpencodeStatus();
await loadModels(state.currentCli);
await loadSessions();
if (!state.sessions.length) await createSession();
// Periodically check opencode status
setInterval(checkOpencodeStatus, 30000);
// Keep polling going even in background (for running processes)
// Start with reasonable interval that will be adjusted by refreshCurrentSession
setPollingInterval(5000);
})();

138
chat_v2/public/index.html Normal file
View File

@@ -0,0 +1,138 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chat with OpenCode</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600&family=Inter:wght@400;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/chat/styles.css">
</head>
<body>
<div class="app-shell">
<aside class="sidebar">
<div class="brand">
<div class="brand-mark">OC</div>
<div>
<div class="brand-title">OpenCode Desk</div>
<div class="brand-sub">Terminal + Chat</div>
</div>
</div>
<button class="primary" id="new-chat">+ New chat</button>
<button class="ghost" id="home-btn" style="margin-top:8px;">HOME Home</button>
<div class="sidebar-section">
<div class="section-heading">Sessions</div>
<div id="session-list" class="session-list"></div>
</div>
<div class="sidebar-section slim">
<div class="section-heading">Version control</div>
<div style="display:flex; flex-direction:column; gap:8px;">
<button id="github-button" class="primary">GitHub</button>
<button id="diagnostics-button" class="ghost" style="margin-left:8px;">Diagnostics</button>
<div id="git-output" class="git-output"></div>
</div>
</div>
</aside>
<main class="main">
<header class="topbar">
<div class="topbar-left">
<div class="crumb">domain.com</div>
<div class="title" id="chat-title">Chat</div>
</div>
<div class="topbar-actions">
<div class="queue-indicator" id="queue-indicator">Idle</div>
</div>
</header>
<div class="panel" id="session-meta">
<div>
<div class="label">Session ID</div>
<div id="session-id" class="value">-</div>
</div>
<div>
<div class="label">Active model</div>
<div id="session-model" class="value">-</div>
</div>
<div>
<div class="label">Pending</div>
<div id="session-pending" class="value">0</div>
</div>
</div>
<section id="home-view" style="display:none; padding:20px;">
<h2 style="margin-bottom:20px;">Your Chats</h2>
<div id="home-session-list" style="display:grid; gap:12px;"></div>
</section>
<section class="chat-area" id="chat-area"></section>
<div class="composer">
<div class="input-row">
<textarea id="message-input" rows="3" placeholder="Type your message to OpenCode..."></textarea>
<button id="send-btn" class="primary">Send</button>
</div>
<div style="margin-top:10px; display:flex; gap:8px; align-items:center;">
<label class="model-select" style="background:transparent; border:1px solid var(--border);">
<span style="color:var(--muted); margin-right:6px;">CLI</span>
<select id="cli-select" style="color:var(--text); background:#fff; color: var(--text); border: none; padding:4px 8px; border-radius:6px;"></select>
</label>
<label class="model-select" style="background:transparent; border:1px solid var(--border);">
<span style="color:var(--muted); margin-right:6px;">Model</span>
<select id="model-select" style="color:var(--text); background:#fff; color: var(--text); border: none; padding:4px 8px; border-radius:6px;"></select>
</label>
<label class="model-select" style="background:transparent; border:1px solid var(--border);">
<span style="color:var(--muted); margin-right:6px;">Mode</span>
<select id="mode-select" style="color:var(--text); background:#fff; color: var(--text); border: none; padding:4px 8px; border-radius:6px;">
<option value="agent">Agent</option>
<option value="plan">Plan</option>
</select>
</label>
<label id="custom-model-label" class="model-select" style="display:none; margin-left:8px; background:transparent; border:1px solid var(--border);">
<span style="color:var(--muted); margin-right:6px;">Custom</span>
<input id="custom-model-input" type="text" placeholder="provider/model or model-name" style="background:transparent; border:none; color:var(--text); padding:0 8px;" />
</label>
</div>
<div class="status-line" id="status-line"></div>
</div>
</main>
</div>
<!-- GitHub modal -->
<div id="github-modal" class="modal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.4); align-items:center; justify-content:center; z-index:10000;">
<div style="background:var(--panel); padding:20px; border-radius:10px; width:420px; box-shadow:0 10px 40px rgba(0,0,0,0.2);">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">
<strong style="color:var(--text);">GitHub</strong>
<button id="github-close" style="border:none; background:transparent; color:var(--muted); cursor:pointer;">CLOSE</button>
</div>
<div style="display:flex; flex-direction:column; gap:8px;">
<div style="display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
<button data-git="pull" class="ghost">Pull</button>
<button data-git="fetch" class="ghost">Fetch</button>
<button data-git="status" class="ghost">Status</button>
<button data-git="log" class="ghost">Log</button>
</div>
<div style="display:flex; gap:8px;">
<button data-git="push" class="ghost">Commit & Push</button>
<button data-git="sync" class="ghost">Sync</button>
</div>
<input id="modal-commit-message" type="text" placeholder="Commit message" value="Update from chat UI" style="padding:8px; border-radius:6px; border:1px solid var(--border);" />
<details style="margin-top:8px; padding:8px; border-radius:6px; background:var(--panel);">
<summary style="cursor:pointer; font-weight:600;">What commands will run</summary>
<div style="margin-top:8px; color:var(--muted);">
<ul>
<li><strong>Pull</strong> — git pull</li>
<li><strong>Fetch</strong> — git fetch --all</li>
<li><strong>Status</strong> — git status --short</li>
<li><strong>Log</strong> — git log --oneline -n 20</li>
<li><strong>Commit & Push</strong> — git add .; git commit -m "message"; git push origin main</li>
<li><strong>Sync</strong> — git pull; git add .; git commit -m "message"; git push origin main</li>
</ul>
<div style="font-size:12px; color:var(--muted);">Note: Commit will run with the provided message. The server runs git in the workspace root (configurable via the CHAT_REPO_ROOT env var)</div>
</div>
</details>
</div>
</div>
</div>
</body>
<script src="/chat/app.js"></script>
</html>

71
chat_v2/public/styles.css Normal file
View File

@@ -0,0 +1,71 @@
/* Copy of original styles for the chat UI */
:root { --bg: #fbf6ef; --panel: #fffaf2; --panel-strong: #f3e9d8; --border: rgba(0,0,0,0.06); --accent: #004225; --accent-2: #006B3D; --muted: #6b6b6b; --text: #2b2b2b; --danger: #b00020; --font-body: "Space Grotesk", "Inter", system-ui, -apple-system, sans-serif; }
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text); font-family: var(--font-body); min-height: 100vh; }
.app-shell { display: grid; grid-template-columns: 320px 1fr; min-height: 100vh; }
.sidebar { background: var(--panel); border-right: 1px solid var(--border); padding: 20px; display: flex; flex-direction: column; gap: 18px; }
.brand { display: flex; align-items: center; gap: 12px; }
.brand-mark { width: 42px; height: 42px; border-radius: 10px; background: linear-gradient(135deg, var(--accent), var(--accent-2)); display: grid; place-items: center; color: #fff; font-weight: 700; letter-spacing: 0.4px; }
.brand-title { font-weight: 700; letter-spacing: 0.2px; } .brand-sub { color: var(--muted); font-size: 13px; }
.primary { background: linear-gradient(135deg, var(--accent), var(--accent-2)); color: #fff; border: none; border-radius: 12px; padding: 12px 14px; font-weight: 700; cursor: pointer; transition: transform 120ms ease, box-shadow 120ms ease; }
.primary:hover { transform: translateY(-1px); box-shadow: 0 10px 25px rgba(0, 66, 37, 0.18); } button { font-family: inherit; } button:disabled { opacity: 0.5; cursor: not-allowed; }
.sidebar-section { border: 1px solid var(--border); border-radius: 14px; padding: 12px; background: rgba(255,255,255,0.02); display: flex; flex-direction: column; gap: 10px; }
.sidebar-section.slim { gap: 8px; } .section-heading { color: var(--muted); font-size: 12px; letter-spacing: 0.4px; text-transform: uppercase; }
.session-list { display: flex; flex-direction: column; gap: 8px; max-height: 420px; overflow: auto; } .session-item { padding: 12px; border-radius: 12px; border: 1px solid var(--border); background: var(--panel); cursor: pointer; transition: border-color 120ms ease, transform 120ms ease; position: relative; }
.session-item:hover { border-color: rgba(0, 66, 37, 0.6); transform: translateX(2px); } .session-item.active { border-color: var(--accent); background: rgba(0, 66, 37, 0.06); }
.session-title { font-weight: 600; margin-bottom: 4px; } .session-meta { display: flex; gap: 8px; color: var(--muted); font-size: 12px; }
.badge { padding: 2px 8px; border-radius: 999px; background: rgba(255,255,255,0.06); }
.badge.accent { background: rgba(0, 66, 37, 0.12); color: #e0f4ea; }
/* Delete button and confirmation menu */
.session-item .session-delete { position: absolute; right: 10px; top: 8px; display: flex; gap: 6px; align-items: center; }
.session-delete button.delete-btn { display: none; border: none; background: transparent; cursor: pointer; color: var(--muted); padding: 6px; border-radius: 6px; }
.session-item:hover .session-delete button.delete-btn { display: inline-flex; }
.session-delete button.delete-btn:hover { color: var(--danger); background: rgba(176, 0, 32, 0.06); }
.session-delete .delete-menu { display: none; position: absolute; right: 0; top: 34px; background: var(--panel); border: 1px solid var(--border); padding: 8px; border-radius: 8px; box-shadow: 0 6px 18px rgba(0,0,0,0.08); z-index: 30; width: 240px; }
.session-item.show-delete-menu .session-delete .delete-menu { display: block; }
.session-delete .delete-menu .title { font-weight: 700; margin-bottom: 6px; }
.session-delete .delete-menu .actions { display:flex; gap:8px; justify-content:flex-end; }
.session-delete .delete-menu button.confirm-delete { background: rgba(176,0,32,0.12); color: var(--danger); border: none; padding: 8px 10px; border-radius: 8px; cursor: pointer; }
.session-delete .delete-menu button.cancel-delete { background: transparent; border: 1px dashed var(--border); padding: 8px 10px; border-radius: 8px; cursor: pointer; }
.git-actions { display: flex; flex-direction: column; gap: 8px; }
.git-actions button { background: rgba(255,255,255,0.05); color: var(--text); border: 1px solid var(--border); border-radius: 10px; padding: 10px; text-align: left; cursor: pointer; }
.git-actions button:hover { border-color: rgba(0, 66, 37, 0.6); } .git-actions input { width: 100%; padding: 10px; border-radius: 10px; border: 1px solid var(--border); background: var(--panel); color: var(--text); }
.git-output { min-height: 40px; font-size: 12px; color: var(--muted); white-space: pre-wrap; background: var(--panel); border: 1px dashed var(--border); border-radius: 10px; padding: 8px; }
.raw-output { font-family: monospace; background: var(--panel); border: 1px solid var(--border); padding: 8px; margin-top: 8px; white-space: pre-wrap; }
.attachments { margin-top: 10px; display: flex; flex-direction: column; gap: 8px; }
.attachment-image { border-radius: 8px; box-shadow: 0 6px 16px rgba(0,0,0,0.06); }
.main { padding: 24px; display: flex; flex-direction: column; gap: 16px; }
.topbar { display: flex; justify-content: space-between; align-items: center; } .crumb { color: var(--muted); font-size: 13px; } .title { font-size: 24px; font-weight: 700; }
.topbar-actions { display: flex; align-items: center; gap: 12px; }
.model-select { display: flex; align-items: center; gap: 8px; background: var(--panel); padding: 10px 12px; border: 1px solid var(--border); border-radius: 12px; }
.model-select select { background: transparent; color: var(--text); border: none; font-weight: 600; }
.queue-indicator { padding: 10px 12px; border-radius: 12px; border: 1px solid var(--border); background: rgba(255,255,255,0.05); min-width: 120px; text-align: center; }
.panel { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 14px; background: var(--panel-strong); border: 1px solid var(--border); border-radius: 14px; padding: 14px; }
.label { color: var(--muted); font-size: 12px; letter-spacing: 0.3px; }
.value { font-weight: 700; }
.chat-area { flex: 1; border: 1px solid var(--border); border-radius: 16px; background: #fff; padding: 16px; overflow-y: auto; display: flex; flex-direction: column; gap: 12px; min-height: 420px; }
.empty-message { text-align: center; margin-top: 100px; }
.empty-message { text-align: center; margin-top: 100px; }
.message { padding: 12px 14px; border-radius: 12px; border: 1px solid var(--border); position: relative; }
.message.user { background: rgba(224, 123, 57, 0.06); border-color: rgba(224, 123, 57, 0.12); }
.message.assistant { background: rgba(240,240,240,1); }
.message .meta { display: flex; gap: 10px; align-items: center; color: var(--muted); font-size: 12px; margin-bottom: 6px; }
.message .body { white-space: pre-wrap; line-height: 1.5; }
.status-chip { border-radius: 999px; padding: 4px 8px; border: 1px solid var(--border); font-size: 12px; }
.status-chip.running { border-color: var(--accent-2); color: var(--accent-2); }
.status-chip.queued { color: var(--muted); }
.status-chip.done { border-color: var(--accent); color: #e0f4ea; }
.status-chip.error { border-color: var(--danger); color: var(--danger); }
.composer { border: 1px solid var(--border); border-radius: 16px; padding: 14px; background: var(--panel); display: flex; flex-direction: column; gap: 10px; }
.prompt-helpers { display: flex; gap: 8px; flex-wrap: wrap; }
.ghost { background: transparent; border: 1px dashed var(--border); color: var(--text); border-radius: 10px; padding: 8px 10px; cursor: pointer; }
.input-row { display: grid; grid-template-columns: 1fr 140px; gap: 10px; }
textarea { width: 100%; border: 1px solid var(--border); border-radius: 12px; background: #fff; color: var(--text); padding: 12px; font-family: var(--font-body); resize: vertical; }
.status-line { color: var(--muted); font-size: 12px; min-height: 16px; }
.muted { color: var(--muted); }
@media (max-width: 1080px) { .app-shell { grid-template-columns: 1fr; } .sidebar { order: 2; } .main { order: 1; } }
/* Modal styles */
.modal { display: none; align-items: center; justify-content: center; position: fixed; inset: 0; z-index: 10000; }
.modal > div { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 16px; }
.modal button.ghost { background: transparent; border: 1px solid var(--border); padding: 8px 10px; border-radius: 8px; color: var(--text); }

15
chat_v2/server.js Normal file
View File

@@ -0,0 +1,15 @@
// chat_v2: wrapper that starts the main chat/server.js file
// using robust path detection for both container (/opt/webchat) and local environments
const fs = require('fs');
const path = require('path');
// Container path: /opt/webchat/server.js
// Local path: ../chat/server.js
const containerPath = '/opt/webchat/server.js';
const localPath = path.join(__dirname, '../chat/server.js');
if (fs.existsSync(containerPath)) {
require(containerPath);
} else {
require(localPath);
}