// Promptheus Web UI - Modern Application Controller
class PromptheusApp {
constructor() {
this.apiBaseUrl = window.location.origin;
this.currentHistoryPage = 0; // 0-based page index
this.currentPageSize = 20;
this.totalHistoryPages = 0;
this.currentAbortController = null;
this.currentEventSource = null;
this.streamingText = '';
this.streamingInterval = null;
this.currentOptimizedPrompt = ''; // Store current prompt
this.cachedModels = {}; // Store fetched models by provider ID
this.providerCapabilities = {}; // Store provider capability hints
this.hasResults = false; // Track if we have results displayed
this.init();
}
init() {
// Configure marked.js to reduce extra spacing
if (typeof marked !== 'undefined') {
marked.setOptions({
breaks: false, // Don't convert \n to <br>
gfm: true, // GitHub Flavored Markdown
headerIds: false,
mangle: false
});
}
this.bindEvents();
this.loadProviders();
this.loadHistory();
this.loadSettings();
this.loadVersion();
this.initCustomDropdowns();
}
bindEvents() {
// Mobile menu toggle
const hamburgerBtn = document.getElementById('hamburger-btn');
const mobileMenu = document.getElementById('mobile-menu');
const mobileMenuClose = document.getElementById('mobile-menu-close');
const settingsBtnMobile = document.getElementById('settings-btn-mobile');
hamburgerBtn?.addEventListener('click', () => {
const isOpen = mobileMenu.classList.contains('active');
mobileMenu.classList.toggle('active');
hamburgerBtn.classList.toggle('active');
hamburgerBtn.setAttribute('aria-expanded', !isOpen);
mobileMenu.setAttribute('aria-hidden', isOpen);
});
mobileMenuClose?.addEventListener('click', () => {
mobileMenu.classList.remove('active');
hamburgerBtn.classList.remove('active');
hamburgerBtn.setAttribute('aria-expanded', 'false');
mobileMenu.setAttribute('aria-hidden', 'true');
});
// Close mobile menu when clicking on settings
settingsBtnMobile?.addEventListener('click', () => {
mobileMenu.classList.remove('active');
hamburgerBtn.classList.remove('active');
hamburgerBtn.setAttribute('aria-expanded', 'false');
mobileMenu.setAttribute('aria-hidden', 'true');
// Trigger desktop settings button
document.getElementById('settings-btn')?.click();
});
// Close menu when clicking outside
document.addEventListener('click', (e) => {
if (mobileMenu?.classList.contains('active') &&
!mobileMenu.contains(e.target) &&
!hamburgerBtn.contains(e.target)) {
mobileMenu.classList.remove('active');
hamburgerBtn.classList.remove('active');
hamburgerBtn.setAttribute('aria-expanded', 'false');
mobileMenu.setAttribute('aria-hidden', 'true');
}
});
// Main prompt submission
document.getElementById('submit-btn').addEventListener('click', () => this.submitPrompt());
// New prompt button (hidden initially, shown after results)
document.getElementById('start-over-btn').addEventListener('click', () => {
this.startNewPrompt();
});
// Track input changes to detect new prompts (but only when we have results)
const promptInput = document.getElementById('prompt-input');
let lastPromptValue = '';
promptInput.addEventListener('input', () => {
if (this.hasResults) {
this.handleInputChange(promptInput.value, lastPromptValue);
lastPromptValue = promptInput.value;
}
});
// Provider selection
document.getElementById('provider-select').addEventListener('change', (e) => {
this.selectProvider(e.target.value);
this.loadModelsForProvider(e.target.value);
// Sync mobile provider dropdown and show indicator
const providerSelectMobile = document.getElementById('provider-select-mobile');
if (providerSelectMobile) {
providerSelectMobile.value = e.target.value;
this.showSyncIndicator('provider-sync-indicator');
}
});
// Model selection
document.getElementById('model-select').addEventListener('change', (e) => {
this.selectModel(e.target.value);
// Sync mobile model dropdown and show indicator
const modelSelectMobile = document.getElementById('model-select-mobile');
if (modelSelectMobile) {
modelSelectMobile.value = e.target.value;
this.showSyncIndicator('model-sync-indicator');
}
});
// Mobile Provider selection
const providerSelectMobile = document.getElementById('provider-select-mobile');
if (providerSelectMobile) {
providerSelectMobile.addEventListener('change', (e) => {
// Update main provider dropdown
const providerSelect = document.getElementById('provider-select');
if (providerSelect) {
providerSelect.value = e.target.value;
}
this.selectProvider(e.target.value);
this.loadModelsForProvider(e.target.value);
this.showSyncIndicator('provider-sync-indicator');
});
}
// Mobile Model selection
const modelSelectMobile = document.getElementById('model-select-mobile');
if (modelSelectMobile) {
modelSelectMobile.addEventListener('change', (e) => {
// Update main model dropdown
const modelSelect = document.getElementById('model-select');
if (modelSelect) {
modelSelect.value = e.target.value;
}
this.selectModel(e.target.value);
this.showSyncIndicator('model-sync-indicator');
});
}
// Copy button
document.getElementById('copy-btn').addEventListener('click', () => {
this.copyOutputToClipboard();
});
// Refresh models cache (in Settings panel)
document.getElementById('refresh-models-cache-btn').addEventListener('click', () => {
const provider = document.getElementById('provider-select').value || this.provider;
this.refreshModelsCache(provider || 'google');
});
// Validate all providers (in Settings panel)
document.getElementById('validate-all-providers-btn').addEventListener('click', () => {
this.runProviderPreflight();
});
// Provider status indicator - clicking opens settings
document.getElementById('provider-status-btn').addEventListener('click', () => {
this.openSettings();
});
// Tweak button
document.getElementById('tweak-btn').addEventListener('click', () => {
this.showTweakPromptDialog();
});
// Settings panel controls
document.getElementById('settings-btn').addEventListener('click', () => {
this.openSettings();
});
document.getElementById('settings-close-btn').addEventListener('click', () => {
this.closeSettings();
});
document.getElementById('settings-overlay').addEventListener('click', (e) => {
if (e.target === e.currentTarget) {
this.closeSettings();
}
});
// Global keyboard shortcuts
document.addEventListener('keydown', (e) => {
// Escape key to close settings, tooltips, dropdowns, and cancel ongoing operations
if (e.key === 'Escape') {
e.preventDefault();
// Cancel any ongoing request
if (this.currentAbortController) {
this.cancelCurrentRequest();
}
this.closeSettings();
// Close help tooltips
document.querySelectorAll('.help-tooltip.visible').forEach(tooltip => {
tooltip.classList.remove('visible');
});
document.querySelectorAll('.toolbar-help-icon.active').forEach(icon => {
icon.classList.remove('active');
});
// Close dropdowns
document.querySelectorAll('.alchemical-dropdown.active').forEach(dropdown => {
this.closeDropdown(dropdown);
});
return;
}
// Don't process other shortcuts when typing in input/textarea
const target = e.target;
const isTyping = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA';
// Alt+Enter or Ctrl+Enter to submit (works even when typing)
if ((e.altKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
const submitBtn = document.getElementById('submit-btn');
if (!submitBtn.classList.contains('hidden')) {
this.submitPrompt();
}
return;
}
// Skip other Ctrl shortcuts when typing (except Ctrl+Enter above)
if (isTyping && e.ctrlKey && e.key.toLowerCase() !== 'enter') {
return;
}
// Ctrl+M - Toggle Mode help tooltip
if (e.ctrlKey && e.key.toLowerCase() === 'm') {
e.preventDefault();
const modeIcon = document.querySelector('.toolbar-help-icon[data-help-for="mode"]');
if (modeIcon) modeIcon.click();
return;
}
// Ctrl+Shift+S - Toggle Style help tooltip
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 's') {
e.preventDefault();
const styleIcon = document.querySelector('.toolbar-help-icon[data-help-for="style"]');
if (styleIcon) styleIcon.click();
return;
}
// Ctrl+P - Focus Provider dropdown
if (e.ctrlKey && e.key.toLowerCase() === 'p') {
e.preventDefault();
const providerSelect = document.getElementById('provider-select');
providerSelect.focus();
return;
}
// Ctrl+L - Focus Model dropdown
if (e.ctrlKey && e.key.toLowerCase() === 'l') {
e.preventDefault();
const modelSelect = document.getElementById('model-select');
if (!modelSelect.disabled) {
modelSelect.focus();
}
return;
}
// Ctrl+, - Open Settings
if (e.ctrlKey && e.key === ',') {
e.preventDefault();
this.openSettings();
return;
}
// Ctrl+I - Focus Input textarea
if (e.ctrlKey && e.key.toLowerCase() === 'i') {
e.preventDefault();
const promptInput = document.getElementById('prompt-input');
promptInput.focus();
return;
}
// Ctrl+Shift+C - Copy output
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'c') {
e.preventDefault();
const copyBtn = document.getElementById('copy-btn');
if (!copyBtn.classList.contains('hidden')) {
this.copyOutputToClipboard();
}
return;
}
// Ctrl+T - Tweak prompt
if (e.ctrlKey && e.key.toLowerCase() === 't') {
e.preventDefault();
const tweakBtn = document.getElementById('tweak-btn');
if (!tweakBtn.classList.contains('hidden')) {
this.showTweakPromptDialog();
}
return;
}
// Ctrl+N - Start Over (New prompt)
if (e.ctrlKey && e.key.toLowerCase() === 'n') {
e.preventDefault();
const startOverBtn = document.getElementById('start-over-btn');
if (!startOverBtn.classList.contains('hidden')) {
this.startNewPrompt();
}
return;
}
// Ctrl+Shift+X - Cancel operation
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'x') {
e.preventDefault();
const cancelBtn = document.getElementById('cancel-btn');
if (!cancelBtn.classList.contains('hidden')) {
this.cancelCurrentRequest();
}
return;
}
// Ctrl+Shift+H - Clear History
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'h') {
e.preventDefault();
const clearHistoryBtn = document.getElementById('clear-history-btn');
if (clearHistoryBtn) {
this.showConfirmDialog(
'Clear All History',
'Are you sure you want to clear all history? This action cannot be undone.',
() => this.clearHistory()
);
}
return;
}
// Ctrl+[ - Previous history page
if (e.ctrlKey && e.key === '[') {
e.preventDefault();
const prevBtn = document.getElementById('prev-page-btn');
if (!prevBtn.disabled) {
this.previousHistoryPage();
}
return;
}
// Ctrl+] - Next history page
if (e.ctrlKey && e.key === ']') {
e.preventDefault();
const nextBtn = document.getElementById('next-page-btn');
if (!nextBtn.disabled) {
this.nextHistoryPage();
}
return;
}
// ? - Show keyboard shortcuts help
if (e.key === '?' && !isTyping) {
e.preventDefault();
this.showKeyboardShortcuts();
return;
}
});
// History pagination
document.getElementById('prev-page-btn').addEventListener('click', () => {
this.previousHistoryPage();
});
document.getElementById('next-page-btn').addEventListener('click', () => {
this.nextHistoryPage();
});
document.getElementById('page-size-select').addEventListener('change', (e) => {
this.currentPageSize = parseInt(e.target.value);
this.currentHistoryPage = 0;
this.loadHistory();
});
// Cancel button
document.getElementById('cancel-btn').addEventListener('click', () => {
this.cancelCurrentRequest();
});
// Clear history button
document.getElementById('clear-history-btn').addEventListener('click', () => {
this.showConfirmDialog(
'Clear All History',
'Are you sure you want to clear all history? This action cannot be undone.',
() => this.clearHistory()
);
});
}
/* ===================================================================
SETTINGS PANEL MANAGEMENT
=================================================================== */
openSettings() {
const overlay = document.getElementById('settings-overlay');
const panel = document.getElementById('settings-panel');
overlay.classList.add('active');
panel.classList.add('active');
// Load cached validation results if available
this.loadCachedValidationResults();
// Focus first focusable element
setTimeout(() => {
const firstInput = panel.querySelector('input, button, select');
if (firstInput) firstInput.focus();
}, 300);
}
closeSettings() {
const overlay = document.getElementById('settings-overlay');
const panel = document.getElementById('settings-panel');
overlay.classList.remove('active');
panel.classList.remove('active');
}
/* ===================================================================
INPUT STATE MANAGEMENT
=================================================================== */
startNewPrompt() {
const promptInput = document.getElementById('prompt-input');
const outputDiv = document.getElementById('output');
const tweakBtn = document.getElementById('tweak-btn');
const copyBtn = document.getElementById('copy-btn');
const startOverBtn = document.getElementById('start-over-btn');
// Clear the input field and reset state
promptInput.value = '';
this.hasResults = false;
// Clear output with transition
this.clearOutputWithTransition();
// Hide action buttons and start over button
tweakBtn.classList.add('hidden');
copyBtn.classList.add('hidden');
startOverBtn.classList.add('hidden');
// Update placeholder text to initial state
promptInput.placeholder = 'Enter your prompt here...';
// Focus input for convenience
promptInput.focus();
}
showResultsState() {
const promptInput = document.getElementById('prompt-input');
const startOverBtn = document.getElementById('start-over-btn');
this.hasResults = true;
// Update placeholder to indicate they can optimize another prompt
promptInput.placeholder = 'Optimize another prompt...';
// Show the "Start Over" button
startOverBtn.classList.remove('hidden');
}
handleInputChange(currentValue, lastValue) {
const outputDiv = document.getElementById('output');
const hasOutput = outputDiv.querySelector('.optimized-prompt-content');
if (!hasOutput) return;
// Calculate similarity between current and last prompt
const isSimilar = this.calculateSimilarity(currentValue, lastValue) > 0.7;
// If user is typing something significantly different (more than 10 chars changed)
if (!isSimilar && currentValue.length > 10 && Math.abs(currentValue.length - lastValue.length) > 5) {
this.clearOutputWithTransition();
this.hasResults = false;
document.getElementById('start-over-btn').classList.add('hidden');
document.getElementById('tweak-btn').classList.add('hidden');
document.getElementById('copy-btn').classList.add('hidden');
}
}
calculateSimilarity(str1, str2) {
if (!str1 || !str2) return 0;
const longer = str1.length > str2.length ? str1 : str2;
const shorter = str1.length > str2.length ? str2 : str1;
if (longer.length === 0) return 1.0;
const editDistance = this.levenshteinDistance(longer, shorter);
return (longer.length - editDistance) / longer.length;
}
levenshteinDistance(str1, str2) {
const matrix = [];
for (let i = 0; i <= str2.length; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= str1.length; j++) {
matrix[0][j] = j;
}
for (let i = 1; i <= str2.length; i++) {
for (let j = 1; j <= str1.length; j++) {
if (str2.charAt(i - 1) === str1.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1];
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j - 1] + 1,
matrix[i][j - 1] + 1,
matrix[i - 1][j] + 1
);
}
}
}
return matrix[str2.length][str1.length];
}
clearOutputWithTransition() {
const outputDiv = document.getElementById('output');
const tweakBtn = document.getElementById('tweak-btn');
const copyBtn = document.getElementById('copy-btn');
// Add fade-out class
outputDiv.style.opacity = '0';
outputDiv.style.transition = 'opacity 0.2s ease-out';
setTimeout(() => {
// Clear output and show ready state
outputDiv.innerHTML = '';
// Hide tweak and copy buttons
tweakBtn.classList.add('hidden');
copyBtn.classList.add('hidden');
// Fade back in
setTimeout(() => {
outputDiv.style.opacity = '1';
outputDiv.style.transition = 'opacity 0.3s ease-in';
}, 50);
}, 200);
}
/* ===================================================================
PROMPT SUBMISSION & PROCESSING
=================================================================== */
async submitPrompt() {
const promptInput = document.getElementById('prompt-input');
const outputDiv = document.getElementById('output');
const submitBtn = document.getElementById('submit-btn');
const cancelBtn = document.getElementById('cancel-btn');
const questionMode = document.getElementById('question-mode').value;
const style = document.getElementById('style-select')?.value || 'default';
const prompt = promptInput.value.trim();
if (!prompt) {
this.showMessage('error', 'Please enter a prompt');
return;
}
const provider = document.getElementById('provider-select').value;
let model = document.getElementById('model-select')?.value || null;
// Don't send the "load all models" placeholder as an actual model
if (model === '__load_all__') {
model = null; // Let backend use auto/default model
}
// Determine skip_questions and force_questions from mode
const skipQuestions = questionMode === 'skip';
const forceQuestions = questionMode === 'force';
// Cancel any existing request
if (this.currentAbortController) {
this.currentAbortController.abort();
}
// Create new AbortController
this.currentAbortController = new AbortController();
// Show loading state - keep button visible at top
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner"></span><span>Processing...</span>';
cancelBtn.classList.remove('hidden');
try {
// Check if clarifying questions are needed
if (!skipQuestions) {
// Show analyzing indicator while generating questions
this.showProgressIndicator('analyzing');
const questionsResponse = await fetch(`${this.apiBaseUrl}/api/questions/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt,
provider: provider || null,
model: model || null,
force_questions: forceQuestions
}),
signal: this.currentAbortController.signal
});
if (this.currentAbortController.signal.aborted) return;
const questionsData = await questionsResponse.json();
if (this.currentAbortController.signal.aborted) return;
if (questionsData.success && questionsData.questions && questionsData.questions.length > 0) {
const answersResult = await this.showQuestionsAndCollectAnswers(questionsData.questions);
if (answersResult === null) {
submitBtn.disabled = false;
submitBtn.innerHTML = '<span>Optimize Prompt</span>';
cancelBtn.classList.add('hidden');
return;
}
const { responses, mapping } = answersResult;
// Show refining indicator
this.showProgressIndicator('refining');
// Submit with answers
const response = await fetch(`${this.apiBaseUrl}/api/prompt/submit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt,
provider: provider || null,
model: model || null,
skip_questions: false,
refine: forceQuestions,
style,
answers: responses,
question_mapping: mapping
}),
signal: this.currentAbortController.signal
});
if (this.currentAbortController.signal.aborted) return;
const data = await response.json();
this.handlePromptResponse(data);
} else {
await this.submitPromptDirect(prompt, provider, skipQuestions, forceQuestions, style);
}
} else {
await this.submitPromptDirect(prompt, provider, skipQuestions, forceQuestions, style);
}
} catch (error) {
if (error.name === 'AbortError') return;
console.error('Error submitting prompt:', error);
this.showMessage('error', 'Network error: ' + error.message);
} finally {
this.currentAbortController = null;
submitBtn.disabled = false;
submitBtn.innerHTML = '<span>Optimize Prompt</span>';
cancelBtn.classList.add('hidden');
}
}
async submitPromptDirect(prompt, provider, skipQuestions, forceQuestions = false, style = 'default') {
const outputDiv = document.getElementById('output');
const tweakBtn = document.getElementById('tweak-btn');
const copyBtn = document.getElementById('copy-btn');
let model = document.getElementById('model-select')?.value || null;
// Don't send the "load all models" placeholder as an actual model
if (model === '__load_all__') {
model = null; // Let backend use auto/default model
}
// Use streaming endpoint
const params = new URLSearchParams({
prompt,
skip_questions: skipQuestions,
refine: forceQuestions,
style
});
if (provider) params.append('provider', provider);
if (model) params.append('model', model);
// Show optimizing indicator briefly before streaming
this.showProgressIndicator(forceQuestions ? 'refining' : 'optimizing');
// Small delay to let the progress indicator display
await new Promise(resolve => setTimeout(resolve, 200));
this.streamingText = '';
outputDiv.innerHTML = '<div class="optimized-prompt-content streaming"><span class="streaming-cursor">|</span></div>';
const eventSource = new EventSource(`${this.apiBaseUrl}/api/prompt/stream?${params.toString()}`);
this.currentEventSource = eventSource;
const contentDiv = outputDiv.querySelector('.optimized-prompt-content');
const cursorSpan = contentDiv.querySelector('.streaming-cursor');
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'token') {
this.streamingText += data.content;
contentDiv.textContent = this.streamingText;
contentDiv.appendChild(cursorSpan);
} else if (data.type === 'done') {
eventSource.close();
this.currentEventSource = null;
cursorSpan.remove();
contentDiv.classList.remove('streaming');
this.currentOptimizedPrompt = this.streamingText; // Store for markdown toggle
tweakBtn.classList.remove('hidden'); // Show tweak button
copyBtn.classList.remove('hidden'); // Show copy button
this.showResultsState(); // Show contextual results state
this.loadHistory();
} else if (data.type === 'error') {
eventSource.close();
this.currentEventSource = null;
tweakBtn.classList.add('hidden');
copyBtn.classList.add('hidden');
// Check if this is an API key related error and add settings link
if (data.content && (
data.content.toLowerCase().includes('api key') ||
data.content.toLowerCase().includes('missing') ||
data.content.toLowerCase().includes('unauthorized') ||
data.content.toLowerCase().includes('authentication')
)) {
this.showMessageWithSettings(data.content, 'error');
} else {
this.showMessage('error', data.content);
}
}
};
eventSource.onerror = (error) => {
console.error('EventSource error:', error);
eventSource.close();
this.currentEventSource = null;
if (!this.streamingText) {
this.showMessage('error', 'Streaming connection failed');
} else {
cursorSpan.remove();
contentDiv.classList.remove('streaming');
}
};
}
handlePromptResponse(data) {
const outputDiv = document.getElementById('output');
const tweakBtn = document.getElementById('tweak-btn');
const copyBtn = document.getElementById('copy-btn');
if (data.success) {
this.currentOptimizedPrompt = data.refined_prompt;
this.renderOutput();
tweakBtn.classList.remove('hidden'); // Show tweak button
copyBtn.classList.remove('hidden'); // Show copy button
this.showResultsState(); // Show contextual results state
this.loadHistory();
} else {
this.showMessage('error', data.error || 'Failed to process prompt');
tweakBtn.classList.add('hidden');
copyBtn.classList.add('hidden');
}
}
renderOutput() {
const outputDiv = document.getElementById('output');
// Remove redundant "Optimized Prompt" heading if present
let cleanedPrompt = this.currentOptimizedPrompt.trim();
// Remove common redundant headings
const redundantHeadings = [
/^#\s*Optimized Prompt\s*\n+/i,
/^##\s*Optimized Prompt\s*\n+/i,
/^\*\*Optimized Prompt\*\*\s*\n+/i,
/^Optimized Prompt:\s*\n+/i
];
for (const pattern of redundantHeadings) {
cleanedPrompt = cleanedPrompt.replace(pattern, '');
}
// Enhanced plain text rendering with selective formatting
const formattedText = this.formatEnhancedText(cleanedPrompt);
outputDiv.innerHTML = `<div class="optimized-prompt-content">${formattedText}</div>`;
}
formatEnhancedText(text) {
// Escape HTML first
let formatted = this.escapeHtml(text);
// Convert **bold** to <strong>
formatted = formatted.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
// Convert *italic* to <em>
formatted = formatted.replace(/\*(.+?)\*/g, '<em>$1</em>');
// Convert `inline code` to <code>
formatted = formatted.replace(/`([^`]+)`/g, '<code>$1</code>');
// Preserve line breaks
formatted = formatted.replace(/\n/g, '<br>');
return formatted;
}
async showQuestionsAndCollectAnswers(questions) {
const outputDiv = document.getElementById('output');
const submitBtn = document.getElementById('submit-btn');
let currentQuestionIndex = 0;
const answers = {};
const questionMapping = {};
const renderQuestion = (index) => {
const question = questions[index];
const questionKey = `q${index}`;
const questionText = question.question || `Question ${index + 1}`;
const questionType = question.type || 'text';
const required = question.required !== false;
const isLastQuestion = index === questions.length - 1;
questionMapping[questionKey] = questionText;
let formHtml = '<div class="question-wizard">';
// Progress dots
formHtml += '<div class="wizard-progress">';
for (let i = 0; i < questions.length; i++) {
formHtml += `<span class="progress-dot ${i === index ? 'active' : ''} ${i < index ? 'completed' : ''}"></span>`;
}
formHtml += '</div>';
// Question content
formHtml += '<div class="wizard-content">';
formHtml += `<div class="wizard-question-number">Question ${index + 1} of ${questions.length}</div>`;
formHtml += `<h3 class="wizard-question-text">${this.escapeHtml(questionText)}${required ? ' <span style="color: var(--color-primary);">*</span>' : ''}</h3>`;
formHtml += '<form id="wizard-form">';
formHtml += '<div class="wizard-input-container">';
if (questionType === 'radio' && question.options && question.options.length > 0) {
question.options.forEach((option, optIndex) => {
formHtml += `<label class="wizard-option">`;
formHtml += `<input type="radio" name="${questionKey}" value="${this.escapeHtml(option)}" ${optIndex === 0 ? 'checked' : ''}>`;
formHtml += `<span class="wizard-option-text">${this.escapeHtml(option)}</span>`;
formHtml += `</label>`;
});
} else if (questionType === 'checkbox' && question.options && question.options.length > 0) {
question.options.forEach((option, optIndex) => {
formHtml += `<label class="wizard-option">`;
formHtml += `<input type="checkbox" name="${questionKey}" value="${this.escapeHtml(option)}">`;
formHtml += `<span class="wizard-option-text">${this.escapeHtml(option)}</span>`;
formHtml += `</label>`;
});
} else {
const savedAnswer = answers[questionKey] || '';
const placeholder = required ? "Type your answer here..." : "Optional - Leave blank to skip";
formHtml += `<textarea id="${questionKey}" name="${questionKey}" ${required ? 'required' : ''} class="wizard-input" placeholder="${placeholder}" rows="4">${this.escapeHtml(savedAnswer)}</textarea>`;
}
formHtml += '</div>'; // wizard-input-container
// Navigation buttons
formHtml += '<div class="wizard-actions">';
if (index > 0) {
formHtml += '<button type="button" id="wizard-prev-btn" class="btn btn-secondary">';
formHtml += '<span>← Previous</span>';
formHtml += '</button>';
} else {
formHtml += '<div></div>'; // Spacer
}
if (!required) {
formHtml += '<button type="button" id="wizard-skip-btn" class="btn btn-tertiary">';
formHtml += '<span>Skip</span>';
formHtml += '</button>';
}
if (isLastQuestion) {
formHtml += '<button type="submit" class="btn btn-primary">';
formHtml += '<span>Submit Answers</span>';
formHtml += '</button>';
} else {
formHtml += '<button type="submit" class="btn btn-primary">';
formHtml += '<span>Next →</span>';
formHtml += '</button>';
}
formHtml += '</div>'; // wizard-actions
formHtml += '</form>';
formHtml += '</div>'; // wizard-content
// Cancel button
formHtml += '<button type="button" id="wizard-cancel-btn" class="wizard-cancel-btn" title="Cancel">✕</button>';
formHtml += '</div>'; // question-wizard
outputDiv.innerHTML = formHtml;
// Focus the input
const firstInput = outputDiv.querySelector('input, textarea');
if (firstInput && firstInput.type !== 'radio' && firstInput.type !== 'checkbox') {
setTimeout(() => firstInput.focus(), 100);
}
};
return new Promise((resolve) => {
const handleNext = (form) => {
const question = questions[currentQuestionIndex];
const questionKey = `q${currentQuestionIndex}`;
const questionType = question.type || 'text';
// Collect answer
if (questionType === 'checkbox') {
const checkboxes = form.querySelectorAll(`input[name="${questionKey}"]:checked`);
answers[questionKey] = Array.from(checkboxes).map(cb => cb.value);
} else if (questionType === 'radio') {
const radio = form.querySelector(`input[name="${questionKey}"]:checked`);
answers[questionKey] = radio ? radio.value : '';
} else {
const input = form.querySelector(`[name="${questionKey}"]`);
answers[questionKey] = input ? input.value : '';
}
// Move to next question or finish
if (currentQuestionIndex < questions.length - 1) {
currentQuestionIndex++;
renderQuestion(currentQuestionIndex);
attachEventListeners();
} else {
// All questions answered
submitBtn.innerHTML = '<span class="spinner"></span><span>Generating optimized prompt...</span>';
this.showProgressIndicator('optimizing');
resolve({ responses: answers, mapping: questionMapping });
}
};
const handlePrevious = () => {
if (currentQuestionIndex > 0) {
currentQuestionIndex--;
renderQuestion(currentQuestionIndex);
attachEventListeners();
}
};
const handleSkip = () => {
const questionKey = `q${currentQuestionIndex}`;
answers[questionKey] = '';
if (currentQuestionIndex < questions.length - 1) {
currentQuestionIndex++;
renderQuestion(currentQuestionIndex);
attachEventListeners();
} else {
submitBtn.innerHTML = '<span class="spinner"></span><span>Generating optimized prompt...</span>';
this.showProgressIndicator('optimizing');
resolve({ responses: answers, mapping: questionMapping });
}
};
const handleCancel = () => {
this.cancelCurrentRequest();
resolve(null);
};
const attachEventListeners = () => {
const form = document.getElementById('wizard-form');
const prevBtn = document.getElementById('wizard-prev-btn');
const skipBtn = document.getElementById('wizard-skip-btn');
const cancelBtn = document.getElementById('wizard-cancel-btn');
if (form) {
form.addEventListener('submit', (e) => {
e.preventDefault();
handleNext(form);
});
}
if (prevBtn) {
prevBtn.addEventListener('click', handlePrevious);
}
if (skipBtn) {
skipBtn.addEventListener('click', handleSkip);
}
if (cancelBtn) {
cancelBtn.addEventListener('click', handleCancel);
}
// Enter key to submit
const textInput = form?.querySelector('textarea');
if (textInput) {
textInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && e.ctrlKey) {
e.preventDefault();
form.dispatchEvent(new Event('submit'));
}
});
}
};
// Render first question
renderQuestion(currentQuestionIndex);
attachEventListeners();
});
}
cancelCurrentRequest() {
if (this.currentAbortController) {
this.currentAbortController.abort();
this.currentAbortController = null;
}
if (this.currentEventSource) {
this.currentEventSource.close();
this.currentEventSource = null;
}
const submitBtn = document.getElementById('submit-btn');
const cancelBtn = document.getElementById('cancel-btn');
const outputDiv = document.getElementById('output');
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.innerHTML = '<span>Optimize Prompt</span>';
}
if (cancelBtn) {
cancelBtn.classList.add('hidden');
}
if (outputDiv) {
this.showMessage('info', 'Request cancelled');
}
}
/* ===================================================================
PROVIDERS MANAGEMENT
=================================================================== */
updateModelCacheInfo(timestamp) {
const el = document.getElementById('model-cache-info');
if (!el) return;
if (!timestamp) {
el.textContent = 'Models last updated: --';
} else {
el.textContent = `Models last updated: ${new Date(timestamp).toLocaleString()}`;
}
}
updateCapabilitiesInfo(providerId) {
const el = document.getElementById('provider-capabilities');
if (!el) return;
const caps = this.providerCapabilities?.[providerId] || {};
if (!Object.keys(caps).length) {
el.textContent = 'Capabilities: --';
return;
}
const parts = [];
if (caps.supports_json) parts.push('JSON mode');
if (caps.supports_tools) parts.push('Tools');
if (caps.supports_vision) parts.push('Vision');
if (caps.max_output_tokens) parts.push(`Max output tokens: ${caps.max_output_tokens}`);
el.textContent = `Capabilities: ${parts.join(', ') || '--'}`;
}
async loadProviders() {
try {
const response = await fetch(`${this.apiBaseUrl}/api/providers`);
const data = await response.json();
// Invalidate any stale preflight cache
this.clearProviderStatusCache();
// Cache capabilities and cache timestamp
this.providerCapabilities = {};
data.available_providers.forEach(p => {
this.providerCapabilities[p.id] = p.capabilities || {};
// Cache models for all providers from the initial load
if (p.models && p.models.length > 0) {
this.cachedModels[p.id] = {
models: p.models,
current_model: p.id === data.current_provider ? data.current_model : p.default_model,
fetchedAll: false
};
}
});
this.updateCapabilitiesInfo(data.current_provider);
this.updateModelCacheInfo(data.cache_last_updated);
// Update cache timestamp in Settings panel
const cacheTimestampEl = document.getElementById('cache-timestamp-settings');
if (cacheTimestampEl && data.cache_last_updated) {
const cacheDate = new Date(data.cache_last_updated);
cacheTimestampEl.textContent = `Last updated: ${this.formatTimestamp(cacheDate)}`;
}
// Update provider select dropdown
const providerSelect = document.getElementById('provider-select');
providerSelect.innerHTML = '<option value="">Auto</option>';
// Sort providers alphabetically by name for consistent ordering
const sortedProviders = [...data.available_providers].sort((a, b) => a.name.localeCompare(b.name));
sortedProviders.forEach(provider => {
const option = document.createElement('option');
option.value = provider.id;
option.textContent = provider.name;
option.selected = provider.id === data.current_provider;
// Add status indicator based on availability (no implicit preflight)
if (provider.available) {
option.setAttribute('data-status', 'configured');
option.setAttribute('title', `${provider.name} - Ready`);
} else {
option.setAttribute('data-status', 'unconfigured');
option.setAttribute('title', `${provider.name} - Needs API key`);
}
providerSelect.appendChild(option);
});
// Update mobile provider dropdown
const providerSelectMobile = document.getElementById('provider-select-mobile');
if (providerSelectMobile) {
providerSelectMobile.innerHTML = '<option value="">Auto</option>';
sortedProviders.forEach(provider => {
const option = document.createElement('option');
option.value = provider.id;
option.textContent = provider.name;
option.selected = provider.id === data.current_provider;
// Add status indicator based on availability
if (provider.available) {
option.setAttribute('data-status', 'configured');
option.setAttribute('title', `${provider.name} - Ready`);
} else {
option.setAttribute('data-status', 'unconfigured');
option.setAttribute('title', `${provider.name} - Needs API key`);
}
providerSelectMobile.appendChild(option);
});
}
// Load models for current provider
if (data.current_provider) {
await this.loadModelsForProvider(data.current_provider);
}
} catch (error) {
console.error('Error loading providers:', error);
this.showMessage('error', 'Failed to load providers');
}
}
async refreshModelsCache(providerId) {
try {
this.showToast('info', 'Refreshing model cache...');
const resp = await fetch(`${this.apiBaseUrl}/api/providers/cache/refresh`, {
method: 'POST'
});
const data = await resp.json();
if (resp.ok) {
this.updateModelCacheInfo(data.cache_last_updated);
// Update cache timestamp in Settings panel
const cacheTimestampEl = document.getElementById('cache-timestamp-settings');
if (cacheTimestampEl && data.cache_last_updated) {
const cacheDate = new Date(data.cache_last_updated);
cacheTimestampEl.textContent = `Last updated: ${this.formatTimestamp(cacheDate)}`;
}
if (providerId) {
await this.loadModelsForProvider(providerId, true);
}
this.showToast('success', 'Model cache refreshed');
} else {
this.showToast('error', data.detail || 'Failed to refresh cache');
}
} catch (error) {
console.error('Error refreshing model cache:', error);
this.showToast('error', 'Failed to refresh cache');
}
}
async runProviderPreflight() {
try {
// Clear cache to force fresh validation
this.clearProviderStatusCache();
// Show persistent loading state in the table
const tbody = document.getElementById('provider-status-tbody');
if (tbody) {
tbody.innerHTML = `
<tr>
<td colspan="4" style="text-align: center; padding: var(--space-6);">
<div class="progress-indicator">
<div class="progress-symbol">⚗</div>
<div class="progress-text">Validating all providers...</div>
</div>
</td>
</tr>
`;
}
this.showToast('info', 'Validating providers...');
const resp = await fetch(`${this.apiBaseUrl}/api/providers/preflight`);
const data = await resp.json();
if (!resp.ok) {
this.showToast('error', data.detail || 'Validation failed');
// Clear loading state
if (tbody) tbody.innerHTML = '';
return;
}
// Cache the validation results with timestamp
const cacheData = {
results: data,
timestamp: Date.now()
};
localStorage.setItem('promptheus_validation_cache', JSON.stringify(cacheData));
// Also cache provider status for dropdown indicators
this.cacheProviderStatus(data);
// Update the provider status table in Settings panel
this.updateProviderStatusTable(data);
// Update the status dot based on results
this.updateSystemStatus(data);
// Only count actual errors, not missing keys (which is expected)
const errors = data.filter(r => r.status === 'error');
const configured = data.filter(r => r.status === 'ok').length;
if (errors.length) {
this.showToast('error', `${errors.length} configured provider(s) failed validation`);
} else if (configured > 0) {
this.showToast('success', `${configured} provider(s) validated successfully`);
} else {
this.showToast('info', 'No providers configured yet');
}
} catch (error) {
console.error('Preflight error:', error);
this.showToast('error', 'Validation failed');
// Clear loading state
const tbody = document.getElementById('provider-status-tbody');
if (tbody) tbody.innerHTML = '';
}
}
loadCachedValidationResults() {
try {
const cached = localStorage.getItem('promptheus_validation_cache');
if (!cached) return false;
const cacheData = JSON.parse(cached);
const age = Date.now() - cacheData.timestamp;
const oneHour = 60 * 60 * 1000;
// Expire after 1 hour
if (age > oneHour) {
localStorage.removeItem('promptheus_validation_cache');
return false;
}
// Update the table with cached results
this.updateProviderStatusTable(cacheData.results);
this.updateSystemStatus(cacheData.results);
// Show age of cached data
const ageMinutes = Math.floor(age / 60000);
const ageText = ageMinutes < 1 ? 'just now' :
ageMinutes === 1 ? '1 minute ago' :
`${ageMinutes} minutes ago`;
// Add cache indicator to the table
const tbody = document.getElementById('provider-status-tbody');
if (tbody && tbody.querySelector('tr')) {
const indicator = document.createElement('tr');
indicator.className = 'cache-indicator-row';
indicator.innerHTML = `
<td colspan="4" style="text-align: center; padding: var(--space-2); font-size: var(--text-xs); color: var(--text-tertiary); font-style: italic;">
Cached results from ${ageText}
</td>
`;
tbody.insertBefore(indicator, tbody.firstChild);
}
return true;
} catch (error) {
console.error('Error loading cached validation:', error);
return false;
}
}
updateProviderStatusTable(results) {
const tbody = document.getElementById('provider-status-tbody');
if (!tbody) return;
tbody.innerHTML = '';
results.forEach(result => {
const row = document.createElement('tr');
// Provider name
const nameCell = document.createElement('td');
nameCell.textContent = result.display_name;
row.appendChild(nameCell);
// Status
const statusCell = document.createElement('td');
const statusIcon = result.status === 'ok' ? '✓' :
result.status === 'missing_key' ? '⚠' : '✗';
const statusColor = result.status === 'ok' ? 'var(--color-success)' :
result.status === 'missing_key' ? 'var(--color-warning)' :
'var(--color-error)';
statusCell.innerHTML = `<span style="color: ${statusColor}; font-weight: var(--font-semibold);">${statusIcon} ${result.message || result.status}</span>`;
row.appendChild(statusCell);
// Models count (only for successful validations)
const modelsCell = document.createElement('td');
if (result.status === 'ok') {
// Fetch model count from cached data
const providerId = result.provider_id;
const cachedData = this.cachedModels[providerId];
if (cachedData && cachedData.models) {
modelsCell.textContent = `${cachedData.models.length} models`;
} else {
modelsCell.textContent = '--';
}
} else {
modelsCell.textContent = '--';
}
row.appendChild(modelsCell);
// Updated timestamp
const timeCell = document.createElement('td');
if (result.duration_ms) {
timeCell.textContent = `${result.duration_ms}ms`;
timeCell.style.fontFamily = 'var(--font-mono)';
timeCell.style.fontSize = 'var(--text-xs)';
} else {
timeCell.textContent = 'Just now';
}
row.appendChild(timeCell);
tbody.appendChild(row);
});
// Update cache timestamp in Settings panel
const cacheTimestampEl = document.getElementById('cache-timestamp-settings');
if (cacheTimestampEl) {
cacheTimestampEl.textContent = `Last validated: ${this.formatTimestamp(new Date())}`;
}
}
updateSystemStatus(results) {
const statusBtn = document.getElementById('provider-status-btn');
const statusDot = statusBtn?.querySelector('.status-dot');
if (!statusDot) return;
const hasErrors = results.some(r => r.status === 'error');
const hasConfigured = results.some(r => r.status === 'ok');
// Remove all status classes
statusDot.classList.remove('status-ok', 'status-warning', 'status-error');
// Add appropriate status class
// Only show error if configured providers have actual errors
if (hasErrors) {
statusDot.classList.add('status-error');
statusBtn.title = 'Configured providers have errors - click to view details';
} else if (hasConfigured) {
statusDot.classList.add('status-ok');
statusBtn.title = 'All configured providers working';
} else {
// No providers configured - show neutral/info state
statusDot.classList.add('status-ok');
statusBtn.title = 'All systems operational';
}
}
/**
* Show sync indicator briefly to indicate mobile/main dropdown sync
* @param {string} indicatorId - ID of the sync indicator element
*/
showSyncIndicator(indicatorId) {
const indicator = document.getElementById(indicatorId);
if (!indicator) return;
indicator.classList.add('active');
setTimeout(() => {
indicator.classList.remove('active');
}, 600); // Match animation duration
}
formatTimestamp(date) {
const now = new Date();
const diffMs = now - date;
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) return 'Just now';
if (diffMin < 60) return `${diffMin}m ago`;
if (diffHour < 24) return `${diffHour}h ago`;
if (diffDay < 7) return `${diffDay}d ago`;
return date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
async selectProvider(providerId) {
try {
const payloadId = providerId || "";
if (!payloadId) {
const modelSelect = document.getElementById('model-select');
if (modelSelect) {
modelSelect.innerHTML = '<option value="">Auto</option>';
modelSelect.disabled = true;
}
}
const response = await fetch(`${this.apiBaseUrl}/api/providers/select`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider_id: payloadId })
});
const data = await response.json();
if (response.ok) {
if (payloadId === "") {
this.showToast('success', 'Provider reset to auto-detect');
} else {
this.showToast('success', `Provider changed to ${data.current_provider}${data.available === false ? ' (key not set yet)' : ''}`);
}
// Persist selection in UI/state and reload models
const providerSelect = document.getElementById('provider-select');
if (providerSelect) {
providerSelect.value = payloadId;
}
this.provider = payloadId || '';
await this.loadModelsForProvider(payloadId || data.current_provider || '');
this.updateCapabilitiesInfo(payloadId || data.current_provider || this.provider);
} else {
this.showToast('error', data.detail || 'Failed to change provider');
}
} catch (error) {
console.error('Error selecting provider:', error);
this.showToast('error', 'Network error: ' + error.message);
}
}
async loadModelsForProvider(providerId, fetchAll = false) {
if (!providerId) {
const modelSelect = document.getElementById('model-select');
if (modelSelect) {
modelSelect.innerHTML = '<option value="">Auto</option>';
modelSelect.disabled = true;
}
// Sync mobile model dropdown using helper
this.syncMobileModelDropdown(null, null, true);
return;
}
const modelSelect = document.getElementById('model-select');
if (!modelSelect) return;
try {
// Check if we have cached models for this provider
if (this.cachedModels[providerId] && !fetchAll) {
const cachedData = this.cachedModels[providerId];
const hasCachedModels = Array.isArray(cachedData.models) && cachedData.models.length > 0;
// Do not stick to an empty cache; allow a refetch after transient failures
if (!hasCachedModels) {
delete this.cachedModels[providerId];
} else {
modelSelect.disabled = false;
this.populateModelSelect(modelSelect, cachedData.models, cachedData.current_model, cachedData.fetchedAll || false);
return;
}
}
// Show loading state if fetching all models
if (fetchAll) {
modelSelect.disabled = true;
modelSelect.innerHTML = '<option>Loading all models...</option>';
}
const url = fetchAll
? `${this.apiBaseUrl}/api/providers/${providerId}/models?fetch_all=true`
: `${this.apiBaseUrl}/api/providers/${providerId}/models`;
const response = await fetch(url);
const data = await response.json();
this.updateModelCacheInfo(data.cache_last_updated || data.cacheLastUpdated);
this.updateCapabilitiesInfo(providerId);
// Cache the models for this provider
if (fetchAll && data.models && data.models.length > 0) {
this.cachedModels[providerId] = {
models: data.models,
current_model: data.current_model,
fetchedAll: true
};
}
modelSelect.innerHTML = '';
modelSelect.disabled = false;
this.populateModelSelect(modelSelect, data.models, data.current_model, fetchAll);
// Show toast notification if we loaded all models
if (fetchAll && data.models && data.models.length > 0) {
this.showToast('success', `Loaded ${data.models.length} models for ${providerId}`);
}
} catch (error) {
console.error('Error loading models:', error);
modelSelect.innerHTML = '<option value="">Error loading models</option>';
modelSelect.disabled = false;
this.showToast('error', 'Failed to load models');
}
}
/**
* Helper method to sync mobile model dropdown with main dropdown
* @param {Array} models - Array of model names
* @param {string} currentModel - Currently selected model
* @param {boolean} disabled - Whether dropdown should be disabled
* @param {boolean} isOpenRouter - Whether this is OpenRouter provider
*/
syncMobileModelDropdown(models, currentModel, disabled = false, isOpenRouter = false) {
const modelSelectMobile = document.getElementById('model-select-mobile');
if (!modelSelectMobile) return;
modelSelectMobile.innerHTML = '';
if (models && models.length > 0) {
models.forEach((model, index) => {
const option = document.createElement('option');
option.value = model;
// For OpenRouter, show friendly "Auto" text instead of "openrouter/auto"
if (isOpenRouter && model === 'openrouter/auto') {
option.textContent = 'Auto';
} else {
option.textContent = model;
}
// Select current model if set, otherwise select first model
if (currentModel) {
option.selected = model === currentModel;
} else if (index === 0) {
option.selected = true;
}
modelSelectMobile.appendChild(option);
});
modelSelectMobile.disabled = disabled;
} else {
const emptyOption = document.createElement('option');
emptyOption.value = '';
emptyOption.textContent = models === null ? 'Auto' : 'No models available';
modelSelectMobile.appendChild(emptyOption);
modelSelectMobile.disabled = true;
}
}
populateModelSelect(modelSelect, models, currentModel, fetchedAll) {
modelSelect.innerHTML = '';
// Check if this is OpenRouter provider
const providerId = document.getElementById('provider-select')?.value;
const isOpenRouter = providerId === 'openrouter';
// If we got models, populate them
if (models && models.length > 0) {
models.forEach((model, index) => {
const option = document.createElement('option');
option.value = model;
// For OpenRouter, show friendly "Auto" text instead of "openrouter/auto"
if (isOpenRouter && model === 'openrouter/auto') {
option.textContent = 'Auto';
} else {
option.textContent = model;
}
// Select current model if set, otherwise select first model
if (currentModel) {
option.selected = model === currentModel;
} else if (index === 0) {
option.selected = true;
}
modelSelect.appendChild(option);
});
// Sync mobile model dropdown using helper
this.syncMobileModelDropdown(models, currentModel, false, isOpenRouter);
} else {
// No models found
const emptyOption = document.createElement('option');
emptyOption.value = '';
emptyOption.textContent = 'No models available';
modelSelect.appendChild(emptyOption);
// Sync mobile model dropdown using helper
this.syncMobileModelDropdown([], currentModel, true, isOpenRouter);
}
// Append the "Load All Models" option last so real models stay primary
// Skip for OpenRouter since it only returns the recommended model
if (!fetchedAll && models && models.length > 0 && !isOpenRouter) {
const loadAllOption = document.createElement('option');
loadAllOption.value = '__load_all__';
loadAllOption.textContent = '↻ Load All Models...';
loadAllOption.style.fontStyle = 'italic';
loadAllOption.style.color = 'var(--text-secondary)';
modelSelect.appendChild(loadAllOption);
}
}
async selectModel(model) {
const providerId = document.getElementById('provider-select').value;
if (!providerId || !model) return;
// Check if user selected "Load All Models"
if (model === '__load_all__') {
await this.loadModelsForProvider(providerId, true);
return;
}
try {
const response = await fetch(`${this.apiBaseUrl}/api/providers/select-model`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
provider_id: providerId,
model: model
})
});
const data = await response.json();
if (response.ok) {
this.showToast('success', `Model changed to ${data.current_model}`);
} else {
this.showToast('error', data.detail || 'Failed to change model');
}
} catch (error) {
console.error('Error selecting model:', error);
this.showToast('error', 'Network error: ' + error.message);
}
}
/* ===================================================================
HISTORY MANAGEMENT
=================================================================== */
async loadHistory() {
try {
const offset = this.currentHistoryPage * this.currentPageSize;
const response = await fetch(`${this.apiBaseUrl}/api/history?limit=${this.currentPageSize}&offset=${offset}`);
const data = await response.json();
const historyList = document.getElementById('history-list');
historyList.innerHTML = '';
if (data.entries.length === 0) {
historyList.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">📜</div>
<div class="empty-state-title">No History Yet</div>
<div class="empty-state-description">Your optimized prompts will appear here</div>
</div>
`;
this.totalHistoryPages = 0;
this.updateHistoryPagination();
return;
}
this.totalHistoryPages = Math.ceil(data.total / this.currentPageSize);
data.entries.forEach(entry => {
const card = this.createHistoryCard(entry);
historyList.appendChild(card);
});
this.updateHistoryPagination();
} catch (error) {
console.error('Error loading history:', error);
this.showMessage('error', 'Failed to load history');
}
}
createHistoryCard(entry) {
const card = document.createElement('div');
card.className = 'history-card';
card.setAttribute('role', 'listitem');
const timestamp = new Date(entry.timestamp).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
const taskType = entry.task_type || 'general';
const badgeClass = `badge-${taskType.toLowerCase()}`;
card.innerHTML = `
<div class="history-card-header">
<div class="history-card-timestamp">${timestamp}</div>
<span class="history-card-badge ${badgeClass}">${this.escapeHtml(taskType)}</span>
</div>
<div class="history-card-content">
<div class="history-card-preview">${this.escapeHtml(entry.original_prompt)}</div>
</div>
<div class="history-card-footer">
<div class="history-card-meta">${this.escapeHtml(entry.provider)} • ${this.escapeHtml(entry.model)}</div>
<button class="history-card-delete" title="Delete this entry" data-timestamp="${entry.timestamp}">
🗑
</button>
</div>
`;
// Add delete button handler
const deleteBtn = card.querySelector('.history-card-delete');
deleteBtn.addEventListener('click', async (e) => {
e.stopPropagation(); // Prevent card click event
await this.deleteHistoryEntry(entry.timestamp);
});
card.addEventListener('click', () => {
// Restore both input and output
const promptInput = document.getElementById('prompt-input');
const outputDiv = document.getElementById('output');
const tweakBtn = document.getElementById('tweak-btn');
const copyBtn = document.getElementById('copy-btn');
// Set the input value
promptInput.value = entry.original_prompt;
// Restore the optimized output if available
if (entry.refined_prompt) {
this.currentOptimizedPrompt = entry.refined_prompt;
this.renderOutput();
tweakBtn.classList.remove('hidden');
copyBtn.classList.remove('hidden');
this.showResultsState(); // Show contextual results state
}
// Scroll to top to see the restored content
window.scrollTo({ top: 0, behavior: 'smooth' });
});
return card;
}
previousHistoryPage() {
if (this.currentHistoryPage > 0) {
this.currentHistoryPage--;
this.loadHistory();
}
}
nextHistoryPage() {
if (this.currentHistoryPage < this.totalHistoryPages - 1) {
this.currentHistoryPage++;
this.loadHistory();
}
}
updateHistoryPagination() {
const prevBtn = document.getElementById('prev-page-btn');
const nextBtn = document.getElementById('next-page-btn');
const currentPageSpan = document.getElementById('current-page');
prevBtn.disabled = this.currentHistoryPage <= 0;
nextBtn.disabled = this.currentHistoryPage >= this.totalHistoryPages - 1 || this.totalHistoryPages <= 1;
const currentPageDisplay = this.currentHistoryPage + 1;
const totalPagesDisplay = this.totalHistoryPages || 1;
currentPageSpan.textContent = `${currentPageDisplay} of ${totalPagesDisplay}`;
}
async deleteHistoryEntry(timestamp) {
try {
const response = await fetch(`${this.apiBaseUrl}/api/history/${encodeURIComponent(timestamp)}`, {
method: 'DELETE'
});
if (response.ok) {
this.showToast('success', 'History entry deleted');
this.loadHistory(); // Reload to refresh the list
} else {
this.showToast('error', 'Failed to delete history entry');
}
} catch (error) {
console.error('Error deleting history entry:', error);
this.showToast('error', 'Network error: ' + error.message);
}
}
async clearHistory() {
try {
const response = await fetch(`${this.apiBaseUrl}/api/history`, {
method: 'DELETE'
});
if (response.ok) {
this.showToast('success', 'History cleared successfully');
this.currentHistoryPage = 0;
this.loadHistory();
} else {
this.showToast('error', 'Failed to clear history');
}
} catch (error) {
console.error('Error clearing history:', error);
this.showToast('error', 'Network error: ' + error.message);
}
}
/* ===================================================================
SETTINGS MANAGEMENT
=================================================================== */
async loadSettings() {
try {
const response = await fetch(`${this.apiBaseUrl}/api/settings`);
const data = await response.json();
const settingsForm = document.getElementById('settings-form');
settingsForm.innerHTML = '';
// Check if settings is an array
if (!Array.isArray(data.settings)) {
console.error('Settings is not an array:', data.settings);
settingsForm.innerHTML = '<p style="color: var(--text-error);">Error: Invalid settings format</p>';
return;
}
// Group settings by category
const groupedSettings = {};
data.settings.forEach(setting => {
if (!groupedSettings[setting.category]) {
groupedSettings[setting.category] = [];
}
groupedSettings[setting.category].push(setting);
});
// Render each category
Object.entries(groupedSettings).forEach(([category, settings]) => {
// Skip provider category (handled in top bar)
if (category === 'provider') return;
// Only show API Keys and General sections
if (category !== 'api_keys' && category !== 'general') return;
// Create category section
const categorySection = document.createElement('div');
categorySection.className = 'settings-category';
// Add special class and heading for categories
if (category === 'general') {
categorySection.classList.add('general-settings');
const categoryHeading = document.createElement('h2');
categoryHeading.className = 'settings-category-heading';
categoryHeading.textContent = 'General';
categorySection.appendChild(categoryHeading);
} else if (category === 'api_keys') {
categorySection.classList.add('api-keys-settings');
const categoryHeading = document.createElement('h2');
categoryHeading.className = 'settings-category-heading';
categoryHeading.textContent = 'API Keys';
categorySection.appendChild(categoryHeading);
}
// Render each setting in the category
settings.forEach(setting => {
const item = document.createElement('div');
item.className = 'settings-item';
// Add special class for checkbox items
if (setting.type === 'checkbox') {
item.classList.add('checkbox-item');
}
const labelContainer = document.createElement('div');
labelContainer.className = 'settings-label-container';
const label = document.createElement('label');
label.className = 'settings-label';
label.textContent = setting.label;
label.htmlFor = `setting-${setting.key}`;
const description = document.createElement('p');
description.className = 'settings-description';
description.textContent = setting.description;
labelContainer.appendChild(label);
labelContainer.appendChild(description);
const inputContainer = document.createElement('div');
inputContainer.className = 'settings-input-container';
// Create input based on type
let inputElement;
if (setting.type === 'select') {
inputElement = document.createElement('select');
inputElement.className = 'settings-input';
setting.options.forEach(option => {
const opt = document.createElement('option');
opt.value = option;
opt.textContent = option === '' ? 'Auto' : option.charAt(0).toUpperCase() + option.slice(1);
if (option === setting.value) opt.selected = true;
inputElement.appendChild(opt);
});
} else if (setting.type === 'checkbox') {
// Create custom toggle switch
inputElement = document.createElement('input');
inputElement.type = 'checkbox';
inputElement.className = 'settings-checkbox';
inputElement.checked = setting.value === 'true';
// Create toggle wrapper
const toggleWrapper = document.createElement('label');
toggleWrapper.className = 'settings-toggle-wrapper';
toggleWrapper.htmlFor = `setting-${setting.key}`;
// Create toggle switch
const toggleSwitch = document.createElement('div');
toggleSwitch.className = 'settings-toggle-switch';
// Create toggle label
const toggleLabel = document.createElement('span');
toggleLabel.className = 'settings-toggle-label';
toggleLabel.textContent = setting.value === 'true' ? 'Enabled' : 'Disabled';
toggleWrapper.appendChild(toggleSwitch);
toggleWrapper.appendChild(toggleLabel);
// Auto-save checkbox changes immediately
inputElement.addEventListener('change', async (e) => {
const newValue = e.target.checked ? 'true' : 'false';
toggleLabel.textContent = e.target.checked ? 'Enabled' : 'Disabled';
try {
await this.updateSetting(setting.key, newValue);
this.showToast('success', `${setting.label} ${e.target.checked ? 'enabled' : 'disabled'}`);
// If disabling history, offer to clear existing history
if (setting.key === 'PROMPTHEUS_ENABLE_HISTORY' && !e.target.checked) {
// Ask user if they want to clear history with custom dialog
setTimeout(() => {
this.showConfirmDialog(
'Clear History?',
'History has been disabled. Would you like to clear existing history as well?',
() => this.clearHistory()
);
}, 500);
}
} catch (error) {
console.error('Error saving checkbox setting:', error);
this.showToast('error', 'Failed to save setting');
// Revert the checkbox state
e.target.checked = !e.target.checked;
toggleLabel.textContent = e.target.checked ? 'Enabled' : 'Disabled';
}
});
// Store the toggle wrapper to insert later
inputElement.toggleWrapper = toggleWrapper;
// Prevent the default change handler from triggering
inputElement.dataset.autoSave = 'true';
} else if (setting.type === 'password') {
inputElement = document.createElement('input');
inputElement.type = 'password';
inputElement.className = 'settings-input';
// If key exists (masked), show as placeholder and keep field empty
// User must enter new key to update it
if (setting.masked) {
inputElement.value = '';
inputElement.placeholder = `${setting.value} (current key)`;
inputElement.dataset.hasExistingKey = 'true';
} else {
inputElement.value = '';
inputElement.placeholder = 'Enter your API key';
inputElement.dataset.hasExistingKey = 'false';
}
// Add eye icon for password visibility toggle
const eyeButton = document.createElement('button');
eyeButton.type = 'button';
eyeButton.className = 'settings-eye-btn';
eyeButton.innerHTML = '👁';
eyeButton.title = 'Show/Hide API Key';
eyeButton.addEventListener('click', () => {
if (inputElement.type === 'password') {
inputElement.type = 'text';
eyeButton.innerHTML = '👁🗨';
} else {
inputElement.type = 'password';
eyeButton.innerHTML = '👁';
}
});
inputContainer.appendChild(eyeButton);
} else {
inputElement = document.createElement('input');
inputElement.type = 'text';
inputElement.className = 'settings-input';
inputElement.value = setting.value || '';
}
inputElement.id = `setting-${setting.key}`;
inputElement.name = setting.key;
// Auto-save on change for non-checkbox/non-password fields
if (!inputElement.dataset.autoSave && setting.type !== 'password') {
inputElement.addEventListener('change', async () => {
try {
await this.updateSetting(setting.key, inputElement.value);
this.showToast('success', `${setting.label} updated`);
} catch (error) {
console.error('Error saving setting:', error);
this.showToast('error', 'Failed to save setting');
}
});
}
// Insert checkbox and toggle wrapper if it's a checkbox
if (setting.type === 'checkbox' && inputElement.toggleWrapper) {
inputContainer.appendChild(inputElement);
inputContainer.appendChild(inputElement.toggleWrapper);
} else {
inputContainer.insertBefore(inputElement, inputContainer.firstChild);
}
// Add Save & Validate button for API keys
if (setting.type === 'password') {
// Determine provider from key name
const providerMatch = setting.key.match(/^(\w+)_API_KEY$/);
let providerName = providerMatch ? providerMatch[1].toLowerCase() : null;
if (providerName === 'dashscope') providerName = 'qwen';
if (providerName === 'gemini') providerName = 'google';
if (providerName === 'zai' || providerName === 'zhipuai') providerName = 'glm';
// Create button container
const buttonContainer = document.createElement('div');
buttonContainer.style.display = 'flex';
buttonContainer.style.gap = 'var(--space-2)';
buttonContainer.style.alignItems = 'center';
buttonContainer.style.marginTop = 'var(--space-2)';
// Add Save button for individual API key
const saveBtn = document.createElement('button');
saveBtn.type = 'button';
saveBtn.className = 'btn btn-secondary btn-sm';
saveBtn.innerHTML = '<span>💾</span><span>Save</span>';
saveBtn.style.minWidth = '80px';
// Create status container for validation feedback
const statusContainer = document.createElement('div');
statusContainer.id = `status-${setting.key}`;
statusContainer.className = 'validation-status-container';
saveBtn.addEventListener('click', async () => {
await this.saveAndValidateApiKey(setting.key, inputElement, providerName, statusContainer);
});
buttonContainer.appendChild(saveBtn);
item.appendChild(labelContainer);
item.appendChild(inputContainer);
item.appendChild(buttonContainer);
item.appendChild(statusContainer);
} else {
item.appendChild(labelContainer);
item.appendChild(inputContainer);
}
categorySection.appendChild(item);
});
settingsForm.appendChild(categorySection);
});
} catch (error) {
console.error('Error loading settings:', error);
this.showToast('error', 'Failed to load settings');
}
}
async updateSetting(key, value) {
try {
const response = await fetch(`${this.apiBaseUrl}/api/settings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, value })
});
const data = await response.json();
if (response.ok) {
this.showToast('success', `Setting "${key}" updated`);
this.loadProviders();
} else {
this.showToast('error', data.detail || 'Failed to update setting');
}
} catch (error) {
console.error('Error updating setting:', error);
this.showToast('error', 'Network error: ' + error.message);
}
}
async saveAndValidateApiKey(key, inputElement, providerName, statusContainer) {
const apiKey = inputElement.value.trim();
const hasExistingKey = inputElement.dataset.hasExistingKey === 'true';
// If field is empty and there's an existing key, we need to get it from backend to validate
if (!apiKey && hasExistingKey) {
// User wants to test existing key without re-entering it
// We can't validate without the actual key, so just show a message
this.showToast('info', 'Enter a new API key to save and validate, or use the existing key');
return;
}
if (!apiKey) {
this.showToast('error', 'Please enter an API key');
return;
}
// Save the API key first
try {
await this.updateSetting(key, apiKey);
// After saving, mark that we now have an existing key and remember the value for quick retries
inputElement.dataset.hasExistingKey = 'true';
inputElement.dataset.lastSavedKey = apiKey;
// Clear the field and update placeholder
const maskedKey = '●'.repeat(Math.max(0, apiKey.length - 4)) + apiKey.slice(-4);
inputElement.value = '';
inputElement.placeholder = `${maskedKey} (current key)`;
} catch (error) {
this.showToast('error', 'Failed to save API key');
return;
}
// Now validate the connection if provider is known
if (!providerName) {
this.showToast('success', 'API key saved');
return;
}
await this.validateApiKey(providerName, apiKey, key, inputElement, statusContainer);
// Refresh provider availability after successful save/validate
this.loadProviders();
}
async validateApiKey(provider, apiKey, settingKey, inputElement, statusContainer) {
// Show validating status
inputElement.classList.remove('valid', 'invalid');
inputElement.classList.add('validating');
// Normalize provider aliases
const normalizedProvider = (() => {
const p = (provider || '').toLowerCase();
if (p === 'dashscope') return 'qwen';
if (p === 'gemini') return 'google';
if (p === 'zai' || p === 'zhipuai') return 'glm';
return p;
})();
statusContainer.innerHTML = `
<div class="validation-status validating">
<span class="validation-status-icon">⚗</span>
<span class="validation-status-text">Verifying connection...</span>
</div>
`;
try {
const response = await fetch(`${this.apiBaseUrl}/api/settings/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider: normalizedProvider, api_key: apiKey })
});
const data = await response.json();
inputElement.classList.remove('validating');
if (data.valid) {
// Success state
inputElement.classList.add('valid');
const modelsInfo = data.models_available && data.models_available.length > 0
? `<div style="margin-top: var(--space-1); font-size: var(--text-xs); opacity: 0.8;">Models: ${data.models_available.slice(0, 3).join(', ')}${data.models_available.length > 3 ? '...' : ''}</div>`
: '';
statusContainer.innerHTML = `
<div class="validation-status success">
<span class="validation-status-icon">✓</span>
<span class="validation-status-text">Connected to ${provider}</span>
<span class="validation-status-time">Just now</span>
</div>
${modelsInfo}
`;
this.showToast('success', `✓ ${provider} API key validated`);
} else {
// Error state
inputElement.classList.add('invalid');
statusContainer.innerHTML = `
<div class="validation-status error">
<span class="validation-status-icon">✗</span>
<span class="validation-status-text">Connection Failed</span>
</div>
<div class="validation-error-details">
<strong>Error:</strong> ${this.escapeHtml(data.error || 'Unknown error')}<br>
<strong>Suggestion:</strong> ${this.escapeHtml(data.suggestion || 'Check your API key and try again')}
</div>
<button class="validation-retry-btn" onclick="window.promptheusApp.retryValidation('${provider}', '${settingKey}')">
↻ Retry
</button>
`;
this.showToast('error', `✗ ${provider} validation failed`);
}
} catch (error) {
inputElement.classList.remove('validating');
inputElement.classList.add('invalid');
statusContainer.innerHTML = `
<div class="validation-status error">
<span class="validation-status-icon">✗</span>
<span class="validation-status-text">Validation Error</span>
</div>
<div class="validation-error-details">
Network error: ${this.escapeHtml(error.message)}
</div>
`;
this.showToast('error', 'Network error during validation');
}
}
retryValidation(provider, settingKey) {
const inputElement = document.getElementById(`setting-${settingKey}`);
const statusContainer = document.getElementById(`status-${settingKey}`);
if (!inputElement || !statusContainer) {
this.showToast('error', 'Unable to retry validation—input not found');
return;
}
let apiKey = inputElement.value.trim();
if (!apiKey) {
apiKey = inputElement.dataset.lastSavedKey || '';
}
if (!apiKey) {
this.showToast('info', 'Enter an API key to validate');
return;
}
this.validateApiKey(provider, apiKey, settingKey, inputElement, statusContainer);
}
/* ===================================================================
PROMPT TWEAKING
=================================================================== */
async showTweakPromptDialog() {
const outputDiv = document.getElementById('output');
const optimizedPromptDiv = outputDiv.querySelector('.optimized-prompt-content');
if (!optimizedPromptDiv) {
this.showMessage('error', 'No prompt to tweak');
return;
}
const currentPrompt = optimizedPromptDiv.textContent || optimizedPromptDiv.innerText;
// Show tweak input form
let formHtml = '<div class="tweak-container">';
formHtml += '<div class="tweak-header">';
formHtml += '<h3 class="tweak-title">Tweak Your Prompt</h3>';
formHtml += '<p class="tweak-description">Describe how you want to modify the optimized prompt:</p>';
formHtml += '</div>';
formHtml += '<form id="tweak-form">';
formHtml += '<div class="tweak-item">';
formHtml += '<textarea id="tweak-instruction" placeholder="e.g., Make it more formal, Add more details about X, Make it shorter" required class="tweak-input"></textarea>';
formHtml += '</div>';
formHtml += '<div class="tweak-examples">';
formHtml += '<p style="font-size: var(--text-sm); color: var(--text-secondary); margin-bottom: var(--space-2);">Examples:</p>';
formHtml += '<ul style="font-size: var(--text-sm); color: var(--text-secondary); margin-left: var(--space-4);">';
formHtml += '<li>Make it more formal and professional</li>';
formHtml += '<li>Add specific examples for each point</li>';
formHtml += '<li>Make it more concise and direct</li>';
formHtml += '<li>Convert to bullet points</li>';
formHtml += '</ul>';
formHtml += '</div>';
formHtml += '<div class="tweak-actions">';
formHtml += '<button type="submit" class="btn btn-primary">Apply Tweak</button>';
formHtml += '<button type="button" id="cancel-tweak-btn" class="btn btn-secondary">Cancel</button>';
formHtml += '</div>';
formHtml += '</form></div>';
outputDiv.innerHTML = formHtml;
const form = document.getElementById('tweak-form');
const cancelBtn = document.getElementById('cancel-tweak-btn');
const tweakBtn = document.getElementById('tweak-btn');
const copyBtn = document.getElementById('copy-btn');
tweakBtn.classList.add('hidden'); // Hide tweak button while tweaking
copyBtn.classList.add('hidden'); // Hide copy button while tweaking
cancelBtn.addEventListener('click', () => {
// Restore the original prompt
outputDiv.innerHTML = `<div class="optimized-prompt-content">${this.escapeHtml(currentPrompt)}</div>`;
tweakBtn.classList.remove('hidden');
copyBtn.classList.remove('hidden');
});
form.addEventListener('submit', async (e) => {
e.preventDefault();
const tweakInstruction = document.getElementById('tweak-instruction').value.trim();
if (!tweakInstruction) {
this.showMessage('error', 'Please enter a tweak instruction');
return;
}
const submitButton = form.querySelector('button[type="submit"]');
submitButton.disabled = true;
submitButton.innerHTML = '<span class="spinner"></span><span>Applying tweak...</span>';
try {
const provider = document.getElementById('provider-select').value;
const response = await fetch(`${this.apiBaseUrl}/api/prompt/tweak`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
current_prompt: currentPrompt,
tweak_instruction: tweakInstruction,
provider: provider || null
})
});
const data = await response.json();
if (data.success) {
this.currentOptimizedPrompt = data.tweaked_prompt;
this.renderOutput();
tweakBtn.classList.remove('hidden');
copyBtn.classList.remove('hidden');
} else {
this.showMessage('error', data.error || 'Failed to tweak prompt');
}
} catch (error) {
console.error('Error tweaking prompt:', error);
this.showMessage('error', 'Network error: ' + error.message);
} finally {
submitButton.disabled = false;
submitButton.innerHTML = '<span>Apply Tweak</span>';
}
});
}
/* ===================================================================
UI HELPERS
=================================================================== */
extractTextWithNewlines(node) {
if (!node) return '';
// Work off rendered HTML to normalize line breaks consistently
const html = node.innerHTML;
const withBreaks = html
.replace(/<br\s*\/?>(\r?\n)?/gi, '\n')
.replace(/<\/(p|div|section|article|h[1-6])>/gi, '\n\n')
.replace(/<\/(li)>/gi, '\n')
.replace(/<li>/gi, '- ')
.replace(/<[^>]+>/g, '')
.replace(/ /gi, ' ')
.replace(/&/gi, '&')
.replace(/</gi, '<')
.replace(/>/gi, '>')
.replace(/"/gi, '"')
.replace(/'/gi, "'");
return withBreaks.replace(/\n{3,}/g, '\n\n').trim();
}
async copyOutputToClipboard() {
const outputDiv = document.getElementById('output');
const optimizedPromptDiv = outputDiv.querySelector('.optimized-prompt-content');
if (!optimizedPromptDiv) {
this.showToast('info', 'Nothing to copy');
return;
}
const textFromState = (this.currentOptimizedPrompt || '').trim();
const textFromDom = this.extractTextWithNewlines(optimizedPromptDiv);
const textToCopy = textFromState || textFromDom;
try {
await navigator.clipboard.writeText(textToCopy);
this.showToast('success', 'Copied to clipboard!');
} catch (err) {
console.error('Failed to copy:', err);
// Fallback
const textArea = document.createElement('textarea');
textArea.value = textToCopy;
textArea.style.position = 'fixed';
textArea.style.opacity = '0';
document.body.appendChild(textArea);
textArea.select();
try {
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
if (successful) {
this.showToast('success', 'Copied to clipboard!');
} else {
this.showToast('error', 'Failed to copy');
}
} catch (err) {
document.body.removeChild(textArea);
this.showToast('error', 'Failed to copy');
}
}
}
showToast(type, message) {
// Ensure toast container exists
let toastContainer = document.querySelector('.toast-container');
if (!toastContainer) {
toastContainer = document.createElement('div');
toastContainer.className = 'toast-container';
document.body.appendChild(toastContainer);
}
// Create toast element
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
const iconMap = {
success: '✓',
error: '⚠',
info: '💡',
warning: '⚡'
};
toast.innerHTML = `
<span class="toast-icon">${iconMap[type] || '💡'}</span>
<span class="toast-message">${this.escapeHtml(message)}</span>
`;
// Add to container (prepend so new toasts appear at bottom)
toastContainer.appendChild(toast);
// Trigger animation
setTimeout(() => toast.classList.add('show'), 10);
// Limit to maximum 4 toasts
const allToasts = toastContainer.querySelectorAll('.toast');
if (allToasts.length > 4) {
// Remove oldest toast (first in the stack)
const oldestToast = allToasts[0];
oldestToast.classList.remove('show');
setTimeout(() => oldestToast.remove(), 300);
}
// Remove after 3 seconds
setTimeout(() => {
if (toast.parentElement) {
toast.classList.remove('show');
setTimeout(() => {
if (toast.parentElement) {
toast.remove();
// Clean up container if empty
if (toastContainer.children.length === 0) {
toastContainer.remove();
}
}
}, 300);
}
}, 3000);
}
showConfirmDialog(title, message, onConfirm) {
// Create overlay
const overlay = document.createElement('div');
overlay.className = 'confirm-dialog-overlay';
// Create dialog
const dialog = document.createElement('div');
dialog.className = 'confirm-dialog';
dialog.innerHTML = `
<div class="confirm-dialog-header">
<h3 class="confirm-dialog-title">${this.escapeHtml(title)}</h3>
</div>
<div class="confirm-dialog-body">
<p class="confirm-dialog-message">${this.escapeHtml(message)}</p>
</div>
<div class="confirm-dialog-actions">
<button class="btn btn-secondary confirm-dialog-cancel">Cancel</button>
<button class="btn btn-primary confirm-dialog-confirm">Confirm</button>
</div>
`;
overlay.appendChild(dialog);
document.body.appendChild(overlay);
// Trigger animation
setTimeout(() => {
overlay.classList.add('active');
dialog.classList.add('active');
}, 10);
// Handle confirm
const confirmBtn = dialog.querySelector('.confirm-dialog-confirm');
confirmBtn.addEventListener('click', () => {
this.closeConfirmDialog(overlay, dialog);
if (onConfirm) onConfirm();
});
// Handle cancel
const cancelBtn = dialog.querySelector('.confirm-dialog-cancel');
cancelBtn.addEventListener('click', () => {
this.closeConfirmDialog(overlay, dialog);
});
// Handle overlay click
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
this.closeConfirmDialog(overlay, dialog);
}
});
// Handle escape key
const escapeHandler = (e) => {
if (e.key === 'Escape') {
this.closeConfirmDialog(overlay, dialog);
document.removeEventListener('keydown', escapeHandler);
}
};
document.addEventListener('keydown', escapeHandler);
}
closeConfirmDialog(overlay, dialog) {
dialog.classList.remove('active');
overlay.classList.remove('active');
setTimeout(() => overlay.remove(), 300);
}
showKeyboardShortcuts() {
// Create overlay
const overlay = document.createElement('div');
overlay.className = 'confirm-dialog-overlay';
// Create dialog
const dialog = document.createElement('div');
dialog.className = 'confirm-dialog keyboard-shortcuts-dialog';
dialog.innerHTML = `
<div class="confirm-dialog-header">
<h3 class="confirm-dialog-title">⌨️ Keyboard Shortcuts</h3>
<button class="help-tooltip-close" aria-label="Close">✕</button>
</div>
<div class="confirm-dialog-body shortcuts-body">
<div class="shortcuts-section">
<h4 class="shortcuts-section-title">Primary Actions</h4>
<div class="shortcuts-list">
<div class="shortcut-row">
<div class="shortcut-keys">Ctrl + ↵</div>
<div class="shortcut-desc">Submit/Optimize Prompt</div>
</div>
<div class="shortcut-row">
<div class="shortcut-keys">Ctrl + N</div>
<div class="shortcut-desc">Start Over</div>
</div>
<div class="shortcut-row">
<div class="shortcut-keys">Ctrl + Shift + X</div>
<div class="shortcut-desc">Cancel</div>
</div>
<div class="shortcut-row">
<div class="shortcut-keys">Ctrl + Shift + C</div>
<div class="shortcut-desc">Copy output</div>
</div>
<div class="shortcut-row">
<div class="shortcut-keys">Ctrl + T</div>
<div class="shortcut-desc">Tweak prompt</div>
</div>
</div>
</div>
<div class="shortcuts-section">
<h4 class="shortcuts-section-title">Configuration</h4>
<div class="shortcuts-list">
<div class="shortcut-row">
<div class="shortcut-keys">Ctrl + M</div>
<div class="shortcut-desc">Mode help</div>
</div>
<div class="shortcut-row">
<div class="shortcut-keys">Ctrl + Shift + S</div>
<div class="shortcut-desc">Style help</div>
</div>
<div class="shortcut-row">
<div class="shortcut-keys">Ctrl + P</div>
<div class="shortcut-desc">Provider</div>
</div>
<div class="shortcut-row">
<div class="shortcut-keys">Ctrl + L</div>
<div class="shortcut-desc">Model</div>
</div>
<div class="shortcut-row">
<div class="shortcut-keys">Ctrl + ,</div>
<div class="shortcut-desc">Settings</div>
</div>
</div>
</div>
<div class="shortcuts-section">
<h4 class="shortcuts-section-title">Navigation</h4>
<div class="shortcuts-list">
<div class="shortcut-row">
<div class="shortcut-keys">Ctrl + I</div>
<div class="shortcut-desc">Focus input</div>
</div>
<div class="shortcut-row">
<div class="shortcut-keys">Ctrl + Shift + H</div>
<div class="shortcut-desc">Clear history</div>
</div>
<div class="shortcut-row">
<div class="shortcut-keys">Ctrl + [</div>
<div class="shortcut-desc">Prev page</div>
</div>
<div class="shortcut-row">
<div class="shortcut-keys">Ctrl + ]</div>
<div class="shortcut-desc">Next page</div>
</div>
</div>
</div>
<div class="shortcuts-section">
<h4 class="shortcuts-section-title">General</h4>
<div class="shortcuts-list">
<div class="shortcut-row">
<div class="shortcut-keys">Esc</div>
<div class="shortcut-desc">Close all & cancel</div>
</div>
<div class="shortcut-row">
<div class="shortcut-keys">?</div>
<div class="shortcut-desc">Show this help</div>
</div>
</div>
</div>
</div>
<div class="confirm-dialog-actions">
<button class="btn btn-primary confirm-dialog-confirm">Got it</button>
</div>
`;
overlay.appendChild(dialog);
document.body.appendChild(overlay);
// Trigger animation
setTimeout(() => {
overlay.classList.add('active');
dialog.classList.add('active');
}, 10);
// Handle close button
const closeBtn = dialog.querySelector('.help-tooltip-close');
closeBtn.addEventListener('click', () => {
this.closeConfirmDialog(overlay, dialog);
});
// Handle confirm button
const confirmBtn = dialog.querySelector('.confirm-dialog-confirm');
confirmBtn.addEventListener('click', () => {
this.closeConfirmDialog(overlay, dialog);
});
// Handle overlay click
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
this.closeConfirmDialog(overlay, dialog);
}
});
// Handle escape key
const escapeHandler = (e) => {
if (e.key === 'Escape') {
this.closeConfirmDialog(overlay, dialog);
document.removeEventListener('keydown', escapeHandler);
}
};
document.addEventListener('keydown', escapeHandler);
}
showMessage(type, message) {
const outputDiv = document.getElementById('output');
const iconMap = {
success: '✓',
error: '⚠',
info: '💡'
};
// Generate contextual help for errors
let helpHTML = '';
if (type === 'error') {
helpHTML = this.generateErrorHelp(message);
}
outputDiv.innerHTML = `
<div class="message message-${type}">
<span class="message-icon">${iconMap[type]}</span>
<div class="message-content">
<div class="message-text">${this.escapeHtml(message)}</div>
${helpHTML}
</div>
</div>
`;
}
generateErrorHelp(message) {
const messageLower = message.toLowerCase();
// Model not found error
if (messageLower.includes('404') || (messageLower.includes('model') && messageLower.includes('not found'))) {
return `
<div class="error-help">
<strong>Model Not Available</strong>
<ul>
<li>Try selecting a different model from the dropdown</li>
<li>Use "↻ Load All Models" to see all available options</li>
<li>Some models may have been deprecated or renamed</li>
</ul>
</div>
`;
}
// API key / authentication error
if (messageLower.includes('api key') || messageLower.includes('authentication') ||
messageLower.includes('401') || messageLower.includes('403')) {
return `
<div class="error-help">
<strong>Authentication Issue</strong>
<ul>
<li>Check your API key in Settings ⚙️</li>
<li>Ensure the key has proper permissions</li>
<li>Verify the key hasn't expired</li>
</ul>
</div>
`;
}
// Rate limit error
if (messageLower.includes('rate limit') || messageLower.includes('429') ||
messageLower.includes('too many requests')) {
return `
<div class="error-help">
<strong>Rate Limit Reached</strong>
<ul>
<li>Wait a few moments before trying again</li>
<li>Consider upgrading your API plan for higher limits</li>
</ul>
</div>
`;
}
// Network/connection error
if (messageLower.includes('network') || messageLower.includes('connection') ||
messageLower.includes('timeout') || messageLower.includes('econnrefused')) {
return `
<div class="error-help">
<strong>Connection Error</strong>
<ul>
<li>Check your internet connection</li>
<li>Verify the API endpoint is accessible</li>
<li>Try again in a few moments</li>
</ul>
</div>
`;
}
return '';
}
showMessageWithSettings(message, type = 'error') {
const outputDiv = document.getElementById('output');
outputDiv.innerHTML = `
<div class="message message-${type}">
<div class="message-content">
<div class="message-text">
${this.escapeHtml(message)}
<button class="message-settings-btn" onclick="window.promptheusApp.openSettings()">
⚙️ Settings
</button>
</div>
</div>
</div>
`;
}
showProgressIndicator(phase) {
const outputDiv = document.getElementById('output');
const phases = {
analyzing: {
symbol: '🔮',
text: 'Analyzing prompt...'
},
generating_questions: {
symbol: '❓',
text: 'Generating questions...'
},
optimizing: {
symbol: '⚗',
text: 'Optimizing...'
},
refining: {
symbol: '✨',
text: 'Refining with your answers...'
}
};
const phaseData = phases[phase] || phases.optimizing;
outputDiv.innerHTML = `
<div class="progress-indicator">
<div class="progress-symbol">${phaseData.symbol}</div>
<div class="progress-text">${phaseData.text}</div>
</div>
`;
}
escapeHtml(text) {
if (typeof text !== 'string') return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Provider status caching methods
getCachedProviderStatus() {
try {
const cached = localStorage.getItem('promptheus_provider_status_cache');
if (!cached) return null;
const cacheData = JSON.parse(cached);
const age = Date.now() - cacheData.timestamp;
const twentyFourHours = 24 * 60 * 60 * 1000;
// Expire after 24 hours
if (age > twentyFourHours) {
localStorage.removeItem('promptheus_provider_status_cache');
return null;
}
return cacheData.status;
} catch (error) {
console.error('Error loading provider status cache:', error);
return null;
}
}
cacheProviderStatus(status) {
try {
const cacheData = {
status: status,
timestamp: Date.now()
};
localStorage.setItem('promptheus_provider_status_cache', JSON.stringify(cacheData));
} catch (error) {
console.error('Error caching provider status:', error);
}
}
clearProviderStatusCache() {
localStorage.removeItem('promptheus_provider_status_cache');
}
clearProviderStatusCache() {
localStorage.removeItem('promptheus_provider_status_cache');
}
// Style Help functionality
// ===================================================================
// CUSTOM ALCHEMICAL DROPDOWN FUNCTIONALITY
// ===================================================================
initCustomDropdowns() {
const dropdowns = document.querySelectorAll('.alchemical-dropdown');
dropdowns.forEach(dropdown => {
const select = dropdown.querySelector('select');
const panel = dropdown.querySelector('.alchemical-dropdown-panel');
const options = dropdown.querySelectorAll('.alchemical-option');
// Initialize selected state
this.updateDropdownSelectedState(dropdown, select.value);
// Toggle dropdown on select click
select.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
this.toggleDropdown(dropdown);
});
// Handle option selection
options.forEach(option => {
option.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const value = option.dataset.value;
select.value = value;
this.updateDropdownSelectedState(dropdown, value);
this.closeDropdown(dropdown);
// Trigger change event on the original select
select.dispatchEvent(new Event('change', { bubbles: true }));
});
option.addEventListener('mouseenter', () => {
// Add mystical glow effect on hover
option.style.boxShadow = 'inset 0 0 20px rgba(212, 165, 116, 0.1)';
});
option.addEventListener('mouseleave', () => {
option.style.boxShadow = '';
});
});
// Sync with original select change events
select.addEventListener('change', () => {
this.updateDropdownSelectedState(dropdown, select.value);
});
});
// Initialize help tooltip system
const helpIcons = document.querySelectorAll('.toolbar-help-icon');
helpIcons.forEach(icon => {
const helpFor = icon.getAttribute('data-help-for');
const tooltip = document.getElementById(`${helpFor}-help-tooltip`);
const closeBtn = tooltip?.querySelector('.help-tooltip-close');
if (!tooltip) return;
// Move tooltip to body for proper stacking
document.body.appendChild(tooltip);
// Set initial position immediately to prevent jump on first open
const iconRect = icon.getBoundingClientRect();
const tooltipWidth = 400; // max-width from CSS
const tooltipHeight = 300; // estimated max height
const spacing = 8;
// Calculate horizontal position (clamp to viewport)
let left = iconRect.left;
if (left + tooltipWidth > window.innerWidth) {
// Would overflow right edge, align to right edge instead
left = window.innerWidth - tooltipWidth - spacing;
}
if (left < spacing) {
// Would overflow left edge
left = spacing;
}
// Calculate vertical position (prefer below, but show above if no room)
let top = iconRect.bottom + spacing;
let showAbove = false;
if (top + tooltipHeight > window.innerHeight) {
// Not enough room below, try above
const topAbove = iconRect.top - tooltipHeight - spacing;
if (topAbove >= 0 || topAbove > top + tooltipHeight - window.innerHeight) {
// Either fits above, or fits better above than below
top = topAbove;
showAbove = true;
}
}
tooltip.style.position = 'fixed';
tooltip.style.left = `${left}px`;
tooltip.style.top = `${top}px`;
tooltip.dataset.showAbove = showAbove;
// Toggle tooltip on icon click
icon.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
// Close other tooltips
document.querySelectorAll('.help-tooltip.visible').forEach(t => {
if (t !== tooltip) {
t.classList.remove('visible');
}
});
document.querySelectorAll('.toolbar-help-icon.active').forEach(i => {
if (i !== icon) {
i.classList.remove('active');
}
});
const isVisible = tooltip.classList.contains('visible');
if (!isVisible) {
// Position tooltip relative to icon BEFORE making visible
const iconRect = icon.getBoundingClientRect();
const tooltipWidth = 400; // max-width from CSS
const tooltipHeight = 300; // estimated max height
const spacing = 8;
// Calculate horizontal position (clamp to viewport)
let left = iconRect.left;
if (left + tooltipWidth > window.innerWidth) {
// Would overflow right edge, align to right edge instead
left = window.innerWidth - tooltipWidth - spacing;
}
if (left < spacing) {
// Would overflow left edge
left = spacing;
}
// Calculate vertical position (prefer below, but show above if no room)
let top = iconRect.bottom + spacing;
let showAbove = false;
if (top + tooltipHeight > window.innerHeight) {
// Not enough room below, try above
const topAbove = iconRect.top - tooltipHeight - spacing;
if (topAbove >= 0 || topAbove > top + tooltipHeight - window.innerHeight) {
// Either fits above, or fits better above than below
top = topAbove;
showAbove = true;
}
}
tooltip.style.position = 'fixed';
tooltip.style.left = `${left}px`;
tooltip.style.top = `${top}px`;
tooltip.dataset.showAbove = showAbove;
}
// Toggle current tooltip
tooltip.classList.toggle('visible');
icon.classList.toggle('active');
});
// Close tooltip on close button click
if (closeBtn) {
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
tooltip.classList.remove('visible');
icon.classList.remove('active');
});
}
});
// Close dropdowns and tooltips when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('.alchemical-dropdown')) {
document.querySelectorAll('.alchemical-dropdown.active').forEach(dropdown => {
this.closeDropdown(dropdown);
});
}
// Close help tooltips when clicking outside
if (!e.target.closest('.toolbar-help-icon') && !e.target.closest('.help-tooltip')) {
document.querySelectorAll('.help-tooltip.visible').forEach(tooltip => {
tooltip.classList.remove('visible');
});
document.querySelectorAll('.toolbar-help-icon.active').forEach(icon => {
icon.classList.remove('active');
});
}
});
}
toggleDropdown(dropdown) {
if (dropdown.classList.contains('active')) {
this.closeDropdown(dropdown);
} else {
// Close all other dropdowns first
document.querySelectorAll('.alchemical-dropdown.active').forEach(other => {
if (other !== dropdown) {
this.closeDropdown(other);
}
});
this.openDropdown(dropdown);
}
}
openDropdown(dropdown) {
dropdown.classList.add('active');
const select = dropdown.querySelector('select');
// Add mystical entrance animation
const panel = dropdown.querySelector('.alchemical-dropdown-panel');
panel.style.animation = 'dropdownMysticalEntrance 0.6s cubic-bezier(0.68, -0.55, 0.265, 1.55)';
// Add accessibility
select.setAttribute('aria-expanded', 'true');
}
closeDropdown(dropdown) {
dropdown.classList.remove('active');
const select = dropdown.querySelector('select');
// Add mystical exit animation
const panel = dropdown.querySelector('.alchemical-dropdown-panel');
panel.style.animation = 'dropdownMysticalExit 0.3s cubic-bezier(0.4, 0, 0.2, 1)';
// Add accessibility
select.setAttribute('aria-expanded', 'false');
}
updateDropdownSelectedState(dropdown, value) {
const options = dropdown.querySelectorAll('.alchemical-option');
const select = dropdown.querySelector('select');
options.forEach(option => {
if (option.dataset.value === value) {
option.classList.add('selected');
// Add mystical selection animation
option.style.animation = 'optionMysticalSelect 0.4s cubic-bezier(0.68, -0.55, 0.265, 1.55)';
setTimeout(() => {
option.style.animation = '';
}, 400);
} else {
option.classList.remove('selected');
}
});
// Update the original select display
this.updateSelectDisplay(select);
}
updateSelectDisplay(select) {
const selectedOption = select.options[select.selectedIndex];
const dropdown = select.closest('.alchemical-dropdown');
// Update visual feedback without changing the actual value
const panel = dropdown.querySelector('.alchemical-dropdown-panel');
if (panel) {
// Add a brief glow effect to indicate change
panel.style.boxShadow = '0 0 40px rgba(212, 165, 116, 0.3)';
setTimeout(() => {
panel.style.boxShadow = '';
}, 300);
}
}
async loadVersion() {
try {
console.log('Loading version from:', `${this.apiBaseUrl}/api/version`);
const response = await fetch(`${this.apiBaseUrl}/api/version`);
console.log('Version response status:', response.status);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
console.log('Version data:', data);
const versionText = document.getElementById('version-text');
const buildInfo = document.getElementById('build-info');
const footerVersion = document.getElementById('footer-version');
console.log('Found versionText element:', !!versionText);
console.log('Found buildInfo element:', !!buildInfo);
console.log('Found footerVersion element:', !!footerVersion);
if (versionText) {
let versionStr = data.full_version || data.version || 'Unknown';
if (data.commit_hash) {
versionStr += ` (${data.commit_hash}`;
if (data.is_dirty) {
versionStr += '-dirty';
}
versionStr += ')';
}
versionText.textContent = versionStr;
console.log('Set version text to:', versionStr);
}
if (footerVersion) {
let footerVersionStr = data.full_version || data.version || 'Unknown';
if (data.commit_hash) {
footerVersionStr += ` (${data.commit_hash}`;
if (data.is_dirty) {
footerVersionStr += '-dirty';
}
footerVersionStr += ')';
}
footerVersion.textContent = footerVersionStr;
console.log('Set footer version to:', footerVersionStr);
}
if (buildInfo && data.commit_date) {
const buildType = data.build_type === 'dev' ? 'Development' : 'Clean';
buildInfo.innerHTML = `
<div class="build-row">
<span class="build-field">Type:</span>
<span class="build-value ${data.build_type === 'dev' ? 'build-dev' : 'build-clean'}">${buildType}</span>
</div>
<div class="build-row">
<span class="build-field">Updated:</span>
<span class="build-value">${new Date(data.commit_date).toLocaleDateString()}</span>
</div>
${data.commit_hash ? `
<div class="build-row">
<span class="build-field">Commit:</span>
<span class="build-value code">${data.commit_hash}</span>
</div>
` : ''}
`;
console.log('Set build info');
}
} catch (error) {
console.error('Failed to load version info:', error);
const versionText = document.getElementById('version-text');
const footerVersion = document.getElementById('footer-version');
if (versionText) {
versionText.textContent = 'Error';
}
if (footerVersion) {
footerVersion.textContent = 'Error';
}
}
}
}
// Initialize the app when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
window.promptheusApp = new PromptheusApp();
});