// OpenQode Web Interface Application // API base URL - use the same origin as the current page for API requests const API_BASE_URL = 'http://127.0.0.1:15044'; class OpenQodeWeb { constructor() { this.apiBaseUrl = API_BASE_URL; this.currentSession = 'default'; this.sessions = {}; this.isAuthenticated = false; this.currentModel = 'qwen/coder-model'; this.currentAttachment = null; // For file/image attachments // IDE state (v1.02) this.workspaceTree = []; this.openTabs = []; // { path, name, original, content } this.activeTabPath = null; this.dirtyTabs = new Set(); this.attachedPaths = new Set(); this.lastTreeRefresh = 0; this.isIDEInitialized = false; this.features = { lakeview: false, sequentialThinking: false }; this.init(); } async init() { // Check if API is reachable first try { const healthCheck = await fetch(`${this.apiBaseUrl}/api/files/tree`, { method: 'GET', signal: AbortSignal.timeout(5000) }); if (!healthCheck.ok) { console.warn('⚠️ API health check failed:', healthCheck.status); } else { console.log('✅ API is reachable'); } } catch (error) { console.error('❌ Cannot reach API server:', error.message); // Show a persistent warning setTimeout(() => { this.addMessage('system', `⚠️ Cannot connect to server at ${this.apiBaseUrl}
Please ensure the server is running with: node server.js 15044
Then access this page at: http://127.0.0.1:15044/`); }, 500); } this.setupEventListeners(); this.authToken = localStorage.getItem('openqode_token'); await this.checkAuthentication(); await this.loadSessions(); await this.initIDE(); this.updateHeroPreviewLink(); this.hideLoading(); } setupEventListeners() { // View toggle const guiViewBtn = document.getElementById('gui-view-btn'); const tuiViewBtn = document.getElementById('tui-view-btn'); guiViewBtn?.addEventListener('click', () => this.switchView('gui')); tuiViewBtn?.addEventListener('click', () => this.switchView('tui')); // Send message const sendBtn = document.getElementById('send-btn'); const messageInput = document.getElementById('message-input'); sendBtn?.addEventListener('click', () => this.sendMessageStream()); messageInput?.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this.sendMessageStream(); // Use streaming by default } }); // Auto-resize textarea messageInput?.addEventListener('input', () => { messageInput.style.height = 'auto'; messageInput.style.height = Math.min(messageInput.scrollHeight, 120) + 'px'; }); // Model selection document.getElementById('model-select')?.addEventListener('change', (e) => { this.currentModel = e.target.value; this.updateModelStatus(); }); // Feature toggles document.getElementById('lakeview-mode')?.addEventListener('change', (e) => { this.features.lakeview = e.target.checked; this.showSuccess(`Lakeview mode ${e.target.checked ? 'enabled' : 'disabled'}`); }); document.getElementById('sequential-thinking')?.addEventListener('change', (e) => { this.features.sequentialThinking = e.target.checked; this.showSuccess(`Sequential thinking ${e.target.checked ? 'enabled' : 'disabled'}`); }); // Temperature slider const tempSlider = document.getElementById('temperature-slider'); const tempValue = document.querySelector('.slider-value'); tempSlider?.addEventListener('input', (e) => { const val = parseFloat(e.target.value).toFixed(1); if (tempValue) tempValue.textContent = val; this.temperature = parseFloat(val); }); // Authentication document.getElementById('auth-btn')?.addEventListener('click', () => { this.authenticate(); }); document.getElementById('reauth-btn')?.addEventListener('click', () => { this.authenticate(); }); // Settings panel reauth button document.getElementById('reauth-btn-panel')?.addEventListener('click', () => { this.hideSettings(); this.authenticate(); }); // Settings modal document.getElementById('settings-btn')?.addEventListener('click', () => { this.showSettings(); }); document.getElementById('close-settings')?.addEventListener('click', () => { this.hideSettings(); }); // Sessions const newSessionBtn = document.getElementById('new-session-btn'); console.log('🔧 new-session-btn element:', newSessionBtn); newSessionBtn?.addEventListener('click', (e) => { console.log('🖱️ New Session button clicked!', e); this.createNewSession(); }); // New Project document.getElementById('new-project-btn')?.addEventListener('click', () => { this.startNewProjectFlow(); }); // File attachment document.getElementById('attach-btn')?.addEventListener('click', () => { this.attachFile(); }); // IDE buttons document.getElementById('refresh-tree-btn')?.addEventListener('click', () => this.refreshFileTree()); document.getElementById('new-file-btn')?.addEventListener('click', () => this.promptCreateFileOrFolder()); document.getElementById('save-file-btn')?.addEventListener('click', () => this.saveCurrentFile()); document.getElementById('rename-file-btn')?.addEventListener('click', () => this.renameCurrentFile()); document.getElementById('delete-file-btn')?.addEventListener('click', () => this.deleteCurrentFile()); // Deployment & Preview document.getElementById('deploy-btn')?.addEventListener('click', () => { console.log('🖱️ Deploy button clicked'); this.deployToVercel(); }); document.getElementById('preview-btn')?.addEventListener('click', () => { console.log('🖱️ Preview button clicked'); this.startLocalPreview(); }); document.getElementById('show-diff-btn')?.addEventListener('click', () => this.showDiff()); document.getElementById('apply-diff-btn')?.addEventListener('click', () => this.applyDiff()); document.getElementById('apply-diff-btn-panel')?.addEventListener('click', () => this.applyDiff()); document.getElementById('close-diff')?.addEventListener('click', () => this.hideDiff()); document.getElementById('cancel-diff-btn')?.addEventListener('click', () => this.hideDiff()); // Terminal document.getElementById('terminal-run-btn')?.addEventListener('click', () => this.runTerminalCommand()); const terminalInput = document.getElementById('terminal-input'); terminalInput?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); this.runTerminalCommand(); } }); // File tree delegation const fileTreeEl = document.getElementById('file-tree'); fileTreeEl?.addEventListener('click', (e) => this.onFileTreeClick(e)); // Close modals on outside click document.getElementById('settings-modal')?.addEventListener('click', (e) => { if (e.target.id === 'settings-modal') { this.hideSettings(); } }); document.getElementById('diff-modal')?.addEventListener('click', (e) => { if (e.target.id === 'diff-modal') { this.hideDiff(); } }); // Global keyboard shortcuts document.addEventListener('keydown', (e) => { // Ctrl+S to save if (e.ctrlKey && e.key === 's') { e.preventDefault(); this.saveCurrentFile(); } // Escape to close modals if (e.key === 'Escape') { this.hideSettings(); this.hideDiff(); } }); } updateHeroPreviewLink() { const link = document.getElementById('hero-local-preview'); if (!link) return; const origin = window.location.origin; link.setAttribute('href', origin); link.setAttribute('title', `OpenQode Web @ ${origin}`); } async checkAuthentication() { if (!this.authToken) { this.updateAuthStatus({ authenticated: false, provider: 'none' }); return; } try { const response = await fetch(`${this.apiBaseUrl}/api/auth/status`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: this.authToken }) }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); this.isAuthenticated = data.authenticated; const provider = (data.user && data.user.provider) || 'Qwen'; this.updateAuthStatus({ authenticated: data.authenticated, provider }); if (data.authenticated) { const authBtn = document.getElementById('auth-btn'); authBtn.innerHTML = ` Authenticated `; authBtn.className = 'inline-flex items-center px-4 py-2 bg-green-600 hover:bg-green-700 text-white text-sm font-medium rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500'; await this.refreshFileTree(); } } catch (error) { console.error('Auth check failed:', error); // Handle network errors if (error.message.includes('Failed to fetch') || error.message.includes('NetworkError')) { this.updateAuthStatus({ authenticated: false, provider: 'none' }); console.warn('Server not available for auth check'); } else { this.updateAuthStatus({ authenticated: false, provider: 'none' }); } } } async authenticate() { this.showLoading('Authenticating with Qwen...'); try { const response = await fetch(`${this.apiBaseUrl}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider: 'qwen' }) }); if (!response.ok) { if (response.status === 401) { throw new Error('Authentication expired. Please re-authenticate.'); } else if (response.status === 429) { throw new Error('Rate limit exceeded. Please try again later.'); } else { throw new Error(`HTTP error! status: ${response.status}`); } } const data = await response.json(); if (data.success) { if (data.alreadyAuthenticated) { const visionMsg = data.hasVisionSupport ? '✅ Vision API enabled - you can analyze images!' : '⚠️ Text chat only. Click "Authenticate Qwen" again for Vision API access.'; this.addMessage('system', `Already authenticated with Qwen!\n${visionMsg}`); this.showSuccess('Already authenticated with Qwen!'); } else if (data.requiresDeviceCode) { // Device Code Flow - show user code and verification URL const verificationUrl = data.verificationUriComplete || data.verificationUri; this.addMessage('system', `🔐 To authenticate with Qwen:\n\n1. Go to: ${data.verificationUri}\n2. Enter code: ${data.userCode}\n3. Complete login, then refresh this page\n\nThe code expires in ${Math.floor(data.expiresIn / 60)} minutes.`); // Open verification URL in new tab window.open(verificationUrl, '_blank'); this.showInfo('Please complete the authentication in the opened browser window.'); // Start polling for completion this.pollAuthCompletion(); return; } else if (data.requiresBrowser) { // Legacy browser flow this.addMessage('system', 'Opening browser for Qwen authentication...'); window.open(data.browserUrl, '_blank'); this.addMessage('system', 'Please complete authentication in the browser, then click "Complete Authentication" when done.'); this.authState = data.state; this.showCompleteAuthButton(); return; } else { this.addMessage('system', 'Successfully authenticated with Qwen!'); this.showSuccess('Successfully authenticated with Qwen!'); } if (data.token) { this.authToken = data.token; localStorage.setItem('openqode_token', data.token); } this.isAuthenticated = true; this.updateAuthStatus({ authenticated: true, provider: 'qwen' }); this.updateAuthButton(true); } else { this.addMessage('system', `Authentication failed: ${data.error}`); this.showError(`Authentication failed: ${data.error}`); } } catch (error) { console.error('Authentication error:', error); // Better error handling for network issues if (error.message.includes('Failed to fetch') || error.message.includes('NetworkError')) { this.addMessage('system', 'Authentication error: Unable to connect to server. Please check if the server is running and try again.'); this.showError('Unable to connect to server. Please check if the backend server is running.'); } else { this.addMessage('system', `Authentication error: ${error.message}`); this.showError(`Authentication failed: ${error.message}`); } } finally { this.hideLoading(); } } async pollAuthCompletion() { // Poll every 5 seconds to check if auth completed const pollInterval = setInterval(async () => { try { const response = await fetch(`${this.apiBaseUrl}/api/auth/status`); const data = await response.json(); if (data.authenticated) { clearInterval(pollInterval); this.addMessage('system', '✅ Authentication completed successfully!'); this.isAuthenticated = true; this.updateAuthStatus({ authenticated: true, provider: 'qwen' }); this.updateAuthButton(true); // Get a new token const loginResponse = await fetch(`${this.apiBaseUrl}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider: 'qwen' }) }); const loginData = await loginResponse.json(); if (loginData.token) { this.authToken = loginData.token; localStorage.setItem('openqode_token', loginData.token); } } } catch (error) { console.error('Polling error:', error); } }, 5000); // Stop polling after 15 minutes setTimeout(() => clearInterval(pollInterval), 900000); } updateAuthButton(authenticated) { const authBtn = document.getElementById('auth-btn'); if (authenticated) { authBtn.innerHTML = ` Authenticated `; authBtn.className = 'inline-flex items-center px-4 py-2 bg-green-600 hover:bg-green-700 text-white text-sm font-medium rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500'; } } async completeAuthentication() { if (!this.authState) { this.addMessage('system', 'No pending authentication found.'); return; } this.showLoading('Completing authentication...'); try { const response = await fetch(`${this.apiBaseUrl}/api/auth/complete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ state: this.authState }) }); const data = await response.json(); if (data.success) { this.addMessage('system', 'Authentication completed successfully!'); if (data.token) { this.authToken = data.token; localStorage.setItem('openqode_token', data.token); } this.isAuthenticated = true; this.updateAuthStatus({ authenticated: true, provider: 'qwen' }); const authBtn = document.getElementById('auth-btn'); authBtn.innerHTML = ` Authenticated `; authBtn.className = 'inline-flex items-center px-4 py-2 bg-green-600 hover:bg-green-700 text-white text-sm font-medium rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500'; // Hide complete auth button this.hideCompleteAuthButton(); this.authState = null; } else { this.addMessage('system', `Authentication completion failed: ${data.error}`); } } catch (error) { console.error('Authentication completion error:', error); this.addMessage('system', 'Authentication completion error. Please try again.'); } finally { this.hideLoading(); } } showCompleteAuthButton() { const authContainer = document.querySelector('.auth-section'); if (!document.getElementById('complete-auth-btn')) { const completeBtn = document.createElement('button'); completeBtn.id = 'complete-auth-btn'; completeBtn.className = 'w-full mt-3 px-4 py-2 bg-green-600 hover:bg-green-700 text-white text-sm font-medium rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500'; completeBtn.textContent = 'Complete Authentication'; completeBtn.addEventListener('click', () => this.completeAuthentication()); authContainer.appendChild(completeBtn); } } hideCompleteAuthButton() { const completeBtn = document.getElementById('complete-auth-btn'); if (completeBtn) { completeBtn.remove(); } } updateAuthStatus(authData) { const statusText = document.getElementById('auth-status-text'); const authStatus = document.getElementById('auth-status'); if (authData.authenticated) { statusText.textContent = `Authenticated with ${authData.provider}`; authStatus.textContent = `✓ Authenticated (${authData.provider})`; authStatus.className = 'text-green-600 dark:text-green-400 font-medium'; } else { statusText.textContent = 'Not authenticated'; authStatus.textContent = 'Not authenticated - Click to authenticate'; authStatus.className = 'text-gray-600 dark:text-gray-400'; } } updateModelStatus() { const modelStatus = document.getElementById('model-status'); const modelName = this.currentModel.includes('vision') ? 'Qwen Vision' : 'Qwen Coder'; modelStatus.textContent = `Model: ${modelName}`; modelStatus.className = 'text-sm text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-700 px-2 py-1 rounded'; } async sendMessage() { const input = document.getElementById('message-input'); const message = input.value.trim(); if (!message) return; if (!this.isAuthenticated) { this.addMessage('system', 'Please authenticate with Qwen first using the "Authenticate Qwen" button.'); return; } // Add user message this.addMessage('user', message); input.value = ''; input.style.height = 'auto'; // Show typing indicator this.showTypingIndicator(); try { const response = await fetch(`${this.apiBaseUrl}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: message, model: this.currentModel, session: this.currentSession, features: this.features, token: this.authToken || localStorage.getItem('openqode_token') }) }); const data = await response.json(); this.hideTypingIndicator(); if (data.success) { this.addMessage('assistant', data.response, data.metadata); } else { throw new Error(data.error || 'Failed to get response'); } } catch (error) { this.hideTypingIndicator(); console.error('Chat error:', error); this.addMessage('system', `Error: ${error.message}`); } } retryLastMessage() { if (this.lastUserMessage) { console.log('🔄 Retrying last message:', this.lastUserMessage); this.sendMessageStream(this.lastUserMessage); } else { this.showError('No message to retry.'); } } async sendMessageStream(manualMessage = null, retryCount = 0) { console.log(`🚀 sendMessageStream called (Attempt ${retryCount + 1})`, manualMessage ? '(manual)' : '(user input)'); const input = document.getElementById('message-input'); const message = manualMessage || input.value.trim(); console.log('Message:', message); if (!message) return false; // INTERCEPT: Local Preview Port Check if (this.pendingAction && this.pendingAction.type === 'awaiting_preview_port') { const portStr = message.trim() || '3000'; if (!/^\d+$/.test(portStr)) { this.addMessage('system', '❌ Please enter a valid numeric port.'); return; } const port = parseInt(portStr, 10); // Convert to number for server API const previewPath = this.pendingAction.path || '.'; this.pendingAction = null; // Clear state this.addMessage('user', portStr); // Show user's choice as string document.getElementById('message-input').value = ''; this.launchLocalPreview(port, previewPath); return; // STOP here, do not send to AI } this.lastUserMessage = message; if (!this.isAuthenticated) { this.addMessage('system', 'Please authenticate with Qwen first using the "Authenticate Qwen" button.'); this.showWarning('Please authenticate with Qwen first.'); return; } if (retryCount === 0) { this.addMessage('user', message); input.value = ''; input.style.height = 'auto'; } else { this.showInfo(`🔄 Auto-retrying request (Attempt ${retryCount + 1})...`); } const assistantMessageId = this.addMessage('assistant', '', { streaming: true }); const messageDiv = document.querySelector(`[data-message-id="${assistantMessageId}"]`); const assistantMessageElement = messageDiv?.querySelector('.message-text'); if (assistantMessageElement) { assistantMessageElement.innerHTML = `
Qwen is thinking
`; } const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 90000); // Additional timeout to detect if message is not registering with AI const registrationTimeout = setTimeout(() => { // This timeout triggers if no content has been received after 10 seconds // Check if the thinking animation is still there but no actual content if (assistantMessageElement && assistantMessageElement.querySelector('.thinking-animation')) { const currentContent = assistantMessageElement.textContent || ''; if (currentContent.trim() === '' || currentContent.includes('Qwen is thinking')) { // Message hasn't registered with AI, show option to resend assistantMessageElement.innerHTML = `
⚠️ Message may not have registered with AI
`; // Remove the streaming class to stop the animation if (messageDiv) messageDiv.classList.remove('streaming'); } } }, 15000); // 15 seconds timeout for message registration try { let enhancedMessage = message; if (this.activeTabPath) { const activeTab = this.openTabs.find(t => t.path === this.activeTabPath); if (activeTab && activeTab.content) { const fileExt = activeTab.name.split('.').pop().toLowerCase(); enhancedMessage = `IMPORTANT SYSTEM INSTRUCTION:\nYou are an Agentic IDE. \n1. To EDIT the open file, output the COMPLETE content in a code block.\n2. To CREATE a new file, you MUST output exactly: ">>> CREATE: path/to/filename" followed by a code block with the content.\n\n[Current file: ${activeTab.path}]\n\`\`\`${fileExt}\n${activeTab.content}\n\`\`\`\n\nUser request: ${message}`; } else { enhancedMessage = `IMPORTANT SYSTEM INSTRUCTION:\nYou are an Agentic IDE. To CREATE a new file, you MUST output exactly: ">>> CREATE: path/to/filename" followed by a code block with the content. Do not just say you created it.\n\nUser request: ${message}`; } } else { enhancedMessage = `IMPORTANT SYSTEM INSTRUCTION:\nYou are an Agentic IDE. To CREATE a new file, you MUST output exactly: ">>> CREATE: path/to/filename" followed by a code block with the content. Do not just say you created it.\n\nUser request: ${message}`; } const requestBody = { message: enhancedMessage, model: this.currentModel, session: this.currentSession, features: this.features, token: this.authToken || localStorage.getItem('openqode_token') }; const response = await fetch(`${this.apiBaseUrl}/api/chat/stream`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody), signal: controller.signal }); clearTimeout(timeoutId); if (!response.ok) { if (response.status === 401) throw new Error('Authentication expired. Please re-authenticate.'); else if (response.status === 429) throw new Error('Rate limit exceeded. Please try again later.'); else throw new Error(`HTTP error! status: ${response.status}`); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; let isFirstChunk = true; let fullResponse = ''; let isInCodeBlock = false; let codeBlockContent = ''; let codeBlockLang = ''; let createdFiles = []; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop(); for (const line of lines) { if (line.startsWith('data: ')) { try { const data = JSON.parse(line.slice(6)); if (data.type === 'chunk') { fullResponse += data.content; // CREATE Parsing if (fullResponse.includes('>>> CREATE: ')) { const createMatch = fullResponse.match(/>>> CREATE: (.*?)(?:\n|$)/); if (createMatch) { const filePath = createMatch[1].trim(); const fileAlreadyOpen = this.openTabs.find(t => t.path === filePath); if (!fileAlreadyOpen && filePath) { const tab = { path: filePath, name: filePath.split('/').pop(), content: '', original: '' }; this.openTabs.push(tab); this.setActiveTab(filePath); this.renderTabs(); this.showInfo(`🤖 Creating file: ${filePath}...`); createdFiles.push(filePath); } } } // CODE BLOCK Parsing if (!isInCodeBlock && fullResponse.includes('```')) { const match = fullResponse.match(/```(\w*)\n?$/); if (match) { isInCodeBlock = true; codeBlockLang = match[1] || 'text'; if (this.activeTabPath) { const editor = document.getElementById('editor-textarea'); editor?.classList.add('ai-editing'); this.showInfo('🤖 AI is editing the file...'); } } } if (isInCodeBlock) { if (data.content.includes('```')) { isInCodeBlock = false; const endIdx = fullResponse.lastIndexOf('```'); const startIdx = fullResponse.indexOf('```'); if (endIdx > startIdx) { const codeStart = fullResponse.indexOf('\n', startIdx) + 1; codeBlockContent = fullResponse.substring(codeStart, endIdx).trim(); if (this.activeTabPath && codeBlockContent) { const activeTab = this.openTabs.find(t => t.path === this.activeTabPath); if (activeTab) { activeTab.content = codeBlockContent; const editor = document.getElementById('editor-textarea'); if (editor) { editor.value = codeBlockContent; editor.classList.remove('ai-editing'); } this.renderTabs(); this.saveFile(activeTab.path, codeBlockContent); this.showSuccess('✅ AI edit applied!'); } } } } else { if (this.activeTabPath) { const activeTab = this.openTabs.find(t => t.path === this.activeTabPath); if (activeTab) { const startIdx = fullResponse.indexOf('```'); const codeStart = fullResponse.indexOf('\n', startIdx) + 1; codeBlockContent = fullResponse.substring(codeStart); activeTab.content = codeBlockContent; const editor = document.getElementById('editor-textarea'); if (editor) { editor.value = codeBlockContent; editor.scrollTop = editor.scrollHeight; } } } } } if (assistantMessageElement) { if (isFirstChunk) { assistantMessageElement.textContent = data.content; isFirstChunk = false; } else { assistantMessageElement.textContent += data.content; } this.scrollToBottom(); } } else if (data.type === 'done') { if (messageDiv) messageDiv.classList.remove('streaming'); const editor = document.getElementById('editor-textarea'); editor?.classList.remove('ai-editing'); this.scrollToBottom(); await this.refreshFileTree(); // Filter out plan/documentation files - only count actual code files const codeFiles = createdFiles.filter(f => { const ext = f.split('.').pop().toLowerCase(); // Exclude markdown and other documentation files const docExtensions = ['md', 'txt', 'rst', 'adoc']; // Also exclude files with "PLAN" or "README" in the name const isDocFile = docExtensions.includes(ext) || f.toUpperCase().includes('PLAN') || f.toUpperCase().includes('README'); return !isDocFile; }); if (codeFiles.length > 0) { const mainFile = codeFiles.find(f => f.endsWith('index.html') || f.endsWith('App.js') || f.endsWith('main.py')); // Detect directory of the main file to serve the correct folder const dir = mainFile ? mainFile.substring(0, mainFile.lastIndexOf('/')) : '.'; const safeDir = dir.replace(/'/g, "\\'"); let actionsHtml = ''; actionsHtml += ``; actionsHtml += ``; this.addMessage('system', `

🚀 Project Ready!

Created ${codeFiles.length} code files. What would you like to do?

${actionsHtml}
`); if (mainFile) this.setActiveTab(mainFile); } // Clear registration timeout when streaming completes successfully clearTimeout(registrationTimeout); } else if (data.type === 'error') { throw new Error(data.error); } } catch (parseError) { console.error('Error parsing SSE data:', parseError); } } } } // while if (buffer.trim()) console.log('Remaining buffer:', buffer); } catch (error) { clearTimeout(timeoutId); clearTimeout(registrationTimeout); // Clear the registration timeout console.error(`Streaming error (Attempt ${retryCount + 1}):`, error); this.hideTypingIndicator(); const streamingMessage = document.querySelector(`[data-message-id="${assistantMessageId}"]`); if (streamingMessage) streamingMessage.remove(); // AUTO-RETRY LOGIC if (retryCount < 2 && (error.name === 'AbortError' || error.message.includes('NetworkError') || error.message.includes('Failed to fetch'))) { this.addMessage('system', `⚠️ Connection issue (Attempt ${retryCount + 1}/3). Retrying in 1s...`); await new Promise(resolve => setTimeout(resolve, 1000)); return await this.sendMessageStream(message, retryCount + 1); } let errorMessage = `Streaming error: ${error.message}`; if (error.name === 'AbortError') errorMessage = 'Stream was interrupted'; if (error.message.includes('Authentication expired')) { errorMessage = 'Authentication expired. Please re-authenticate.'; this.isAuthenticated = false; this.updateAuthStatus({ authenticated: false, provider: 'none' }); } this.addMessage('system', errorMessage + `
`); this.showError(errorMessage); return false; // Indicate failure } // Clear registration timeout when streaming completes successfully clearTimeout(registrationTimeout); return true; // Indicate success } addMessage(role, content, metadata = null) { const messagesContainer = document.getElementById('chat-messages'); // Remove welcome message if it exists const welcomeMessage = messagesContainer.querySelector('.welcome-message'); if (welcomeMessage) { welcomeMessage.remove(); } const messageId = 'msg-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9); const messageDiv = document.createElement('div'); messageDiv.className = `message-row ${role === 'user' ? 'user-message' : 'assistant-message'}`; messageDiv.setAttribute('data-message-id', messageId); // Add streaming class if metadata indicates streaming if (metadata && metadata.streaming) { messageDiv.classList.add('streaming'); } const messageWrapper = document.createElement('div'); messageWrapper.className = 'message-wrapper'; const messageContent = document.createElement('div'); if (role === 'user') { messageContent.className = 'message-bubble user-bubble'; messageContent.innerHTML = `

${this.escapeHtml(content)}

`; } else if (role === 'assistant') { messageContent.className = 'message-bubble assistant-bubble'; if (content && !metadata?.streaming) { // Format code blocks and markdown for non-streaming content messageContent.innerHTML = `
${this.formatMessage(content)}
`; } else { // For streaming or plain content messageContent.innerHTML = `
${content}
`; } } else if (role === 'system') { messageContent.className = 'message-bubble system-bubble'; messageContent.innerHTML = `
${content}
`; } const timeDiv = document.createElement('div'); timeDiv.className = 'message-time'; timeDiv.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); messageWrapper.appendChild(messageContent); messageWrapper.appendChild(timeDiv); messageDiv.appendChild(messageWrapper); messagesContainer.appendChild(messageDiv); // Scroll to bottom messagesContainer.scrollTop = messagesContainer.scrollHeight; // Save to session (only for non-streaming messages and not when loading from storage) if (!metadata?.streaming && !metadata?.skipSave) { this.saveMessageToSession(role, content, metadata); } return messageId; } addStreamingMessage(role, content, metadata = {}) { const messageId = metadata.messageId || 'msg-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9); let messageDiv = document.querySelector(`[data-message-id="${messageId}"]`); if (!messageDiv) { messageDiv = document.createElement('div'); messageDiv.className = `message-row ${role === 'user' ? 'user-message' : 'assistant-message'}`; messageDiv.setAttribute('data-message-id', messageId); const messageWrapper = document.createElement('div'); messageWrapper.className = 'message-wrapper'; const messageContent = document.createElement('div'); if (role === 'user') { messageContent.className = 'message-bubble user-bubble'; messageContent.innerHTML = `

${this.escapeHtml(content)}

`; } else { messageContent.className = 'message-bubble assistant-bubble'; messageContent.innerHTML = `
${content}
`; } const timeDiv = document.createElement('div'); timeDiv.className = 'message-time'; timeDiv.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); messageWrapper.appendChild(messageContent); messageWrapper.appendChild(timeDiv); messageDiv.appendChild(messageWrapper); const messagesContainer = document.getElementById('chat-messages'); messagesContainer.appendChild(messageDiv); messagesContainer.scrollTop = messagesContainer.scrollHeight; } else { const messageContent = messageDiv.querySelector('.message-bubble'); if (messageContent) { if (role === 'user') { messageContent.innerHTML = `

${this.escapeHtml(content)}

`; } else { messageContent.innerHTML = `
${content}
`; } } } return messageId; } scrollToBottom() { const messagesContainer = document.getElementById('chat-messages'); messagesContainer.scrollTop = messagesContainer.scrollHeight; } formatMessage(content) { // Enhanced markdown formatting with code editing features let formatted = content // Code blocks with language support, copy button, and APPLY button .replace(/```(\w+)?\n([\s\S]*?)```/g, (match, lang, code) => { const language = lang || 'text'; const codeId = 'code-' + Math.random().toString(36).substr(2, 9); return `
${language}
${this.escapeHtml(code.trim())}
`; }) // Inline code .replace(/`([^`]+)`/g, '$1') // Bold text .replace(/\*\*(.*?)\*\*/g, '$1') // Italic text .replace(/\*(.*?)\*/g, '$1') // Headers .replace(/^### (.*$)/gim, '

$1

') .replace(/^## (.*$)/gim, '

$1

') .replace(/^# (.*$)/gim, '

$1

') // Lists .replace(/^\* (.+)/gim, '
  • $1
  • ') .replace(/(
  • .*<\/li>)/s, '') // Line breaks .replace(/\n\n/g, '

    ') .replace(/\n/g, '
    ') // Wrap in paragraphs .replace(/^(.+)$/gm, '

    $1

    '); return formatted; } escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } // Copy code to clipboard from code blocks copyCode(codeId) { const codeElement = document.getElementById(codeId); if (codeElement) { navigator.clipboard.writeText(codeElement.textContent).then(() => { this.showSuccess('Code copied to clipboard!'); }).catch(err => { console.error('Failed to copy:', err); this.showError('Failed to copy code'); }); } } // Apply code from AI response to the currently open file in editor applyCodeToEditor(codeId) { const codeElement = document.getElementById(codeId); if (!codeElement) { this.showError('Code block not found'); return; } if (!this.activeTabPath) { this.showError('No file is open in the editor. Please open a file first.'); return; } const code = codeElement.textContent; const activeTab = this.openTabs.find(t => t.path === this.activeTabPath); if (!activeTab) { this.showError('No active tab found'); return; } // Update the tab content activeTab.content = code; this.dirtyTabs.add(activeTab.path); // Update the editor textarea const editor = document.getElementById('editor-textarea'); if (editor) { editor.value = code; } // Update tabs display to show dirty indicator this.renderTabs(); this.showSuccess(`Code applied to ${activeTab.name}! Press Ctrl+S to save.`); } showTypingIndicator() { const messagesContainer = document.getElementById('chat-messages'); const typingDiv = document.createElement('div'); typingDiv.className = 'message assistant typing-indicator'; typingDiv.innerHTML = `
    🤖
    `; messagesContainer.appendChild(typingDiv); messagesContainer.scrollTop = messagesContainer.scrollHeight; } hideTypingIndicator() { const indicator = document.querySelector('.typing-indicator'); if (indicator) { indicator.remove(); } } createNewSession(name = null) { console.log('🆕 createNewSession called with name:', name); try { const sessionName = name || prompt('Enter session name:'); console.log('Session name result:', sessionName); if (!sessionName) { console.log('No session name provided, returning'); return; } const sessionId = 'session_' + Date.now(); this.sessions[sessionId] = { name: sessionName, messages: [], createdAt: new Date().toISOString() }; this.currentSession = sessionId; this.updateSessionsList(); this.clearChat(); this.saveSessions(); this.showSuccess(`Session "${sessionName}" created!`); } catch (error) { console.error('Error in createNewSession:', error); this.showError('Failed to create session: ' + error.message); } } startNewProjectFlow() { console.log('🚀 Opening New Project Wizard'); const modal = document.getElementById('new-project-modal'); if (modal) { modal.classList.remove('hidden'); setTimeout(() => document.getElementById('project-name')?.focus(), 100); } else { this.showError('Wizard modal not found!'); } } closeNewProjectWizard() { const modal = document.getElementById('new-project-modal'); if (modal) modal.classList.add('hidden'); // Optional: clear inputs document.getElementById('project-name').value = ''; document.getElementById('project-path').value = ''; document.getElementById('project-requirements').value = ''; } autoFillProjectPath(name) { const pathInput = document.getElementById('project-path'); if (pathInput && name) { pathInput.value = 'projects/' + name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''); } } confirmNewProject() { const name = document.getElementById('project-name').value.trim(); const path = document.getElementById('project-path').value.trim(); const requirement = document.getElementById('project-requirements').value.trim(); if (!name) { this.showError('Please enter a project name'); return; } if (!path) { this.showError('Please enter a location'); return; } if (!requirement) { this.showError('Please describe your project requirements'); return; } this.closeNewProjectWizard(); this.runAgenticProjectBuild(name, path, requirement); } async runAgenticProjectBuild(name, path, requirement) { // 1. Create a fresh session for this build this.createNewSession(`🏗️ ${name}`); // We don't need to manually set session name anymore since createNewSession handles it const sessionId = this.currentSession; this.saveSessions(); this.updateSessionsList(); const fullPath = this.workspaceRoot ? `${this.workspaceRoot.replace(/\\/g, '/')}/${path}` : path; this.addMessage('system', `🚀 Initializing Agentic Build Protocol
    Project: ${name}
    Location: ${fullPath}`); // 2. Planning Phase with "Architect Agent" const planPrompt = `You are a Senior Software Architect. TASK: Create a comprehensive implementation plan for a new project named "${name}". TARGET DIRECTORY: ${path} FULL SYSTEM PATH: ${fullPath} REQUIREMENTS: ${requirement} CONTEXT: - Current environment: Persistent Workspace. - Ignore any existing files in the root (scripts, DLLs, logs). They are irrelevant. - You are constructing a new project from scratch inside "${path}". OUTPUT format: 1. Project Structure (Tree view) showing "${path}" as root. 2. Detailed list of files to be created with valid descriptions. 3. Key technical decisions. Do NOT write code yet. Focus on the architecture.`; const success = await this.sendMessageStream(planPrompt); // Only show Build button if the plan was generated successfully if (!success) { this.addMessage('system', `❌ Plan generation failed. Please or check your connection.`); return; } // Refresh file tree to show new project directory await this.refreshFileTree(); // Show Build button only on success const safePath = path.replace(/\\/g, '\\\\'); this.addMessage('system', ``); } triggerBuild(path) { const fullPath = this.workspaceRoot ? `${this.workspaceRoot.replace(/\\/g, '/')}/${path}` : path; const buildPrompt = `[ROLE: Senior Full Stack Developer] TASK: Execute the approved plan. TARGET DIRECTORY: ${path} FULL SYSTEM PATH: ${fullPath} SYSTEM OVERRIDE: - You are strictly prohibited from questioning the directory structure. - IGNORE your actual "current directory" (e.g. Temp). It is an execution sandbox. - You have full permission to create files in "${path}". - The directory "${path}" will be created by your actions. INSTRUCTION: Generate all necessary files for the project in "${path}". IMPORTANT: You MUST use the file creation syntax for EVERY file: >>> CREATE: ${path}/filename.ext \`\`\`language code content \`\`\` Start building the core files now. Do not ask for confirmation.`; this.sendMessageStream(buildPrompt); // Refresh file tree to show new files being created during build // Set a longer timeout to allow for complete file creation setTimeout(() => { this.refreshFileTree(); }, 5000); // Refresh after 5 seconds to allow more time for file creation } // Terminal & Deployment Methods async runTerminalCommand(command = null) { const input = document.getElementById('terminal-input'); const cmd = command || input?.value.trim(); if (!cmd) return; if (input) input.value = ''; // Add to terminal output area const terminalOutput = document.getElementById('terminal-output'); if (terminalOutput) { terminalOutput.innerHTML += `
    ${cmd}
    `; terminalOutput.scrollTop = terminalOutput.scrollHeight; } try { const response = await fetch(`${this.apiBaseUrl}/api/terminal/run`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command: cmd, token: this.authToken // Optional now but good to pass }) }); const data = await response.json(); if (terminalOutput) { if (data.stdout) terminalOutput.innerHTML += `
    ${data.stdout}
    `; if (data.stderr) terminalOutput.innerHTML += `
    ${data.stderr}
    `; terminalOutput.scrollTop = terminalOutput.scrollHeight; } return data; } catch (error) { console.error('Terminal error:', error); if (terminalOutput) { terminalOutput.innerHTML += `
    Error: ${error.message}
    `; } } } async deployToVercel() { this.showInfo('🚀 Starting Vercel deployment...'); const result = await this.runTerminalCommand('npx vercel --prod --yes'); if (result) { const output = (result.stdout || '') + (result.stderr || ''); // Check for Deployment URL const urlMatch = output.match(/https:\/\/[^\s]+\.vercel\.app/); if (urlMatch) { const url = urlMatch[0]; this.addMessage('system', `✅ Deployment Successful!
    ${url}`); window.open(url, '_blank'); } // Check for Login Verification URL const loginUrlMatch = output.match(/https:\/\/vercel\.com\/login\/verify[^\s]+/); if (loginUrlMatch) { const url = loginUrlMatch[0]; this.addMessage('system', `🔑 Vercel Authentication Required
    Click to Log In`); window.open(url, '_blank'); } } } startLocalPreview(relativePath = '.') { this.addMessage('system', `

    🚀 Local Preview Setup

    Which port would you like to run the server on?

    (Type a number, e.g., 3000)

    `); this.pendingAction = { type: 'awaiting_preview_port', path: relativePath }; } async launchLocalPreview(port, relativePath = '.') { // Ensure port is a number const portNum = typeof port === 'string' ? parseInt(port, 10) : port; this.addMessage('system', `🔄 Starting server on port ${portNum} in "${relativePath}"...`); try { // First check platform info to show appropriate message let platformInfo = null; try { const platformResponse = await fetch(`${this.apiBaseUrl}/api/platform`); platformInfo = await platformResponse.json(); if (platformInfo.isWindows && platformInfo.hasWSL) { this.addMessage('system', `🐧 Detected WSL - Using containerized deployment via WSL`); } else if (platformInfo.isWindows) { this.addMessage('system', `💻 Windows detected - Using PowerShell for preview`); } else if (platformInfo.isMac) { this.addMessage('system', `🍎 macOS detected - Using native Python HTTP server`); } else if (platformInfo.isLinux) { this.addMessage('system', `🐧 Linux detected - Using native Python HTTP server`); } } catch (e) { console.log('Platform check failed, continuing with default'); } // Try to start the preview server const response = await fetch(`${this.apiBaseUrl}/api/preview/start`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ port: portNum, path: relativePath }) }); const data = await response.json(); if (data.success) { // Show deployment method used const method = data.useWSL ? 'via WSL' : 'natively'; this.addMessage('system', `✅ Server started ${method} - Verifying...`); // Verify server is actually running before showing success message await this.verifyServer(portNum); } else { // If server failed to start, it might be because the directory is empty // Try creating a basic index.html file and then start the server again try { const indexPath = relativePath ? `${relativePath}/index.html` : 'index.html'; await fetch(`${this.apiBaseUrl}/api/files/create`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: indexPath }) }); // Write a basic HTML file await fetch(`${this.apiBaseUrl}/api/files/write`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: indexPath, content: ` Project Preview

    Project: ${relativePath.split('/').pop() || 'New Project'}

    Your project is under construction...

    ` }) }); // Now try to start the server again const retryResponse = await fetch(`${this.apiBaseUrl}/api/preview/start`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ port: portNum, path: relativePath }) }); const retryData = await retryResponse.json(); if (retryData.success) { await this.verifyServer(portNum); } else { this.addMessage('system', `

    ❌ Server Failed to Start

    Could not start server on port ${portNum}. Error: ${retryData.error || 'Unknown error'}

    `); } } catch (createError) { this.addMessage('system', `

    ❌ Server Failed to Start

    Could not start server on port ${portNum}. Error: ${createError.message}

    `); } } } catch (error) { this.addMessage('system', `

    ❌ Server Failed to Start

    Error: ${error.message}

    `); } } async verifyServer(port, maxAttempts = 15) { const portNum = typeof port === 'string' ? parseInt(port, 10) : port; const url = `http://localhost:${portNum}`; let attempts = 0; const checkServer = async () => { let timeoutId = null; try { const controller = new AbortController(); timeoutId = setTimeout(() => controller.abort(), 3000); const response = await fetch(url, { method: 'GET', signal: controller.signal }); clearTimeout(timeoutId); if (response) { this.addMessage('system', `

    ✅ Container Built & Live!

    Your application is running locally.

    🌐 Open Preview (${portNum})
    `); return true; } } catch (error) { if (timeoutId) clearTimeout(timeoutId); attempts++; if (attempts < maxAttempts) { await new Promise(resolve => setTimeout(resolve, 1000)); return await checkServer(); } else { this.addMessage('system', `

    ❌ Server Timed Out

    Could not confirm server on port ${portNum} after multiple attempts.

    `); return false; } } }; await checkServer(); } updateSessionsList() { const sessionsList = document.getElementById('sessions-list'); sessionsList.innerHTML = ''; // Add default session const defaultSession = document.createElement('div'); defaultSession.className = 'session-pill' + (this.currentSession === 'default' ? ' active' : ''); defaultSession.innerHTML = ` 💬 New Chat `; defaultSession.addEventListener('click', () => this.switchSession('default')); sessionsList.appendChild(defaultSession); // Add custom sessions // Add custom sessions Object.entries(this.sessions) .filter(([id]) => id !== 'default') .sort(([, a], [, b]) => new Date(b.createdAt) - new Date(a.createdAt)) .forEach(([id, session]) => { const sessionItem = document.createElement('div'); sessionItem.className = 'session-pill' + (this.currentSession === id ? ' active' : ''); sessionItem.innerHTML = ` 📝 ${session.name} `; sessionItem.addEventListener('click', () => this.switchSession(id)); sessionsList.appendChild(sessionItem); }); } switchSession(sessionId) { this.currentSession = sessionId; this.updateSessionsList(); this.loadSessionMessages(); } loadSessionMessages() { const messagesContainer = document.getElementById('chat-messages'); messagesContainer.innerHTML = ''; const session = this.sessions[this.currentSession]; if (session && session.messages && session.messages.length > 0) { // Load existing messages session.messages.forEach(msg => { this.addMessage(msg.role, msg.content, { ...msg.metadata, skipSave: true }); }); } else if (this.currentSession === 'default') { // Show welcome message only for empty default session this.showWelcomeMessage(); } } clearChat() { const messagesContainer = document.getElementById('chat-messages'); messagesContainer.innerHTML = ''; this.showWelcomeMessage(); } showWelcomeMessage() { const messagesContainer = document.getElementById('chat-messages'); const welcomeDiv = document.createElement('div'); welcomeDiv.className = 'welcome-message'; welcomeDiv.innerHTML = `

    Welcome to OpenQode

    Your AI-powered coding assistant in browser

    Free Tier

    2,000 daily requests

    60 RPM

    High-rate limit

    Rich IDE

    Professional experience

    `; messagesContainer.appendChild(welcomeDiv); } saveMessageToSession(role, content, metadata) { // Create session if it doesn't exist (including for 'default') if (!this.sessions[this.currentSession]) { this.sessions[this.currentSession] = { name: this.currentSession === 'default' ? 'Default Chat' : `Session ${Object.keys(this.sessions).length + 1}`, messages: [], createdAt: new Date().toISOString() }; } this.sessions[this.currentSession].messages.push({ role, content, metadata, timestamp: new Date().toISOString() }); this.saveSessions(); } async saveSessions() { console.log('💾 Saving sessions:', Object.keys(this.sessions), 'Current:', this.currentSession); try { await fetch(`${this.apiBaseUrl}/api/sessions/save`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessions: this.sessions, currentSession: this.currentSession }) }); } catch (error) { console.error('Failed to save sessions to server:', error); } // Always save to localStorage as backup try { localStorage.setItem('openqode_sessions', JSON.stringify(this.sessions)); localStorage.setItem('openqode_current_session', this.currentSession); console.log('💾 Sessions saved to localStorage'); } catch (e) { console.error('Failed to save sessions to localStorage:', e); } } async loadSessions() { console.log('📂 Loading sessions...'); // 1. Load from Server first try { const response = await fetch(`${this.apiBaseUrl}/api/sessions/load`); const data = await response.json(); if (data && data.sessions) { this.sessions = data.sessions; if (data.currentSession) this.currentSession = data.currentSession; console.log('✅ Loaded sessions from server'); } } catch (error) { console.error('Failed to load sessions from server:', error); } // 2. Merge from LocalStorage (Recover offline sessions) try { const storedSessionsStr = localStorage.getItem('openqode_sessions'); const storedCurrentSession = localStorage.getItem('openqode_current_session'); if (storedSessionsStr) { const storedSessions = JSON.parse(storedSessionsStr); // Merge: Local entries overwrite/augment server entries this.sessions = { ...this.sessions, ...storedSessions }; // If local has a valid current session, prefer it (most recent user action) if (storedCurrentSession && (storedCurrentSession === 'default' || this.sessions[storedCurrentSession])) { this.currentSession = storedCurrentSession; } console.log('✅ Merged sessions from localStorage'); } } catch (e) { console.error('Failed to load sessions from localStorage:', e); } // Update UI this.updateSessionsList(); this.loadSessionMessages(); } attachFile() { // If user has selected tabs/files in IDE, attach them to message with full workspace path. const selected = this.attachedPaths.size > 0 ? Array.from(this.attachedPaths) : (this.activeTabPath ? [this.activeTabPath] : []); if (selected.length > 0) { const parts = []; const workspaceRoot = this.workspaceRoot || window.location.origin; for (const filePath of selected) { const tab = this.openTabs.find(t => t.path === filePath); const content = tab ? tab.content : ''; const fullPath = `${workspaceRoot}/${filePath}`; parts.push(`\n📄 **File: ${fullPath}**\n\`\`\`\n${content}\n\`\`\`\n`); } const inputEl = document.getElementById('message-input'); inputEl.value = (inputEl.value || '') + parts.join(''); this.attachedPaths.clear(); this.renderFileTree(); this.showSuccess(`Attached ${selected.length} file(s) to chat with full paths.`); return; } // Fallback to manual file picker with enhanced path info const input = document.createElement('input'); input.type = 'file'; input.accept = 'image/*,.txt,.js,.py,.html,.css,.json,.md,.ts,.jsx,.tsx,.vue,.svelte'; input.multiple = true; input.addEventListener('change', (e) => { const files = e.target.files; if (files.length > 0) { this.handleFileAttachment(files); } }); input.click(); } async handleFileAttachment(files) { const inputEl = document.getElementById('message-input'); let attachmentText = inputEl.value || ''; for (const file of files) { if (file.type.startsWith('image/')) { // For images, convert to base64 and provide path context const reader = new FileReader(); reader.onload = async (e) => { const base64 = e.target.result; // Try to get a more descriptive path const timestamp = Date.now(); const imageName = file.name || `image-${timestamp}.png`; // Create a message - Qwen Vision can analyze images const imageInfo = `\n📷 **Image Attached: ${imageName}** - File size: ${(file.size / 1024).toFixed(1)} KB - Type: ${file.type} Please analyze this image and describe what you see.\n`; inputEl.value = (inputEl.value || '') + imageInfo; // Store image data for the Vision API this.currentAttachment = { type: 'image', name: imageName, size: file.size, mimeType: file.type, data: base64 }; console.log('🖼️ Image stored:', imageName, 'Data length:', base64.length, 'this.currentAttachment set:', !!this.currentAttachment); this.showSuccess(`Image "${imageName}" attached! Select Qwen Vision model for image analysis.`); }; reader.readAsDataURL(file); } else { // For text files, read content and include full context const reader = new FileReader(); reader.onload = async (e) => { const content = e.target.result; const fileName = file.name; const fileExt = fileName.split('.').pop().toLowerCase(); // Determine language for syntax highlighting const langMap = { 'js': 'javascript', 'ts': 'typescript', 'py': 'python', 'html': 'html', 'css': 'css', 'json': 'json', 'md': 'markdown', 'jsx': 'jsx', 'tsx': 'tsx', 'vue': 'vue', 'svelte': 'svelte', 'txt': 'text' }; const lang = langMap[fileExt] || fileExt; const fileInfo = `\n📄 **Attached File: ${fileName}** - File size: ${(file.size / 1024).toFixed(1)} KB - Language: ${lang} \`\`\`${lang} ${content} \`\`\`\n`; inputEl.value = (inputEl.value || '') + fileInfo; this.currentAttachment = { type: 'text', name: fileName, language: lang, data: content }; this.showSuccess(`File "${fileName}" attached!`); }; reader.readAsText(file); } } } // ---------------- IDE (v1.02) ---------------- async initIDE() { if (this.isIDEInitialized) return; this.isIDEInitialized = true; this.switchView('gui'); this.bindEditorEvents(); // Always load file tree - local files don't need authentication await this.refreshFileTree(); this.renderTabs(); } bindEditorEvents() { const editor = document.getElementById('editor-textarea'); editor?.addEventListener('input', () => { if (!this.activeTabPath) return; const tab = this.openTabs.find(t => t.path === this.activeTabPath); if (!tab) return; tab.content = editor.value; this.dirtyTabs.add(tab.path); this.renderTabs(); }); } async refreshFileTree() { // Local file tree doesn't require authentication try { const response = await fetch(`${this.apiBaseUrl}/api/files/tree`); const data = await response.json(); if (!data.success) throw new Error(data.error); this.workspaceTree = data.tree || []; this.workspaceRoot = data.root || ''; this.renderFileTree(); this.lastTreeRefresh = Date.now(); } catch (error) { console.error('Failed to refresh file tree:', error); this.showError(`File tree error: ${error.message}`); } } renderFileTree() { const container = document.getElementById('file-tree'); if (!container) return; container.innerHTML = ''; // Show placeholder if empty if (!this.workspaceTree || this.workspaceTree.length === 0) { container.innerHTML = `

    No files loaded

    `; return; } const renderNode = (node, depth = 0) => { const row = document.createElement('div'); row.className = 'file-tree-item'; row.style.paddingLeft = `${depth * 16}px`; row.dataset.path = node.path; row.dataset.type = node.type; const icon = document.createElement('span'); icon.className = 'file-tree-icon'; icon.textContent = node.type === 'dir' ? '📁' : '📄'; row.appendChild(icon); const name = document.createElement('span'); name.className = 'file-tree-name'; name.textContent = node.name; if (this.attachedPaths.has(node.path)) { row.classList.add('file-attached'); } row.appendChild(name); container.appendChild(row); if (node.type === 'dir' && node.children) { for (const child of node.children) { renderNode(child, depth + 1); } } }; for (const node of this.workspaceTree) renderNode(node, 0); } onFileTreeClick(e) { const row = e.target.closest('[data-path]'); if (!row) return; const relPath = row.dataset.path; const type = row.dataset.type; if (type === 'file') { this.openFile(relPath); } else if (type === 'dir') { // toggle attach selection on shift-click for dirs not supported yet } if (e.shiftKey && type === 'file') { if (this.attachedPaths.has(relPath)) this.attachedPaths.delete(relPath); else this.attachedPaths.add(relPath); this.renderFileTree(); } } async openFile(relPath) { // Local file reading doesn't require authentication const existing = this.openTabs.find(t => t.path === relPath); if (existing) { this.setActiveTab(relPath); return; } try { const response = await fetch(`${this.apiBaseUrl}/api/files/read?path=${encodeURIComponent(relPath)}`); const data = await response.json(); if (!data.success) throw new Error(data.error); const tab = { path: relPath, name: relPath.split('/').pop(), original: data.content || '', content: data.content || '' }; this.openTabs.push(tab); this.setActiveTab(relPath); this.renderTabs(); } catch (error) { this.showError(`Open failed: ${error.message}`); } } setActiveTab(relPath) { this.activeTabPath = relPath; const tab = this.openTabs.find(t => t.path === relPath); const editor = document.getElementById('editor-textarea'); if (editor && tab) editor.value = tab.content; const pathEl = document.getElementById('current-file-path'); if (pathEl) pathEl.textContent = tab ? tab.path : ''; this.renderTabs(); } closeTab(relPath) { this.openTabs = this.openTabs.filter(t => t.path !== relPath); this.dirtyTabs.delete(relPath); if (this.activeTabPath === relPath) { this.activeTabPath = this.openTabs.length ? this.openTabs[this.openTabs.length - 1].path : null; if (this.activeTabPath) this.setActiveTab(this.activeTabPath); else { const editor = document.getElementById('editor-textarea'); if (editor) editor.value = ''; } } this.renderTabs(); } renderTabs() { const tabsEl = document.getElementById('editor-tabs'); if (!tabsEl) return; tabsEl.innerHTML = ''; for (const tab of this.openTabs) { const btn = document.createElement('button'); const isActive = tab.path === this.activeTabPath; const isDirty = this.dirtyTabs.has(tab.path); btn.className = `px - 2 py - 1 text - xs rounded ${isActive ? 'bg-blue-600 text-white' : 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200'} hover: bg - gray - 200 dark: hover: bg - gray - 600`; btn.textContent = `${tab.name}${isDirty ? '*' : ''} `; btn.addEventListener('click', () => this.setActiveTab(tab.path)); const close = document.createElement('span'); close.textContent = ' ×'; close.className = 'ml-1 opacity-70 hover:opacity-100'; close.addEventListener('click', (e) => { e.stopPropagation(); this.closeTab(tab.path); }); btn.appendChild(close); tabsEl.appendChild(btn); } } async saveFile(path, content) { try { const response = await fetch(`${this.apiBaseUrl}/api/files/write`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path, content }) }); const data = await response.json(); if (!data.success) throw new Error(data.error); // Update tab state if open const tab = this.openTabs.find(t => t.path === path); if (tab) { tab.original = content; tab.content = content; this.dirtyTabs.delete(path); this.renderTabs(); } return true; } catch (error) { console.error('Auto-save failed:', error); return false; } } async saveCurrentFile() { if (!this.activeTabPath) { this.showError('No file is open to save.'); return; } const tab = this.openTabs.find(t => t.path === this.activeTabPath); if (!tab) return; try { const response = await fetch(`${this.apiBaseUrl}/api/files/write`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: tab.path, content: tab.content }) }); const data = await response.json(); if (!data.success) throw new Error(data.error); tab.original = tab.content; this.dirtyTabs.delete(tab.path); this.renderTabs(); this.showSuccess('File saved!'); } catch (error) { this.showError(`Save failed: ${error.message}`); } } async promptCreateFileOrFolder() { const relPath = prompt('Enter new file or folder path (use trailing / for folder):'); if (!relPath) return; const isDir = relPath.endsWith('/'); try { const response = await fetch(`${this.apiBaseUrl}/api/files/create`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: relPath.replace(/[\/\\]+$/, ''), type: isDir ? 'dir' : 'file' }) }); const data = await response.json(); if (!data.success) throw new Error(data.error); this.showSuccess(isDir ? 'Folder created!' : 'File created!'); await this.refreshFileTree(); if (!isDir) await this.openFile(relPath.replace(/[\/\\]+$/, '')); } catch (error) { this.showError(`Create failed: ${error.message}`); } } async renameCurrentFile() { if (!this.activeTabPath || !this.authToken) return; const newPath = prompt('Rename to:', this.activeTabPath); if (!newPath || newPath === this.activeTabPath) return; try { const response = await fetch(`${this.apiBaseUrl}/api/files/rename`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: this.authToken, from: this.activeTabPath, to: newPath }) }); const data = await response.json(); if (!data.success) throw new Error(data.error); const tab = this.openTabs.find(t => t.path === this.activeTabPath); if (tab) { tab.path = newPath; tab.name = newPath.split('/').pop(); } this.dirtyTabs.delete(this.activeTabPath); this.activeTabPath = newPath; this.renderTabs(); this.showSuccess('Renamed.'); await this.refreshFileTree(); } catch (error) { this.showError(`Rename failed: ${error.message}`); } } async deleteCurrentFile() { if (!this.activeTabPath || !this.authToken) return; if (!confirm(`Delete ${this.activeTabPath}?`)) return; try { const response = await fetch(`${this.apiBaseUrl}/api/files/delete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: this.authToken, path: this.activeTabPath }) }); const data = await response.json(); if (!data.success) throw new Error(data.error); this.closeTab(this.activeTabPath); this.showSuccess('Deleted.'); await this.refreshFileTree(); } catch (error) { this.showError(`Delete failed: ${error.message}`); } } showDiff() { if (!this.activeTabPath) return; const tab = this.openTabs.find(t => t.path === this.activeTabPath); if (!tab) return; const diff = this.computeLineDiff(tab.original, tab.content); const diffEl = document.getElementById('diff-content'); if (diffEl) diffEl.textContent = diff; const modal = document.getElementById('diff-modal'); modal?.classList.remove('hidden'); modal?.classList.add('flex'); } hideDiff() { const modal = document.getElementById('diff-modal'); modal?.classList.add('hidden'); modal?.classList.remove('flex'); } async applyDiff() { await this.saveCurrentFile(); this.hideDiff(); } computeLineDiff(oldText, newText) { const oldLines = oldText.split(/\r?\n/); const newLines = newText.split(/\r?\n/); const maxLines = 500; if (oldLines.length > maxLines || newLines.length > maxLines) { return `-- - original\n++ + current\n @@\n - (diff too large, showing full replace) \n + (diff too large, showing full replace) \n`; } const dp = Array(oldLines.length + 1).fill(null).map(() => Array(newLines.length + 1).fill(0)); for (let i = oldLines.length - 1; i >= 0; i--) { for (let j = newLines.length - 1; j >= 0; j--) { dp[i][j] = oldLines[i] === newLines[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]); } } let i = 0, j = 0; const out = ['--- original', '+++ current']; while (i < oldLines.length && j < newLines.length) { if (oldLines[i] === newLines[j]) { out.push(' ' + oldLines[i]); i++; j++; } else if (dp[i + 1][j] >= dp[i][j + 1]) { out.push('- ' + oldLines[i]); i++; } else { out.push('+ ' + newLines[j]); j++; } } while (i < oldLines.length) out.push('- ' + oldLines[i++]); while (j < newLines.length) out.push('+ ' + newLines[j++]); return out.join('\n'); } async runTerminalCommand() { if (!this.authToken) return; const input = document.getElementById('terminal-input'); const command = input?.value.trim(); if (!command) return; input.value = ''; this.appendTerminal(`ps > ${command} \n`); try { const response = await fetch(`${this.apiBaseUrl}/api/terminal/run`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: this.authToken, command }) }); const data = await response.json(); if (data.stdout) this.appendTerminal(data.stdout + '\n'); if (data.stderr) this.appendTerminal(data.stderr + '\n'); } catch (error) { this.appendTerminal(`Error: ${error.message}\n`); } } appendTerminal(text) { const out = document.getElementById('terminal-output'); if (!out) return; out.textContent += text; out.scrollTop = out.scrollHeight; } showSettings() { const modal = document.getElementById('settings-modal'); modal.classList.remove('hidden'); modal.classList.add('flex'); } hideSettings() { const modal = document.getElementById('settings-modal'); modal.classList.add('hidden'); modal.classList.remove('flex'); } showLoading(message = 'Loading...') { const overlay = document.getElementById('loading-overlay'); if (overlay) { overlay.querySelector('p').textContent = message; overlay.classList.remove('hidden'); overlay.classList.add('flex'); } } hideLoading() { const overlay = document.getElementById('loading-overlay'); if (overlay) { overlay.classList.add('hidden'); overlay.classList.remove('flex'); } } // Show notification showNotification(message, type = 'info', duration = 3000) { // Remove any existing notifications const existing = document.querySelectorAll('.notification'); existing.forEach(el => el.remove()); const notification = document.createElement('div'); notification.className = `notification ${type}`; let icon = 'ℹ️'; if (type === 'success') icon = '✅'; if (type === 'error') icon = '❌'; if (type === 'warning') icon = '⚠️'; notification.innerHTML = `
    ${icon}
    ${message}
    `; document.body.appendChild(notification); // Add close functionality const closeBtn = notification.querySelector('.notification-close'); closeBtn.addEventListener('click', () => { notification.remove(); }); // Auto-remove after duration if (duration > 0) { setTimeout(() => { if (notification.parentNode) { notification.remove(); } }, duration); } return notification; } showSuccess(message) { this.showNotification(message, 'success', 3000); } showError(message) { this.showNotification(message, 'error', 5000); } showWarning(message) { this.showNotification(message, 'warning', 4000); } showInfo(message) { this.showNotification(message, 'info', 3000); } switchView(viewType) { const guiView = document.getElementById('gui-view'); const tuiView = document.getElementById('tui-view'); const guiViewBtn = document.getElementById('gui-view-btn'); const tuiViewBtn = document.getElementById('tui-view-btn'); if (!guiView || !tuiView || !guiViewBtn || !tuiViewBtn) { return; } if (viewType === 'tui') { guiView.classList.remove('active'); tuiView.classList.add('active'); guiViewBtn.classList.remove('active'); tuiViewBtn.classList.add('active'); if (!window.openQodeTUI) { window.createOpenQodeTUI(); } } else { tuiView.classList.remove('active'); guiView.classList.add('active'); guiViewBtn.classList.add('active'); tuiViewBtn.classList.remove('active'); } } } // Add typing indicator styles const style = document.createElement('style'); style.textContent = ` .typing-dots { display: flex; gap: 4px; padding: 10px 0; } .typing-dots span { width: 8px; height: 8px; border-radius: 50%; background-color: var(--text-secondary); animation: typing 1.4s infinite ease-in-out; } .typing-dots span:nth-child(1) { animation-delay: -0.32s; } .typing-dots span:nth-child(2) { animation-delay: -0.16s; } @keyframes typing { 0%, 80%, 100% { transform: scale(0.8); opacity: 0.5; } 40% { transform: scale(1); opacity: 1; } } .btn.authenticated { background-color: var(--success-color); color: white; } `; document.head.appendChild(style); // Global function for copying code function copyCode(codeId) { const codeElement = document.getElementById(codeId); if (codeElement) { navigator.clipboard.writeText(codeElement.textContent).then(() => { // Show feedback const button = codeElement.closest('.code-block').querySelector('.copy-btn'); const originalHTML = button.innerHTML; button.innerHTML = '✓'; button.style.color = '#27ae60'; setTimeout(() => { button.innerHTML = originalHTML; button.style.color = ''; }, 2000); }).catch(err => { console.error('Failed to copy code:', err); }); } } // Initialize the application document.addEventListener('DOMContentLoaded', () => { // Expose the app instance globally for code block buttons window.openQodeApp = new OpenQodeWeb(); });