- Simplified scrollChatToBottom() with more reliable techniques - Added scrollIntoView on last message as fallback - Added additional scroll with direct scrollTop assignment after delay - Added CSS optimizations for scrolling behavior (-webkit-overflow-scrolling, transform, will-change) - Added more robust scroll attempts in renderMessages after DOM updates
4606 lines
164 KiB
JavaScript
4606 lines
164 KiB
JavaScript
// builder.js
|
|
/* global setStatus */
|
|
|
|
// Builder state management - must be defined before any code uses it
|
|
const BUILDER_STATE_KEY = 'builder_state';
|
|
|
|
function loadBuilderState() {
|
|
try {
|
|
const saved = localStorage.getItem(BUILDER_STATE_KEY);
|
|
if (saved) {
|
|
return JSON.parse(saved);
|
|
}
|
|
} catch (e) {
|
|
console.warn('Failed to load builder state:', e);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function saveBuilderState(state) {
|
|
try {
|
|
localStorage.setItem(BUILDER_STATE_KEY, JSON.stringify(state));
|
|
} catch (e) {
|
|
console.warn('Failed to save builder state:', e);
|
|
}
|
|
}
|
|
|
|
// Initialize builderState with persisted data
|
|
const savedState = loadBuilderState();
|
|
const builderState = savedState || {
|
|
mode: 'plan',
|
|
planApproved: false,
|
|
lastUserRequest: '',
|
|
lastPlanText: '',
|
|
pluginPrompt: '',
|
|
subsequentPrompt: ''
|
|
};
|
|
|
|
// Auto-save builderState changes to localStorage
|
|
const builderStateProxy = new Proxy(builderState, {
|
|
set(target, prop, value) {
|
|
target[prop] = value;
|
|
saveBuilderState(target);
|
|
return true;
|
|
}
|
|
});
|
|
|
|
// Replace builderState reference in global scope with proxy
|
|
window.builderState = builderStateProxy;
|
|
|
|
// Function to clear builder state (for new sessions, logout, etc.)
|
|
window.clearBuilderState = function() {
|
|
const preservedPluginPrompt = builderState.pluginPrompt || '';
|
|
const preservedSubsequentPrompt = builderState.subsequentPrompt || '';
|
|
const resetState = {
|
|
mode: 'plan',
|
|
planApproved: false,
|
|
lastUserRequest: '',
|
|
lastPlanText: '',
|
|
pluginPrompt: preservedPluginPrompt,
|
|
subsequentPrompt: preservedSubsequentPrompt
|
|
};
|
|
Object.assign(builderState, resetState);
|
|
saveBuilderState(builderState);
|
|
console.log('[BUILDER] Builder state cleared');
|
|
};
|
|
|
|
// Update build mode UI - switch to OpenCode immediately for uploaded apps
|
|
function updateBuildModeUI(modeOverride) {
|
|
const mode = modeOverride || (builderState && builderState.mode) || 'plan';
|
|
|
|
const modeIcon = document.getElementById('mode-icon');
|
|
const modeText = document.getElementById('mode-text');
|
|
const modeDescription = document.getElementById('mode-description');
|
|
|
|
if (modeIcon && modeText && modeDescription) {
|
|
if (mode === 'build') {
|
|
modeIcon.innerHTML = `
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon>
|
|
</svg>
|
|
`;
|
|
modeText.textContent = 'OpenCode';
|
|
modeDescription.textContent = 'Imported apps open directly in coding mode';
|
|
} else {
|
|
modeIcon.innerHTML = `
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M9 11l3 3L22 4"></path>
|
|
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"></path>
|
|
</svg>
|
|
`;
|
|
modeText.textContent = 'Planning';
|
|
modeDescription.textContent = 'Discuss and refine your app plan';
|
|
}
|
|
}
|
|
|
|
// Hide provider box (model-select-wrap) for plan messages
|
|
if (el.modelSelectWrap) {
|
|
el.modelSelectWrap.style.display = mode === 'build' ? 'inline-flex' : 'none';
|
|
}
|
|
|
|
if (typeof window.updateUsageProgressBar === 'function') {
|
|
window.updateUsageProgressBar();
|
|
}
|
|
}
|
|
|
|
async function proceedWithBuild(planContent) {
|
|
if (!planContent) return;
|
|
pendingPlanContent = planContent;
|
|
if (el.confirmBuildModal) {
|
|
el.confirmBuildModal.style.display = 'flex';
|
|
} else {
|
|
// Fallback to confirm if modal not found for some reason
|
|
const confirmBuild = confirm("Are you sure you want to proceed with this plan? This will start the build process.");
|
|
if (confirmBuild) executeBuild(planContent);
|
|
}
|
|
}
|
|
|
|
async function executeBuild(planContent) {
|
|
console.log('executeBuild called with planContent:', planContent ? planContent.substring(0, 100) + '...' : 'null');
|
|
builderState.mode = 'build';
|
|
builderState.planApproved = true;
|
|
updateBuildModeUI();
|
|
|
|
// Get the user-selected model (preserved across model list refreshes)
|
|
let selectedModel = state.selectedModelId || (el.modelSelect && el.modelSelect.value);
|
|
if (!selectedModel) {
|
|
setStatus('Select a model configured by your admin');
|
|
alert('Please select a model before proceeding with the build.');
|
|
return;
|
|
}
|
|
|
|
console.log('Selected model for build:', selectedModel);
|
|
console.log('Current session ID:', state.currentSessionId);
|
|
|
|
// Construct the build prompt - replace {{USER_REQUEST}} with actual user request
|
|
let promptTemplate = builderState.pluginPrompt || '';
|
|
const userRequest = builderState.lastUserRequest || '';
|
|
const session = state.sessions.find(s => s.id === state.currentSessionId);
|
|
const pluginSlug = (session && session.pluginSlug) || 'plugin-name';
|
|
const pluginName = (session && session.pluginName) || `Plugin Compass ${(session && session.title) || 'Plugin'}`;
|
|
promptTemplate = promptTemplate.replace('{{USER_REQUEST}}', userRequest)
|
|
.replace(/{{PLUGIN_SLUG}}/g, pluginSlug)
|
|
.replace(/{{PLUGIN_NAME}}/g, pluginName);
|
|
const buildPrompt = promptTemplate + '\n\nAPPROVED PLAN:\n' + planContent;
|
|
|
|
// Send to opencode
|
|
try {
|
|
state.currentCli = 'opencode';
|
|
if (el.cliSelect) el.cliSelect.value = 'opencode';
|
|
|
|
// Show loading indicator with "building" text
|
|
showLoadingIndicator('building');
|
|
|
|
console.log('Sending build message to opencode...');
|
|
console.log('Current session opencodeSessionId:', session?.opencodeSessionId);
|
|
|
|
const buildPayload = {
|
|
content: buildPrompt,
|
|
displayContent: "**Starting Build Process...**",
|
|
model: selectedModel,
|
|
cli: 'opencode',
|
|
isProceedWithBuild: true,
|
|
planContent: planContent
|
|
};
|
|
// Preserve opencodeSessionId for session continuity
|
|
if (session && session.opencodeSessionId) {
|
|
buildPayload.opencodeSessionId = session.opencodeSessionId;
|
|
console.log('[BUILD] Preserving opencodeSessionId:', session.opencodeSessionId);
|
|
}
|
|
|
|
const response = await api(`/api/sessions/${state.currentSessionId}/messages`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(buildPayload),
|
|
});
|
|
|
|
console.log('Build message response:', response);
|
|
|
|
// Start streaming if message was created
|
|
if (response.message && response.message.id) {
|
|
console.log('Starting stream for message:', response.message.id);
|
|
streamMessage(state.currentSessionId, response.message.id);
|
|
} else {
|
|
console.warn('No message ID in response:', response);
|
|
}
|
|
|
|
await refreshCurrentSession();
|
|
await loadUsageSummary();
|
|
console.log('Build process initiated successfully');
|
|
} catch (e) {
|
|
console.error('Failed to start build:', e);
|
|
alert('Failed to start build: ' + (e.message || 'Unknown error'));
|
|
builderState.mode = 'plan'; // Revert
|
|
updateBuildModeUI();
|
|
hideLoadingIndicator();
|
|
|
|
// Update usage on error
|
|
loadUsageSummary().catch(err => {
|
|
console.warn('[USAGE] Usage update after build error failed:', err.message);
|
|
});
|
|
}
|
|
}
|
|
|
|
// Redo a proceed-with-build message - skips confirmation, rebuilds prompt properly
|
|
async function redoProceedWithBuild(planContent, model) {
|
|
if (!planContent) {
|
|
setStatus('Cannot redo: no plan content available');
|
|
return;
|
|
}
|
|
|
|
setStatus('Redoing build process...');
|
|
builderState.mode = 'build';
|
|
builderState.planApproved = true;
|
|
updateBuildModeUI();
|
|
|
|
// Use provided model or get from dropdown
|
|
let selectedModel = model;
|
|
if (!selectedModel || selectedModel === 'default') {
|
|
selectedModel = state.selectedModelId || (el.modelSelect && el.modelSelect.value);
|
|
}
|
|
if (!selectedModel) {
|
|
setStatus('Select a model configured by your admin');
|
|
return;
|
|
}
|
|
|
|
// Construct the build prompt - replace {{USER_REQUEST}} with actual user request
|
|
let promptTemplate = builderState.pluginPrompt || '';
|
|
const userRequest = builderState.lastUserRequest || '';
|
|
const session = state.sessions.find(s => s.id === state.currentSessionId);
|
|
const pluginSlug = (session && session.pluginSlug) || 'plugin-name';
|
|
const pluginName = (session && session.pluginName) || `Plugin Compass ${(session && session.title) || 'Plugin'}`;
|
|
promptTemplate = promptTemplate.replace('{{USER_REQUEST}}', userRequest)
|
|
.replace(/{{PLUGIN_SLUG}}/g, pluginSlug)
|
|
.replace(/{{PLUGIN_NAME}}/g, pluginName);
|
|
const buildPrompt = promptTemplate + '\n\nAPPROVED PLAN:\n' + planContent;
|
|
|
|
try {
|
|
state.currentCli = 'opencode';
|
|
if (el.cliSelect) el.cliSelect.value = 'opencode';
|
|
|
|
// Show loading indicator with "building" text
|
|
showLoadingIndicator('building');
|
|
|
|
const buildPayload = {
|
|
content: buildPrompt,
|
|
displayContent: "**Retrying Build Process...**",
|
|
model: selectedModel,
|
|
cli: 'opencode',
|
|
isProceedWithBuild: true,
|
|
planContent: planContent
|
|
};
|
|
// Preserve opencodeSessionId for session continuity
|
|
if (session && session.opencodeSessionId) {
|
|
buildPayload.opencodeSessionId = session.opencodeSessionId;
|
|
console.log('[REDO] Preserving opencodeSessionId:', session.opencodeSessionId);
|
|
}
|
|
|
|
const response = await api(`/api/sessions/${state.currentSessionId}/messages`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(buildPayload),
|
|
});
|
|
|
|
// Start streaming for the new message
|
|
if (response.message && response.message.id) {
|
|
streamMessage(state.currentSessionId, response.message.id);
|
|
}
|
|
|
|
await refreshCurrentSession();
|
|
await loadUsageSummary();
|
|
setStatus('Build process restarted');
|
|
} catch (e) {
|
|
setStatus('Failed to redo build: ' + e.message);
|
|
builderState.mode = 'plan';
|
|
updateBuildModeUI();
|
|
hideLoadingIndicator();
|
|
|
|
// Update usage on error
|
|
loadUsageSummary().catch(err => {
|
|
console.warn('[USAGE] Usage update after redo error failed:', err.message);
|
|
});
|
|
}
|
|
}
|
|
|
|
// Redo a message - handles OpenRouter, Opencode, and Proceed-with-Build cases
|
|
async function redoMessage(msg, session) {
|
|
const isOpencodeMessage = msg.cli === 'opencode';
|
|
const isProceedWithBuild = msg.isProceedWithBuild || (msg.displayContent && msg.displayContent.includes('Starting Build Process'));
|
|
const isOpenRouterMessage = !isOpencodeMessage || msg.phase === 'plan';
|
|
|
|
setStatus('Preparing to redo...');
|
|
|
|
// Case 1: Proceed with Build - undo first, then rebuild with plan content
|
|
if (isProceedWithBuild) {
|
|
// First, send undo to revert any file changes
|
|
try {
|
|
await api(`/api/sessions/${session.id}/messages/${msg.id}/undo`, {
|
|
method: 'POST',
|
|
});
|
|
setStatus('Undo complete, rebuilding...');
|
|
} catch (err) {
|
|
console.warn('Undo failed, continuing with redo:', err.message);
|
|
}
|
|
|
|
// Get the plan content - either stored on the message or try to extract from content
|
|
let planContent = msg.planContent;
|
|
if (!planContent && msg.content) {
|
|
// Try to extract plan from the content (after "APPROVED PLAN:" marker)
|
|
const planMatch = msg.content.match(/APPROVED PLAN:\s*([\s\S]*)/);
|
|
if (planMatch) {
|
|
planContent = planMatch[1].trim();
|
|
}
|
|
}
|
|
|
|
if (planContent) {
|
|
await redoProceedWithBuild(planContent, msg.model);
|
|
} else {
|
|
setStatus('Cannot redo: plan content not available');
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Case 2: Regular Opencode message (not proceed-with-build) - use /redo command in the same session
|
|
if (isOpencodeMessage) {
|
|
setStatus('Redoing with OpenCode...');
|
|
|
|
// Show loading indicator with "building" text
|
|
showLoadingIndicator('building');
|
|
|
|
try {
|
|
// Use the /redo endpoint to redo in the same OpenCode session
|
|
await api(`/api/sessions/${session.id}/messages/${msg.id}/redo`, {
|
|
method: 'POST',
|
|
});
|
|
|
|
setStatus('Redo command sent, waiting for response...');
|
|
|
|
// Refresh the session to get the updated state
|
|
await refreshCurrentSession();
|
|
await loadUsageSummary();
|
|
setStatus('Redo complete');
|
|
} catch (err) {
|
|
setStatus('Redo failed: ' + err.message);
|
|
console.error('Redo failed:', err);
|
|
hideLoadingIndicator();
|
|
|
|
// Update usage on error
|
|
loadUsageSummary().catch(loadErr => {
|
|
console.warn('[USAGE] Usage update after redo error failed:', loadErr.message);
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Case 3: OpenRouter (plan) message - resend to /api/plan
|
|
if (isOpenRouterMessage) {
|
|
setStatus('Resending to OpenRouter...');
|
|
// Find the original user message (the content before it was processed)
|
|
const userContent = msg.displayContent || msg.content || builderState.lastUserRequest || '';
|
|
|
|
await sendPlanMessage(userContent);
|
|
setStatus('Plan message resent');
|
|
return;
|
|
}
|
|
|
|
setStatus('Unable to redo this message type');
|
|
}
|
|
|
|
|
|
const state = {
|
|
|
|
sessions: [],
|
|
sessionsLoaded: false,
|
|
currentSessionId: null,
|
|
models: [],
|
|
modelsSignature: null,
|
|
selectedModelId: null,
|
|
pollingTimer: null,
|
|
cliOptions: ['opencode'],
|
|
currentCli: 'opencode',
|
|
activeStreams: new Map(), // Track active SSE connections
|
|
opencodeStatus: null,
|
|
userId: null,
|
|
accountPlan: 'hobby',
|
|
isAdmin: false,
|
|
usageSummary: null,
|
|
todos: [], // Current todos from OpenCode
|
|
currentMessageId: null, // Track which message we're displaying todos for
|
|
isSending: false, // Track if a message is currently being sent
|
|
currentSendingMessageId: null, // Track the ID of the message being sent for cancellation
|
|
};
|
|
|
|
// Expose state for builder.html
|
|
window.state = state;
|
|
|
|
const TOKENS_TO_WORD_RATIO = window.TOKENS_TO_WORD_RATIO || 0.75;
|
|
|
|
// Message input caching constants
|
|
const MESSAGE_INPUT_CACHE_KEY = 'builder_message_input';
|
|
const MESSAGE_INPUT_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // Cache for 24 hours
|
|
|
|
// Save message input to localStorage
|
|
function cacheMessageInput(content) {
|
|
if (!el?.messageInput) return;
|
|
try {
|
|
const data = {
|
|
content: content || '',
|
|
timestamp: Date.now()
|
|
};
|
|
localStorage.setItem(MESSAGE_INPUT_CACHE_KEY, JSON.stringify(data));
|
|
} catch (e) {
|
|
console.warn('Failed to cache message input:', e);
|
|
}
|
|
}
|
|
|
|
// Restore message input from localStorage
|
|
function restoreMessageInput() {
|
|
if (!el?.messageInput) return false;
|
|
try {
|
|
const raw = localStorage.getItem(MESSAGE_INPUT_CACHE_KEY);
|
|
if (!raw) return false;
|
|
|
|
const data = JSON.parse(raw);
|
|
if (!data || !data.content) return false;
|
|
|
|
// Check if cache is expired (older than 24 hours)
|
|
if (Date.now() - data.timestamp > MESSAGE_INPUT_CACHE_MAX_AGE_MS) {
|
|
localStorage.removeItem(MESSAGE_INPUT_CACHE_KEY);
|
|
return false;
|
|
}
|
|
|
|
// Restore the content
|
|
el.messageInput.value = data.content;
|
|
|
|
// Adjust textarea height for restored content
|
|
el.messageInput.style.height = 'auto';
|
|
el.messageInput.style.height = (el.messageInput.scrollHeight) + 'px';
|
|
|
|
console.log('[BUILDER-CACHE] Restored cached message input:', {
|
|
contentLength: data.content.length,
|
|
age: Date.now() - data.timestamp
|
|
});
|
|
|
|
return true;
|
|
} catch (e) {
|
|
console.warn('Failed to restore message input:', e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Clear cached message input
|
|
function clearMessageInputCache() {
|
|
try {
|
|
localStorage.removeItem(MESSAGE_INPUT_CACHE_KEY);
|
|
console.log('[BUILDER-CACHE] Cleared message input cache');
|
|
} catch (e) {
|
|
console.warn('Failed to clear message input cache:', e);
|
|
}
|
|
}
|
|
|
|
function syncAdminVisibility() {
|
|
const adminOnly = document.querySelectorAll('[data-admin-only]');
|
|
adminOnly.forEach((node) => {
|
|
node.style.display = state.isAdmin ? '' : 'none';
|
|
});
|
|
}
|
|
|
|
async function detectAdminSession() {
|
|
try {
|
|
// Ensure credentials sent so admin session cookie is included
|
|
const res = await fetch('/api/admin/me', { credentials: 'same-origin' });
|
|
state.isAdmin = res.ok;
|
|
} catch (_) {
|
|
state.isAdmin = false;
|
|
}
|
|
syncAdminVisibility();
|
|
}
|
|
|
|
function isFreePlan() {
|
|
return (state.accountPlan || '').toLowerCase() === 'hobby';
|
|
}
|
|
|
|
function formatPlanLabel(plan) {
|
|
const normalized = (plan || 'hobby').toLowerCase();
|
|
const pretty = normalized.charAt(0).toUpperCase() + normalized.slice(1);
|
|
return `${pretty} plan`;
|
|
}
|
|
|
|
// Expose for builder.html
|
|
window.formatPlanLabel = formatPlanLabel;
|
|
|
|
function applyAccountPlan(plan) {
|
|
const normalized = (plan || 'hobby').toLowerCase();
|
|
state.accountPlan = normalized;
|
|
const badge = document.getElementById('user-badge');
|
|
if (badge) badge.dataset.plan = normalized;
|
|
const planEl = document.getElementById('user-plan');
|
|
if (planEl) planEl.textContent = formatPlanLabel(normalized);
|
|
|
|
// Hide upgrade header button for enterprise users
|
|
const upgradeHeaderBtn = document.getElementById('upgrade-header-btn');
|
|
if (upgradeHeaderBtn) {
|
|
upgradeHeaderBtn.style.display = normalized === 'enterprise' ? 'none' : 'flex';
|
|
}
|
|
|
|
// Hide upgrade button in token limit modal for enterprise and professional users
|
|
const tokenLimitUpgrade = document.getElementById('token-limit-upgrade');
|
|
if (tokenLimitUpgrade) {
|
|
tokenLimitUpgrade.style.display = (normalized === 'enterprise' || normalized === 'professional') ? 'none' : 'flex';
|
|
}
|
|
|
|
syncUploadButtonState();
|
|
applyPlanModelLock();
|
|
}
|
|
|
|
// Expose for builder.html
|
|
window.applyAccountPlan = applyAccountPlan;
|
|
|
|
const tokensToFriendly = window.tokensToFriendly || function tokensToFriendlyLocal(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`;
|
|
};
|
|
|
|
function updateUsageProgressBar(summary = state.usageSummary) {
|
|
if (!el.usageMeterFill || !el.usageMeterTrack || !el.usageMeterPercent || !el.usageMeterTitle) {
|
|
console.warn('[USAGE] Usage meter DOM elements not found');
|
|
return;
|
|
}
|
|
|
|
const used = summary?.used || 0;
|
|
const limit = summary?.limit || 0;
|
|
const remaining = summary?.remaining || (limit - used);
|
|
const plan = summary?.plan || 'hobby';
|
|
const percent = limit > 0 ? Math.max(0, Math.min(100, parseFloat(((used / limit) * 100).toFixed(1)))) : null;
|
|
const nearOut = percent !== null && percent >= 90;
|
|
|
|
console.log('[USAGE] Updating progress bar:', { used, limit, remaining, plan, percent });
|
|
|
|
el.usageMeterTitle.textContent = 'Token Usage';
|
|
|
|
if (percent === null) {
|
|
el.usageMeterPercent.textContent = '—';
|
|
el.usageMeterFill.style.width = '0%';
|
|
el.usageMeterTrack.setAttribute('aria-valuenow', '0');
|
|
el.usageMeterTrack.title = '';
|
|
el.usageMeterTrack.style.background = 'rgba(0, 128, 96, 0.12)';
|
|
return;
|
|
}
|
|
|
|
const percentText = `${percent}% used${nearOut ? ' • almost out' : ''}`;
|
|
const remainingText = `${remaining.toLocaleString()} remaining`;
|
|
|
|
el.usageMeterPercent.innerHTML = `${percentText}<br><span style="font-size:10px; font-weight:500;">${remainingText}</span>`;
|
|
el.usageMeterFill.style.width = `${percent}%`;
|
|
|
|
if (nearOut) {
|
|
el.usageMeterFill.style.background = 'linear-gradient(135deg, #fcd34d, #f59e0b)';
|
|
} else {
|
|
el.usageMeterFill.style.background = 'linear-gradient(135deg, var(--shopify-green), var(--shopify-green-dark))';
|
|
}
|
|
|
|
el.usageMeterTrack.setAttribute('aria-valuenow', String(percent));
|
|
el.usageMeterTrack.title = `${used.toLocaleString()} / ${limit.toLocaleString()} tokens`;
|
|
|
|
const actionsDiv = el.usageMeterFill.closest('.usage-meter')?.querySelector('.usage-meter-actions');
|
|
const hintSpan = actionsDiv?.querySelector('.usage-hint');
|
|
if (hintSpan) {
|
|
if (plan === 'enterprise') {
|
|
hintSpan.textContent = 'You are on the top tier';
|
|
} else {
|
|
hintSpan.textContent = 'Need more runway?';
|
|
}
|
|
}
|
|
|
|
// Hide upgrade link for enterprise users
|
|
const upgradeLinks = actionsDiv?.querySelectorAll('a[href^="/upgrade"]');
|
|
upgradeLinks?.forEach(link => {
|
|
link.style.display = plan === 'enterprise' ? 'none' : 'inline-flex';
|
|
});
|
|
|
|
console.log('[USAGE] Progress bar updated successfully');
|
|
}
|
|
|
|
window.updateUsageProgressBar = updateUsageProgressBar;
|
|
|
|
async function loadUsageSummary() {
|
|
try {
|
|
const data = await api(`/api/account/usage?_t=${Date.now()}`);
|
|
console.log('[USAGE] API response:', data);
|
|
if (data?.summary) {
|
|
state.usageSummary = data.summary;
|
|
console.log('[USAGE] Usage summary loaded:', {
|
|
month: data.summary.month,
|
|
plan: data.summary.plan,
|
|
used: data.summary.used,
|
|
limit: data.summary.limit,
|
|
percent: data.summary.percent
|
|
});
|
|
console.log('[USAGE] DOM elements found:', {
|
|
usageMeterTitle: !!el.usageMeterTitle,
|
|
usageMeterPercent: !!el.usageMeterPercent,
|
|
usageMeterFill: !!el.usageMeterFill,
|
|
usageMeterTrack: !!el.usageMeterTrack
|
|
});
|
|
updateUsageProgressBar(state.usageSummary);
|
|
if (typeof window.checkTokenLimitAndShowModal === 'function') {
|
|
setTimeout(() => window.checkTokenLimitAndShowModal(), 500);
|
|
}
|
|
} else {
|
|
console.warn('[USAGE] No summary in usage response:', data);
|
|
}
|
|
} catch (err) {
|
|
setAdminStatus(`usage fetch failed: ${err?.message || String(err)}`);
|
|
console.error('[USAGE] Failed to fetch usage summary:', err);
|
|
}
|
|
}
|
|
|
|
// Expose for builder.html
|
|
window.loadUsageSummary = loadUsageSummary;
|
|
|
|
function checkTokenLimitAndShowModal() {
|
|
const remaining = state.usageSummary?.remaining || 0;
|
|
if (remaining <= 5000) {
|
|
const modal = document.getElementById('token-limit-modal');
|
|
if (modal && modal.style.display !== 'flex') {
|
|
modal.style.display = 'flex';
|
|
}
|
|
}
|
|
}
|
|
|
|
window.checkTokenLimitAndShowModal = checkTokenLimitAndShowModal;
|
|
|
|
// Poll usage more aggressively when OpenCode is running
|
|
let usagePollingInterval = null;
|
|
|
|
function startUsagePolling() {
|
|
if (usagePollingInterval) clearInterval(usagePollingInterval);
|
|
usagePollingInterval = setInterval(() => {
|
|
loadUsageSummary().catch(err => {
|
|
console.warn('[USAGE] Polling failed:', err);
|
|
});
|
|
}, 60000); // Poll every 60 seconds
|
|
console.log('[USAGE] Started usage polling');
|
|
}
|
|
|
|
function stopUsagePolling() {
|
|
if (usagePollingInterval) {
|
|
clearInterval(usagePollingInterval);
|
|
usagePollingInterval = null;
|
|
console.log('[USAGE] Stopped usage polling');
|
|
}
|
|
}
|
|
|
|
// Expose for builder.html
|
|
window.startUsagePolling = startUsagePolling;
|
|
window.stopUsagePolling = stopUsagePolling;
|
|
|
|
async function buyBoost(tier) {
|
|
const res = await api('/api/account/boost', { method: 'POST', body: JSON.stringify(tier ? { tier } : {}) });
|
|
if (res?.summary) {
|
|
state.usageSummary = res.summary;
|
|
updateUsageProgressBar(state.usageSummary);
|
|
setStatus('Extra AI energy added to your plan');
|
|
}
|
|
}
|
|
|
|
// Cached account fetch to avoid duplicate/competing requests
|
|
let _cachedAccountPromise = null;
|
|
function getAccountInfo(forceRefresh = false) {
|
|
if (!forceRefresh && _cachedAccountPromise) return _cachedAccountPromise;
|
|
_cachedAccountPromise = api('/api/account').catch((err) => {
|
|
// Clear cache on error so future attempts can retry
|
|
_cachedAccountPromise = null;
|
|
throw err;
|
|
});
|
|
return _cachedAccountPromise;
|
|
}
|
|
// Expose for legacy inline scripts
|
|
window.getAccountInfo = getAccountInfo;
|
|
|
|
async function loadAccountPlanWithRetry(attempt = 1) {
|
|
const maxRetries = 3;
|
|
const baseTimeoutMs = 10000; // 10 seconds timeout
|
|
|
|
console.log(`[BUILDER-PLAN] loadAccountPlanWithRetry called, attempt ${attempt}/${maxRetries}`);
|
|
|
|
try {
|
|
// Start fetching account info with longer timeout
|
|
const accountPromise = getAccountInfo();
|
|
|
|
// Race against a configurable timeout
|
|
const timeoutPromise = new Promise((resolve) => {
|
|
setTimeout(() => {
|
|
console.log(`[BUILDER-PLAN] Timeout reached after ${baseTimeoutMs}ms (attempt ${attempt})`);
|
|
resolve(null);
|
|
}, baseTimeoutMs);
|
|
});
|
|
|
|
const data = await Promise.race([accountPromise, timeoutPromise]);
|
|
|
|
if (!data) {
|
|
// Timed out - apply a safe default so the UI moves on, and continue fetching in background
|
|
console.log(`[BUILDER-PLAN] Account fetch timed out, applying default plan (attempt ${attempt})`);
|
|
applyAccountPlan(state.accountPlan || 'hobby');
|
|
syncUploadButtonState();
|
|
|
|
if (attempt < maxRetries) {
|
|
// Retry with exponential backoff
|
|
const delayMs = 2000 * Math.pow(2, attempt - 1);
|
|
console.log(`[BUILDER-PLAN] Scheduling retry in ${delayMs}ms...`);
|
|
setTimeout(() => {
|
|
loadAccountPlanWithRetry(attempt + 1).catch(err => {
|
|
console.error(`[BUILDER-PLAN] Retry ${attempt} failed:`, err.message);
|
|
});
|
|
}, delayMs);
|
|
} else {
|
|
console.warn(`[BUILDER-PLAN] All ${maxRetries} retries exhausted, using default plan`);
|
|
}
|
|
|
|
accountPromise.then((fullData) => {
|
|
if (fullData?.account?.plan) {
|
|
console.log(`[BUILDER-PLAN] Background fetch completed, applying plan:`, fullData.account.plan);
|
|
applyAccountPlan(fullData.account.plan);
|
|
}
|
|
if (fullData?.account?.tokenUsage) {
|
|
state.usageSummary = fullData.account.tokenUsage;
|
|
updateUsageProgressBar(state.usageSummary);
|
|
console.log(`[BUILDER-PLAN] Usage summary updated from background fetch`);
|
|
} else {
|
|
loadUsageSummary().catch(() => {});
|
|
}
|
|
}).catch((err) => {
|
|
setAdminStatus(`account fetch failed: ${err?.message || String(err)}`);
|
|
console.error(`[BUILDER-PLAN] Background account fetch failed:`, err.message);
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
if (data?.account?.plan) {
|
|
console.log(`[BUILDER-PLAN] Account plan loaded successfully:`, data.account.plan);
|
|
applyAccountPlan(data.account.plan);
|
|
syncUploadButtonState();
|
|
if (data.account.tokenUsage) {
|
|
state.usageSummary = data.account.tokenUsage;
|
|
updateUsageProgressBar(state.usageSummary);
|
|
} else {
|
|
await loadUsageSummary();
|
|
}
|
|
} else {
|
|
console.warn(`[BUILDER-PLAN] No account plan in response, using default`);
|
|
applyAccountPlan(state.accountPlan || 'hobby');
|
|
}
|
|
} catch (err) {
|
|
setAdminStatus(`account fetch failed: ${err?.message || String(err)}`);
|
|
console.error(`[BUILDER-PLAN] Account fetch error (attempt ${attempt}):`, {
|
|
error: err.message,
|
|
stack: err.stack
|
|
});
|
|
|
|
if (attempt < maxRetries) {
|
|
const delayMs = 2000 * Math.pow(2, attempt - 1);
|
|
console.log(`[BUILDER-PLAN] Scheduling retry in ${delayMs}ms...`);
|
|
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
return loadAccountPlanWithRetry(attempt + 1);
|
|
}
|
|
|
|
console.error(`[BUILDER-PLAN] All ${maxRetries} retries exhausted, using default plan`);
|
|
applyAccountPlan(state.accountPlan || 'hobby');
|
|
syncUploadButtonState();
|
|
}
|
|
}
|
|
|
|
// Legacy wrapper for backward compatibility
|
|
async function loadAccountPlan() {
|
|
return loadAccountPlanWithRetry(1);
|
|
}
|
|
|
|
let modelPreviewDropdown = null;
|
|
let modelPreviewAttached = false;
|
|
let modelPreviewHandler = null;
|
|
let customDropdownOpen = false;
|
|
|
|
function toggleCustomDropdown() {
|
|
if (!el.modelSelectDropdown) return;
|
|
customDropdownOpen = !customDropdownOpen;
|
|
el.modelSelectDropdown.style.display = customDropdownOpen ? 'block' : 'none';
|
|
if (el.modelSelectBtn) {
|
|
el.modelSelectBtn.setAttribute('aria-expanded', customDropdownOpen ? 'true' : 'false');
|
|
}
|
|
}
|
|
|
|
function closeCustomDropdown() {
|
|
if (!el.modelSelectDropdown) return;
|
|
customDropdownOpen = false;
|
|
el.modelSelectDropdown.style.display = 'none';
|
|
if (el.modelSelectBtn) {
|
|
el.modelSelectBtn.setAttribute('aria-expanded', 'false');
|
|
}
|
|
}
|
|
|
|
function showBlurredModelPreviewInline() {
|
|
if (!isFreePlan()) return;
|
|
if (modelPreviewDropdown) {
|
|
try { modelPreviewDropdown.remove(); } catch (_) { }
|
|
modelPreviewDropdown = null;
|
|
}
|
|
const wrap = document.getElementById('model-select-wrap') || (el.modelSelectBtn && el.modelSelectBtn.parentElement);
|
|
if (!wrap) return;
|
|
// Ensure the wrapper is positioned for absolute dropdown
|
|
wrap.style.position = wrap.style.position || 'relative';
|
|
|
|
const dropdown = document.createElement('div');
|
|
dropdown.className = 'model-preview-dropdown';
|
|
dropdown.setAttribute('role', 'menu');
|
|
dropdown.setAttribute('aria-label', 'Available models (blurred)');
|
|
|
|
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';
|
|
(state.models || []).forEach((m) => {
|
|
const row = document.createElement('div');
|
|
row.className = 'model-preview-item';
|
|
row.textContent = m.label || m.name || m.id || 'Model';
|
|
list.appendChild(row);
|
|
});
|
|
if (!list.children.length) {
|
|
const placeholder = document.createElement('div');
|
|
placeholder.className = 'model-preview-item';
|
|
placeholder.textContent = 'Admin has not published models yet';
|
|
list.appendChild(placeholder);
|
|
}
|
|
|
|
// Add upgrade link at bottom
|
|
const upgradeContainer = document.createElement('div');
|
|
upgradeContainer.style.marginTop = '16px';
|
|
upgradeContainer.style.paddingTop = '12px';
|
|
upgradeContainer.style.borderTop = '1px solid #e5e7eb';
|
|
const upgradeLink = document.createElement('a');
|
|
upgradeLink.href = '/upgrade?source=builder_model';
|
|
upgradeLink.style.color = '#008060';
|
|
upgradeLink.style.textDecoration = 'none';
|
|
upgradeLink.style.fontWeight = '700';
|
|
upgradeLink.style.fontSize = '14px';
|
|
upgradeLink.style.display = 'flex';
|
|
upgradeLink.style.alignItems = 'center';
|
|
upgradeLink.style.gap = '6px';
|
|
upgradeLink.textContent = 'Upgrade Plan to Access All Models';
|
|
|
|
const upgradeIcon = document.createElement('svg');
|
|
upgradeIcon.setAttribute('width', '16');
|
|
upgradeIcon.setAttribute('height', '16');
|
|
upgradeIcon.setAttribute('viewBox', '0 0 24 24');
|
|
upgradeIcon.setAttribute('fill', 'none');
|
|
upgradeIcon.setAttribute('stroke', 'currentColor');
|
|
upgradeIcon.setAttribute('stroke-width', '2');
|
|
upgradeIcon.setAttribute('stroke-linecap', 'round');
|
|
upgradeIcon.setAttribute('stroke-linejoin', 'round');
|
|
|
|
const upgradePath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
upgradePath.setAttribute('d', 'M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5zM2 12l10 5 10-5');
|
|
upgradeIcon.appendChild(upgradePath);
|
|
|
|
upgradeLink.appendChild(upgradeIcon);
|
|
upgradeContainer.appendChild(upgradeLink);
|
|
|
|
dropdown.appendChild(title);
|
|
dropdown.appendChild(subtitle);
|
|
dropdown.appendChild(list);
|
|
dropdown.appendChild(upgradeContainer);
|
|
wrap.appendChild(dropdown);
|
|
modelPreviewDropdown = dropdown;
|
|
|
|
// Close dropdown when clicking outside
|
|
const onDocClick = (e) => {
|
|
if (!dropdown.contains(e.target) && !wrap.contains(e.target)) {
|
|
try { dropdown.remove(); } catch (_) { }
|
|
modelPreviewDropdown = null;
|
|
document.removeEventListener('click', onDocClick);
|
|
}
|
|
};
|
|
// Delay attaching the listener to avoid immediately closing when opening
|
|
setTimeout(() => document.addEventListener('click', onDocClick), 0);
|
|
}
|
|
|
|
function applyPlanModelLock() {
|
|
if (!el.modelSelect) return;
|
|
if (!isFreePlan()) {
|
|
// Paid plan: enable normal model selection
|
|
if (modelPreviewAttached && modelPreviewHandler) {
|
|
if (el.modelSelectBtn) {
|
|
el.modelSelectBtn.removeEventListener('click', modelPreviewHandler);
|
|
}
|
|
modelPreviewHandler = null;
|
|
modelPreviewAttached = false;
|
|
}
|
|
el.modelSelect.dataset.locked = '';
|
|
state.selectedModelId = el.modelSelect.value || state.selectedModelId;
|
|
renderModelIcon(el.modelSelect.value);
|
|
updateModelSelectDisplay(el.modelSelect.value);
|
|
return;
|
|
}
|
|
|
|
// Hobby (free) plan: allow model selection but ensure 'auto' is available
|
|
// Only add 'auto' if it doesn't exist - don't clear existing options
|
|
const hasAutoOption = Array.from(el.modelSelect.options).some(opt => opt.value === 'auto');
|
|
if (!hasAutoOption) {
|
|
const opt = document.createElement('option');
|
|
opt.value = 'auto';
|
|
opt.textContent = 'Auto (admin managed)';
|
|
el.modelSelect.appendChild(opt);
|
|
}
|
|
|
|
// Only set to 'auto' if current value is empty or invalid to avoid overwriting user selection
|
|
let valueToDisplay = el.modelSelect.value;
|
|
if (!valueToDisplay || valueToDisplay === '') {
|
|
el.modelSelect.value = 'auto';
|
|
el.modelSelect.selectedIndex = 0;
|
|
valueToDisplay = 'auto';
|
|
}
|
|
|
|
state.selectedModelId = valueToDisplay;
|
|
updateModelSelectDisplay(valueToDisplay);
|
|
|
|
el.modelSelect.dataset.locked = 'true';
|
|
|
|
// Attach click handler to show blurred preview (but NOT by default)
|
|
if (!modelPreviewAttached && el.modelSelectBtn) {
|
|
modelPreviewHandler = (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
// Show the blurred inline preview on click, not by default
|
|
try { showBlurredModelPreviewInline(); } catch (_) { }
|
|
};
|
|
el.modelSelectBtn.addEventListener('click', modelPreviewHandler);
|
|
modelPreviewAttached = true;
|
|
}
|
|
|
|
renderModelIcon(valueToDisplay !== 'auto' ? valueToDisplay : null);
|
|
}
|
|
|
|
const el = {
|
|
sessionList: null, // Not used in builder
|
|
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'),
|
|
modelSelectBtn: document.getElementById('model-select-btn'),
|
|
modelSelectDropdown: document.getElementById('model-select-dropdown'),
|
|
modelSelectOptions: document.getElementById('model-select-options'),
|
|
modelSelectText: document.getElementById('model-select-text'),
|
|
modelSelectMultiplier: document.getElementById('model-select-multiplier'),
|
|
modelIcon: document.getElementById('model-icon'),
|
|
modelSelectWrap: document.getElementById('model-select-wrap'),
|
|
customModelLabel: document.getElementById('custom-model-label'),
|
|
customModelInput: document.getElementById('custom-model-input'),
|
|
newChat: document.getElementById('new-chat'),
|
|
historyBtn: document.getElementById('history-btn'),
|
|
historyModal: document.getElementById('history-modal'),
|
|
historyList: document.getElementById('history-list'),
|
|
historyEmpty: document.getElementById('history-empty'),
|
|
historyClose: document.getElementById('history-close'),
|
|
messageInput: document.getElementById('message-input'),
|
|
// sendBtn: document.getElementById('send-btn'), // Removed
|
|
miniSendBtn: document.getElementById('mini-send-btn'),
|
|
uploadMediaBtn: document.getElementById('upload-media-btn'),
|
|
uploadMediaInput: document.getElementById('upload-media-input'),
|
|
attachmentPreview: document.getElementById('attachment-preview'),
|
|
statusLine: document.getElementById('status-line'),
|
|
statusLineAdmin: document.getElementById('status-line-admin'),
|
|
usageMeterTitle: document.getElementById('usage-meter-title'),
|
|
usageMeterPercent: document.getElementById('usage-meter-percent'),
|
|
usageMeterFill: document.getElementById('usage-meter-fill'),
|
|
usageMeterTrack: document.getElementById('usage-meter-track'),
|
|
quickButtons: document.querySelectorAll('[data-quick]'),
|
|
confirmBuildModal: document.getElementById('confirm-build-modal'),
|
|
confirmBuildProceed: document.getElementById('confirm-build-proceed'),
|
|
confirmBuildCancel: document.getElementById('confirm-build-cancel'),
|
|
confirmBuildClose: document.getElementById('confirm-build-close'),
|
|
};
|
|
|
|
console.log('Builder DOM elements initialized:', {
|
|
uploadMediaBtn: el.uploadMediaBtn,
|
|
uploadMediaInput: el.uploadMediaInput,
|
|
messageInput: el.messageInput
|
|
});
|
|
|
|
const pendingAttachments = [];
|
|
|
|
function isPaidPlanClient() {
|
|
return !isFreePlan();
|
|
}
|
|
|
|
function currentModelSupportsMedia() {
|
|
const selectedModelId = state.selectedModelId || el.modelSelect?.value;
|
|
if (!selectedModelId) return false;
|
|
|
|
// When model is 'auto', check if any available model supports media
|
|
if (selectedModelId === 'auto') {
|
|
// Check if any model in the list supports media
|
|
return state.models.some((m) => m.supportsMedia === true);
|
|
}
|
|
|
|
const model = state.models.find((m) => (m.id || m.name || m) === selectedModelId);
|
|
return model?.supportsMedia === true;
|
|
}
|
|
|
|
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) {
|
|
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 (_) {
|
|
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 isPaid = isPaidPlanClient();
|
|
const modelsLoaded = state.models && state.models.length > 0;
|
|
const modelSupportsMedia = modelsLoaded ? currentModelSupportsMedia() : true; // If models not loaded yet, assume supported for paid plans
|
|
const allowed = isPaid && modelSupportsMedia;
|
|
el.uploadMediaBtn.style.display = isPaid ? 'flex' : 'none';
|
|
el.uploadMediaBtn.style.opacity = modelsLoaded ? (modelSupportsMedia ? '1' : '0.5') : '1';
|
|
el.uploadMediaBtn.style.cursor = modelsLoaded ? (modelSupportsMedia ? 'pointer' : 'not-allowed') : 'pointer';
|
|
|
|
// Update title based on why button is disabled
|
|
if (!isPaid) {
|
|
el.uploadMediaBtn.title = 'Upload media (available on Professional/Enterprise plans)';
|
|
} else if (!modelsLoaded) {
|
|
el.uploadMediaBtn.title = 'Attach images';
|
|
} else if (!modelSupportsMedia) {
|
|
el.uploadMediaBtn.title = 'Current model does not support media. Select a different model.';
|
|
} else {
|
|
el.uploadMediaBtn.title = 'Attach images';
|
|
}
|
|
}
|
|
|
|
// Consolidated with change listener at the bottom of the file
|
|
|
|
// Set up custom dropdown button click handler
|
|
if (el.modelSelectBtn) {
|
|
el.modelSelectBtn.addEventListener('click', (e) => {
|
|
if (isFreePlan()) {
|
|
// For free plans, check if user has manually selected a non-auto model
|
|
// If yes, allow them to change it via dropdown
|
|
// If no, show blurred preview
|
|
if (el.modelSelect.value !== 'auto') {
|
|
toggleCustomDropdown();
|
|
} else {
|
|
// Let the modelPreviewHandler handle it (shows blurred preview)
|
|
if (modelPreviewHandler) {
|
|
modelPreviewHandler(e);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
// Toggle dropdown for paid plans
|
|
toggleCustomDropdown();
|
|
});
|
|
}
|
|
|
|
// Close dropdown when clicking outside
|
|
document.addEventListener('click', (e) => {
|
|
if (customDropdownOpen && el.modelSelectDropdown && !el.modelSelectDropdown.contains(e.target) && !el.modelSelectBtn.contains(e.target)) {
|
|
closeCustomDropdown();
|
|
}
|
|
});
|
|
|
|
let pendingPlanContent = null;
|
|
|
|
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('wordpress_plugin_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('wordpress_plugin_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 {
|
|
const keys = ['wordpress_plugin_ai_user', 'shopify_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();
|
|
|
|
async function hydrateUserIdFromServerSession() {
|
|
if (state.userId) return state.userId;
|
|
try {
|
|
const res = await fetch('/api/me', { credentials: 'same-origin' });
|
|
if (!res.ok) return '';
|
|
const data = await res.json().catch(() => null);
|
|
const serverUserId = data?.user?.id || '';
|
|
if (serverUserId) {
|
|
state.userId = serverUserId;
|
|
return serverUserId;
|
|
}
|
|
} catch (_) {
|
|
// ignore
|
|
}
|
|
return '';
|
|
}
|
|
|
|
(async () => {
|
|
if (!state.userId) {
|
|
await hydrateUserIdFromServerSession();
|
|
}
|
|
|
|
// We keep the legacy chat_user cookie in sync when we can, but we no longer
|
|
// hard-require localStorage identity for authenticated sessions.
|
|
if (state.userId) {
|
|
try {
|
|
document.cookie = `chat_user=${encodeURIComponent(state.userId)}; path=/; SameSite=Lax`;
|
|
} catch (_) { /* ignore */ }
|
|
}
|
|
|
|
// If neither localStorage nor server-session auth is present, send them to login.
|
|
if (!state.userId) {
|
|
const next = encodeURIComponent(window.location.pathname + window.location.search);
|
|
window.location.href = `/login?next=${next}`;
|
|
return;
|
|
}
|
|
|
|
detectAdminSession();
|
|
|
|
// Load usage summary on page load
|
|
loadUsageSummary().catch(err => {
|
|
console.warn('[USAGE] Initial loadUsageSummary failed:', err.message);
|
|
});
|
|
})();
|
|
|
|
function setAdminStatus(msg) {
|
|
if (!el.statusLineAdmin) return;
|
|
el.statusLineAdmin.textContent = msg || '';
|
|
}
|
|
|
|
function classifyStatusMessage(msg) {
|
|
const text = (msg || '').toString();
|
|
const lower = text.toLowerCase();
|
|
|
|
if (!text) return { userText: '', adminText: '' };
|
|
|
|
if (lower.startsWith('no models configured')) {
|
|
return {
|
|
userText: 'No models are configured. Please contact support.',
|
|
adminText: text,
|
|
};
|
|
}
|
|
|
|
if (lower.startsWith('model load failed:')) {
|
|
return {
|
|
userText: 'Models are currently unavailable. Please contact support.',
|
|
adminText: text,
|
|
};
|
|
}
|
|
|
|
if (lower.startsWith('planning failed:')) {
|
|
return {
|
|
userText: 'Planning is currently unavailable. Please contact support.',
|
|
adminText: text,
|
|
};
|
|
}
|
|
|
|
// Surface missing provider API keys to the user with actionable text
|
|
if (lower.includes('missing provider api keys') || lower.includes('no configured planning providers')) {
|
|
// Try to extract provider list from the message if present
|
|
const m = text.match(/Missing provider API keys:\s*([^\n\r]+)/i);
|
|
const providers = m ? m[1].trim() : null;
|
|
return {
|
|
userText: providers
|
|
? `Planning unavailable: missing API keys for ${providers}. Please set the environment variables (e.g. GROQ_API_KEY) or configure providers in Admin.`
|
|
: 'Planning unavailable: missing provider API keys. Please check server configuration.',
|
|
adminText: text,
|
|
};
|
|
}
|
|
|
|
if (lower.includes('openrouter api key') || lower.includes('openrouter request failed')) {
|
|
return {
|
|
userText: 'Planning is currently unavailable. Please contact support.',
|
|
adminText: text,
|
|
};
|
|
}
|
|
|
|
if (lower.startsWith('warning: opencode cli not available')) {
|
|
return {
|
|
userText: 'Builder service is currently unavailable. Please contact support.',
|
|
adminText: text,
|
|
};
|
|
}
|
|
|
|
if (lower.includes('proper prefixing') ||
|
|
lower.includes('tool call format') ||
|
|
lower.includes('tool call prefix') ||
|
|
lower.includes('session terminated') ||
|
|
lower.includes('early termination')) {
|
|
return {
|
|
userText: 'Connection interrupted. Resuming...',
|
|
adminText: `Early termination detected: ${text}`,
|
|
type: 'warning'
|
|
};
|
|
}
|
|
|
|
return { userText: text, adminText: '' };
|
|
}
|
|
|
|
function setStatus(msg) {
|
|
const { userText, adminText } = classifyStatusMessage(msg);
|
|
if (el.statusLine) el.statusLine.textContent = userText || '';
|
|
setAdminStatus(adminText);
|
|
}
|
|
|
|
// Expose for builder.html
|
|
window.setStatus = setStatus;
|
|
|
|
async function api(path, options = {}) {
|
|
const headers = {
|
|
'Content-Type': 'application/json',
|
|
...(state.userId ? { 'X-User-Id': state.userId } : {}),
|
|
...(options.headers || {}),
|
|
};
|
|
const res = await fetch(path, {
|
|
headers,
|
|
credentials: 'same-origin',
|
|
...options,
|
|
});
|
|
const text = await res.text();
|
|
let json;
|
|
try {
|
|
json = text ? JSON.parse(text) : {};
|
|
} catch (parseErr) {
|
|
console.error('[API] JSON parse error:', parseErr.message, 'Response:', text.substring(0, 300));
|
|
throw new Error(`Invalid JSON response from server: ${parseErr.message}`);
|
|
}
|
|
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;
|
|
}
|
|
|
|
// Expose for builder.html
|
|
window.api = api;
|
|
|
|
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;
|
|
}
|
|
|
|
// No-op for builder - session list not used
|
|
function renderSessions() {
|
|
// Builder doesn't render session list, but we still render history
|
|
renderHistoryList();
|
|
}
|
|
|
|
function formatSessionTime(value) {
|
|
if (!value) return '—';
|
|
const dt = new Date(value);
|
|
if (Number.isNaN(dt.getTime())) return '—';
|
|
return dt.toLocaleString(undefined, {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: 'numeric',
|
|
minute: '2-digit'
|
|
});
|
|
}
|
|
|
|
function getSessionPreview(session) {
|
|
const messages = Array.isArray(session.messages) ? session.messages : [];
|
|
if (!messages.length) return 'No messages yet.';
|
|
const last = messages[messages.length - 1];
|
|
const preview = (last.reply || last.displayContent || last.content || '').trim();
|
|
return preview ? preview.slice(0, 140) : 'No messages yet.';
|
|
}
|
|
|
|
function renderHistoryList() {
|
|
if (!el.historyList || !el.historyEmpty) return;
|
|
|
|
let sessions = Array.isArray(state.sessions) ? state.sessions.slice() : [];
|
|
|
|
// Get current app ID from current session (not from URL since that's handled separately now)
|
|
const currentSession = state.sessions.find(s => s.id === state.currentSessionId);
|
|
let currentAppId = currentSession?.appId || null;
|
|
|
|
// If we have a current appId, filter to show only chats for this app
|
|
if (currentAppId) {
|
|
sessions = sessions.filter(s => s.appId === currentAppId);
|
|
}
|
|
|
|
sessions.sort((a, b) => new Date(b.updatedAt || b.createdAt || 0) - new Date(a.updatedAt || a.createdAt || 0));
|
|
|
|
el.historyList.innerHTML = '';
|
|
|
|
if (!sessions.length) {
|
|
el.historyEmpty.style.display = 'block';
|
|
el.historyEmpty.textContent = 'No past chats';
|
|
return;
|
|
}
|
|
|
|
el.historyEmpty.style.display = 'none';
|
|
|
|
sessions.forEach((session) => {
|
|
const item = document.createElement('div');
|
|
item.className = 'history-item';
|
|
if (session.id === state.currentSessionId) {
|
|
item.classList.add('active');
|
|
}
|
|
|
|
const content = document.createElement('div');
|
|
content.style.flex = '1';
|
|
|
|
const title = document.createElement('div');
|
|
title.className = 'history-title';
|
|
title.textContent = session.title || 'Untitled chat';
|
|
|
|
const preview = document.createElement('div');
|
|
preview.className = 'history-preview';
|
|
preview.textContent = getSessionPreview(session);
|
|
|
|
content.appendChild(title);
|
|
content.appendChild(preview);
|
|
|
|
const meta = document.createElement('div');
|
|
meta.className = 'history-meta';
|
|
meta.textContent = formatSessionTime(session.updatedAt || session.createdAt);
|
|
|
|
item.appendChild(content);
|
|
item.appendChild(meta);
|
|
|
|
item.addEventListener('click', async (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
if (session.id === state.currentSessionId) {
|
|
console.log('[HISTORY] Clicked on current session, just closing modal');
|
|
closeHistoryModal();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
console.log('[HISTORY] Clicked on session:', { id: session.id, title: session.title });
|
|
console.log('[HISTORY] Current session ID before select:', state.currentSessionId);
|
|
await selectSession(session.id);
|
|
console.log('[HISTORY] Session selected successfully, new currentSessionId:', state.currentSessionId);
|
|
console.log('[HISTORY] Closing modal');
|
|
closeHistoryModal();
|
|
} catch (err) {
|
|
console.error('[HISTORY] Failed to select session:', err);
|
|
setStatus('Failed to load chat: ' + err.message);
|
|
}
|
|
});
|
|
|
|
el.historyList.appendChild(item);
|
|
});
|
|
}
|
|
|
|
async function openHistoryModal() {
|
|
// Refresh sessions from server to ensure we have the latest chats
|
|
await loadSessions().catch(err => console.warn('Failed to refresh sessions for history:', err.message));
|
|
renderHistoryList();
|
|
if (el.historyModal) el.historyModal.style.display = 'flex';
|
|
}
|
|
|
|
function closeHistoryModal() {
|
|
if (el.historyModal) el.historyModal.style.display = 'none';
|
|
}
|
|
|
|
function scrollChatToBottom() {
|
|
if (!el.chatArea) return;
|
|
const target = el.chatArea;
|
|
|
|
const doScroll = () => {
|
|
if (!target) return;
|
|
target.scrollTop = target.scrollHeight;
|
|
};
|
|
|
|
// Use multiple scroll techniques in sequence for reliability
|
|
// 1. Immediate scroll
|
|
doScroll();
|
|
|
|
// 2. After DOM updates
|
|
setTimeout(doScroll, 50);
|
|
setTimeout(doScroll, 100);
|
|
setTimeout(doScroll, 200);
|
|
setTimeout(doScroll, 500);
|
|
|
|
// 3. After content fully renders
|
|
setTimeout(doScroll, 1000);
|
|
|
|
// 4. requestAnimationFrame for smooth scrolling
|
|
requestAnimationFrame(() => {
|
|
doScroll();
|
|
requestAnimationFrame(doScroll);
|
|
});
|
|
|
|
// 5. Scroll to last message element if it exists (most reliable method)
|
|
setTimeout(() => {
|
|
const lastMessage = target.querySelector('.message:last-child');
|
|
if (lastMessage) {
|
|
lastMessage.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
|
} else {
|
|
// Fallback to regular scroll
|
|
doScroll();
|
|
}
|
|
}, 100);
|
|
|
|
// 6. ResizeObserver for dynamic content
|
|
if (typeof ResizeObserver !== 'undefined') {
|
|
const resizeObserver = new ResizeObserver(() => doScroll());
|
|
resizeObserver.observe(target);
|
|
setTimeout(() => resizeObserver.disconnect(), 2000);
|
|
}
|
|
|
|
// 7. MutationObserver for DOM changes
|
|
if (typeof MutationObserver !== 'undefined') {
|
|
const observer = new MutationObserver(() => doScroll());
|
|
observer.observe(target, { childList: true, subtree: true });
|
|
setTimeout(() => observer.disconnect(), 2000);
|
|
}
|
|
}
|
|
|
|
window.scrollChatToBottom = scrollChatToBottom;
|
|
|
|
function renderSessionMeta(session) {
|
|
if (!session) {
|
|
console.error('renderSessionMeta called with null/undefined session');
|
|
return;
|
|
}
|
|
if (el.sessionId) el.sessionId.textContent = session.id ?? '-';
|
|
if (el.sessionModel) el.sessionModel.textContent = session.model ? `${session.cli ?? 'opencode'} / ${session.model}` : '-';
|
|
if (el.sessionPending) el.sessionPending.textContent = session.pending ?? 0;
|
|
if (el.queueIndicator) {
|
|
el.queueIndicator.textContent = session.pending ? `${session.pending} queued` : 'Idle';
|
|
el.queueIndicator.style.borderColor = session.pending ? 'var(--accent)' : 'var(--border)';
|
|
}
|
|
|
|
// Update chat title while preserving the badge
|
|
if (el.chatTitle) {
|
|
const badge = el.chatTitle.querySelector('.shopify-badge');
|
|
const titleText = session.title || 'New Project';
|
|
if (badge) {
|
|
// Clone badge to preserve it, clear title, add text, then re-add badge
|
|
const badgeClone = badge.cloneNode(true);
|
|
el.chatTitle.textContent = titleText;
|
|
el.chatTitle.appendChild(badgeClone);
|
|
} else {
|
|
el.chatTitle.textContent = titleText;
|
|
}
|
|
}
|
|
|
|
if (session.cli && el.cliSelect && el.cliSelect.value !== session.cli) {
|
|
el.cliSelect.value = session.cli;
|
|
state.currentCli = session.cli;
|
|
}
|
|
// Don't update model if user just changed it manually
|
|
if (session.model && !userJustChangedModel) {
|
|
console.log(`[renderSessionMeta] Server model: ${session.model}, Current UI model: ${el.modelSelect.value}, userJustChangedModel: false - syncing from server`);
|
|
if (!isFreePlan()) {
|
|
// For paid plans: only update if current value is empty/default, preserve user selection
|
|
const currentValue = el.modelSelect.value;
|
|
const isDefaultOrEmpty = !currentValue || currentValue === 'default' || currentValue === '';
|
|
const isDifferentModel = currentValue !== session.model;
|
|
|
|
// Only overwrite if user hasn't selected something specific
|
|
if ((isDefaultOrEmpty || !currentValue) && isDifferentModel) {
|
|
programmaticModelChange = true;
|
|
el.modelSelect.value = session.model;
|
|
state.selectedModelId = session.model;
|
|
updateModelSelectDisplay(session.model);
|
|
setTimeout(() => { programmaticModelChange = false; }, 100);
|
|
}
|
|
} else {
|
|
// Hobby plan shows Auto, but preserve user selection if they've manually selected a non-auto model
|
|
// Only update if the current value is 'auto' to avoid overwriting user selections
|
|
if (el.modelSelect.value === 'auto' && session.model !== 'auto') {
|
|
programmaticModelChange = true;
|
|
el.modelSelect.value = session.model;
|
|
state.selectedModelId = session.model;
|
|
updateModelSelectDisplay(session.model);
|
|
setTimeout(() => { programmaticModelChange = false; }, 100);
|
|
} else if (el.modelSelect.value !== 'auto' && session.model === 'auto') {
|
|
// Server has 'auto', but user has selected a specific model - keep user selection
|
|
// Don't overwrite it
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Helper functions for loading indicator
|
|
function showLoadingIndicator(type) {
|
|
// Remove any existing loading indicator first
|
|
hideLoadingIndicator();
|
|
|
|
const loadingDiv = document.createElement('div');
|
|
loadingDiv.className = 'loading-indicator animate-in';
|
|
loadingDiv.id = 'loading-indicator';
|
|
loadingDiv.style.cssText = 'display: flex; flex-direction: column; align-items: flex-start; padding: 16px; background: var(--shopify-bg); border-radius: 8px; margin: 8px 0;';
|
|
|
|
// Create status container
|
|
const statusContainer = document.createElement('div');
|
|
statusContainer.style.cssText = 'display: flex; align-items: center; gap: 12px; margin-bottom: 8px;';
|
|
|
|
// Create animated dots
|
|
const dotsContainer = document.createElement('div');
|
|
dotsContainer.style.cssText = 'display: flex; gap: 4px;';
|
|
|
|
for (let i = 0; i < 3; i++) {
|
|
const dot = document.createElement('span');
|
|
dot.style.cssText = `width: 8px; height: 8px; background: var(--shopify-green); border-radius: 50%; animation: loadingDot 1.4s ease-in-out ${i * 0.2}s infinite;`;
|
|
dotsContainer.appendChild(dot);
|
|
}
|
|
|
|
// Create status text
|
|
const statusText = document.createElement('span');
|
|
statusText.className = 'loading-status-text';
|
|
statusText.style.cssText = 'font-weight: 600; color: var(--shopify-text); font-size: 14px;';
|
|
|
|
// Set appropriate text based on type
|
|
if (type === 'planning') {
|
|
statusText.textContent = 'Starting planning process';
|
|
} else {
|
|
statusText.textContent = 'Starting build process';
|
|
}
|
|
|
|
statusContainer.appendChild(dotsContainer);
|
|
statusContainer.appendChild(statusText);
|
|
|
|
// Create detailed info text
|
|
const detailText = document.createElement('span');
|
|
detailText.className = 'loading-detail-text';
|
|
detailText.style.cssText = 'font-size: 12px; color: var(--shopify-text-secondary); margin-left: 32px;';
|
|
|
|
if (type === 'planning') {
|
|
detailText.textContent = 'Analyzing your request and creating a development plan...';
|
|
} else {
|
|
detailText.textContent = 'Building your plugin with AI-generated code...';
|
|
}
|
|
|
|
// Add animation keyframes if not already added
|
|
if (!document.getElementById('loading-animations')) {
|
|
const style = document.createElement('style');
|
|
style.id = 'loading-animations';
|
|
style.textContent = `
|
|
@keyframes loadingDot {
|
|
0%, 80%, 100% { transform: scale(0.6); opacity: 0.5; }
|
|
40% { transform: scale(1); opacity: 1; }
|
|
}
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.7; }
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
loadingDiv.appendChild(statusContainer);
|
|
loadingDiv.appendChild(detailText);
|
|
|
|
el.chatArea.appendChild(loadingDiv);
|
|
|
|
// Scroll to bottom to show the loading indicator
|
|
el.chatArea.scrollTop = el.chatArea.scrollHeight;
|
|
}
|
|
|
|
// Expose for builder.html
|
|
window.showLoadingIndicator = showLoadingIndicator;
|
|
|
|
function hideLoadingIndicator() {
|
|
const existing = document.getElementById('loading-indicator');
|
|
if (existing) {
|
|
existing.remove();
|
|
}
|
|
}
|
|
|
|
// Expose for builder.html
|
|
window.hideLoadingIndicator = hideLoadingIndicator;
|
|
|
|
function renderMessages(session) {
|
|
// Don't clear the loading indicator if it exists
|
|
const loadingIndicator = document.getElementById('loading-indicator');
|
|
// Store reference before clearing, and remove animation class to prevent bounce on re-render
|
|
if (loadingIndicator) {
|
|
loadingIndicator.classList.remove('animate-in');
|
|
// Instead of removing from DOM, just hide it temporarily
|
|
// This prevents layout shifts when re-adding the element
|
|
loadingIndicator.style.visibility = 'hidden';
|
|
loadingIndicator.style.position = 'absolute';
|
|
}
|
|
|
|
// Remove all message elements while preserving the loading indicator
|
|
// This prevents the loading indicator from being removed from the DOM
|
|
const messageElements = el.chatArea.querySelectorAll('.message');
|
|
messageElements.forEach(el => el.remove());
|
|
const emptyMessage = el.chatArea.querySelector('.empty-message');
|
|
if (emptyMessage) emptyMessage.remove();
|
|
|
|
if (!session.messages || !session.messages.length) {
|
|
// Remove any existing empty message
|
|
const existingEmpty = el.chatArea.querySelector('.empty-message');
|
|
if (existingEmpty) existingEmpty.remove();
|
|
|
|
if (!loadingIndicator) {
|
|
const emptyDiv = document.createElement('div');
|
|
emptyDiv.className = 'muted empty-message';
|
|
emptyDiv.textContent = 'Send a message to start the conversation.';
|
|
el.chatArea.appendChild(emptyDiv);
|
|
} else {
|
|
// Restore loading indicator visibility if it was there
|
|
loadingIndicator.style.visibility = '';
|
|
loadingIndicator.style.position = '';
|
|
}
|
|
// Ensure loading indicator is at the end if it exists
|
|
if (loadingIndicator && loadingIndicator.parentNode !== el.chatArea) {
|
|
el.chatArea.appendChild(loadingIndicator);
|
|
}
|
|
scrollChatToBottom();
|
|
return;
|
|
}
|
|
|
|
// Find the latest message (excluding background continuations)
|
|
const latestMessage = [...session.messages].reverse().find(msg => !msg.isBackgroundContinuation);
|
|
|
|
session.messages.forEach((msg) => {
|
|
if (msg.isBackgroundContinuation) return;
|
|
|
|
const status = msg.status || 'done';
|
|
|
|
const userCard = document.createElement('div');
|
|
userCard.className = 'message user';
|
|
const userMeta = document.createElement('div');
|
|
userMeta.className = 'meta';
|
|
// Hide model badge for plan messages (OpenRouter messages)
|
|
const isPlanMessage = (msg.cli !== 'opencode') || (msg.phase === 'plan');
|
|
const modelBadge = '';
|
|
userMeta.innerHTML = `
|
|
<span>You</span>
|
|
${modelBadge}
|
|
<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 = '150px';
|
|
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)) {
|
|
console.log('[RenderMessages] Creating assistant card for message:', {
|
|
id: msg.id,
|
|
cli: msg.cli,
|
|
phase: msg.phase,
|
|
status: status,
|
|
hasReply: !!msg.reply,
|
|
hasError: !!msg.error,
|
|
hasPartialOutput: !!msg.partialOutput
|
|
});
|
|
// Don't hide loading indicator during streaming - only hide when completely done
|
|
// This allows the spinner to continue until the OpenCode session is fully complete
|
|
const assistantCard = document.createElement('div');
|
|
assistantCard.className = 'message assistant' + (status === 'cancelled' ? ' cancelled' : '');
|
|
const assistantMeta = document.createElement('div');
|
|
assistantMeta.className = 'meta';
|
|
const msgCliLabel = (msg.cli || session.cli || 'opencode');
|
|
// For OpenCode messages, don't show provider/model label in builder
|
|
// Only show CLI label for non-OpenCode messages (e.g., OpenRouter)
|
|
if (msgCliLabel !== 'opencode') {
|
|
const cliSpan = document.createElement('span');
|
|
cliSpan.textContent = String(msgCliLabel).toUpperCase();
|
|
assistantMeta.appendChild(cliSpan);
|
|
}
|
|
const rawBtn = document.createElement('button');
|
|
rawBtn.className = 'ghost';
|
|
rawBtn.style.marginLeft = '8px';
|
|
rawBtn.textContent = 'Plugin Compass';
|
|
assistantMeta.appendChild(rawBtn);
|
|
|
|
// Add Undo/Redo buttons - show for latest message when done, errored, or cancelled
|
|
const isOpencodeMsg = msg.cli === 'opencode' || msg.phase === 'build';
|
|
const isLatestMessage = latestMessage && latestMessage.id === msg.id;
|
|
const shouldShowUndoRedo = isOpencodeMsg && isLatestMessage && (status === 'done' || status === 'error' || status === 'cancelled');
|
|
|
|
if (shouldShowUndoRedo) {
|
|
const undoBtn = document.createElement('button');
|
|
undoBtn.className = 'ghost';
|
|
undoBtn.style.marginLeft = '8px';
|
|
undoBtn.textContent = '↺ Undo';
|
|
undoBtn.onclick = async () => {
|
|
undoBtn.disabled = true;
|
|
undoBtn.textContent = '↺ Undoing...';
|
|
try {
|
|
// Clear todos before undoing
|
|
clearTodos();
|
|
await api(`/api/sessions/${session.id}/messages/${msg.id}/undo`, {
|
|
method: 'POST',
|
|
});
|
|
await refreshCurrentSession();
|
|
setStatus('Undo complete');
|
|
} catch (err) {
|
|
setStatus('Undo failed: ' + err.message);
|
|
} finally {
|
|
undoBtn.disabled = false;
|
|
undoBtn.textContent = '↺ Undo';
|
|
}
|
|
};
|
|
assistantMeta.appendChild(undoBtn);
|
|
|
|
const redoBtn = document.createElement('button');
|
|
redoBtn.className = 'ghost';
|
|
redoBtn.style.marginLeft = '8px';
|
|
redoBtn.textContent = '↻ Redo';
|
|
redoBtn.onclick = async () => {
|
|
redoBtn.disabled = true;
|
|
redoBtn.textContent = '↻ Redoing...';
|
|
try {
|
|
// Clear todos before redoing
|
|
clearTodos();
|
|
await redoMessage(msg, session);
|
|
} catch (err) {
|
|
setStatus('Redo failed: ' + err.message);
|
|
} finally {
|
|
redoBtn.disabled = false;
|
|
redoBtn.textContent = '↻ Redo';
|
|
}
|
|
};
|
|
assistantMeta.appendChild(redoBtn);
|
|
}
|
|
|
|
|
|
const assistantBody = document.createElement('div');
|
|
assistantBody.className = 'body';
|
|
assistantBody.appendChild(renderContentWithTodos(msg.reply || msg.partialOutput || msg.opencodeSummary || ''));
|
|
assistantCard.appendChild(assistantMeta);
|
|
assistantCard.appendChild(assistantBody);
|
|
|
|
// Check if this is an OpenRouter (plan) message - show "Proceed with Build" on ALL OpenRouter messages, not just the first
|
|
const isOpenRouterMessage = (msg.cli !== 'opencode') || (msg.phase === 'plan') || (msg.model && !msg.model.includes('opencode') && msg.cli !== 'opencode');
|
|
const hasReply = msg.reply || msg.partialOutput;
|
|
const isPlanPhase = msg.phase === 'plan';
|
|
const isBuildPhase = msg.phase === 'build';
|
|
|
|
if (isOpenRouterMessage && hasReply && (isPlanPhase || isBuildPhase)) {
|
|
const proceedBtn = document.createElement('button');
|
|
proceedBtn.className = 'primary';
|
|
proceedBtn.style.marginTop = '12px';
|
|
proceedBtn.style.width = '100%';
|
|
proceedBtn.textContent = 'Proceed with Build';
|
|
proceedBtn.onclick = () => proceedWithBuild(msg.reply || msg.partialOutput);
|
|
assistantBody.appendChild(proceedBtn);
|
|
}
|
|
|
|
// Add Download ZIP button for OpenCode messages that are finished and are the latest message
|
|
const isOpenCodeMessage = (msg.cli === 'opencode') || (msg.phase === 'build');
|
|
if (isOpenCodeMessage && hasReply && status === 'done' && isLatestMessage) {
|
|
const downloadBtn = document.createElement('button');
|
|
downloadBtn.className = 'primary';
|
|
downloadBtn.style.marginTop = '12px';
|
|
downloadBtn.style.width = '100%';
|
|
downloadBtn.textContent = 'Download ZIP';
|
|
downloadBtn.onclick = () => {
|
|
const modal = document.getElementById('export-modal');
|
|
if (modal) modal.style.display = 'flex';
|
|
};
|
|
assistantBody.appendChild(downloadBtn);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
// Render todos if they exist on the message
|
|
if (msg.todos && Array.isArray(msg.todos) && msg.todos.length > 0) {
|
|
const todoContainer = renderStructuredTodos(msg.todos);
|
|
if (todoContainer) {
|
|
assistantCard.appendChild(todoContainer);
|
|
}
|
|
}
|
|
|
|
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);
|
|
|
|
// Approve & Build button removed - keeping users in plan mode only
|
|
}
|
|
});
|
|
|
|
// Restore loading indicator visibility after all messages are rendered
|
|
if (loadingIndicator) {
|
|
loadingIndicator.style.visibility = '';
|
|
loadingIndicator.style.position = '';
|
|
// Ensure loading indicator is at the end of chat area
|
|
if (loadingIndicator.parentNode !== el.chatArea) {
|
|
el.chatArea.appendChild(loadingIndicator);
|
|
} else {
|
|
// Move to end to ensure it's after all messages
|
|
el.chatArea.appendChild(loadingIndicator);
|
|
}
|
|
}
|
|
|
|
// Scroll to bottom after DOM updates
|
|
scrollChatToBottom();
|
|
|
|
// Additional scroll with longer delay to ensure all content is rendered
|
|
setTimeout(() => {
|
|
scrollChatToBottom();
|
|
// Force scroll by accessing scrollHeight directly
|
|
if (el.chatArea) {
|
|
el.chatArea.scrollTop = el.chatArea.scrollHeight;
|
|
// Scroll last element into view as fallback
|
|
const lastMessage = el.chatArea.querySelector('.message:last-child');
|
|
if (lastMessage) {
|
|
lastMessage.scrollIntoView({ block: 'end' });
|
|
}
|
|
}
|
|
}, 200);
|
|
|
|
updateExportButtonVisibility(session);
|
|
}
|
|
|
|
function updateExportButtonVisibility(session) {
|
|
const exportZipBtn = document.getElementById('export-zip-btn');
|
|
if (!exportZipBtn) return;
|
|
|
|
if (!session || !session.messages || !session.messages.length) {
|
|
exportZipBtn.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
// Show the export button when the session contains at least one completed
|
|
// OpenCode/build message with output. Relying on session.status caused
|
|
// the button to disappear in some cases when the session-level status
|
|
// wasn't set yet even though messages were complete. Inspect messages
|
|
// directly for a more reliable check.
|
|
const msgs = Array.isArray(session.messages) ? session.messages.slice().reverse() : [];
|
|
const hasExportable = msgs.find((msg) => {
|
|
if (msg.isBackgroundContinuation) return false;
|
|
const isOpencode = (msg.cli === 'opencode') || (msg.phase === 'build');
|
|
const isFinished = msg.status === 'done' || msg.status === 'error';
|
|
const hasOutput = !!(msg.reply || msg.partialOutput);
|
|
return isOpencode && isFinished && hasOutput;
|
|
});
|
|
|
|
exportZipBtn.style.display = hasExportable ? '' : 'none';
|
|
}
|
|
|
|
// Expose for builder.html
|
|
window.renderMessages = renderMessages;
|
|
|
|
// 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+(?=[A-Z])/g, '$1\n\n');
|
|
const lines = processedText.split(/\r?\n/);
|
|
let currentList = null;
|
|
let inCodeBlock = false;
|
|
let codeBuffer = [];
|
|
|
|
for (const line of lines) {
|
|
if (line.trim().startsWith('```')) {
|
|
if (inCodeBlock) {
|
|
const pre = document.createElement('pre');
|
|
pre.className = 'code-block';
|
|
pre.style.background = '#1e1e1e';
|
|
pre.style.color = '#d4d4d4';
|
|
pre.style.padding = '12px';
|
|
pre.style.borderRadius = '8px';
|
|
pre.style.overflowX = 'auto';
|
|
pre.style.fontFamily = 'monospace';
|
|
pre.style.fontSize = '13px';
|
|
pre.style.margin = '10px 0';
|
|
pre.textContent = codeBuffer.join('\n');
|
|
wrapper.appendChild(pre);
|
|
codeBuffer = [];
|
|
inCodeBlock = false;
|
|
} else {
|
|
inCodeBlock = true;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (inCodeBlock) {
|
|
codeBuffer.push(line);
|
|
continue;
|
|
}
|
|
|
|
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.style.marginBottom = '8px';
|
|
p.textContent = line;
|
|
wrapper.appendChild(p);
|
|
}
|
|
}
|
|
return wrapper;
|
|
}
|
|
|
|
// Render structured todos with status and priority
|
|
function renderStructuredTodos(todos) {
|
|
if (!todos || !Array.isArray(todos) || todos.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const container = document.createElement('div');
|
|
container.className = 'todo-container';
|
|
container.style.marginTop = '16px';
|
|
container.style.marginBottom = '16px';
|
|
container.style.padding = '16px';
|
|
container.style.background = '#f8fffc';
|
|
container.style.border = '1px solid rgba(0, 128, 96, 0.2)';
|
|
container.style.borderRadius = '12px';
|
|
|
|
const header = document.createElement('div');
|
|
header.style.display = 'flex';
|
|
header.style.alignItems = 'center';
|
|
header.style.gap = '8px';
|
|
header.style.marginBottom = '12px';
|
|
header.style.fontWeight = '600';
|
|
header.style.color = 'var(--shopify-green)';
|
|
header.style.fontSize = '14px';
|
|
header.innerHTML = `
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M9 11l3 3L22 4"></path>
|
|
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"></path>
|
|
</svg>
|
|
Tasks (${todos.length})
|
|
`;
|
|
container.appendChild(header);
|
|
|
|
const list = document.createElement('div');
|
|
list.style.display = 'flex';
|
|
list.style.flexDirection = 'column';
|
|
list.style.gap = '8px';
|
|
|
|
todos.forEach((todo) => {
|
|
const item = document.createElement('div');
|
|
item.className = `todo-item todo-status-${todo.status}`;
|
|
item.style.display = 'flex';
|
|
item.style.alignItems = 'flex-start';
|
|
item.style.gap = '10px';
|
|
item.style.padding = '10px 12px';
|
|
item.style.borderRadius = '8px';
|
|
item.style.background = '#fff';
|
|
item.style.border = '1px solid var(--border)';
|
|
item.style.transition = 'all 0.2s ease';
|
|
|
|
// Status icon
|
|
const statusIcon = document.createElement('span');
|
|
statusIcon.className = 'todo-status-icon';
|
|
statusIcon.style.flexShrink = '0';
|
|
statusIcon.style.width = '20px';
|
|
statusIcon.style.height = '20px';
|
|
statusIcon.style.display = 'flex';
|
|
statusIcon.style.alignItems = 'center';
|
|
statusIcon.style.justifyContent = 'center';
|
|
statusIcon.style.fontSize = '14px';
|
|
|
|
// Set icon and color based on status
|
|
switch (todo.status) {
|
|
case 'completed':
|
|
statusIcon.innerHTML = '✓';
|
|
statusIcon.style.color = '#10b981';
|
|
item.style.borderColor = 'rgba(16, 185, 129, 0.3)';
|
|
item.style.background = 'rgba(16, 185, 129, 0.05)';
|
|
break;
|
|
case 'in_progress':
|
|
statusIcon.innerHTML = '●';
|
|
statusIcon.style.color = '#f59e0b';
|
|
item.style.borderColor = 'rgba(245, 158, 11, 0.3)';
|
|
item.style.background = 'rgba(245, 158, 11, 0.05)';
|
|
break;
|
|
case 'cancelled':
|
|
statusIcon.innerHTML = '✗';
|
|
statusIcon.style.color = '#6b7280';
|
|
item.style.opacity = '0.6';
|
|
item.style.textDecoration = 'line-through';
|
|
break;
|
|
case 'pending':
|
|
default:
|
|
statusIcon.innerHTML = '○';
|
|
statusIcon.style.color = '#9ca3af';
|
|
break;
|
|
}
|
|
|
|
const content = document.createElement('span');
|
|
content.className = 'todo-content';
|
|
content.style.flex = '1';
|
|
content.style.fontSize = '14px';
|
|
content.style.lineHeight = '1.4';
|
|
content.style.color = 'var(--ink)';
|
|
content.textContent = todo.content || '';
|
|
|
|
// Priority badge
|
|
if (todo.priority && todo.priority !== 'medium') {
|
|
const priorityBadge = document.createElement('span');
|
|
priorityBadge.className = `todo-priority-${todo.priority}`;
|
|
priorityBadge.style.fontSize = '10px';
|
|
priorityBadge.style.fontWeight = '700';
|
|
priorityBadge.style.textTransform = 'uppercase';
|
|
priorityBadge.style.letterSpacing = '0.02em';
|
|
priorityBadge.style.padding = '2px 6px';
|
|
priorityBadge.style.borderRadius = '4px';
|
|
priorityBadge.style.flexShrink = '0';
|
|
|
|
if (todo.priority === 'high') {
|
|
priorityBadge.textContent = 'High';
|
|
priorityBadge.style.background = '#fee2e2';
|
|
priorityBadge.style.color = '#dc2626';
|
|
} else if (todo.priority === 'low') {
|
|
priorityBadge.textContent = 'Low';
|
|
priorityBadge.style.background = '#dbeafe';
|
|
priorityBadge.style.color = '#2563eb';
|
|
}
|
|
|
|
content.appendChild(document.createTextNode(' '));
|
|
content.appendChild(priorityBadge);
|
|
}
|
|
|
|
item.appendChild(statusIcon);
|
|
item.appendChild(content);
|
|
list.appendChild(item);
|
|
});
|
|
|
|
container.appendChild(list);
|
|
return container;
|
|
}
|
|
|
|
// Clear todos from the UI
|
|
function clearTodos() {
|
|
state.todos = [];
|
|
state.currentMessageId = null;
|
|
const existingContainer = document.querySelector('.todo-container');
|
|
if (existingContainer) {
|
|
existingContainer.remove();
|
|
}
|
|
console.log('[TODOS] Cleared todos from UI');
|
|
}
|
|
|
|
// Update todos in the UI
|
|
function updateTodos(todos, messageId) {
|
|
if (!todos || !Array.isArray(todos) || todos.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// Only update if this is for the current message or a new message
|
|
if (state.currentMessageId && state.currentMessageId !== messageId) {
|
|
console.log('[TODOS] Ignoring todos for different message', { current: state.currentMessageId, received: messageId });
|
|
return;
|
|
}
|
|
|
|
state.todos = todos;
|
|
state.currentMessageId = messageId;
|
|
|
|
// Remove existing todo container
|
|
const existingContainer = document.querySelector('.todo-container');
|
|
if (existingContainer) {
|
|
existingContainer.remove();
|
|
}
|
|
|
|
// Find the latest assistant message to append todos to
|
|
const assistantMessages = document.querySelectorAll('.message.assistant');
|
|
if (assistantMessages.length === 0) {
|
|
console.log('[TODOS] No assistant message found to attach todos to');
|
|
return;
|
|
}
|
|
|
|
const latestMessage = assistantMessages[assistantMessages.length - 1];
|
|
const todoContainer = renderStructuredTodos(todos);
|
|
if (todoContainer) {
|
|
latestMessage.appendChild(todoContainer);
|
|
console.log('[TODOS] Updated todos in UI', { count: todos.length, messageId });
|
|
}
|
|
}
|
|
|
|
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.id || m.name || 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';
|
|
}
|
|
|
|
function updateModelSelectDisplay(selectedValue) {
|
|
if (!el.modelSelectText) return;
|
|
|
|
const model = state.models.find((m) => (m.id || m.name || m) === selectedValue);
|
|
|
|
if (selectedValue === 'auto') {
|
|
el.modelSelectText.textContent = 'Auto (admin managed)';
|
|
if (el.modelSelectMultiplier) { el.modelSelectMultiplier.textContent = ''; el.modelSelectMultiplier.style.display = 'none'; }
|
|
renderModelIcon(null);
|
|
} else if (model) {
|
|
el.modelSelectText.textContent = model.label || model.name || selectedValue;
|
|
if (el.modelSelectMultiplier) { el.modelSelectMultiplier.textContent = `${model.multiplier || 1}x`; el.modelSelectMultiplier.style.display = 'inline-block'; }
|
|
renderModelIcon(selectedValue);
|
|
} else {
|
|
el.modelSelectText.textContent = 'Select model';
|
|
if (el.modelSelectMultiplier) { el.modelSelectMultiplier.textContent = ''; el.modelSelectMultiplier.style.display = 'none'; }
|
|
renderModelIcon(null);
|
|
}
|
|
}
|
|
|
|
function renderCustomDropdownOptions() {
|
|
if (!el.modelSelectOptions) {
|
|
console.warn('[renderCustomDropdownOptions] el.modelSelectOptions not found!');
|
|
return;
|
|
}
|
|
el.modelSelectOptions.innerHTML = '';
|
|
|
|
console.log(`[renderCustomDropdownOptions] Rendering ${state.models ? state.models.length : 0} models, current selection: ${state.selectedModelId || el.modelSelect.value}`);
|
|
|
|
if (!state.models || state.models.length === 0) {
|
|
const placeholder = document.createElement('div');
|
|
placeholder.className = 'model-option disabled';
|
|
placeholder.textContent = 'No models available';
|
|
el.modelSelectOptions.appendChild(placeholder);
|
|
return;
|
|
}
|
|
|
|
const currentSelection = state.selectedModelId || el.modelSelect.value;
|
|
|
|
state.models.forEach((model) => {
|
|
const option = document.createElement('div');
|
|
option.className = 'model-option';
|
|
option.dataset.value = model.id || model.name || model;
|
|
option.setAttribute('role', 'option');
|
|
|
|
// Add icon if available
|
|
if (model.icon) {
|
|
const icon = document.createElement('img');
|
|
icon.src = model.icon;
|
|
icon.alt = model.label || model.name || model.name;
|
|
option.appendChild(icon);
|
|
}
|
|
|
|
// Add text
|
|
const text = document.createElement('span');
|
|
text.className = 'model-option-text';
|
|
text.textContent = model.label || model.name || model.id || model;
|
|
option.appendChild(text);
|
|
|
|
// Add multiplier badge (e.g., "1x", "2x")
|
|
const mult = document.createElement('span');
|
|
mult.className = 'model-option-multiplier';
|
|
mult.textContent = `${model.multiplier || 1}x`;
|
|
mult.title = `Usage rate: ${model.multiplier || 1}x`;
|
|
option.appendChild(mult);
|
|
|
|
// Mark as selected if matches current selection
|
|
const modelId = model.id || model.name || model;
|
|
if (currentSelection === modelId) {
|
|
option.classList.add('selected');
|
|
console.log(`[renderCustomDropdownOptions] Marked model as selected: ${modelId}`);
|
|
}
|
|
|
|
// Click handler
|
|
option.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
console.log(`[renderCustomDropdownOptions] Model clicked: ${modelId}`);
|
|
selectModel(modelId);
|
|
});
|
|
|
|
el.modelSelectOptions.appendChild(option);
|
|
});
|
|
}
|
|
|
|
// Flag to track if model selection is programmatic (not user-triggered)
|
|
let programmaticModelChange = false;
|
|
|
|
// Flag to track when user manually changed model (prevent server from overwriting)
|
|
// This persists longer to survive polling cycles - cleared when user stops interacting
|
|
let userJustChangedModel = false;
|
|
let userModelChangeTimer = null;
|
|
|
|
function markUserModelChange() {
|
|
userJustChangedModel = true;
|
|
// Clear any existing timer
|
|
if (userModelChangeTimer) {
|
|
clearTimeout(userModelChangeTimer);
|
|
userModelChangeTimer = null;
|
|
}
|
|
// Set timer to clear the flag - this gives time for polling to complete
|
|
userModelChangeTimer = setTimeout(() => {
|
|
console.log(`[markUserModelChange] Timer expired, clearing userJustChangedModel (was true for 3s)`);
|
|
userJustChangedModel = false;
|
|
userModelChangeTimer = null;
|
|
}, 3000); // 3 seconds to survive polling cycles
|
|
}
|
|
|
|
function selectModel(modelId) {
|
|
// This is a user-triggered change via the custom dropdown (even though we dispatch a synthetic change event).
|
|
console.log(`[selectModel] User selected model: ${modelId}, setting userJustChangedModel=true`);
|
|
markUserModelChange();
|
|
state.selectedModelId = modelId;
|
|
|
|
programmaticModelChange = true;
|
|
el.modelSelect.value = modelId;
|
|
updateModelSelectDisplay(modelId);
|
|
closeCustomDropdown();
|
|
|
|
// Update selected state in options
|
|
const options = el.modelSelectOptions.querySelectorAll('.model-option');
|
|
options.forEach((opt) => {
|
|
if (opt.dataset.value === modelId) {
|
|
opt.classList.add('selected');
|
|
} else {
|
|
opt.classList.remove('selected');
|
|
}
|
|
});
|
|
|
|
// Manually trigger change event to sync with server
|
|
el.modelSelect.dispatchEvent(new Event('change'));
|
|
|
|
// Reset flag after event is dispatched - but let the timer handle final clearing
|
|
setTimeout(() => {
|
|
programmaticModelChange = false;
|
|
}, 100);
|
|
}
|
|
|
|
async function loadModels(cli = state.currentCli || 'opencode') {
|
|
try {
|
|
const previousSelection = state.selectedModelId || el.modelSelect?.value || '';
|
|
const previousSignature = state.modelsSignature;
|
|
|
|
state.currentCli = cli;
|
|
if (el.cliSelect && el.cliSelect.value !== cli) el.cliSelect.value = cli;
|
|
|
|
const data = await api(`/api/models?cli=${encodeURIComponent(cli)}`);
|
|
const nextModels = data.models || [];
|
|
const nextSignature = JSON.stringify(nextModels.map((m) => {
|
|
if (!m) return { id: '', label: '', icon: '', mult: 1 };
|
|
if (typeof m === 'string') return { id: m, label: m, icon: '', mult: 1 };
|
|
const id = m.id || m.name || '';
|
|
return {
|
|
id,
|
|
label: m.label || m.name || id,
|
|
icon: m.icon || '',
|
|
mult: m.multiplier || 1,
|
|
};
|
|
}));
|
|
|
|
state.models = nextModels;
|
|
state.modelsSignature = nextSignature;
|
|
|
|
const selectionToRestore = state.selectedModelId || el.modelSelect?.value || previousSelection;
|
|
|
|
console.log(`[loadModels] Loaded ${state.models.length} models for CLI "${cli}"`, {
|
|
modelsChanged: previousSignature !== nextSignature,
|
|
previousSelection,
|
|
selectionToRestore,
|
|
modelsCount: state.models.length,
|
|
});
|
|
|
|
el.modelSelect.innerHTML = '';
|
|
|
|
const optionValues = new Set();
|
|
|
|
// For free plans, populate custom dropdown for preview but keep select as "auto"
|
|
if (isFreePlan()) {
|
|
el.modelSelect.disabled = false;
|
|
renderModelIcon(null);
|
|
setStatus('');
|
|
|
|
state.models.forEach((m) => {
|
|
const option = document.createElement('option');
|
|
option.value = m.id || m.name || m;
|
|
option.textContent = m.label || m.name || m.id || m;
|
|
if (m.icon) option.dataset.icon = m.icon;
|
|
el.modelSelect.appendChild(option);
|
|
optionValues.add(option.value);
|
|
});
|
|
|
|
// Try to keep whatever the user previously selected (e.g. during async reloads)
|
|
// But don't overwrite if user just changed it manually
|
|
if (selectionToRestore && optionValues.has(selectionToRestore) && !userJustChangedModel) {
|
|
el.modelSelect.value = selectionToRestore;
|
|
}
|
|
|
|
if (state.models.length > 0) {
|
|
renderCustomDropdownOptions();
|
|
}
|
|
|
|
// For free plans, don't call applyPlanModelLock() here to avoid overwriting user selection
|
|
// The lock will be applied later when needed
|
|
// Only update state.selectedModelId if user hasn't just changed it
|
|
if (!userJustChangedModel) {
|
|
state.selectedModelId = el.modelSelect.value || state.selectedModelId;
|
|
updateModelSelectDisplay(state.selectedModelId);
|
|
} else {
|
|
// User just changed model, ensure display is in sync
|
|
updateModelSelectDisplay(el.modelSelect.value || state.selectedModelId);
|
|
}
|
|
updateUsageProgressBar();
|
|
return;
|
|
}
|
|
|
|
if (!state.models.length) {
|
|
el.modelSelect.disabled = true;
|
|
state.selectedModelId = null;
|
|
renderModelIcon(null);
|
|
setStatus('No models configured. Ask an admin to add models in the admin panel.');
|
|
applyPlanModelLock();
|
|
updateUsageProgressBar();
|
|
return;
|
|
}
|
|
|
|
el.modelSelect.disabled = false;
|
|
|
|
state.models.forEach((m) => {
|
|
const option = document.createElement('option');
|
|
option.value = m.id || m.name || m;
|
|
option.textContent = m.label || m.name || m.id || m;
|
|
if (m.icon) option.dataset.icon = m.icon;
|
|
el.modelSelect.appendChild(option);
|
|
optionValues.add(option.value);
|
|
});
|
|
|
|
const activeSessionModel = state.currentSessionId
|
|
? (state.sessions.find(s => s.id === state.currentSessionId)?.model || '')
|
|
: '';
|
|
|
|
const preferred = selectionToRestore || activeSessionModel;
|
|
|
|
// Don't overwrite user's selection if they just changed it
|
|
if (userJustChangedModel) {
|
|
console.log(`[loadModels] User just changed model (userJustChangedModel=true), preserving current selection: ${el.modelSelect.value}`);
|
|
// Still update state.selectedModelId to match current UI
|
|
state.selectedModelId = el.modelSelect.value || state.selectedModelId;
|
|
} else if (preferred && optionValues.has(preferred)) {
|
|
el.modelSelect.value = preferred;
|
|
} else if (activeSessionModel && optionValues.has(activeSessionModel)) {
|
|
el.modelSelect.value = activeSessionModel;
|
|
} else if (state.models.length > 0) {
|
|
el.modelSelect.value = state.models[0].id || state.models[0].name || state.models[0] || 'default';
|
|
}
|
|
|
|
// Preserve the user's model selection across reloads
|
|
const finalSelection = el.modelSelect.value || state.selectedModelId;
|
|
state.selectedModelId = finalSelection;
|
|
|
|
// Update the display first, then render options to ensure synchronization
|
|
updateModelSelectDisplay(finalSelection);
|
|
renderCustomDropdownOptions();
|
|
|
|
setStatus('');
|
|
applyPlanModelLock();
|
|
updateUsageProgressBar();
|
|
} catch (error) {
|
|
setStatus(`Model load failed: ${error.message}`);
|
|
state.models = [];
|
|
state.modelsSignature = null;
|
|
state.selectedModelId = null;
|
|
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();
|
|
updateUsageProgressBar();
|
|
}
|
|
}
|
|
|
|
// Load sessions list (builder-specific) with retry logic and detailed logging
|
|
const MAX_RETRIES = 3;
|
|
const BASE_DELAY_MS = 1000;
|
|
|
|
async function loadSessionsWithRetry(attempt = 1) {
|
|
console.log(`[BUILDER-SESSIONS] loadSessionsWithRetry called, attempt ${attempt}/${MAX_RETRIES}`);
|
|
try {
|
|
console.log(`[BUILDER-SESSIONS] Loading sessions from API... (attempt ${attempt})`);
|
|
const data = await api('/api/sessions');
|
|
console.log(`[BUILDER-SESSIONS] Sessions API response:`, {
|
|
sessionCount: data.sessions?.length || 0,
|
|
attempt,
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
state.sessions = data.sessions || [];
|
|
state.sessionsLoaded = true;
|
|
|
|
// Adopt CLI from sessions if not set
|
|
if (state.sessions.length && !state.currentCli) {
|
|
state.currentCli = state.sessions[0].cli || 'opencode';
|
|
if (el.cliSelect) el.cliSelect.value = state.currentCli;
|
|
console.log(`[BUILDER-SESSIONS] Adopted CLI from first session:`, state.currentCli);
|
|
}
|
|
|
|
renderSessions();
|
|
console.log(`[BUILDER-SESSIONS] Sessions rendered, count:`, state.sessions.length);
|
|
|
|
// If no current session, select the first available session
|
|
if (!state.currentSessionId && state.sessions.length) {
|
|
console.log(`[BUILDER-SESSIONS] No current session, selecting first available:`, state.sessions[0].id);
|
|
await selectSession(state.sessions[0].id);
|
|
} else if (!state.currentSessionId && !state.sessions.length) {
|
|
console.log(`[BUILDER-SESSIONS] No sessions available after loading`);
|
|
// Don't create a session here - let the sendMessage function handle that
|
|
}
|
|
// If state.currentSessionId is already set, use it (this is the normal case for plugin builder)
|
|
|
|
return { success: true, sessionCount: state.sessions.length };
|
|
} catch (err) {
|
|
console.error(`[BUILDER-SESSIONS] Failed to load sessions (attempt ${attempt}/${MAX_RETRIES}):`, {
|
|
error: err.message,
|
|
stack: err.stack,
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
|
|
if (attempt < MAX_RETRIES) {
|
|
const delayMs = BASE_DELAY_MS * Math.pow(2, attempt - 1); // Exponential backoff
|
|
console.log(`[BUILDER-SESSIONS] Retrying in ${delayMs}ms...`);
|
|
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
return loadSessionsWithRetry(attempt + 1);
|
|
}
|
|
|
|
// All retries exhausted - don't block initialization, but log the failure
|
|
console.error(`[BUILDER-SESSIONS] All ${MAX_RETRIES} retries exhausted, proceeding with empty sessions`);
|
|
state.sessions = state.sessions || [];
|
|
state.sessionsLoaded = true;
|
|
return { success: false, sessionCount: 0, error: err.message };
|
|
}
|
|
}
|
|
|
|
// Legacy wrapper for backward compatibility
|
|
async function loadSessions() {
|
|
return loadSessionsWithRetry(1);
|
|
}
|
|
|
|
// Expose for builder.html
|
|
window.loadSessions = loadSessions;
|
|
|
|
// Load current session data (fetch only the single session to avoid redundant work)
|
|
async function loadCurrentSession() {
|
|
if (!state.currentSessionId) return;
|
|
try {
|
|
const { session } = await api(`/api/sessions/${state.currentSessionId}`);
|
|
|
|
// Update sessions array with fresh session data
|
|
const idx = state.sessions.findIndex((s) => s.id === session.id);
|
|
if (idx >= 0) state.sessions[idx] = session;
|
|
else state.sessions.push(session);
|
|
|
|
renderSessionMeta(session);
|
|
renderMessages(session);
|
|
|
|
// Set up streaming for any running messages
|
|
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
|
|
if (running.length > 0) setPollingInterval(2000);
|
|
else setPollingInterval(5000);
|
|
|
|
return session;
|
|
} catch (error) {
|
|
setStatus(error.message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function restorePlanStateFromSession(session) {
|
|
if (!session) return;
|
|
|
|
const planMessages = Array.isArray(session.messages)
|
|
? session.messages.filter((m) => m.phase === 'plan')
|
|
: [];
|
|
const latestPlan = planMessages[planMessages.length - 1];
|
|
|
|
// Only restore lastPlanText from session if not already set in persisted state
|
|
if (!builderState.lastPlanText) {
|
|
if (session.planSummary) {
|
|
builderState.lastPlanText = session.planSummary;
|
|
} else if (latestPlan) {
|
|
builderState.lastPlanText = latestPlan.reply || latestPlan.partialOutput || latestPlan.content || '';
|
|
}
|
|
}
|
|
|
|
// Only restore lastUserRequest from session if not already set in persisted state
|
|
if (!builderState.lastUserRequest) {
|
|
if (session.planUserRequest) {
|
|
builderState.lastUserRequest = session.planUserRequest;
|
|
} else if (latestPlan?.content) {
|
|
builderState.lastUserRequest = latestPlan.content;
|
|
}
|
|
}
|
|
|
|
// Only restore planApproved from session if:
|
|
// 1. Not already true in persisted state (don't downgrade)
|
|
// 2. Session explicitly indicates plan is approved
|
|
if (!builderState.planApproved && typeof session.planApproved !== 'undefined') {
|
|
builderState.planApproved = !!session.planApproved;
|
|
}
|
|
}
|
|
|
|
function applyEntryMode(session) {
|
|
if (!session) return;
|
|
const isUploaded = session.entryMode === 'opencode' || session.source === 'upload';
|
|
|
|
// For uploaded apps, always use build mode
|
|
if (isUploaded) {
|
|
builderState.mode = 'build';
|
|
builderState.planApproved = true;
|
|
state.currentCli = 'opencode';
|
|
if (el.cliSelect) el.cliSelect.value = 'opencode';
|
|
updateBuildModeUI('build');
|
|
return;
|
|
}
|
|
|
|
// Check if app has EVER had build messages sent (plan was approved and executed)
|
|
// This means any session with this appId has messages with cli='opencode'
|
|
const appHasBeenBuilt = state.sessions.some(s =>
|
|
s.appId === session.appId &&
|
|
s.messages &&
|
|
s.messages.some(m => m.cli === 'opencode')
|
|
);
|
|
|
|
// Set mode based on app build history to ensure correct behavior when switching
|
|
// between apps (ignoring persisted state which may be stale from previous app)
|
|
if (appHasBeenBuilt) {
|
|
// App has been built before - all chats should use build mode
|
|
builderState.mode = 'build';
|
|
builderState.planApproved = true;
|
|
updateBuildModeUI('build');
|
|
} else {
|
|
// App has never been built - start in plan mode
|
|
builderState.mode = 'plan';
|
|
builderState.planApproved = false;
|
|
updateBuildModeUI('plan');
|
|
}
|
|
}
|
|
|
|
async function selectSession(id) {
|
|
console.log('[BUILDER] Selecting session:', id);
|
|
state.currentSessionId = id;
|
|
await selectSessionById(id);
|
|
}
|
|
|
|
// Expose for builder.html
|
|
window.selectSessionById = async function(id) {
|
|
console.log('[BUILDER] selectSessionById called with ID:', id);
|
|
console.log('[BUILDER] Available sessions:', state.sessions.map(s => ({ id: s.id, title: s.title })));
|
|
console.log('[BUILDER] Current session ID before selection:', state.currentSessionId);
|
|
console.log('[BUILDER] Chat area exists:', !!el.chatArea, 'in DOM:', el.chatArea && document.contains(el.chatArea));
|
|
|
|
const session = state.sessions.find(s => s.id === id);
|
|
if (!session) {
|
|
console.warn('[BUILDER] Session not found for ID:', id);
|
|
throw new Error('Session not found');
|
|
}
|
|
state.currentSessionId = session.id;
|
|
|
|
console.log('[BUILDER] Session selected by ID:', { id: session.id, appId: session.appId, title: session.title });
|
|
state.currentCli = session.cli || 'opencode';
|
|
if (el.cliSelect) el.cliSelect.value = state.currentCli;
|
|
|
|
try {
|
|
await loadModels(state.currentCli);
|
|
} catch (err) {
|
|
console.warn('[BUILDER] Failed to load models:', err);
|
|
}
|
|
|
|
restorePlanStateFromSession(session);
|
|
applyEntryMode(session);
|
|
|
|
console.log('[BUILDER] About to refresh current session from server...');
|
|
// Refresh session data from server and get fresh data
|
|
const freshSession = await refreshCurrentSession();
|
|
|
|
console.log('[BUILDER] Session refresh complete, freshSession:', freshSession ? 'yes' : 'no');
|
|
|
|
// Ensure that UI is updated with the new session
|
|
if (freshSession) {
|
|
console.log('[BUILDER] Rendering UI with fresh session data');
|
|
renderSessionMeta(freshSession);
|
|
renderMessages(freshSession);
|
|
// Wait for content to fully render before scrolling
|
|
// Use multiple delays to catch content that loads asynchronously
|
|
setTimeout(() => scrollChatToBottom(), 100);
|
|
setTimeout(() => scrollChatToBottom(), 500);
|
|
setTimeout(() => scrollChatToBottom(), 1000);
|
|
} else {
|
|
// If refresh failed, use the local session data instead
|
|
console.warn('[BUILDER] Using local session data for UI render');
|
|
renderSessionMeta(session);
|
|
renderMessages(session);
|
|
// Wait for content to fully render before scrolling
|
|
setTimeout(() => scrollChatToBottom(), 100);
|
|
setTimeout(() => scrollChatToBottom(), 500);
|
|
setTimeout(() => scrollChatToBottom(), 1000);
|
|
}
|
|
|
|
// Update URL to reflect the new session (but don't reload page)
|
|
const url = new URL(window.location);
|
|
url.searchParams.set('session', session.id);
|
|
window.history.replaceState({}, '', url);
|
|
console.log('[BUILDER] Updated URL to:', url.href);
|
|
|
|
console.log('[BUILDER] Session selection complete, new currentSessionId:', state.currentSessionId);
|
|
return session;
|
|
};
|
|
|
|
// Expose for builder.html
|
|
window.selectSession = selectSession;
|
|
|
|
// 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 = async (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') {
|
|
// Server is restarting, notify user and prepare for reconnection
|
|
message.status = 'queued';
|
|
setStatus('Server restarting, your session will be restored...');
|
|
renderMessages(session);
|
|
// Keep the connection open to allow automatic reconnection
|
|
return;
|
|
} else if (data.type === 'start') {
|
|
message.status = 'running';
|
|
setStatus('OpenCode is responding...');
|
|
startUsagePolling(); // Start aggressive polling when OpenCode starts
|
|
// Keep loading indicator spinning - don't hide when OpenCode starts
|
|
// Clear todos when a new message starts
|
|
clearTodos();
|
|
} 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';
|
|
|
|
// Keep loading indicator spinning during streaming - don't hide on first chunk
|
|
|
|
// Re-render messages to show new content immediately
|
|
renderMessages(session);
|
|
setStatus('Streaming response...');
|
|
} else if (data.type === 'todos') {
|
|
// Handle todo updates from OpenCode
|
|
if (data.todos && Array.isArray(data.todos)) {
|
|
message.todos = data.todos;
|
|
updateTodos(data.todos, messageId);
|
|
console.log('[TODOS] Received todo update via SSE', { count: data.todos.length, messageId });
|
|
}
|
|
} 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);
|
|
|
|
// Clear todos when message completes (to start fresh for next message)
|
|
clearTodos();
|
|
|
|
renderMessages(session);
|
|
setStatus('Complete');
|
|
|
|
// Update usage meter immediately when opencode completes
|
|
stopUsagePolling(); // Stop aggressive polling
|
|
|
|
// Immediate usage update for better UX
|
|
await loadUsageSummary().catch(err => {
|
|
console.warn('[USAGE] Immediate usage update failed, will retry:', err.message);
|
|
});
|
|
|
|
// Final update after delay to ensure server persistence
|
|
setTimeout(() => {
|
|
loadUsageSummary().catch(err => {
|
|
console.warn('[USAGE] Final usage update failed:', err.message);
|
|
});
|
|
}, 2000);
|
|
|
|
// Restart polling after message completes to keep usage meter updating
|
|
startUsagePolling();
|
|
|
|
// Hide loading indicator only when message is confirmed as done
|
|
if (message.status === 'done') {
|
|
hideLoadingIndicator();
|
|
}
|
|
|
|
// Auto-scroll to bottom after message completes
|
|
scrollChatToBottom();
|
|
|
|
// Update session list (no-op in builder)
|
|
renderSessions();
|
|
} else if (data.type === 'error') {
|
|
message.error = data.error || 'Unknown error';
|
|
message.reply = data.content || message.partialOutput || '';
|
|
message.status = 'error';
|
|
message.finishedAt = data.timestamp;
|
|
message.outputType = data.outputType;
|
|
message.opencodeExitCode = data.exitCode;
|
|
|
|
eventSource.close();
|
|
state.activeStreams.delete(messageId);
|
|
|
|
// Clear todos on error
|
|
clearTodos();
|
|
|
|
renderMessages(session);
|
|
scrollChatToBottom();
|
|
|
|
if (!message.isBackgroundContinuation) {
|
|
setStatus('Error: ' + (data.error || 'Unknown error'));
|
|
}
|
|
|
|
stopUsagePolling();
|
|
|
|
// Update usage on error too
|
|
await loadUsageSummary().catch(err => {
|
|
console.warn('[USAGE] Usage update after error failed:', err.message);
|
|
});
|
|
setTimeout(() => {
|
|
loadUsageSummary().catch(err => {
|
|
console.warn('[USAGE] Final usage update after error failed:', err.message);
|
|
});
|
|
}, 2000);
|
|
|
|
// Restart polling after message completes/errors to keep usage meter updating
|
|
startUsagePolling();
|
|
|
|
// Hide loading indicator only when message is confirmed as error
|
|
if (message.status === 'error') {
|
|
hideLoadingIndicator();
|
|
}
|
|
renderSessions();
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to parse SSE message', err);
|
|
}
|
|
};
|
|
|
|
eventSource.onerror = async (err) => {
|
|
console.error('SSE error', err);
|
|
eventSource.close();
|
|
state.activeStreams.delete(messageId);
|
|
|
|
// Check if server is restarting by examining message status
|
|
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
|
|
console.log('Server restart detected, attempting reconnection...', { messageId, sessionId });
|
|
let reconnectAttempts = 0;
|
|
const maxReconnectAttempts = 30; // 30 seconds max
|
|
const reconnectInterval = setInterval(async () => {
|
|
reconnectAttempts++;
|
|
try {
|
|
const { session: freshSession } = await api(`/api/sessions/${sessionId}`);
|
|
const freshMessage = freshSession?.messages?.find(m => m.id === messageId);
|
|
|
|
if (freshMessage && freshMessage.status !== 'queued') {
|
|
clearInterval(reconnectInterval);
|
|
const idx = state.sessions.findIndex(s => s.id === sessionId);
|
|
if (idx !== -1) state.sessions[idx] = freshSession;
|
|
renderSessionMeta(freshSession);
|
|
renderMessages(freshSession);
|
|
scrollChatToBottom();
|
|
setStatus('Reconnected to server');
|
|
console.log('Successfully reconnected after server restart');
|
|
}
|
|
} catch (e) {
|
|
// Server still down
|
|
if (reconnectAttempts >= maxReconnectAttempts) {
|
|
clearInterval(reconnectInterval);
|
|
if (message) {
|
|
message.status = 'error';
|
|
message.error = 'Server restart took too long. Please refresh and try again.';
|
|
renderMessages(session);
|
|
scrollChatToBottom();
|
|
}
|
|
setStatus('Server reconnection failed');
|
|
}
|
|
}
|
|
}, 1000);
|
|
return;
|
|
}
|
|
|
|
// When SSE connection fails, determine if it's a user action or server issue
|
|
// Check if page is visible - if not, likely user left page
|
|
const pageVisible = !document.hidden;
|
|
const userLeftPage = !pageVisible;
|
|
|
|
console.log('SSE connection failed', {
|
|
messageId,
|
|
sessionId,
|
|
pageVisible,
|
|
userLeftPage,
|
|
messageStatus: message?.status
|
|
});
|
|
|
|
// Stop usage polling and update usage for non-restart failures
|
|
stopUsagePolling();
|
|
await loadUsageSummary().catch(err => {
|
|
console.warn('[USAGE] Usage update after SSE error failed:', err.message);
|
|
});
|
|
// Restart polling to keep usage meter updated
|
|
startUsagePolling();
|
|
|
|
// First, refresh the session to get the latest state from the server
|
|
// This handles the case where the message actually completed but the
|
|
// client missed the final event (e.g., user slept their device)
|
|
(async () => {
|
|
try {
|
|
const { session: freshSession } = await api(`/api/sessions/${sessionId}`);
|
|
const freshMessage = freshSession?.messages?.find(m => m.id === messageId);
|
|
|
|
if (freshMessage && (freshMessage.status === 'done' || freshMessage.status === 'error')) {
|
|
console.log('Message completed on server, displaying final state', {
|
|
messageId,
|
|
status: freshMessage.status,
|
|
exitCode: freshMessage.opencodeExitCode,
|
|
tokenExtractionFailed: freshMessage.tokenExtractionFailed,
|
|
potentiallyIncomplete: freshMessage.potentiallyIncomplete
|
|
});
|
|
// Message is actually done, just refresh to show the final state
|
|
const idx = state.sessions.findIndex(s => s.id === sessionId);
|
|
if (idx !== -1) state.sessions[idx] = freshSession;
|
|
renderSessionMeta(freshSession);
|
|
renderMessages(freshSession);
|
|
|
|
// Hide loading indicator only when message is confirmed as done or error
|
|
if (freshMessage.status === 'done' || freshMessage.status === 'error') {
|
|
hideLoadingIndicator();
|
|
}
|
|
|
|
// Detect server-side errors that need recovery
|
|
// This includes:
|
|
// 1. Token extraction failure (indicates server-side issue)
|
|
// 2. Message marked as potentially incomplete
|
|
// 3. Actual OpenCode errors with non-zero exit codes
|
|
const tokenExtractionFailed = freshMessage.tokenExtractionFailed || false;
|
|
const potentiallyIncomplete = freshMessage.potentiallyIncomplete || false;
|
|
const partialOutputLength = freshMessage.partialOutput?.length || 0;
|
|
const hasSubstantialOutput = partialOutputLength > 500;
|
|
const isTinyOutput = partialOutputLength > 0 && partialOutputLength <= 50;
|
|
const connectionErrorCodes = [null, undefined, 'ECONNRESET', 'ETIMEDOUT', 'ENOTCONN'];
|
|
const isConnectionError = connectionErrorCodes.includes(freshMessage.opencodeExitCode) ||
|
|
(typeof freshMessage.opencodeExitCode === 'string' &&
|
|
freshMessage.opencodeExitCode.includes('timeout'));
|
|
|
|
// Check if this is a server-side error that needs continuation
|
|
// Server-side errors (token extraction failure) should trigger continuation even if page is not visible
|
|
// because server-side processing continues independently of client visibility
|
|
const isServerError = tokenExtractionFailed || potentiallyIncomplete;
|
|
const isOpencodeError = freshMessage.status === 'error' &&
|
|
freshMessage.opencodeExitCode &&
|
|
freshMessage.opencodeExitCode !== 0;
|
|
|
|
if ((isServerError && !hasSubstantialOutput) ||
|
|
(pageVisible && isOpencodeError && !isConnectionError && !hasSubstantialOutput) ||
|
|
(pageVisible && isTinyOutput && !isConnectionError)) {
|
|
console.log('Server-side error detected, triggering automatic continuation', {
|
|
messageId,
|
|
status: freshMessage.status,
|
|
isServerError,
|
|
tokenExtractionFailed,
|
|
potentiallyIncomplete,
|
|
isOpencodeError,
|
|
exitCode: freshMessage.opencodeExitCode,
|
|
partialOutputLength,
|
|
isTinyOutput,
|
|
hasSubstantialOutput,
|
|
isConnectionError,
|
|
pageVisible
|
|
});
|
|
// Only trigger continuation if page is visible to avoid UI issues when user left
|
|
if (pageVisible) {
|
|
setTimeout(() => handleEarlyTermination(sessionId, messageId), 500);
|
|
}
|
|
} else if (!pageVisible) {
|
|
console.log('Page not visible, skipping automatic UI actions (server-side processing continues)', {
|
|
messageId,
|
|
pageVisible,
|
|
tokenExtractionFailed,
|
|
potentiallyIncomplete
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
// If message is still running on server, just poll - don't auto-retry
|
|
console.log('Message still running on server, will continue polling', {
|
|
messageId,
|
|
status: freshMessage?.status
|
|
});
|
|
setTimeout(() => refreshCurrentSession(), 1000);
|
|
} catch (e) {
|
|
console.error('Failed to check server state, will poll for updates', e);
|
|
setTimeout(() => refreshCurrentSession(), 1000);
|
|
}
|
|
})();
|
|
};
|
|
|
|
state.activeStreams.set(messageId, eventSource);
|
|
}
|
|
|
|
async function handleEarlyTermination(sessionId, messageId) {
|
|
const session = state.sessions.find(s => s.id === sessionId);
|
|
const message = session?.messages?.find(m => m.id === messageId);
|
|
|
|
if (!message) {
|
|
console.log('handleEarlyTermination: message not found', { messageId });
|
|
return;
|
|
}
|
|
|
|
// Allow handling of messages marked as 'done' but potentially incomplete
|
|
// This catches cases where token extraction failed but message was still marked done
|
|
const isNormalCompletion = message.status === 'done' && !message.potentiallyIncomplete;
|
|
const isError = message.status === 'error';
|
|
|
|
if (isNormalCompletion || isError) {
|
|
console.log('handleEarlyTermination: skipping - message is already completed or in error state', {
|
|
messageId,
|
|
status: message.status,
|
|
potentiallyIncomplete: message.potentiallyIncomplete
|
|
});
|
|
return;
|
|
}
|
|
|
|
console.log('Handling early termination', {
|
|
messageId,
|
|
partialLength: message.partialOutput?.length,
|
|
status: message.status,
|
|
potentiallyIncomplete: message.potentiallyIncomplete,
|
|
tokenExtractionFailed: message.tokenExtractionFailed
|
|
});
|
|
|
|
// Don't trigger client-side model fallback - let the server handle fallback through its proper chain
|
|
// The server fallback sequence: preferred model -> all configured models -> default -> ultimate backup model
|
|
// Client-side fallback was bypassing this chain and jumping straight to the ultimate backup model
|
|
console.log('Early termination detected, letting server handle fallback', {
|
|
messageId,
|
|
status: message.status
|
|
});
|
|
setTimeout(() => refreshCurrentSession(), 1000);
|
|
}
|
|
|
|
function checkFallbackCriteria(message) {
|
|
// Only trigger fallback on actual opencode errors with non-zero exit codes
|
|
// Don't trigger on partial output or SSE disconnections
|
|
const isActualError = message.status === 'error';
|
|
const hasErrorExitCode = message.opencodeExitCode && message.opencodeExitCode !== 0;
|
|
|
|
// Check for token extraction failure (server-side error)
|
|
// This indicates a problem with streaming/tracking, not with user leaving the page
|
|
const tokenExtractionFailed = message.tokenExtractionFailed || false;
|
|
const potentiallyIncomplete = message.potentiallyIncomplete || false;
|
|
const isServerError = tokenExtractionFailed || potentiallyIncomplete;
|
|
|
|
// Check if message has substantial output (indicates model was working fine)
|
|
const partialOutputLength = message.partialOutput?.length || 0;
|
|
const hasSubstantialOutput = partialOutputLength > 500;
|
|
const isTinyOutput = partialOutputLength > 0 && partialOutputLength <= 50;
|
|
|
|
// Don't trigger if message has substantial output - model was working fine
|
|
if (hasSubstantialOutput) {
|
|
console.log('Skipping fallback - message has substantial output', {
|
|
messageId: message.id,
|
|
partialOutputLength,
|
|
status: message.status
|
|
});
|
|
return false;
|
|
}
|
|
|
|
// Check for connection errors that shouldn't trigger fallback
|
|
const connectionErrorCodes = [null, undefined, 'ECONNRESET', 'ETIMEDOUT', 'ENOTCONN'];
|
|
const isConnectionError = connectionErrorCodes.includes(message.opencodeExitCode) ||
|
|
(typeof message.opencodeExitCode === 'string' &&
|
|
message.opencodeExitCode.includes('timeout'));
|
|
|
|
// Don't trigger on connection errors (user left page, network issues)
|
|
if (isConnectionError) {
|
|
console.log('Skipping fallback - connection error detected', {
|
|
messageId: message.id,
|
|
exitCode: message.opencodeExitCode,
|
|
isConnectionError
|
|
});
|
|
return false;
|
|
}
|
|
|
|
console.log('Checking fallback criteria', {
|
|
messageId: message.id,
|
|
status: message.status,
|
|
exitCode: message.opencodeExitCode,
|
|
isActualError,
|
|
hasErrorExitCode,
|
|
tokenExtractionFailed,
|
|
potentiallyIncomplete,
|
|
isServerError,
|
|
hasSubstantialOutput,
|
|
partialOutputLength,
|
|
isTinyOutput,
|
|
isConnectionError
|
|
});
|
|
|
|
// Trigger fallback for:
|
|
// 1. Actual opencode errors with non-zero exit codes
|
|
// 2. Server-side token extraction failures (indicates streaming issue)
|
|
// 3. Tiny outputs (<= 50 chars) which commonly indicate incomplete responses
|
|
return (isActualError && hasErrorExitCode) || isServerError || isTinyOutput;
|
|
}
|
|
|
|
async function triggerModelFallback(messageId, session) {
|
|
const message = session.messages.find(m => m.id === messageId);
|
|
if (!message) return;
|
|
|
|
if (message.failoverAttempts?.length >= 2) {
|
|
console.log('Max fallback attempts reached', { messageId });
|
|
setTimeout(() => refreshCurrentSession(), 1000);
|
|
return;
|
|
}
|
|
|
|
const currentModel = message.model;
|
|
const backupModel = getBackupModel(currentModel);
|
|
|
|
if (!backupModel) {
|
|
console.log('No backup model available', { currentModel });
|
|
setTimeout(() => refreshCurrentSession(), 1000);
|
|
return;
|
|
}
|
|
|
|
console.log('Switching to backup model', {
|
|
messageId,
|
|
from: currentModel,
|
|
to: backupModel
|
|
});
|
|
|
|
const continuationContent = 'continue to ensure that the previous request is fully completed';
|
|
|
|
try {
|
|
// Preserve the opencodeSessionId to continue in the same session
|
|
// Priority order: session.opencodeSessionId > message.opencodeSessionId > session.initialOpencodeSessionId
|
|
const preservedSessionId = session?.opencodeSessionId || message.opencodeSessionId || session?.initialOpencodeSessionId;
|
|
|
|
const payload = {
|
|
content: continuationContent,
|
|
displayContent: '',
|
|
model: backupModel,
|
|
cli: 'opencode',
|
|
isContinuation: true,
|
|
isBackgroundContinuation: true,
|
|
originalMessageId: messageId
|
|
};
|
|
|
|
// Preserve the opencodeSessionId for session continuity
|
|
if (preservedSessionId) {
|
|
payload.opencodeSessionId = preservedSessionId;
|
|
console.log('[FALLBACK] Preserving opencodeSessionId:', preservedSessionId);
|
|
} else {
|
|
console.warn('[FALLBACK] No opencodeSessionId available for continuation');
|
|
}
|
|
|
|
// Preserve workspaceDir for file location continuity
|
|
if (session.workspaceDir) {
|
|
payload.workspaceDir = session.workspaceDir;
|
|
}
|
|
|
|
console.log('Triggering continuation with preserved context', {
|
|
messageId,
|
|
sessionId: session.id,
|
|
opencodeSessionId: payload.opencodeSessionId,
|
|
workspaceDir: payload.workspaceDir,
|
|
model: backupModel
|
|
});
|
|
|
|
const response = await api(`/api/sessions/${session.id}/messages`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload)
|
|
});
|
|
|
|
if (response.message) {
|
|
streamMessage(session.id, response.message.id);
|
|
message.status = 'superseded';
|
|
message.supersededBy = response.message.id;
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to trigger fallback', error);
|
|
setTimeout(() => refreshCurrentSession(), 1000);
|
|
}
|
|
}
|
|
|
|
async function handleServerCutOff(session, message) {
|
|
// This function is called when a message transitions from 'running' to 'queued'
|
|
// indicating that the server-side OpenCode session was cut off
|
|
|
|
// Don't handle if message is already done or has substantial output
|
|
if (message.status === 'done' || message.status === 'error') {
|
|
console.log('[SERVER_CUTOFF] Message already finished, skipping', message.id);
|
|
return;
|
|
}
|
|
|
|
// Check if message has substantial partial output (likely near completion)
|
|
const partialOutputLength = (message.partialOutput || '').length;
|
|
if (partialOutputLength > 1000) {
|
|
console.log('[SERVER_CUTOFF] Message has substantial output, likely near completion', {
|
|
messageId: message.id,
|
|
partialOutputLength
|
|
});
|
|
return;
|
|
}
|
|
|
|
console.log('[SERVER_CUTOFF] Automatically continuing implementation after server cut off', {
|
|
messageId: message.id,
|
|
sessionId: session.id,
|
|
partialOutputLength
|
|
});
|
|
|
|
// Preserve the opencodeSessionId to continue in the same session
|
|
// Priority order: session.opencodeSessionId > message.opencodeSessionId > session.initialOpencodeSessionId
|
|
const preservedSessionId = session?.opencodeSessionId || message.opencodeSessionId || session?.initialOpencodeSessionId;
|
|
|
|
const payload = {
|
|
content: 'continue to ensure that the previous request is fully completed',
|
|
displayContent: '',
|
|
model: message.model || session.model,
|
|
cli: 'opencode',
|
|
isContinuation: true,
|
|
isBackgroundContinuation: true, // Don't show this to user
|
|
isServerCutOffContinuation: true,
|
|
originalMessageId: message.id
|
|
};
|
|
|
|
// Preserve the opencodeSessionId for session continuity
|
|
if (preservedSessionId) {
|
|
payload.opencodeSessionId = preservedSessionId;
|
|
console.log('[SERVER_CUTOFF] Preserving opencodeSessionId:', preservedSessionId);
|
|
} else {
|
|
console.warn('[SERVER_CUTOFF] No opencodeSessionId available for continuation');
|
|
}
|
|
|
|
// Preserve workspaceDir for file location continuity
|
|
if (session.workspaceDir) {
|
|
payload.workspaceDir = session.workspaceDir;
|
|
}
|
|
|
|
try {
|
|
const response = await api(`/api/sessions/${session.id}/messages`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload)
|
|
});
|
|
|
|
if (response.message) {
|
|
console.log('[SERVER_CUTOFF] Continuation message created', response.message.id);
|
|
// Start streaming the continuation message
|
|
streamMessage(session.id, response.message.id);
|
|
// Mark original message as superseded
|
|
message.status = 'superseded';
|
|
message.supersededBy = response.message.id;
|
|
}
|
|
} catch (error) {
|
|
console.error('[SERVER_CUTOFF] Failed to create continuation message', error);
|
|
}
|
|
}
|
|
|
|
function getBackupModel(currentModel) {
|
|
const normalize = (m) => (m && (m.id || m.name || m)) ? String(m.id || m.name || m).trim() : '';
|
|
const configuredModels = (state.models || []).map(normalize).filter(Boolean);
|
|
const current = (currentModel || '').trim();
|
|
|
|
const preferredBackup = normalize(window.providerLimits?.opencodeBackupModel || '');
|
|
|
|
// If we have a preferred backup model, use it if it's different from current
|
|
// Don't require it to be in configured models since OpenCode CLI can access models directly
|
|
if (preferredBackup && preferredBackup !== current) {
|
|
return preferredBackup;
|
|
}
|
|
|
|
if (!configuredModels.length) return null;
|
|
|
|
// If current model is auto/default or not in list, pick the first configured model
|
|
if (!current || current === 'auto' || current === 'default' || !configuredModels.includes(current)) {
|
|
return configuredModels[0];
|
|
}
|
|
|
|
// Pick the first different model as backup
|
|
const fallback = configuredModels.find((m) => m !== current);
|
|
return fallback || configuredModels[0];
|
|
}
|
|
|
|
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 activeStatuses = new Set(['running', 'queued']);
|
|
const completedStatuses = new Set(['done', 'error']);
|
|
const old = state.sessions.find((s) => s.id === session.id);
|
|
const oldStatuses = new Map();
|
|
if (old && Array.isArray(old.messages)) {
|
|
old.messages.forEach((msg) => {
|
|
if (activeStatuses.has(msg.status)) {
|
|
oldStatuses.set(msg.id, msg.status);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Detect messages that transitioned from 'running' to 'queued' - this indicates server cut off
|
|
const transitionedToQueued = [];
|
|
if (old && Array.isArray(old.messages) && Array.isArray(session.messages)) {
|
|
session.messages.forEach((newMsg) => {
|
|
const oldMsg = old.messages.find(m => m.id === newMsg.id);
|
|
if (oldMsg && oldMsg.status === 'running' && newMsg.status === 'queued') {
|
|
console.log('[DETECTION] Message transitioned from running to queued - server cut off detected', {
|
|
messageId: newMsg.id,
|
|
oldStatus: oldMsg.status,
|
|
newStatus: newMsg.status
|
|
});
|
|
transitionedToQueued.push(newMsg);
|
|
}
|
|
});
|
|
}
|
|
|
|
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));
|
|
// Append any temp messages that the server hasn't returned yet
|
|
tempMsgs.forEach((m) => {
|
|
if (!existingIds.has(m.id)) session.messages.push(m);
|
|
});
|
|
|
|
// De-duplicate: if server already returned a real (non-temp) message with the same content,
|
|
// drop the temp message to avoid duplicates
|
|
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;
|
|
});
|
|
|
|
// Ensure messages are time-ordered
|
|
session.messages.sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0));
|
|
}
|
|
|
|
const idx = state.sessions.findIndex((s) => s.id === session.id);
|
|
if (idx === -1) state.sessions.unshift(session);
|
|
else state.sessions[idx] = session;
|
|
renderSessions();
|
|
renderSessionMeta(session);
|
|
renderMessages(session);
|
|
|
|
const completedMessage = oldStatuses.size > 0 && (session.messages || []).some((msg) => (
|
|
oldStatuses.has(msg.id) && completedStatuses.has(msg.status)
|
|
));
|
|
if (completedMessage) {
|
|
loadUsageSummary().catch((err) => {
|
|
console.warn('[USAGE] Usage update after completion failed:', err.message);
|
|
});
|
|
}
|
|
|
|
// Handle messages that transitioned to queued - indicates server cut off
|
|
if (transitionedToQueued.length > 0) {
|
|
for (const msg of transitionedToQueued) {
|
|
console.log('[DETECTION] Handling server cut off for message', msg.id);
|
|
await handleServerCutOff(session, msg);
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
|
|
// Return the session so callers can access fresh data
|
|
return session;
|
|
} catch (error) {
|
|
setStatus(error.message);
|
|
console.error('Failed to refresh session:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Expose for builder.html
|
|
window.refreshCurrentSession = refreshCurrentSession;
|
|
|
|
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;
|
|
// Sessions are used for both planning (OpenRouter) and building (OpenCode).
|
|
// Planning should work even before a model is selected/configured, so fall back
|
|
// to a server-side default model instead of blocking session creation.
|
|
const model = state.selectedModelId || (el.modelSelect && el.modelSelect.value) || (isFreePlan() ? 'auto' : 'default');
|
|
state.selectedModelId = model || state.selectedModelId;
|
|
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.title) payload.title = options.title;
|
|
if (options.appId) {
|
|
payload.appId = options.appId;
|
|
payload.reuseAppId = true;
|
|
}
|
|
let session;
|
|
try {
|
|
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;
|
|
}
|
|
|
|
// Clear builder state for new session (this also clears persisted state)
|
|
if (typeof window.clearBuilderState === 'function') {
|
|
window.clearBuilderState();
|
|
} else {
|
|
// Fallback if clearBuilderState not yet defined
|
|
builderState.mode = 'plan';
|
|
builderState.planApproved = false;
|
|
builderState.lastUserRequest = '';
|
|
builderState.lastPlanText = '';
|
|
}
|
|
|
|
state.sessions.unshift(session);
|
|
renderSessions();
|
|
// Render session meta immediately with newly created session
|
|
renderSessionMeta(session);
|
|
|
|
// Set current session ID directly instead of calling selectSession
|
|
// This avoids refreshing from server which might not have the new session yet
|
|
state.currentSessionId = session.id;
|
|
|
|
// Set up CLI and model from the session
|
|
state.currentCli = session.cli || 'opencode';
|
|
if (el.cliSelect) el.cliSelect.value = state.currentCli;
|
|
|
|
// Load models for the session's CLI
|
|
await loadModels(state.currentCli);
|
|
|
|
// Restore builder state from the new session
|
|
restorePlanStateFromSession(session);
|
|
applyEntryMode(session);
|
|
|
|
// Clear the chat area for the new session
|
|
if (el.chatArea) {
|
|
el.chatArea.innerHTML = '<div class="muted empty-message">Send a message to start the conversation.</div>';
|
|
}
|
|
|
|
// Scroll to bottom of chat
|
|
scrollChatToBottom();
|
|
}
|
|
|
|
// Expose for builder.html
|
|
window.createSession = createSession;
|
|
|
|
// Helper function to ensure we have a valid current session
|
|
async function ensureCurrentSession() {
|
|
console.log('[BUILDER] ensureCurrentSession called:', {
|
|
currentSessionId: state.currentSessionId,
|
|
sessionsCount: state.sessions?.length || 0
|
|
});
|
|
|
|
if (state.currentSessionId) {
|
|
// Check if the current session still exists in our sessions array
|
|
const existingSession = state.sessions?.find(s => s.id === state.currentSessionId);
|
|
if (existingSession) {
|
|
console.log('[BUILDER] Current session is valid:', state.currentSessionId);
|
|
return existingSession;
|
|
} else {
|
|
console.log('[BUILDER] Current session ID exists but session not found in local array');
|
|
}
|
|
}
|
|
|
|
// Try to use an existing session from the sessions array
|
|
if (state.sessions && state.sessions.length > 0) {
|
|
console.log('[BUILDER] Selecting first available session:', state.sessions[0].id);
|
|
await selectSession(state.sessions[0].id);
|
|
return state.sessions[0];
|
|
}
|
|
|
|
// No sessions exist, create a new one
|
|
console.log('[BUILDER] No sessions available, creating new session');
|
|
await createSession();
|
|
return state.sessions[0];
|
|
}
|
|
|
|
// Expose for builder.html
|
|
window.ensureCurrentSession = ensureCurrentSession;
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
// Message sending prevention constants
|
|
const LAST_SENT_MESSAGE_KEY = 'builder_last_sent_message';
|
|
const LAST_SENT_MAX_AGE_MS = 5000; // Prevent resending same message within 5 seconds
|
|
|
|
function getLastSentMessage() {
|
|
try {
|
|
const raw = localStorage.getItem(LAST_SENT_MESSAGE_KEY);
|
|
if (!raw) return null;
|
|
const data = JSON.parse(raw);
|
|
if (!data || !data.content) return null;
|
|
// Check if cache is expired
|
|
if (Date.now() - data.timestamp > LAST_SENT_MAX_AGE_MS) {
|
|
localStorage.removeItem(LAST_SENT_MESSAGE_KEY);
|
|
return null;
|
|
}
|
|
return data;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function setLastSentMessage(content) {
|
|
try {
|
|
const data = {
|
|
content: content,
|
|
timestamp: Date.now()
|
|
};
|
|
localStorage.setItem(LAST_SENT_MESSAGE_KEY, JSON.stringify(data));
|
|
} catch (e) {
|
|
console.warn('Failed to set last sent message:', e);
|
|
}
|
|
}
|
|
|
|
async function sendMessage() {
|
|
const content = el.messageInput.value.trim();
|
|
if (!content && !pendingAttachments.length) return;
|
|
|
|
// Prevent duplicate sends: check if this exact message was recently sent
|
|
const lastSent = getLastSentMessage();
|
|
if (lastSent && lastSent.content === content) {
|
|
console.log('[BUILDER] Ignoring duplicate message send attempt - message was recently sent');
|
|
el.messageInput.value = '';
|
|
clearMessageInputCache();
|
|
return;
|
|
}
|
|
|
|
const displayContent = content;
|
|
const cli = el.cliSelect ? el.cliSelect.value : (state.currentCli || 'opencode');
|
|
state.currentCli = cli;
|
|
|
|
el.messageInput.value = '';
|
|
el.messageInput.style.height = 'auto';
|
|
clearMessageInputCache();
|
|
|
|
// Track this message as sent to prevent duplicates
|
|
setLastSentMessage(content);
|
|
|
|
const model = state.selectedModelId || el.modelSelect?.value || (isFreePlan() ? 'auto' : 'default');
|
|
if (!model) {
|
|
setStatus('Select a model configured by your admin');
|
|
return;
|
|
}
|
|
|
|
const remainingTokens = state.usageSummary?.remaining || 0;
|
|
if (remainingTokens <= 5000) {
|
|
const modal = document.getElementById('token-limit-modal');
|
|
if (modal) {
|
|
modal.style.display = 'flex';
|
|
if (el.miniSendBtn) el.miniSendBtn.disabled = false;
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Set sending state and update button
|
|
state.isSending = true;
|
|
updateSendButtonState();
|
|
|
|
// Ensure we have a valid current session before proceeding
|
|
if (!state.currentSessionId) {
|
|
try {
|
|
if (state.sessions && state.sessions.length > 0) {
|
|
console.log('[BUILDER] No current session ID, using existing session:', state.sessions[0].id);
|
|
await selectSession(state.sessions[0].id);
|
|
} else {
|
|
console.log('[BUILDER] No sessions available, creating new session');
|
|
await createSession();
|
|
}
|
|
} catch (err) {
|
|
console.error('[BUILDER] Failed to establish session:', err);
|
|
setStatus('Failed to establish session: ' + (err.message || 'Unknown error'));
|
|
if (el.miniSendBtn) el.miniSendBtn.disabled = false;
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (!state.currentSessionId) {
|
|
console.error('[BUILDER] Still no session ID after session establishment attempts');
|
|
setStatus('Unable to establish session. Please refresh and try again.');
|
|
if (el.miniSendBtn) el.miniSendBtn.disabled = false;
|
|
return;
|
|
}
|
|
|
|
builderState.lastUserRequest = builderState.lastUserRequest || content;
|
|
|
|
const tempMessageId = 'temp-' + Date.now();
|
|
const currentSession = state.sessions.find(s => s.id === state.currentSessionId);
|
|
if (currentSession) {
|
|
currentSession.messages = currentSession.messages || [];
|
|
currentSession.messages.push({
|
|
id: tempMessageId,
|
|
content,
|
|
displayContent,
|
|
attachments: [...pendingAttachments],
|
|
model,
|
|
cli,
|
|
status: 'queued',
|
|
createdAt: new Date().toISOString()
|
|
});
|
|
renderMessages(currentSession);
|
|
}
|
|
|
|
setStatus('Sending...');
|
|
showLoadingIndicator('building');
|
|
|
|
try {
|
|
let messageContent = content;
|
|
|
|
// if (builderState.mode === 'build' && cli === 'opencode' && !content.includes('proceed with build')) {
|
|
// const subsequentPromptTemplate = builderState.subsequentPrompt || '';
|
|
// const userRequest = builderState.lastUserRequest || content;
|
|
// const pluginSlug = (currentSession && currentSession.pluginSlug) || 'plugin-name';
|
|
// const pluginName = (currentSession && currentSession.pluginName) || `Plugin Compass ${(currentSession && currentSession.title) || 'Plugin'}`;
|
|
// const promptWithRequest = subsequentPromptTemplate.replace('{{USER_REQUEST}}', userRequest);
|
|
// const promptWithSlug = promptWithRequest.replace(/{{PLUGIN_SLUG}}/g, pluginSlug).replace(/{{PLUGIN_NAME}}/g, pluginName);
|
|
// messageContent = promptWithSlug + '\n\n' + content;
|
|
// }
|
|
|
|
const payload = {
|
|
content: messageContent,
|
|
displayContent,
|
|
model,
|
|
cli,
|
|
attachments: pendingAttachments.length ? pendingAttachments : undefined,
|
|
};
|
|
// Preserve opencodeSessionId to continue in the same session
|
|
if (currentSession && currentSession.opencodeSessionId) {
|
|
payload.opencodeSessionId = currentSession.opencodeSessionId;
|
|
console.log('[BUILDER] Preserving opencodeSessionId:', currentSession.opencodeSessionId);
|
|
}
|
|
|
|
const response = await api(`/api/sessions/${state.currentSessionId}/messages`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
// Track the message ID being sent for potential cancellation
|
|
if (response?.message?.id) {
|
|
state.currentSendingMessageId = response.message.id;
|
|
console.log('[BUILDER] Tracking message for cancellation:', response.message.id);
|
|
}
|
|
|
|
// Clear attachments after successful send
|
|
pendingAttachments.length = 0;
|
|
renderAttachmentPreview();
|
|
|
|
// Start SSE streaming immediately so users see output without waiting for polling.
|
|
if (response?.message?.id) {
|
|
streamMessage(state.currentSessionId, response.message.id);
|
|
} else {
|
|
// No message id (unexpected) - check message status before hiding spinner
|
|
setTimeout(() => {
|
|
const session = state.sessions.find(s => s.id === state.currentSessionId);
|
|
if (session) {
|
|
const msg = session.messages.find(m => m.id === tempMessageId);
|
|
if (msg && (msg.status === 'done' || msg.status === 'error')) {
|
|
hideLoadingIndicator();
|
|
} else if (!msg) {
|
|
hideLoadingIndicator();
|
|
}
|
|
} else {
|
|
hideLoadingIndicator();
|
|
}
|
|
}, 100);
|
|
|
|
// Update usage even in unexpected cases
|
|
loadUsageSummary().catch(err => {
|
|
console.warn('[USAGE] Usage update after unexpected case failed:', err.message);
|
|
});
|
|
}
|
|
|
|
await refreshCurrentSession();
|
|
} catch (error) {
|
|
setStatus(error.message || 'Failed to send');
|
|
|
|
// Update usage on error
|
|
loadUsageSummary().catch(err => {
|
|
console.warn('[USAGE] Usage update after send error failed:', err.message);
|
|
});
|
|
|
|
// Hide loading indicator after checking message status
|
|
setTimeout(() => {
|
|
const session = state.sessions.find(s => s.id === state.currentSessionId);
|
|
if (session) {
|
|
const msg = session.messages.find(m => m.id === tempMessageId);
|
|
if (msg && (msg.status === 'done' || msg.status === 'error')) {
|
|
hideLoadingIndicator();
|
|
} else if (!msg) {
|
|
// Temp message was already removed, safe to hide
|
|
hideLoadingIndicator();
|
|
}
|
|
} else {
|
|
// No session found, safe to hide
|
|
hideLoadingIndicator();
|
|
}
|
|
}, 100);
|
|
|
|
// Remove the temp message on error
|
|
if (currentSession) {
|
|
currentSession.messages = (currentSession.messages || []).filter(m => m.id !== tempMessageId);
|
|
renderMessages(currentSession);
|
|
}
|
|
} finally {
|
|
// Reset sending state when message completes, is cancelled, or errors out
|
|
// Only reset if we're not currently in a cancellation (cancelMessage handles that)
|
|
if (state.isSending && state.currentSendingMessageId) {
|
|
const session = state.sessions.find(s => s.id === state.currentSessionId);
|
|
const messageId = state.currentSendingMessageId;
|
|
const message = session?.messages.find(m => m.id === messageId);
|
|
|
|
// Reset if message is done, errored, or cancelled
|
|
if (!message || message.status === 'done' || message.status === 'error' || message.status === 'cancelled') {
|
|
state.isSending = false;
|
|
state.currentSendingMessageId = null;
|
|
updateSendButtonState();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Cancel the current message being sent
|
|
async function cancelMessage() {
|
|
if (!state.isSending || !state.currentSendingMessageId) {
|
|
console.log('[CANCEL] No message to cancel');
|
|
return;
|
|
}
|
|
|
|
const messageId = state.currentSendingMessageId;
|
|
const sessionId = state.currentSessionId;
|
|
|
|
console.log('[CANCEL] Cancelling message:', messageId);
|
|
setStatus('Cancelling...');
|
|
|
|
// Close any active SSE streams for this message
|
|
if (state.activeStreams.has(messageId)) {
|
|
const stream = state.activeStreams.get(messageId);
|
|
stream.close();
|
|
state.activeStreams.delete(messageId);
|
|
console.log('[CANCEL] Closed SSE stream for message:', messageId);
|
|
}
|
|
|
|
// Find and update the message status
|
|
const session = state.sessions.find(s => s.id === sessionId);
|
|
if (session) {
|
|
const message = session.messages.find(m => m.id === messageId);
|
|
if (message) {
|
|
message.status = 'cancelled';
|
|
message.reply = message.reply || '(Cancelled by user)';
|
|
message.cancelled = true;
|
|
|
|
// Show undo/redo buttons for the cancelled message
|
|
message.showUndoRedo = true;
|
|
|
|
console.log('[CANCEL] Message marked as cancelled:', messageId);
|
|
}
|
|
|
|
// Remove temp messages
|
|
const tempMessages = session.messages.filter(m => m.id.startsWith('temp-') && m.status === 'queued');
|
|
tempMessages.forEach(tempMsg => {
|
|
const idx = session.messages.indexOf(tempMsg);
|
|
if (idx > -1) {
|
|
session.messages.splice(idx, 1);
|
|
}
|
|
});
|
|
|
|
renderMessages(session);
|
|
}
|
|
|
|
// Reset sending state
|
|
state.isSending = false;
|
|
state.currentSendingMessageId = null;
|
|
|
|
// Reset send button
|
|
updateSendButtonState();
|
|
|
|
hideLoadingIndicator();
|
|
setStatus('Message cancelled');
|
|
|
|
// Refresh session to sync with server
|
|
try {
|
|
await refreshCurrentSession();
|
|
} catch (err) {
|
|
console.warn('[CANCEL] Failed to refresh session:', err.message);
|
|
}
|
|
}
|
|
|
|
// Update send button appearance based on state
|
|
function updateSendButtonState() {
|
|
const btn = el.miniSendBtn;
|
|
if (!btn) return;
|
|
|
|
if (state.isSending) {
|
|
// Show cancel button (square icon)
|
|
btn.innerHTML = `
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<rect x="6" y="6" width="12" height="12" rx="2" ry="2"></rect>
|
|
</svg>
|
|
`;
|
|
btn.title = 'Cancel message';
|
|
btn.classList.add('cancel-mode');
|
|
btn.disabled = false;
|
|
} else {
|
|
// Show send button (paper plane icon)
|
|
btn.innerHTML = `
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<line x1="22" y1="2" x2="11" y2="13"></line>
|
|
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
|
|
</svg>
|
|
`;
|
|
btn.title = 'Send message';
|
|
btn.classList.remove('cancel-mode');
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
// Handle send button click - either send or cancel
|
|
function handleSendButtonClick(e) {
|
|
if (state.isSending) {
|
|
// Cancel the current message
|
|
cancelMessage();
|
|
} else {
|
|
// Check if we're in plan mode and call the appropriate handler
|
|
if (builderState.mode === 'plan') {
|
|
// Use handleSend from builder.html which handles plan mode
|
|
if (typeof handleSend === 'function') {
|
|
handleSend(e);
|
|
} else {
|
|
console.warn('[BUILDER] handleSend not available');
|
|
}
|
|
} else {
|
|
// Send a new message in build mode
|
|
sendMessage();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Expose for builder.html
|
|
window.sendMessage = sendMessage;
|
|
window.cancelMessage = cancelMessage;
|
|
window.handleSendButtonClick = handleSendButtonClick;
|
|
|
|
function hookEvents() {
|
|
if (el.newChat) {
|
|
el.newChat.addEventListener('click', async () => {
|
|
// Get current session's appId to create new chat within same app
|
|
const currentSession = state.sessions.find(s => s.id === state.currentSessionId);
|
|
let appIdToUse = currentSession?.appId || null;
|
|
if (!appIdToUse) {
|
|
// Fall back to any available appId from existing sessions
|
|
for (const session of state.sessions) {
|
|
if (session.appId) {
|
|
appIdToUse = session.appId;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create new chat within the same app by passing appId with reuseAppId=true
|
|
if (appIdToUse) {
|
|
await createSession({ appId: appIdToUse, reuseAppId: true });
|
|
} else {
|
|
await createSession({});
|
|
}
|
|
});
|
|
}
|
|
|
|
if (el.historyBtn) {
|
|
el.historyBtn.addEventListener('click', openHistoryModal);
|
|
}
|
|
|
|
if (el.historyClose) {
|
|
el.historyClose.addEventListener('click', closeHistoryModal);
|
|
}
|
|
|
|
if (el.historyModal) {
|
|
el.historyModal.addEventListener('click', (e) => {
|
|
if (e.target === el.historyModal) closeHistoryModal();
|
|
});
|
|
}
|
|
|
|
// sendBtn removed - only miniSendBtn is used in builder
|
|
window.showUpgradeModal = function () {
|
|
if (state.accountPlan === 'enterprise') {
|
|
alert('You are already on the Enterprise plan with full access.');
|
|
return;
|
|
}
|
|
const modal = document.getElementById('upgrade-modal');
|
|
if (modal) {
|
|
modal.style.display = 'flex';
|
|
}
|
|
};
|
|
|
|
|
|
|
|
// Upload media button functionality
|
|
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();
|
|
|
|
// Reset input to allow re-uploading the same file
|
|
el.uploadMediaInput.value = '';
|
|
|
|
// Check if user is on free plan
|
|
if (!isPaidPlanClient()) {
|
|
showUpgradeModal();
|
|
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.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()) {
|
|
showUpgradeModal();
|
|
return;
|
|
}
|
|
if (!currentModelSupportsMedia()) {
|
|
setStatus('This model does not support image uploads. Please select a different model that supports media.');
|
|
return;
|
|
}
|
|
setStatus('Preparing images...');
|
|
el.miniSendBtn.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.miniSendBtn.disabled = false;
|
|
}
|
|
});
|
|
} else {
|
|
console.log('Upload media elements NOT found. el.uploadMediaBtn:', el.uploadMediaBtn, 'el.uploadMediaInput:', el.uploadMediaInput);
|
|
}
|
|
|
|
el.messageInput.addEventListener('input', () => {
|
|
el.messageInput.style.height = 'auto';
|
|
el.messageInput.style.height = (el.messageInput.scrollHeight) + 'px';
|
|
// Cache the input content to localStorage for persistence across refreshes
|
|
cacheMessageInput(el.messageInput.value);
|
|
});
|
|
|
|
// 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()) {
|
|
showUpgradeModal();
|
|
return;
|
|
}
|
|
if (!currentModelSupportsMedia()) {
|
|
setStatus('This model does not support image uploads. Please select a different model that supports media.');
|
|
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); }
|
|
});
|
|
|
|
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}`); }
|
|
}
|
|
});
|
|
}
|
|
|
|
if (el.confirmBuildProceed) {
|
|
el.confirmBuildProceed.onclick = async () => {
|
|
if (el.confirmBuildModal) el.confirmBuildModal.style.display = 'none';
|
|
if (pendingPlanContent) {
|
|
await executeBuild(pendingPlanContent);
|
|
pendingPlanContent = null;
|
|
}
|
|
};
|
|
}
|
|
|
|
if (el.confirmBuildCancel) {
|
|
el.confirmBuildCancel.onclick = () => {
|
|
if (el.confirmBuildModal) el.confirmBuildModal.style.display = 'none';
|
|
pendingPlanContent = null;
|
|
};
|
|
}
|
|
|
|
if (el.confirmBuildClose) {
|
|
el.confirmBuildClose.onclick = () => {
|
|
if (el.confirmBuildModal) el.confirmBuildModal.style.display = 'none';
|
|
pendingPlanContent = null;
|
|
};
|
|
}
|
|
|
|
// Export modal functionality
|
|
const exportZipBtn = document.getElementById('export-zip-btn');
|
|
const exportModal = document.getElementById('export-modal');
|
|
const exportCloseBtn = document.getElementById('export-close');
|
|
const downloadZipBtn = document.getElementById('download-zip');
|
|
const exportStatus = document.getElementById('export-status');
|
|
const exportStatusText = document.getElementById('export-status-text');
|
|
|
|
if (exportZipBtn) {
|
|
exportZipBtn.addEventListener('click', async () => {
|
|
if (!state.currentSessionId) {
|
|
setStatus('No active session to export');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
exportZipBtn.disabled = true;
|
|
exportZipBtn.style.opacity = '0.7';
|
|
|
|
const headers = state.userId ? { 'X-User-Id': state.userId } : {};
|
|
const response = await fetch(`/api/export/zip?sessionId=${state.currentSessionId}`, {
|
|
headers,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Export failed');
|
|
}
|
|
|
|
const blob = await response.blob();
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `wordpress-plugin-${state.currentSessionId}.zip`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
window.URL.revokeObjectURL(url);
|
|
|
|
} catch (error) {
|
|
console.error('Export failed:', error);
|
|
setStatus('Export failed: ' + error.message);
|
|
} finally {
|
|
exportZipBtn.disabled = false;
|
|
exportZipBtn.style.opacity = '1';
|
|
}
|
|
});
|
|
}
|
|
|
|
if (exportCloseBtn) {
|
|
exportCloseBtn.addEventListener('click', () => {
|
|
if (exportModal) exportModal.style.display = 'none';
|
|
});
|
|
}
|
|
|
|
// Close modal when clicking outside
|
|
if (exportModal) {
|
|
exportModal.addEventListener('click', (e) => {
|
|
if (e.target === exportModal) {
|
|
exportModal.style.display = 'none';
|
|
}
|
|
});
|
|
}
|
|
|
|
if (downloadZipBtn) {
|
|
downloadZipBtn.addEventListener('click', async () => {
|
|
if (!state.currentSessionId) {
|
|
setStatus('No active session to export');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Show loading state
|
|
downloadZipBtn.disabled = true;
|
|
downloadZipBtn.style.opacity = '0.7';
|
|
if (exportStatus) {
|
|
exportStatus.style.display = 'block';
|
|
exportStatusText.innerHTML = '<div style="width:16px; height:16px; border:2px solid var(--shopify-green); border-top:2px solid transparent; border-radius:50%; animation:spin 1s linear infinite;"></div> Preparing export...';
|
|
}
|
|
|
|
// Make the export request
|
|
const headers = state.userId ? { 'X-User-Id': state.userId } : {};
|
|
const response = await fetch(`/api/export/zip?sessionId=${state.currentSessionId}`, {
|
|
headers,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Export failed');
|
|
}
|
|
|
|
// Create blob and download
|
|
const blob = await response.blob();
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `wordpress-plugin-${state.currentSessionId}.zip`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
window.URL.revokeObjectURL(url);
|
|
|
|
// Show success message
|
|
if (exportStatus) {
|
|
exportStatusText.innerHTML = '<div style="color:var(--shopify-green); font-weight:600; display:flex; align-items:center; gap:8px;"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20,6 9,17 4,12"></polyline></svg> Download started!</div>';
|
|
}
|
|
|
|
// Close modal after short delay
|
|
setTimeout(() => {
|
|
if (exportModal) exportModal.style.display = 'none';
|
|
}, 2000);
|
|
|
|
} catch (error) {
|
|
console.error('Export failed:', error);
|
|
setStatus('Export failed: ' + error.message);
|
|
|
|
// Show error state
|
|
if (exportStatus) {
|
|
exportStatusText.innerHTML = '<div style="color:#dc3545; font-weight:600; display:flex; align-items:center; gap:8px;"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="15" y1="9" x2="9" y2="15"></line><line x1="9" y1="9" x2="15" y2="15"></line></svg> Export failed. Please try again.</div>';
|
|
}
|
|
} finally {
|
|
// Reset button state
|
|
if (downloadZipBtn) {
|
|
downloadZipBtn.disabled = false;
|
|
downloadZipBtn.style.opacity = '1';
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
window.addEventListener('click', (e) => {
|
|
if (e.target === el.confirmBuildModal) {
|
|
el.confirmBuildModal.style.display = 'none';
|
|
pendingPlanContent = null;
|
|
}
|
|
});
|
|
|
|
el.messageInput.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
if (state.isSending) {
|
|
cancelMessage();
|
|
} else {
|
|
sendMessage();
|
|
}
|
|
}
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|
|
|
|
if (el.modelSelect) {
|
|
el.modelSelect.addEventListener('change', async () => {
|
|
const selected = el.modelSelect.value;
|
|
|
|
// Handle Hobby (free) plan lock - but only if NOT a programmatic change and user didn't just change it
|
|
if (isFreePlan() && selected !== 'auto' && !programmaticModelChange && !userJustChangedModel) {
|
|
// For free plans, only reset to auto if the selection is invalid or empty
|
|
const validModels = state.models.map(m => m.id || m.name || m);
|
|
if (!selected || !validModels.includes(selected)) {
|
|
el.modelSelect.value = 'auto';
|
|
state.selectedModelId = 'auto';
|
|
updateModelSelectDisplay('auto');
|
|
updateUsageProgressBar();
|
|
syncUploadButtonState();
|
|
showBlurredModelPreviewInline();
|
|
setStatus('Model selection is automatic on the hobby plan');
|
|
return;
|
|
}
|
|
}
|
|
|
|
state.selectedModelId = selected;
|
|
updateModelSelectDisplay(selected);
|
|
updateUsageProgressBar();
|
|
syncUploadButtonState();
|
|
|
|
if (!selected) return;
|
|
|
|
// Mark that user just changed model (prevent server from overwriting during refresh)
|
|
if (!programmaticModelChange) {
|
|
markUserModelChange();
|
|
}
|
|
|
|
// Sync selection to current session on server
|
|
if (state.currentSessionId) {
|
|
try {
|
|
await api(`/api/sessions/${state.currentSessionId}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify({ model: selected })
|
|
});
|
|
// Refresh session to confirm change was persisted
|
|
await refreshCurrentSession();
|
|
} catch (e) {
|
|
console.error('Failed to update model on server:', e);
|
|
setStatus(`Failed to update model: ${e.message}`);
|
|
} finally {
|
|
// Clear the flag after refresh completes - let timer handle it
|
|
// Don't manually clear here to allow polling to complete
|
|
}
|
|
} else {
|
|
// Clear flag if no session to sync to
|
|
// Let the timer handle it
|
|
}
|
|
});
|
|
}
|
|
|
|
// Upgrade header button functionality (for builder page)
|
|
const upgradeHeaderBtn = document.getElementById('upgrade-header-btn');
|
|
if (upgradeHeaderBtn) {
|
|
upgradeHeaderBtn.addEventListener('click', () => {
|
|
if (typeof window.showUpgradeModal === 'function' && state.accountPlan !== 'enterprise') {
|
|
window.showUpgradeModal();
|
|
} else if (state.accountPlan === 'enterprise') {
|
|
alert('You are already on the Enterprise plan with full access.');
|
|
} else {
|
|
window.location.href = '/upgrade';
|
|
}
|
|
});
|
|
}
|
|
|
|
const tokenLimitClose = document.getElementById('token-limit-close');
|
|
const tokenLimitModal = document.getElementById('token-limit-modal');
|
|
if (tokenLimitClose && tokenLimitModal) {
|
|
tokenLimitClose.addEventListener('click', () => {
|
|
tokenLimitModal.style.display = 'none';
|
|
});
|
|
|
|
tokenLimitModal.addEventListener('click', (e) => {
|
|
if (e.target === tokenLimitModal) {
|
|
tokenLimitModal.style.display = 'none';
|
|
}
|
|
});
|
|
|
|
const upgradeLink = document.getElementById('token-limit-upgrade');
|
|
if (upgradeLink) {
|
|
if (state.accountPlan === 'enterprise' || state.accountPlan === 'professional') {
|
|
upgradeLink.style.display = 'none';
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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));
|
|
}
|
|
});
|
|
|
|
// Builder-specific init - loads sessions but doesn't render session list
|
|
(async function initBuilder() {
|
|
populateCliSelect();
|
|
|
|
// Check opencode status on startup
|
|
checkOpencodeStatus();
|
|
|
|
// Warm account info early to avoid duplicate slow fetches when page loads quickly
|
|
try { getAccountInfo(); } catch (_) { /* ignore warm-up errors */ }
|
|
|
|
// Load sessions data immediately ONLY if not already loaded by builder.html
|
|
// The builder.html DOMContentLoaded handler will call loadSessions() if needed
|
|
if (!state.sessionsLoaded) {
|
|
try {
|
|
const data = await api('/api/sessions');
|
|
state.sessions = data.sessions || [];
|
|
|
|
// Ensure we have a valid current session after loading
|
|
if (!state.currentSessionId && state.sessions.length > 0) {
|
|
console.log('[BUILDER] Initializing current session from loaded sessions:', state.sessions[0].id);
|
|
await selectSession(state.sessions[0].id);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load sessions:', error);
|
|
} finally {
|
|
state.sessionsLoaded = true;
|
|
try { window.dispatchEvent(new Event('sessionsLoaded')); } catch (_) { }
|
|
}
|
|
} else {
|
|
// Sessions were already loaded, but we might still need to ensure current session
|
|
if (!state.currentSessionId && state.sessions && state.sessions.length > 0) {
|
|
console.log('[BUILDER] Sessions already loaded, ensuring current session:', state.sessions[0].id);
|
|
await selectSession(state.sessions[0].id);
|
|
}
|
|
}
|
|
|
|
// Load models and account plan AFTER sessions are loaded, so we can determine the correct CLI
|
|
// Load plan FIRST before attaching event handlers to ensure plan checks work correctly
|
|
try {
|
|
await loadAccountPlan();
|
|
await loadModels(state.currentCli);
|
|
console.log('Models loaded successfully');
|
|
syncUploadButtonState();
|
|
} catch (err) {
|
|
console.warn('Model load failed:', err);
|
|
}
|
|
|
|
// Attach event handlers AFTER account plan is loaded to avoid race conditions
|
|
hookEvents();
|
|
|
|
// Restore cached message input from localStorage (after event handlers are attached)
|
|
restoreMessageInput();
|
|
|
|
// 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);
|
|
|
|
// Load provider limits for fallback model selection
|
|
loadProviderLimits();
|
|
|
|
// Scroll to bottom after initialization completes - use multiple delays
|
|
// to catch content that renders asynchronously
|
|
setTimeout(() => scrollChatToBottom(), 500);
|
|
setTimeout(() => scrollChatToBottom(), 1000);
|
|
setTimeout(() => scrollChatToBottom(), 2000);
|
|
})();
|
|
|
|
async function loadProviderLimits() {
|
|
try {
|
|
const data = await api('/api/provider-limits');
|
|
if (data?.opencodeBackupModel) {
|
|
window.providerLimits = {
|
|
opencodeBackupModel: data.opencodeBackupModel
|
|
};
|
|
console.log('[PROVIDER-LIMITS] Loaded backup model:', window.providerLimits.opencodeBackupModel);
|
|
}
|
|
} catch (err) {
|
|
console.warn('[PROVIDER-LIMITS] Failed to load provider limits:', err);
|
|
window.providerLimits = window.providerLimits || {};
|
|
}
|
|
}
|