diff --git a/css/style.css b/css/style.css index 32c961a..b68e0b8 100644 --- a/css/style.css +++ b/css/style.css @@ -827,6 +827,22 @@ body::before { height: 18px; } +/* 停止按钮样式 */ +.send-btn.stop-btn { + background: linear-gradient(135deg, #ff4757, #ff6b81); + box-shadow: 0 2px 10px rgba(255, 71, 87, 0.4); + animation: stopPulse 1.5s ease-in-out infinite; +} + +.send-btn.stop-btn:hover { + box-shadow: 0 4px 20px rgba(255, 71, 87, 0.6); +} + +@keyframes stopPulse { + 0%, 100% { box-shadow: 0 2px 10px rgba(255, 71, 87, 0.4); } + 50% { box-shadow: 0 2px 16px rgba(255, 71, 87, 0.7); } +} + .spinner { width: 18px; height: 18px; diff --git a/js/components/input-area.js b/js/components/input-area.js index 2aeb9b5..7b8ec92 100644 --- a/js/components/input-area.js +++ b/js/components/input-area.js @@ -8,7 +8,7 @@ import { fileToBase64, debounce, truncate } from '../utils.js'; import { getSelectedModel, isThinkEnabled } from './model-bar.js'; import { renderMessages, appendAssistantPlaceholder, appendSystemMessage, - updateLastAssistantMessage, clearMessages + updateLastAssistantMessage, clearMessages, safeMarkdown } from './chat-area.js'; import { showToast } from './toast.js'; import { checkConnection } from './header.js'; @@ -23,7 +23,13 @@ export function initInputArea() { imagePreviewEl = document.querySelector('#imagePreview'); // 发送 - btnSendEl.addEventListener('click', sendMessage); + btnSendEl.addEventListener('click', () => { + if (state.get(KEYS.IS_STREAMING)) { + stopGeneration(); + } else { + sendMessage(); + } + }); chatInputEl.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); @@ -93,13 +99,31 @@ function renderImagePreviews() { function updateSendButton(streaming) { if (streaming) { - btnSendEl.innerHTML = '
'; - btnSendEl.classList.add('disabled'); + // 流式时显示停止按钮 + btnSendEl.innerHTML = ``; + btnSendEl.classList.add('stop-btn'); + btnSendEl.classList.remove('disabled'); + btnSendEl.title = '停止生成'; } else { + // 恢复发送按钮 btnSendEl.innerHTML = ``; + btnSendEl.classList.remove('stop-btn'); btnSendEl.classList.remove('disabled'); + btnSendEl.title = '发送'; + } +} + +/** + * 停止生成 - 真正中止 Ollama 接口请求 + */ +function stopGeneration() { + const abortController = state.get(KEYS.ABORT_CONTROLLER); + if (abortController) { + abortController.abort(); } } @@ -207,7 +231,7 @@ export async function sendMessage() { if (chunk.done) { finalStats = { eval_count: chunk.eval_count, total_duration: chunk.total_duration }; } - }); + }, abortController); const assistantMsg = { role: 'assistant', @@ -228,13 +252,49 @@ export async function sendMessage() { } catch (err) { console.error('[InputArea] 流式聊天错误:', err); + // 处理用户主动停止(AbortError) + if (err.name === 'AbortError') { + const placeholder = document.querySelector('#messagesContainer .message.assistant:last-child'); + const loadingDots = placeholder?.querySelector('.loading-dots'); + const loadingText = placeholder?.querySelector('.loading-text'); + if (loadingDots) loadingDots.remove(); + if (loadingText) loadingText.remove(); + + // 获取已有的流式内容 + const contentDiv = placeholder?.querySelector('.msg-content'); + const existingContent = contentDiv?.textContent || ''; + + const partialMsg = { + role: 'assistant', + content: existingContent || '', + timestamp: Date.now(), + ...(modelName && { model: modelName }), + ...(thinkContent && { think: thinkContent }), + stopped: true + }; + currentSession.messages.push(partialMsg); + + if (contentDiv && existingContent) { + contentDiv.innerHTML = safeMarkdown(existingContent) + '[已停止]
[已停止]