const state = { sessions: [], currentSessionId: null, models: [], pollingTimer: null, cliOptions: ['opencode'], currentCli: 'opencode', activeStreams: new Map(), // Track active SSE connections opencodeStatus: null, userId: null, accountPlan: 'hobby', usageSummary: null, }; const TOKENS_TO_WORD_RATIO = 0.75; 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'), modelIcon: document.getElementById('model-icon'), customModelLabel: document.getElementById('custom-model-label'), customModelInput: document.getElementById('custom-model-input'), newChat: document.getElementById('new-chat'), messageInput: document.getElementById('message-input'), uploadMediaBtn: document.getElementById('upload-media-btn'), uploadMediaInput: document.getElementById('upload-media-input'), attachmentPreview: document.getElementById('attachment-preview'), 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'), }; console.log('DOM elements initialized:', { uploadMediaBtn: el.uploadMediaBtn, uploadMediaInput: el.uploadMediaInput, messageInput: el.messageInput }); const pendingAttachments = []; function isPaidPlanClient() { const plan = (state.accountPlan || '').toLowerCase(); return plan === 'business' || plan === 'enterprise'; } function isEnterprisePlan() { const plan = (state.accountPlan || '').toLowerCase(); return plan === 'enterprise'; } function bytesToFriendly(bytes) { const n = Number(bytes || 0); if (!Number.isFinite(n) || n <= 0) return '0 B'; if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; return `${(n / (1024 * 1024)).toFixed(2)} MB`; } function renderAttachmentPreview() { if (!el.attachmentPreview) return; if (!pendingAttachments.length) { el.attachmentPreview.style.display = 'none'; el.attachmentPreview.innerHTML = ''; return; } el.attachmentPreview.style.display = 'flex'; el.attachmentPreview.innerHTML = ''; pendingAttachments.forEach((att, idx) => { const chip = document.createElement('div'); chip.className = 'attachment-chip'; const img = document.createElement('img'); img.className = 'attachment-thumb'; img.alt = att.name || 'image'; img.src = att.previewUrl || ''; const meta = document.createElement('div'); meta.className = 'attachment-meta'; const name = document.createElement('div'); name.className = 'name'; name.textContent = att.name || 'image'; const size = document.createElement('div'); size.className = 'size'; size.textContent = `${att.type || 'image'} • ${bytesToFriendly(att.size || 0)}`; meta.appendChild(name); meta.appendChild(size); const remove = document.createElement('button'); remove.className = 'attachment-remove'; remove.type = 'button'; remove.textContent = 'Remove'; remove.onclick = () => { try { const removed = pendingAttachments.splice(idx, 1); if (removed[0] && removed[0].previewUrl && removed[0].previewUrl.startsWith('blob:')) { URL.revokeObjectURL(removed[0].previewUrl); } } catch (_) { } renderAttachmentPreview(); }; chip.appendChild(img); chip.appendChild(meta); chip.appendChild(remove); el.attachmentPreview.appendChild(chip); }); } async function fileToCompressedWebpAttachment(file) { // Best-effort client-side compression to reduce JSON payload sizes. // Server will also compress on write. const maxDim = 1600; const quality = 0.8; const mime = (file && file.type) ? file.type : 'application/octet-stream'; if (!file || !mime.startsWith('image/')) throw new Error('Only images are supported'); let bitmap; try { bitmap = await createImageBitmap(file); } catch (_) { // Fallback: no bitmap support bitmap = null; } if (!bitmap) { const dataUrl = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onerror = () => reject(new Error('Failed to read image')); reader.onload = () => resolve(String(reader.result || '')); reader.readAsDataURL(file); }); const base64 = dataUrl.split(',')[1] || ''; return { name: file.name || 'image', type: mime, data: base64, size: Math.floor((base64.length * 3) / 4), previewUrl: dataUrl }; } const scale = Math.min(1, maxDim / Math.max(bitmap.width, bitmap.height)); const width = Math.max(1, Math.round(bitmap.width * scale)); const height = Math.max(1, Math.round(bitmap.height * scale)); const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d', { alpha: false }); ctx.drawImage(bitmap, 0, 0, width, height); const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/webp', quality)); const outBlob = blob || file; const dataUrl = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onerror = () => reject(new Error('Failed to read compressed image')); reader.onload = () => resolve(String(reader.result || '')); reader.readAsDataURL(outBlob); }); const base64 = dataUrl.split(',')[1] || ''; const previewUrl = URL.createObjectURL(outBlob); return { name: file.name || 'image', type: 'image/webp', data: base64, size: outBlob.size || Math.floor((base64.length * 3) / 4), previewUrl }; } function syncUploadButtonState() { if (!el.uploadMediaBtn) return; const allowed = isPaidPlanClient(); el.uploadMediaBtn.style.display = allowed ? 'flex' : 'none'; el.uploadMediaBtn.title = 'Attach images'; } function cyrb53(str, seed = 0) { let h1 = 0xdeadbeef ^ seed; let h2 = 0x41c6ce57 ^ seed; for (let i = 0, ch; i < str.length; i++) { ch = str.charCodeAt(i); h1 = Math.imul(h1 ^ ch, 2654435761); h2 = Math.imul(h2 ^ ch, 1597334677); } h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909); h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909); return 4294967296 * (2097151 & h2) + (h1 >>> 0); } function computeAccountId(email) { const normalized = (email || '').trim().toLowerCase(); if (!normalized) return ''; const hash = cyrb53(normalized); return `acct-${hash.toString(16)}`; } function getDeviceUserId() { try { const existing = localStorage.getItem('shopify_ai_user_id'); if (existing) return existing; const uuidPart = crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2, 12); const generated = `user-${uuidPart}`; localStorage.setItem('shopify_ai_user_id', generated); return generated; } catch (_) { const fallbackPart = (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID() : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`; return `user-${fallbackPart}`; } } function resolveUserId() { try { // Check both shopify and wordpress keys for compatibility const keys = ['shopify_ai_user', 'wordpress_plugin_ai_user']; for (const key of keys) { const stored = localStorage.getItem(key); if (stored) { const parsed = JSON.parse(stored); if (parsed && parsed.email) { const accountId = parsed.accountId || computeAccountId(parsed.email); if (accountId && (!parsed.accountId || parsed.accountId !== accountId)) { try { localStorage.setItem(key, JSON.stringify({ ...parsed, accountId })); } catch (_) { } } return accountId; } } } } catch (_) { /* ignore */ } return ''; } state.userId = resolveUserId(); if (!state.userId) { const next = encodeURIComponent(window.location.pathname + window.location.search); window.location.href = `/login?next=${next}`; } try { document.cookie = `chat_user=${encodeURIComponent(state.userId)}; path=/; SameSite=Lax`; } catch (_) { /* ignore */ } async function checkAuthAndLoadUser() { try { // Check if we have a valid session with the server const res = await fetch('/api/me'); if (res.ok) { const data = await res.json(); if (data.ok && data.user) { // Update local state with server user info state.userId = data.user.id; return data.user; } } } catch (_) { // Server auth not available, continue with device auth } return null; } function isFreePlan() { return (state.accountPlan || '').toLowerCase() === 'hobby'; } function tokensToFriendly(limit) { const usage = Math.round(Math.max(0, limit || 0) * TOKENS_TO_WORD_RATIO); if (!usage) return '—'; if (usage < 10_000) return `≈ ${usage.toLocaleString()} usage`; return `≈ ${(usage / 1000).toFixed(1)}k usage`; } window.TOKENS_TO_WORD_RATIO = TOKENS_TO_WORD_RATIO; window.tokensToFriendly = tokensToFriendly; function ensureUsageFooter() { let footer = document.getElementById('usage-footer'); if (!footer) { footer = document.createElement('div'); footer.id = 'usage-footer'; footer.style.position = 'fixed'; footer.style.left = '0'; footer.style.right = '0'; footer.style.bottom = '0'; footer.style.zIndex = '9998'; footer.style.background = '#0f172a'; footer.style.color = '#f8fafc'; footer.style.padding = '10px 14px'; footer.style.boxShadow = '0 -8px 30px rgba(0,0,0,0.2)'; footer.style.fontSize = '13px'; footer.style.display = 'none'; document.body.appendChild(footer); } return footer; } window.ensureUsageFooter = window.ensureUsageFooter || ensureUsageFooter; function renderUsageFooter(summary) { const footer = window.ensureUsageFooter ? window.ensureUsageFooter() : ensureUsageFooter(); if (!summary) { footer.style.display = 'none'; return; } // Handle both simple format (used by builder.js) and tiered format (used by app.js) const isSimpleFormat = !summary.tiers; const isTieredFormat = summary.tiers && Object.keys(summary.tiers).length > 0; if (isSimpleFormat) { footer.style.display = 'none'; return; } // Tiered format: { tiers: { free: {...}, plus: {...}, pro: {...} }, plan } const applicable = Object.entries(summary.tiers).filter(([, data]) => (data.limit || 0) > 0); if (!applicable.length) { footer.style.display = 'none'; return; } const tierMeta = { free: { label: '1x models', color: '#34d399', blurb: 'Standard burn (1x)', multiplier: 1 }, plus: { label: '2x models', color: '#38bdf8', blurb: 'Advanced burn (2x)', multiplier: 2 }, pro: { label: '3x models', color: '#22d3ee', blurb: 'Premium burn (3x)', multiplier: 3 }, }; const upgradeCta = summary.plan === 'enterprise' ? '' : `Upgrade`; footer.innerHTML = `
${applicable.map(([tier, data]) => { const remainingRatio = data.limit > 0 ? (data.remaining / data.limit) : 0; const nearOut = remainingRatio <= 0.1; const meta = tierMeta[tier] || tierMeta.free; const burnMultiplier = data.multiplier || meta.multiplier; return `
${meta.label} ${data.used.toLocaleString()} / ${data.limit.toLocaleString()} • ${burnMultiplier}x
${tokensToFriendly(data.limit)} left at ${burnMultiplier}x: ${data.remaining.toLocaleString()}${nearOut ? ' • almost out' : ''}
${meta.blurb}
`; }).join('')}
${upgradeCta ? 'Need more runway?' : 'You are on the top tier.'} ${upgradeCta || ''}
`; footer.style.display = 'block'; footer.querySelectorAll('[data-boost]').forEach((btn) => { btn.onclick = async () => { btn.disabled = true; btn.textContent = 'Adding...'; try { await buyBoost(btn.dataset.boost); } catch (err) { alert(err.message || 'Unable to add boost'); } finally { btn.disabled = false; btn.textContent = 'Add boost'; } }; }); } window.renderUsageFooter = window.renderUsageFooter || renderUsageFooter; async function loadUsageSummary() { try { const data = await api('/api/account/usage'); if (data?.summary) { state.usageSummary = data.summary; renderUsageFooter(state.usageSummary); } } catch (_) { // Ignore silently } } async function buyBoost(tier) { const payload = tier ? { tier } : {}; const res = await api('/api/account/boost', { method: 'POST', body: JSON.stringify(payload) }); if (res?.summary) { state.usageSummary = res.summary; renderUsageFooter(state.usageSummary); setStatus('Added extra AI energy to your account'); } } async function loadAccountPlan() { try { // Start fetching account info but avoid blocking UI indefinitely const accountPromise = api('/api/account'); const timeoutMs = 3000; const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve(null), timeoutMs)); const data = await Promise.race([accountPromise, timeoutPromise]); if (!data) { // Timed out — keep default plan visible and continue fetching in background state.accountPlan = state.accountPlan || 'hobby'; accountPromise.then((fullData) => { if (fullData?.account?.plan) state.accountPlan = fullData.account.plan; if (fullData?.account?.tokenUsage) { state.usageSummary = fullData.account.tokenUsage; renderUsageFooter(state.usageSummary); } else { loadUsageSummary().catch(() => {}); } }).catch((err) => { setStatus('Account fetch failed'); console.warn('loadAccountPlan background fetch failed:', err); }); return; } if (data?.account?.plan) { state.accountPlan = data.account.plan; if (data.account.tokenUsage) { state.usageSummary = data.account.tokenUsage; renderUsageFooter(state.usageSummary); } else { await loadUsageSummary(); } } else { if (window.location.pathname === "/apps" || window.location.pathname === "/apps/") { window.location.href = "/select-plan"; } } } catch (err) { // Ignore failures but log for debugging console.warn('loadAccountPlan failed:', err); } } let modelPreviewOverlay = null; let modelPreviewAttached = false; function showBlurredModelPreview() { if (!isFreePlan()) return; if (modelPreviewOverlay) { try { modelPreviewOverlay.remove(); } catch (_) { } } modelPreviewOverlay = document.createElement('div'); modelPreviewOverlay.style.position = 'fixed'; modelPreviewOverlay.style.inset = '0'; modelPreviewOverlay.style.background = 'rgba(0,0,0,0.35)'; modelPreviewOverlay.style.display = 'flex'; modelPreviewOverlay.style.alignItems = 'center'; modelPreviewOverlay.style.justifyContent = 'center'; modelPreviewOverlay.style.zIndex = '9999'; const panel = document.createElement('div'); panel.style.background = '#fff'; panel.style.borderRadius = '12px'; panel.style.padding = '20px'; panel.style.maxWidth = '420px'; panel.style.width = '90%'; panel.style.boxShadow = '0 12px 40px rgba(0,0,0,0.16)'; const title = document.createElement('div'); title.style.fontWeight = '700'; title.style.marginBottom = '6px'; title.textContent = 'Models are auto-selected on the hobby plan'; const subtitle = document.createElement('div'); subtitle.style.color = '#6b7280'; subtitle.style.fontSize = '13px'; subtitle.style.marginBottom = '12px'; subtitle.textContent = 'Upgrade to choose a specific model. Here are the available options:'; const list = document.createElement('div'); list.style.display = 'grid'; list.style.gap = '8px'; list.style.filter = 'blur(4px)'; list.style.pointerEvents = 'none'; (state.models || []).forEach((m) => { const row = document.createElement('div'); row.style.padding = '10px 12px'; row.style.border = '1px solid #e5e7eb'; row.style.borderRadius = '10px'; row.textContent = m.label || m.name || m.id || 'Model'; list.appendChild(row); }); if (!list.children.length) { const placeholder = document.createElement('div'); placeholder.style.padding = '10px 12px'; placeholder.style.border = '1px dashed #e5e7eb'; placeholder.style.borderRadius = '10px'; placeholder.textContent = 'Admin has not published models yet'; list.appendChild(placeholder); } const closeBtn = document.createElement('button'); closeBtn.textContent = 'Close'; closeBtn.style.marginTop = '14px'; closeBtn.className = 'ghost'; closeBtn.addEventListener('click', () => { if (modelPreviewOverlay) { modelPreviewOverlay.remove(); modelPreviewOverlay = null; } }); panel.appendChild(title); panel.appendChild(subtitle); panel.appendChild(list); panel.appendChild(closeBtn); modelPreviewOverlay.appendChild(panel); modelPreviewOverlay.addEventListener('click', (e) => { if (e.target === modelPreviewOverlay) { modelPreviewOverlay.remove(); modelPreviewOverlay = null; } }); document.body.appendChild(modelPreviewOverlay); } function applyPlanModelLock() { if (!el.modelSelect) return; syncUploadButtonState(); if (!isFreePlan()) { const hasUsableOptions = Array.from(el.modelSelect.options || []).some((opt) => !opt.disabled); if (!hasUsableOptions) return; if (modelPreviewAttached) { el.modelSelect.removeEventListener('click', showBlurredModelPreview); modelPreviewAttached = false; } el.modelSelect.disabled = false; el.modelSelect.dataset.locked = ''; renderModelIcon(el.modelSelect.value); return; } el.modelSelect.innerHTML = ''; const opt = document.createElement('option'); opt.value = 'auto'; opt.textContent = 'Auto (admin managed)'; el.modelSelect.appendChild(opt); el.modelSelect.value = 'auto'; el.modelSelect.dataset.locked = 'true'; el.modelSelect.disabled = false; if (!modelPreviewAttached) { el.modelSelect.addEventListener('click', showBlurredModelPreview); modelPreviewAttached = true; } renderModelIcon(null); } function setStatus(msg) { el.statusLine.textContent = msg || ''; } async function api(path, options = {}) { // Check for session token in localStorage let sessionToken = null; try { const storedUser = localStorage.getItem('wordpress_plugin_ai_user'); if (storedUser) { const parsed = JSON.parse(storedUser); if (parsed && parsed.sessionToken) { sessionToken = parsed.sessionToken; } } } catch (_) { /* ignore */ } const headers = { 'Content-Type': 'application/json', ...(sessionToken ? { 'Authorization': `Bearer ${sessionToken}` } : {}), ...(options.headers || {}), }; // Fallback to old user ID header if no session token if (!sessionToken && state.userId) { headers['X-User-Id'] = state.userId; } const res = await fetch(path, { headers, ...options, }); // Handle authentication errors if (res.status === 401) { // Clear invalid session and redirect to login try { localStorage.removeItem('wordpress_plugin_ai_user'); } catch (_) { /* ignore */ } const next = encodeURIComponent(window.location.pathname + window.location.search); window.location.href = `/login?next=${next}`; return; } const text = await res.text(); const json = text ? JSON.parse(text) : {}; if (!res.ok) { const err = new Error(json.error || res.statusText); 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 = '
No sessions yet.
'; return; } state.sessions.forEach((session) => { const div = document.createElement('div'); div.className = `session-item ${session.id === state.currentSessionId ? 'active' : ''}`; div.innerHTML = `
${session.title || 'Chat'}
${session.cli || 'opencode'} ${session.model} ${session.pending || 0} queued
`; div.addEventListener('click', () => selectSession(session.id)); // Stop clicks from the delete button bubbling up and opening the session const deleteBtn = div.querySelector('.delete-btn'); const deleteMenu = div.querySelector('.delete-menu'); const cancelBtn = div.querySelector('.cancel-delete'); const confirmBtn = div.querySelector('.confirm-delete'); if (deleteBtn) { deleteBtn.addEventListener('click', (e) => { e.stopPropagation(); // Toggle the inline menu const isOpen = div.classList.toggle('show-delete-menu'); if (isOpen) { // close any other menus document.querySelectorAll('.session-item.show-delete-menu').forEach((item) => { if (item !== div) item.classList.remove('show-delete-menu'); }); // attach an outside click handler to close 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' }); // Remove the session from state and re-render state.sessions = state.sessions.filter((s) => s.id !== session.id); if (state.currentSessionId === session.id) { // Pick another session or create new 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 = '
Send a message to start the conversation.
'; 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 = ` You ${msg.model || session.model} ${status} `; const userBody = document.createElement('div'); userBody.className = 'body'; userBody.appendChild(renderContentWithTodos(msg.displayContent || 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 = `${(session.cli || 'opencode').toUpperCase()}`; 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); } 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; 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; } function renderModelIcon(selectedValue) { if (!el.modelIcon) return; if (!selectedValue) { el.modelIcon.src = ''; el.modelIcon.style.display = 'none'; el.modelIcon.title = ''; return; } const model = state.models.find((m) => (m.name || m.id || m) === selectedValue); if (!model || !model.icon) { el.modelIcon.src = ''; el.modelIcon.style.display = 'none'; el.modelIcon.title = model ? (model.label || model.name || selectedValue) : ''; return; } el.modelIcon.src = model.icon; el.modelIcon.alt = model.label || model.name || selectedValue; el.modelIcon.title = model.label || model.name || selectedValue; el.modelIcon.style.display = 'inline-block'; } 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 = ''; if (!state.models.length) { const opt = document.createElement('option'); opt.value = ''; opt.textContent = 'No models configured (ask admin)'; opt.disabled = true; opt.selected = true; el.modelSelect.appendChild(opt); el.modelSelect.disabled = true; renderModelIcon(null); setStatus('No models configured. Ask an admin to add models in the admin panel.'); return; } el.modelSelect.disabled = false; state.models.forEach((m) => { const option = document.createElement('option'); option.value = m.name || m.id || m; const multiplierLabel = m.multiplier ? ` (${m.multiplier}x)` : ''; option.textContent = `${m.label || m.name || m.id || m}${multiplierLabel}`; if (m.icon) option.dataset.icon = m.icon; if (m.multiplier) option.dataset.multiplier = m.multiplier; el.modelSelect.appendChild(option); }); if (el.modelSelect.value === '' && state.models.length > 0) { el.modelSelect.value = state.models[0].name || state.models[0].id || 'default'; } renderModelIcon(el.modelSelect.value); applyPlanModelLock(); } catch (error) { setStatus(`Model load failed: ${error.message}`); state.models = []; el.modelSelect.innerHTML = ''; const opt = document.createElement('option'); opt.value = ''; opt.textContent = 'No models available'; opt.disabled = true; opt.selected = true; el.modelSelect.appendChild(opt); el.modelSelect.disabled = true; renderModelIcon(null); applyPlanModelLock(); } } 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) { 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); } renderSessions(); await refreshCurrentSession(); setPollingInterval(2500); } // 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 === 'server-restart') { message.status = 'queued'; setStatus('Server restarting, your session will be restored...'); eventSource.close(); state.activeStreams.delete(messageId); renderMessages(session); return; } else 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 === 'health') { // Sync status from server heartbeat if (data.status && message.status !== data.status) { console.log('Syncing message status from health event', { messageId, oldStatus: message.status, newStatus: data.status }); message.status = data.status; renderMessages(session); } } 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(); // Update usage summary to show token count loadUsageSummary().catch(() => {}); } 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(); // Update usage summary even on error loadUsageSummary().catch(() => {}); } } catch (err) { console.error('Failed to parse SSE message', err); } }; eventSource.onerror = (err) => { console.error('SSE error', err); eventSource.close(); state.activeStreams.delete(messageId); // Check if server is restarting by attempting to reconnect const session = state.sessions.find(s => s.id === sessionId); const message = session?.messages.find(m => m.id === messageId); if (message && message.status === 'queued') { // Server restart was signaled, poll for reconnection let reconnectAttempts = 0; const maxReconnectAttempts = 30; // 30 seconds max const reconnectInterval = setInterval(() => { reconnectAttempts++; refreshCurrentSession().then(() => { // Successfully reconnected and refreshed const updatedSession = state.sessions.find(s => s.id === sessionId); const updatedMessage = updatedSession?.messages.find(m => m.id === messageId); if (updatedMessage && updatedMessage.status !== 'queued') { clearInterval(reconnectInterval); setStatus('Reconnected to server'); } }).catch(() => { // Server still down if (reconnectAttempts >= maxReconnectAttempts) { clearInterval(reconnectInterval); if (message) { message.status = 'error'; message.error = 'Server restart took too long. Please try again.'; renderMessages(session); } setStatus('Server reconnection failed'); } }); }, 1000); } else { // 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}`); // Preserve optimistic "temp-" messages that may have been added locally const old = state.sessions.find((s) => s.id === session.id); const tempMsgs = (old && Array.isArray(old.messages)) ? old.messages.filter(m => String(m.id).startsWith('temp-')) : []; if (tempMsgs.length) { session.messages = session.messages || []; const existingIds = new Set((session.messages || []).map((m) => m.id)); tempMsgs.forEach((m) => { if (!existingIds.has(m.id)) session.messages.push(m); }); // De-duplicate if server returned a real message with same content const realContents = new Set((session.messages || []).filter(m => !String(m.id).startsWith('temp-')).map(m => (m.displayContent || m.content || '').trim())); session.messages = (session.messages || []).filter(m => { if (String(m.id).startsWith('temp-')) { return !realContents.has((m.displayContent || m.content || '').trim()); } return true; }); session.messages.sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0)); } 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; if (!model) { setStatus('No models available. Ask an admin to add models.'); return; } let session; try { const payload = { model, cli }; // When creating a new chat within an existing app, preserve the app title if (options.appId && options.reuseAppId) { const currentSession = state.sessions.find(s => s.id === state.currentSessionId); if (currentSession && currentSession.title) { payload.title = currentSession.title; } } if (options.appId) { payload.appId = options.appId; payload.reuseAppId = true; } const data = await api('/api/sessions', { method: 'POST', body: JSON.stringify(payload), }); session = data.session; } catch (err) { setStatus(err.message || 'Unable to create app'); throw err; } 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 && !pendingAttachments.length) return; if (!state.currentSessionId) { try { await createSession(); } catch (_) { return; } } const cli = el.cliSelect ? el.cliSelect.value : state.currentCli || 'opencode'; state.currentCli = cli; const model = el.modelSelect.value; if (!model) { setStatus('Select a model configured by your admin'); return; } el.sendBtn.disabled = true; setStatus('Sending...'); try { const attachments = pendingAttachments.map((a) => ({ name: a.name, type: a.type, data: a.data })); const payload = { content, displayContent: content, model, cli, attachments: attachments.length ? attachments : undefined }; // Preserve opencodeSessionId to continue in the same session const currentSession = state.sessions.find(s => s.id === state.currentSessionId); if (currentSession && currentSession.opencodeSessionId) { payload.opencodeSessionId = currentSession.opencodeSessionId; console.log('[APP] Preserving opencodeSessionId:', currentSession.opencodeSessionId); } const response = await api(`/api/sessions/${state.currentSessionId}/messages`, { method: 'POST', body: JSON.stringify(payload), }); el.messageInput.value = ''; // Clear pending attachments after send while (pendingAttachments.length) { const removed = pendingAttachments.pop(); if (removed && removed.previewUrl && removed.previewUrl.startsWith('blob:')) { try { URL.revokeObjectURL(removed.previewUrl); } catch (_) { } } } renderAttachmentPreview(); // Start streaming immediately for the new message if (response.message && response.message.id) { streamMessage(state.currentSessionId, response.message.id); } await refreshCurrentSession(); await loadUsageSummary(); } catch (error) { setStatus(error.message); } finally { el.sendBtn.disabled = false; } } function hookEvents() { el.newChat.addEventListener('click', async () => { const currentSession = state.sessions.find(s => s.id === state.currentSessionId); const currentAppId = currentSession?.appId; if (currentAppId) { await createSession({ appId: currentAppId, reuseAppId: true }); } else { await createSession({}); } }); el.sendBtn.addEventListener('click', sendMessage); if (el.uploadMediaBtn && el.uploadMediaInput) { console.log('Upload media elements found, attaching event listeners'); el.uploadMediaBtn.addEventListener('click', (e) => { console.log('Upload media button clicked, isPaidPlanClient:', isPaidPlanClient()); e.preventDefault(); e.stopPropagation(); // Check if user is on free plan if (!isPaidPlanClient()) { // Show upgrade modal instead of redirecting to pricing if (typeof window.showUpgradeModal === 'function' && !isEnterprisePlan()) { console.log('Showing upgrade modal'); window.showUpgradeModal(); } else if (isEnterprisePlan()) { setStatus('You are already on the Enterprise plan with full access.'); } else { window.location.href = '/upgrade'; } return; } // Check if model supports media if (!currentModelSupportsMedia()) { setStatus('This model does not support image uploads. Please select a different model that supports media.'); return; } // For paid users with media-supporting models, trigger file input click console.log('Triggering file input click'); el.uploadMediaInput.value = ''; el.uploadMediaInput.click(); }); el.uploadMediaInput.addEventListener('change', async () => { console.log('File input changed, files:', el.uploadMediaInput.files); const files = el.uploadMediaInput.files ? Array.from(el.uploadMediaInput.files) : []; // Reset input immediately to allow same file selection again el.uploadMediaInput.value = ''; if (!files.length) return; if (!isPaidPlanClient()) { // Show upgrade modal instead of just showing status if (typeof window.showUpgradeModal === 'function' && !isEnterprisePlan()) { window.showUpgradeModal(); } else if (isEnterprisePlan()) { setStatus('Upload media is available on your Enterprise plan.'); } else { setStatus('Upload media is available on Professional/Enterprise plans'); } return; } setStatus('Preparing images...'); el.sendBtn.disabled = true; try { for (const file of files.slice(0, 6)) { if (!file || !(file.type || '').startsWith('image/')) continue; const att = await fileToCompressedWebpAttachment(file); pendingAttachments.push(att); } renderAttachmentPreview(); // Show visual feedback const attachedCount = pendingAttachments.length; setStatus(`✓ ${attachedCount} image${attachedCount > 1 ? 's' : ''} attached successfully!`); // Briefly highlight the upload button to show feedback el.uploadMediaBtn.style.color = '#4ade80'; el.uploadMediaBtn.style.fontWeight = 'bold'; setTimeout(() => { el.uploadMediaBtn.style.color = ''; el.uploadMediaBtn.style.fontWeight = ''; }, 2000); } catch (err) { setStatus(err.message || 'Failed to attach image'); } finally { el.sendBtn.disabled = false; } }); } else { console.log('Upload media elements NOT found. el.uploadMediaBtn:', el.uploadMediaBtn, 'el.uploadMediaInput:', el.uploadMediaInput); } 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 images el.messageInput.addEventListener('paste', async (e) => { try { 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; e.preventDefault(); if (!isPaidPlanClient()) { setStatus('Paste image is available on Business/Enterprise plans'); return; } const blob = imageItem.getAsFile(); if (!blob) return; const att = await fileToCompressedWebpAttachment(blob); pendingAttachments.push(att); renderAttachmentPreview(); setStatus(`${pendingAttachments.length} image(s) attached`); } catch (err) { console.error('Paste handler error', err); } }); 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 git buttons while running to prevent duplicates 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) { 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 git buttons regardless of outcome el.gitButtons.forEach((b) => b.disabled = false); }); }); 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'); } // Re-enable git buttons 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; renderModelIcon(selected); if (isFreePlan()) { showBlurredModelPreview(); setStatus('Model selection is automatic on the hobby plan'); return; } if (!selected) return; 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}`); } } }); // Upgrade header button functionality (for builder page) const upgradeHeaderBtn = document.getElementById('upgrade-header-btn'); if (upgradeHeaderBtn) { upgradeHeaderBtn.addEventListener('click', () => { if (typeof window.showUpgradeModal === 'function' && !isEnterprisePlan()) { window.showUpgradeModal(); } else if (isEnterprisePlan()) { alert('You are already on the Enterprise plan with full access.'); } else { window.location.href = '/upgrade'; } }); } } // 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 loadAccountPlan(); await loadModels(state.currentCli); await loadSessions(); if (!state.sessions.length) { await createSession(); } // Periodically check opencode status (reduced frequency to reduce CPU usage) setInterval(checkOpencodeStatus, 300000); // Keep polling going even in background (for running processes) // Start with reasonable interval that will be adjusted by refreshCurrentSession setPollingInterval(5000); })();