From 26c6f5f6c7373287dfe5c3d518ee0e2fbc017155 Mon Sep 17 00:00:00 2001 From: southseact-3d Date: Sun, 8 Feb 2026 16:21:21 +0000 Subject: [PATCH] fix: Add pony-alpha model and fix model lookup for prefixed model IDs - Add openrouter/pony-alpha model to models-api.json fixture - Fix getModel() to lookup models with provider prefix (e.g., openrouter/pony-alpha) When user specifies openrouter/pony-alpha, the code now correctly looks for the full model ID including prefix in the provider's models object This fixes the 'ModelNotFoundError' when using OpenRouter models that have prefixed IDs in the database. --- chat/public/builder.html | 58 +++++-- chat/public/builder.js | 157 +++++++++++++++++- .../opencode/src/provider/provider.ts | 6 +- .../test/tool/fixtures/models-api.json | 17 ++ 4 files changed, 218 insertions(+), 20 deletions(-) diff --git a/chat/public/builder.html b/chat/public/builder.html index 53c2bcd..2313648 100644 --- a/chat/public/builder.html +++ b/chat/public/builder.html @@ -300,6 +300,26 @@ box-shadow: 0 10px 25px rgba(0, 128, 96, 0.25); } + /* Cancel mode for send button */ + .primary.cancel-mode { + background: linear-gradient(135deg, #ef4444, #dc2626); + color: #fff; + } + + .primary.cancel-mode:hover { + box-shadow: 0 10px 25px rgba(239, 68, 68, 0.25); + } + + /* Cancelled message styling */ + .message.assistant.cancelled { + opacity: 0.7; + } + + .message.assistant.cancelled .body { + background: #fef2f2; + border: 1px solid #fecaca; + } + .back-home { color: var(--muted); text-decoration: none; @@ -1932,11 +1952,16 @@ } const miniBtn = el.miniSendBtn; - const originalLabel = miniBtn ? miniBtn.textContent : ''; - if (miniBtn) { - miniBtn.disabled = true; - miniBtn.innerHTML = ''; // Clock icon for planning + + // Set sending state for cancellation support + if (typeof state !== 'undefined') { + state.isSending = true; + state.currentSendingMessageId = tempMessageId; } + if (typeof updateSendButtonState === 'function') { + updateSendButtonState(); + } + try { const response = await api('/api/plan', { method: 'POST', body: JSON.stringify(payload) }); @@ -1986,14 +2011,13 @@ renderMessages(session); } } finally { - if (miniBtn) { - miniBtn.disabled = false; - // Restore original SVG icon - miniBtn.innerHTML = ` - - - `; + // Reset sending state + if (typeof state !== 'undefined') { + state.isSending = false; + state.currentSendingMessageId = null; + } + if (typeof updateSendButtonState === 'function') { + updateSendButtonState(); } } } @@ -2278,7 +2302,15 @@ }; // document.getElementById('send-btn') removed - document.getElementById('mini-send-btn').addEventListener('click', handleSend, true); + // Use handleSendButtonClick which handles both send and cancel modes + document.getElementById('mini-send-btn').addEventListener('click', (e) => { + if (typeof handleSendButtonClick === 'function') { + handleSendButtonClick(e); + } else { + // Fallback to old behavior if function not loaded yet + handleSend(e); + } + }, true); // Initialize UI updateBuildModeUI(); diff --git a/chat/public/builder.js b/chat/public/builder.js index beae917..a991072 100644 --- a/chat/public/builder.js +++ b/chat/public/builder.js @@ -384,6 +384,8 @@ const state = { usageSummary: null, todos: [], // Current todos from OpenCode currentMessageId: null, // Track which message we're displaying todos for + isSending: false, // Track if a message is currently being sent + currentSendingMessageId: null, // Track the ID of the message being sent for cancellation }; // Expose state for builder.html @@ -1824,7 +1826,7 @@ function renderMessages(session) { // Don't hide loading indicator during streaming - only hide when completely done // This allows the spinner to continue until the OpenCode session is fully complete const assistantCard = document.createElement('div'); - assistantCard.className = 'message assistant'; + assistantCard.className = 'message assistant' + (status === 'cancelled' ? ' cancelled' : ''); const assistantMeta = document.createElement('div'); assistantMeta.className = 'meta'; const msgCliLabel = (msg.cli || session.cli || 'opencode'); @@ -1841,10 +1843,10 @@ function renderMessages(session) { rawBtn.textContent = 'Plugin Compass'; assistantMeta.appendChild(rawBtn); - // Add Undo/Redo buttons - only show for latest message and for opencode messages when done + // Add Undo/Redo buttons - show for latest message when done, errored, or cancelled const isOpencodeMsg = msg.cli === 'opencode' || msg.phase === 'build'; const isLatestMessage = latestMessage && latestMessage.id === msg.id; - const shouldShowUndoRedo = isOpencodeMsg && isLatestMessage && (status === 'done' || status === 'error'); + const shouldShowUndoRedo = isOpencodeMsg && isLatestMessage && (status === 'done' || status === 'error' || status === 'cancelled'); if (shouldShowUndoRedo) { const undoBtn = document.createElement('button'); @@ -3721,7 +3723,9 @@ async function sendMessage() { } } - if (el.miniSendBtn) el.miniSendBtn.disabled = true; + // Set sending state and update button + state.isSending = true; + updateSendButtonState(); // Ensure we have a valid current session before proceeding if (!state.currentSessionId) { @@ -3801,6 +3805,12 @@ async function sendMessage() { body: JSON.stringify(payload), }); + // Track the message ID being sent for potential cancellation + if (response?.message?.id) { + state.currentSendingMessageId = response.message.id; + console.log('[BUILDER] Tracking message for cancellation:', response.message.id); + } + // Clear attachments after successful send pendingAttachments.length = 0; renderAttachmentPreview(); @@ -3862,12 +3872,143 @@ async function sendMessage() { renderMessages(currentSession); } } finally { - if (el.miniSendBtn) el.miniSendBtn.disabled = false; + // Reset sending state when message completes, is cancelled, or errors out + // Only reset if we're not currently in a cancellation (cancelMessage handles that) + if (state.isSending && state.currentSendingMessageId) { + const session = state.sessions.find(s => s.id === state.currentSessionId); + const messageId = state.currentSendingMessageId; + const message = session?.messages.find(m => m.id === messageId); + + // Reset if message is done, errored, or cancelled + if (!message || message.status === 'done' || message.status === 'error' || message.status === 'cancelled') { + state.isSending = false; + state.currentSendingMessageId = null; + updateSendButtonState(); + } + } + } +} + +// Cancel the current message being sent +async function cancelMessage() { + if (!state.isSending || !state.currentSendingMessageId) { + console.log('[CANCEL] No message to cancel'); + return; + } + + const messageId = state.currentSendingMessageId; + const sessionId = state.currentSessionId; + + console.log('[CANCEL] Cancelling message:', messageId); + setStatus('Cancelling...'); + + // Close any active SSE streams for this message + if (state.activeStreams.has(messageId)) { + const stream = state.activeStreams.get(messageId); + stream.close(); + state.activeStreams.delete(messageId); + console.log('[CANCEL] Closed SSE stream for message:', messageId); + } + + // Find and update the message status + const session = state.sessions.find(s => s.id === sessionId); + if (session) { + const message = session.messages.find(m => m.id === messageId); + if (message) { + message.status = 'cancelled'; + message.reply = message.reply || '(Cancelled by user)'; + message.cancelled = true; + + // Show undo/redo buttons for the cancelled message + message.showUndoRedo = true; + + console.log('[CANCEL] Message marked as cancelled:', messageId); + } + + // Remove temp messages + const tempMessages = session.messages.filter(m => m.id.startsWith('temp-') && m.status === 'queued'); + tempMessages.forEach(tempMsg => { + const idx = session.messages.indexOf(tempMsg); + if (idx > -1) { + session.messages.splice(idx, 1); + } + }); + + renderMessages(session); + } + + // Reset sending state + state.isSending = false; + state.currentSendingMessageId = null; + + // Reset send button + updateSendButtonState(); + + hideLoadingIndicator(); + setStatus('Message cancelled'); + + // Refresh session to sync with server + try { + await refreshCurrentSession(); + } catch (err) { + console.warn('[CANCEL] Failed to refresh session:', err.message); + } +} + +// Update send button appearance based on state +function updateSendButtonState() { + const btn = el.miniSendBtn; + if (!btn) return; + + if (state.isSending) { + // Show cancel button (square icon) + btn.innerHTML = ` + + + + `; + btn.title = 'Cancel message'; + btn.classList.add('cancel-mode'); + btn.disabled = false; + } else { + // Show send button (paper plane icon) + btn.innerHTML = ` + + + + + `; + btn.title = 'Send message'; + btn.classList.remove('cancel-mode'); + btn.disabled = false; + } +} + +// Handle send button click - either send or cancel +function handleSendButtonClick(e) { + if (state.isSending) { + // Cancel the current message + cancelMessage(); + } else { + // Check if we're in plan mode and call the appropriate handler + if (builderState.mode === 'plan') { + // Use handleSend from builder.html which handles plan mode + if (typeof handleSend === 'function') { + handleSend(e); + } else { + console.warn('[BUILDER] handleSend not available'); + } + } else { + // Send a new message in build mode + sendMessage(); + } } } // Expose for builder.html window.sendMessage = sendMessage; +window.cancelMessage = cancelMessage; +window.handleSendButtonClick = handleSendButtonClick; function hookEvents() { if (el.newChat) { @@ -4205,7 +4346,11 @@ function hookEvents() { el.messageInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { - sendMessage(); + if (state.isSending) { + cancelMessage(); + } else { + sendMessage(); + } } }); diff --git a/opencode/packages/opencode/src/provider/provider.ts b/opencode/packages/opencode/src/provider/provider.ts index 6fae0c0..a4ce132 100644 --- a/opencode/packages/opencode/src/provider/provider.ts +++ b/opencode/packages/opencode/src/provider/provider.ts @@ -1200,7 +1200,11 @@ export namespace Provider { throw new ModelNotFoundError({ providerID, modelID, suggestions }) } - const info = provider.models[modelID] + let info = provider.models[modelID] + // Try with provider prefix if not found (e.g., "openrouter/pony-alpha") + if (!info && !modelID.includes("/")) { + info = provider.models[`${providerID}/${modelID}`] + } if (!info) { const availableModels = Object.keys(provider.models) const matches = fuzzysort.go(modelID, availableModels, { limit: 3, threshold: -10000 }) diff --git a/opencode/packages/opencode/test/tool/fixtures/models-api.json b/opencode/packages/opencode/test/tool/fixtures/models-api.json index 32722e8..1983692 100644 --- a/opencode/packages/opencode/test/tool/fixtures/models-api.json +++ b/opencode/packages/opencode/test/tool/fixtures/models-api.json @@ -27310,6 +27310,23 @@ "cost": { "input": 0, "output": 0 }, "limit": { "context": 1840000, "output": 0 } }, + "openrouter/pony-alpha": { + "id": "openrouter/pony-alpha", + "name": "Stealth", + "family": "pony", + "attachment": false, + "reasoning": true, + "tool_call": true, + "structured_output": true, + "temperature": true, + "release_date": "2026-02-06", + "last_updated": "2026-02-06", + "modalities": { "input": ["text"], "output": ["text"] }, + "open_weights": false, + "cost": { "input": 0, "output": 0 }, + "limit": { "context": 200000, "output": 131000 }, + "status": "alpha" + }, "z-ai/glm-4.7": { "id": "z-ai/glm-4.7", "name": "GLM-4.7",