Restore to commit 74e578279624c6045ca440a3459ebfa1f8d54191
This commit is contained in:
792
chat_v2/public/app.js
Normal file
792
chat_v2/public/app.js
Normal 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
138
chat_v2/public/index.html
Normal 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
71
chat_v2/public/styles.css
Normal 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
15
chat_v2/server.js
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user