commit 50d66c2985b4734aac60bf960e3cd31cb8311ab5 Author: Juanjo Gutiérrez Date: Fri Jan 30 10:06:46 2026 +0100 Initial version diff --git a/app.py b/app.py new file mode 100644 index 0000000..2454536 --- /dev/null +++ b/app.py @@ -0,0 +1,18 @@ +from flask import Flask, send_from_directory +import os + +app = Flask(__name__, static_folder='static', static_url_path='/static') + +@app.route('/') +def index(): + return send_from_directory('static', 'index.html') + +# Serve static files +@app.route('/') +def static_files(filename): + return send_from_directory('static', filename) + +if __name__ == '__main__': + import sys + port = int(sys.argv[1]) if len(sys.argv) > 1 else 5000 + app.run(debug=True, host='0.0.0.0', port=port) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5eaf725 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +flask +requests \ No newline at end of file diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..4a9f90c --- /dev/null +++ b/static/index.html @@ -0,0 +1,96 @@ + + + + + + OpenAI Models Viewer + + + +
+
+

OpenAI Models Viewer

+

Enter an OpenAI-compatible endpoint URL to view available models

+
+ +
+ +
+

Server Management

+
+ +
+ + +
+
+
+ +
+ +
+ + + + + + +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/static/script.js b/static/script.js new file mode 100644 index 0000000..5f25bbe --- /dev/null +++ b/static/script.js @@ -0,0 +1,533 @@ +// localStorage key for servers +const LOCAL_STORAGE_SERVERS_KEY = 'openai_servers'; + +document.addEventListener('DOMContentLoaded', () => { + const loadingDiv = document.getElementById('loading'); + const errorMessageDiv = document.getElementById('error-message'); + const resultsDiv = document.getElementById('results'); + const modelsListDiv = document.getElementById('models-list'); + + // Server Management Elements + const serverNameInput = document.getElementById('server-name'); + const serverUrlInput = document.getElementById('server-url'); + const serverApiKeyInput = document.getElementById('server-api-key'); + const addServerButton = document.getElementById('add-server'); + const serverSelector = document.getElementById('server-selector'); + const removeServerButton = document.getElementById('remove-server'); + const serverSettingsBtn = document.getElementById('server-settings-btn'); + const serverModal = document.getElementById('server-modal'); + const closeServerModal = document.getElementById('close-server-modal'); + const serverListItems = document.getElementById('server-list-items'); + + // Chat Modal Elements + const chatModal = document.getElementById('chat-modal'); + const chatModelName = document.getElementById('chat-model-name'); + const chatMessages = document.getElementById('chat-messages'); + const chatInput = document.getElementById('chat-input'); + const sendMessageButton = document.getElementById('send-message'); + const closeChatModalButton = chatModal.querySelector('.close'); + const closeModalBackground = chatModal; + + // Ensure modal is hidden on page load (defensive programming) + if (chatModal) { + chatModal.classList.add('hidden'); + } + + // Current chat state + let currentChatModel = null; + let currentEndpointUrl = null; + let currentApiKey = null; + + // Initialize server management + console.log('Initializing server management...'); + loadServers(); + console.log('Server management initialized'); + + // Add event listeners for server management + addServerButton.addEventListener('click', addServer); + removeServerButton.addEventListener('click', removeServer); + serverSettingsBtn.addEventListener('click', () => { + serverModal.classList.remove('hidden'); + updateServerListDisplay(); + }); + closeServerModal.addEventListener('click', () => { + serverModal.classList.add('hidden'); + }); + + // Auto-fetch models when server selection changes + serverSelector.addEventListener('change', fetchModels); + + // Close modals when clicking outside + serverModal.addEventListener('click', (e) => { + if (e.target === serverModal) { + serverModal.classList.add('hidden'); + } + }); + + // Chat Modal Event Listeners - Improved robustness + if (closeChatModalButton) { + closeChatModalButton.addEventListener('click', closeChatModal); + } + + if (closeModalBackground) { + closeModalBackground.addEventListener('click', (e) => { + if (e.target === closeModalBackground) { + closeChatModal(); + } + }); + } + + // Also allow ESC key to close modal + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && chatModal && !chatModal.classList.contains('hidden')) { + closeChatModal(); + } + }); + sendMessageButton.addEventListener('click', sendMessage); + chatInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } + }); + + function loadServers() { + console.log('Loading servers from localStorage...'); + try { + // Get the raw data from localStorage + const rawData = localStorage.getItem(LOCAL_STORAGE_SERVERS_KEY); + console.log('Raw localStorage data:', rawData); + + // Handle case where no data exists + if (rawData === null || rawData === undefined) { + console.log('No servers found in localStorage'); + updateServerSelector([]); + return; + } + + // Parse the JSON data + const servers = JSON.parse(rawData || '{}'); + console.log('Parsed servers:', servers); + + // Validate that parsed data is an object + if (typeof servers !== 'object' || servers === null || Array.isArray(servers)) { + console.warn('Invalid servers format in localStorage, resetting to empty object'); + localStorage.setItem(LOCAL_STORAGE_SERVERS_KEY, JSON.stringify({})); + updateServerSelector([]); + return; + } + + const serverNames = Object.keys(servers); + console.log('Server names found:', serverNames); + updateServerSelector(serverNames); + updateServerListDisplay(); // Update the modal list display + console.log('Servers loaded successfully'); + + // Add visual feedback if servers were loaded + if (serverNames.length > 0) { + console.log(`Successfully loaded ${serverNames.length} server(s)`); + // Briefly highlight the server selector to show it was updated + serverSelector.style.border = '2px solid #27ae60'; + setTimeout(() => { + serverSelector.style.border = '2px solid #ddd'; + }, 1000); + } + } catch (error) { + console.error('Error loading servers from localStorage:', error); + console.error('localStorage data that caused error:', localStorage.getItem(LOCAL_STORAGE_SERVERS_KEY)); + updateServerSelector([]); + } + } + + function updateServerSelector(serverNames) { + // Clear existing options except the default + serverSelector.innerHTML = ''; + + // Add available servers + serverNames.forEach(serverName => { + const option = document.createElement('option'); + option.value = serverName; + option.textContent = serverName; + serverSelector.appendChild(option); + }); + } + + function updateServerListDisplay() { + try { + const servers = JSON.parse(localStorage.getItem(LOCAL_STORAGE_SERVERS_KEY) || '{}'); + const serverNames = Object.keys(servers); + + serverListItems.innerHTML = ''; + + if (serverNames.length === 0) { + serverListItems.innerHTML = '
  • No servers saved
  • '; + return; + } + + serverNames.forEach(serverName => { + const li = document.createElement('li'); + li.textContent = serverName; + li.title = `URL: ${servers[serverName].url}`; + serverListItems.appendChild(li); + }); + } catch (error) { + console.error('Error updating server list display:', error); + serverListItems.innerHTML = '
  • Error loading servers
  • '; + } + } + + function addServer() { + const name = serverNameInput.value.trim(); + const url = serverUrlInput.value.trim(); + const apiKey = serverApiKeyInput.value.trim(); + + if (!name || !url || !apiKey) { + showError('Please enter a server name, URL, and API key'); + return; + } + + // Validate URL format + try { + new URL(url); + } catch (e) { + showError('Please enter a valid URL (e.g., https://api.openai.com)'); + return; + } + + try { + // Get existing servers from localStorage + const servers = JSON.parse(localStorage.getItem(LOCAL_STORAGE_SERVERS_KEY) || '{}'); + + // Add new server with both URL and API key + servers[name] = { + url: url, + apiKey: apiKey + }; + + // Save back to localStorage + localStorage.setItem(LOCAL_STORAGE_SERVERS_KEY, JSON.stringify(servers)); + + // Clear inputs + serverNameInput.value = ''; + serverUrlInput.value = ''; + serverApiKeyInput.value = ''; + + // Reload servers + loadServers(); + + showError('Server added successfully', 'success'); + } catch (error) { + console.error('Error adding server to localStorage:', error); + showError('Failed to add server. Please try again.'); + } + } + + function removeServer() { + const selectedServerName = serverSelector.value; + + if (!selectedServerName) { + showError('Please select a server to remove'); + return; + } + + if (!confirm(`Are you sure you want to remove the server "${selectedServerName}"?`)) { + return; + } + + try { + // Get existing servers from localStorage + const servers = JSON.parse(localStorage.getItem(LOCAL_STORAGE_SERVERS_KEY) || '{}'); + + // Remove the selected server + delete servers[selectedServerName]; + + // Save back to localStorage + localStorage.setItem(LOCAL_STORAGE_SERVERS_KEY, JSON.stringify(servers)); + + // Reload servers + loadServers(); + + showError('Server removed successfully', 'success'); + } catch (error) { + console.error('Error removing server from localStorage:', error); + showError('Failed to remove server. Please try again.'); + } + } + + async function fetchModels() { + const selectedServerName = serverSelector.value; + + // Must select a server + if (!selectedServerName) { + showError('Please select a server'); + return; + } + + // Get server configuration from localStorage + try { + const servers = JSON.parse(localStorage.getItem(LOCAL_STORAGE_SERVERS_KEY) || '{}'); + const selectedServer = servers[selectedServerName]; + + if (!selectedServer) { + showError(`Server "${selectedServerName}" not found`); + return; + } + + // Show loading, hide results and errors + showLoading(); + hideError(); + hideResults(); + + // Construct models endpoint URL + let url = selectedServer.url; + if (!url.endsWith('/models')) { + url = url.replace(/\/$/, '') + '/models'; + } + + // Set up headers + const headers = { + 'Content-Type': 'application/json' + }; + + if (selectedServer.apiKey) { + headers['Authorization'] = `Bearer ${selectedServer.apiKey}`; + } + + // Make direct API call + const response = await fetch(url, { + method: 'GET', + headers: headers + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`HTTP error! status: ${response.status}, details: ${errorText}`); + } + + const data = await response.json(); + + if (!data.data || !Array.isArray(data.data)) { + throw new Error('Invalid response format: expected "data" array'); + } + + displayModels(data.data, selectedServer.url, selectedServer.apiKey); + } catch (error) { + console.error('Error fetching models:', error); + showError(`Failed to fetch models: ${error.message}`); + } finally { + hideLoading(); + } + } + + function displayModels(models, endpointUrl, apiKey) { + modelsListDiv.innerHTML = ''; + + if (models.length === 0) { + modelsListDiv.innerHTML = '

    No models found.

    '; + } else { + models.forEach(model => { + const modelItem = document.createElement('div'); + modelItem.className = 'model-item'; + modelItem.dataset.modelId = model.id; + modelItem.dataset.endpointUrl = endpointUrl; + modelItem.dataset.apiKey = apiKey || ''; + + const modelName = model.name || model.id || 'Unknown Model'; + const modelId = model.id || 'Unknown ID'; + + modelItem.innerHTML = ` +
    ${modelName}
    +
    ${modelId}
    + `; + + // Add click event listener to open chat + modelItem.addEventListener('click', () => { + openChatModal(modelId, modelName, endpointUrl, apiKey); + }); + + modelsListDiv.appendChild(modelItem); + }); + } + + showResults(); + } + + function openChatModal(modelId, modelName, endpointUrl, apiKey) { + currentChatModel = modelId; + currentEndpointUrl = endpointUrl; + currentApiKey = apiKey; + + chatModelName.textContent = `Chat with ${modelName}`; + chatMessages.innerHTML = ''; + chatInput.value = ''; + + chatModal.classList.remove('hidden'); + chatInput.focus(); + + // Add welcome message + addMessage('ai', `Hello! I'm ${modelName}. How can I help you today?`); + } + + function closeChatModal() { + // Defensive programming - ensure modal exists + if (chatModal) { + chatModal.classList.add('hidden'); + // Additional safety - ensure all chat state is cleared + currentChatModel = null; + currentEndpointUrl = null; + currentApiKey = null; + + // Clear chat messages when closing + if (chatMessages) { + chatMessages.innerHTML = ''; + } + + // Clear input + if (chatInput) { + chatInput.value = ''; + } + + // Disable send button + if (sendMessageButton) { + sendMessageButton.disabled = true; + } + } + } + + function addMessage(role, content) { + const messageDiv = document.createElement('div'); + messageDiv.className = `chat-message ${role}`; + messageDiv.textContent = content; + chatMessages.appendChild(messageDiv); + chatMessages.scrollTop = chatMessages.scrollHeight; + } + + function sendMessage() { + const message = chatInput.value.trim(); + if (!message || !currentChatModel) return; + + // Add user message + addMessage('user', message); + chatInput.value = ''; + sendMessageButton.disabled = true; + + // Add AI response placeholder for streaming + const aiMessageDiv = document.createElement('div'); + aiMessageDiv.className = 'chat-message ai streaming'; + aiMessageDiv.textContent = 'Thinking...'; + chatMessages.appendChild(aiMessageDiv); + chatMessages.scrollTop = chatMessages.scrollHeight; + + // Construct chat completions endpoint URL + let chatUrl = currentEndpointUrl; + if (chatUrl.endsWith('/chat/completions')) { + // Already correct format + } else if (chatUrl.endsWith('/v1')) { + chatUrl = `${chatUrl}/chat/completions`; + } else { + chatUrl = `${chatUrl.replace(/\/$/, '')}/v1/chat/completions`; + } + + // Prepare the request body for direct API call + const requestBody = { + model: currentChatModel, + messages: [ + { role: 'user', content: message } + ], + stream: false // Set to true for streaming support if needed + }; + + // Set up headers + const headers = { + 'Content-Type': 'application/json' + }; + + if (currentApiKey) { + headers['Authorization'] = `Bearer ${currentApiKey}`; + } + + // Make direct API call + fetch(chatUrl, { + method: 'POST', + headers: headers, + body: JSON.stringify(requestBody) + }) + .then(response => { + if (!response.ok) { + return response.text().then(errorText => { + throw new Error(`HTTP error! status: ${response.status}, details: ${errorText}`); + }); + } + return response.json(); + }) + .then(data => { + // Remove streaming placeholder + chatMessages.removeChild(aiMessageDiv); + + // Extract the response content + const content = data.choices && data.choices[0] && data.choices[0].message + ? data.choices[0].message.content + : 'No response received.'; + + // Add actual AI response + const responseDiv = document.createElement('div'); + responseDiv.className = 'chat-message ai'; + responseDiv.textContent = content; + chatMessages.appendChild(responseDiv); + chatMessages.scrollTop = chatMessages.scrollHeight; + }) + .catch(error => { + // Remove streaming placeholder + chatMessages.removeChild(aiMessageDiv); + + // Add error message + const errorDiv = document.createElement('div'); + errorDiv.className = 'chat-message ai'; + errorDiv.textContent = `Error: ${error.message}`; + chatMessages.appendChild(errorDiv); + chatMessages.scrollTop = chatMessages.scrollHeight; + }) + .finally(() => { + sendMessageButton.disabled = false; + }); + } + + function showLoading() { + loadingDiv.classList.remove('hidden'); + } + + function hideLoading() { + loadingDiv.classList.add('hidden'); + } + + function showError(message, type = 'error') { + errorMessageDiv.textContent = message; + errorMessageDiv.classList.remove('hidden'); + + // Add success styling for success messages + if (type === 'success') { + errorMessageDiv.style.backgroundColor = '#27ae60'; + } else { + errorMessageDiv.style.backgroundColor = '#e74c3c'; + } + + // Auto-hide success messages after 3 seconds + if (type === 'success') { + setTimeout(() => { + hideError(); + }, 3000); + } + } + + function hideError() { + errorMessageDiv.classList.add('hidden'); + } + + function showResults() { + resultsDiv.classList.remove('hidden'); + } + + function hideResults() { + resultsDiv.classList.add('hidden'); + } +}); \ No newline at end of file diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..2c33007 --- /dev/null +++ b/static/style.css @@ -0,0 +1,468 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + background-color: #f5f5f5; + color: #333; + line-height: 1.6; +} + +.container { + max-width: 800px; + margin: 0 auto; + padding: 20px; +} + +header { + text-align: center; + margin-bottom: 30px; + padding: 20px 0; + border-bottom: 1px solid #ddd; +} + +header h1 { + color: #2c3e50; + margin-bottom: 10px; +} + +header p { + color: #7f8c8d; + font-size: 1.1em; +} + +.input-section { + background: white; + padding: 25px; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + margin-bottom: 20px; +} + +/* Server Management Section */ +.server-section { + background: white; + padding: 25px; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + margin-bottom: 20px; +} + +.server-section h3 { + color: #2c3e50; + margin-bottom: 15px; + font-weight: 600; +} + +/* Server Selector Container with Settings Button */ +.server-selector-container { + display: flex; + gap: 10px; + align-items: center; +} + +.settings-btn { + background-color: #3498db; + color: white; + border: none; + padding: 12px 15px; + border-radius: 4px; + font-size: 16px; + cursor: pointer; + transition: background-color 0.3s; + flex-shrink: 0; +} + +.settings-btn:hover { + background-color: #2980b9; +} + +/* Server Management Modal Styles */ +#server-modal .modal-content { + max-width: 500px; +} + +.server-list { + margin-top: 20px; + padding-top: 20px; + border-top: 1px solid #eee; +} + +.server-list h4 { + margin-bottom: 10px; + color: #2c3e50; +} + +#server-list-items { + list-style-type: none; + padding: 0; + margin: 0; +} + +#server-list-items li { + padding: 10px; + border: 1px solid #ddd; + border-radius: 4px; + margin-bottom: 5px; + background-color: #f8f9fa; +} + +#server-list-items li:hover { + background-color: #e9ecef; +} + +.server-input-group, +.server-select-group { + margin-bottom: 20px; +} + +.server-input-group label, +.server-select-group label { + display: block; + margin-bottom: 10px; + font-weight: 600; + color: #2c3e50; +} + +.input-section label { + display: block; + margin-bottom: 10px; + font-weight: 600; + color: #2c3e50; +} + +#endpoint-url, +#server-url, +#server-api-key, +#server-name { + width: 100%; + padding: 12px; + border: 2px solid #ddd; + border-radius: 4px; + font-size: 16px; + margin-bottom: 15px; + transition: border-color 0.3s; +} + +#endpoint-url:focus, +#server-url:focus, +#server-api-key:focus, +#server-name:focus { + outline: none; + border-color: #3498db; +} + +#fetch-button, +#add-server, +#remove-server { + background-color: #3498db; + color: white; + border: none; + padding: 12px 24px; + border-radius: 4px; + font-size: 16px; + cursor: pointer; + transition: background-color 0.3s; +} + +#fetch-button:hover, +#add-server:hover, +#remove-server:hover { + background-color: #2980b9; +} + +#fetch-button:disabled { + background-color: #bdc3c7; + cursor: not-allowed; +} + +/* Server Selector Styling */ +#server-selector { + width: 100%; + padding: 12px; + border: 2px solid #ddd; + border-radius: 4px; + font-size: 16px; + margin-bottom: 15px; + transition: border-color 0.3s; + background-color: white; +} + +#server-selector:focus { + outline: none; + border-color: #3498db; +} + +/* Server Button Styling */ +#add-server, +#remove-server { + background-color: #3498db; + color: white; + border: none; + padding: 12px 24px; + border-radius: 4px; + font-size: 16px; + cursor: pointer; + transition: background-color 0.3s; + width: 100%; + margin-top: 10px; +} + +#add-server:hover, +#remove-server:hover { + background-color: #2980b9; +} + +/* Green button for add */ +#add-server { + background-color: #27ae60; +} + +#add-server:hover { + background-color: #219653; +} + +/* Red button for remove */ +#remove-server { + background-color: #e74c3c; +} + +#remove-server:hover { + background-color: #c0392b; +} + +.loading { + text-align: center; + padding: 20px; +} + +.spinner { + border: 4px solid #f3f3f3; + border-top: 4px solid #3498db; + border-radius: 50%; + width: 40px; + height: 40px; + animation: spin 1s linear infinite; + margin: 0 auto 15px; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.error { + background-color: #e74c3c; + color: white; + padding: 15px; + border-radius: 4px; + margin: 20px 0; +} + +.results { + background: white; + padding: 25px; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); +} + +.results h2 { + color: #2c3e50; + margin-bottom: 20px; + text-align: center; +} + +.model-item { + background-color: #f8f9fa; + border: 1px solid #eee; + border-radius: 6px; + padding: 15px; + margin-bottom: 10px; + transition: transform 0.2s; +} + +.model-item:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +.model-name { + font-weight: 600; + color: #2c3e50; + font-size: 1.1em; +} + +.model-id { + color: #7f8c8d; + font-family: monospace; + font-size: 0.9em; + margin-top: 5px; +} + +.hidden { + display: none; +} + +@media (max-width: 600px) { + .container { + padding: 10px; + } + + .input-section, + .results { + padding: 15px; + } + + header h1 { + font-size: 1.5em; + } +} + +/* Chat Modal Styles */ +.modal { + display: flex; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + align-items: center; + justify-content: center; +} + +/* Ensure hidden modals are completely invisible and don't interfere with page interaction */ +.modal.hidden { + display: none !important; + visibility: hidden; +} + +.modal-content { + background-color: white; + border-radius: 8px; + width: 90%; + max-width: 600px; + max-height: 80vh; + display: flex; + flex-direction: column; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); +} + +.modal-header { + padding: 20px; + border-bottom: 1px solid #eee; + display: flex; + justify-content: space-between; + align-items: center; +} + +.modal-header h3 { + margin: 0; + color: #2c3e50; +} + +.close { + font-size: 28px; + font-weight: bold; + cursor: pointer; + color: #aaa; +} + +.close:hover { + color: #000; +} + +.modal-body { + padding: 20px; + flex: 1; + display: flex; + flex-direction: column; +} + +.chat-messages { + flex: 1; + overflow-y: auto; + margin-bottom: 20px; + min-height: 300px; + max-height: 400px; +} + +.chat-message { + margin-bottom: 15px; + padding: 10px; + border-radius: 6px; + max-width: 80%; +} + +.chat-message.user { + background-color: #3498db; + color: white; + margin-left: auto; + text-align: right; +} + +.chat-message.ai { + background-color: #f8f9fa; + color: #333; + margin-right: auto; +} + +.chat-input-container { + display: flex; + gap: 10px; + align-items: flex-end; +} + +#chat-input { + flex: 1; + padding: 12px; + border: 2px solid #ddd; + border-radius: 4px; + font-family: inherit; + font-size: 14px; + resize: vertical; +} + +#chat-input:focus { + outline: none; + border-color: #3498db; +} + +#send-message { + background-color: #3498db; + color: white; + border: none; + padding: 12px 20px; + border-radius: 4px; + cursor: pointer; + height: fit-content; +} + +#send-message:hover:not(:disabled) { + background-color: #2980b9; +} + +#send-message:disabled { + background-color: #bdc3c7; + cursor: not-allowed; +} + +.chat-message.streaming { + opacity: 0.7; +} + +@media (max-width: 600px) { + .modal-content { + width: 95%; + margin: 20px; + } + + .chat-messages { + min-height: 200px; + max-height: 300px; + } +} \ No newline at end of file