1555 lines
57 KiB
JavaScript
1555 lines
57 KiB
JavaScript
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' ? '' : `<a href="/upgrade" style="color:#a5f3fc; font-weight:700; text-decoration:underline;">Upgrade</a>`;
|
|
footer.innerHTML = `
|
|
<div style="display:flex; gap:10px; align-items:flex-start; flex-wrap:wrap; justify-content:space-between;">
|
|
<div style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
|
|
${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 `
|
|
<div style="min-width:220px; background:#0b1221; border:1px solid rgba(255,255,255,0.08); border-radius:10px; padding:10px;">
|
|
<div style="display:flex; justify-content:space-between; align-items:center; gap:6px;">
|
|
<strong>${meta.label}</strong>
|
|
<span style="color:${nearOut ? '#fcd34d' : '#cbd5e1'};">${data.used.toLocaleString()} / ${data.limit.toLocaleString()} • ${burnMultiplier}x</span>
|
|
</div>
|
|
<div style="height:8px; background:rgba(255,255,255,0.07); border-radius:999px; overflow:hidden; margin:8px 0 4px;">
|
|
<div style="width:${data.percent}%; height:100%; background:${meta.color};"></div>
|
|
</div>
|
|
<div style="display:flex; justify-content:space-between; align-items:center; color:#cbd5e1; gap:6px;">
|
|
<span>${tokensToFriendly(data.limit)} left at ${burnMultiplier}x: ${data.remaining.toLocaleString()}${nearOut ? ' • almost out' : ''}</span>
|
|
<button data-boost="${tier}" style="background:transparent; border:1px solid rgba(255,255,255,0.25); color:#f8fafc; border-radius:8px; padding:4px 8px; cursor:pointer;">Add boost</button>
|
|
</div>
|
|
<div style="color:#9ca3af; font-size:11px; margin-top:4px;">${meta.blurb}</div>
|
|
</div>
|
|
`;
|
|
}).join('')}
|
|
</div>
|
|
<div style="display:flex; align-items:center; gap:10px; color:#cbd5e1; flex:1; min-width:200px; justify-content:flex-end;">
|
|
<span>${upgradeCta ? 'Need more runway?' : 'You are on the top tier.'} ${upgradeCta || ''}</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
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 = '<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));
|
|
// 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 = '<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.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 = `<span>${(session.cli || 'opencode').toUpperCase()}</span>`;
|
|
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);
|
|
})();
|