feat: 强制停止AI回复 - 真正中止Ollama接口请求

- ollama-api.js: chatStream 新增 AbortController 参数,同时中止 fetch 和 reader
- input-area.js: 发送按钮流式时变为停止按钮(红色方块),点击立即 abort
- abort 时保留已生成内容,标记 [已停止]
- 停止按钮带脉冲动画,视觉反馈明确
- 真正的接口级别终止:fetch abort + reader.cancel 双重保险
This commit is contained in:
thzxx
2026-04-03 12:31:04 +08:00
parent 6088ea4629
commit f80dd7f390
3 changed files with 101 additions and 9 deletions
+16
View File
@@ -827,6 +827,22 @@ body::before {
height: 18px; 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 { .spinner {
width: 18px; width: 18px;
height: 18px; height: 18px;
+66 -6
View File
@@ -8,7 +8,7 @@ import { fileToBase64, debounce, truncate } from '../utils.js';
import { getSelectedModel, isThinkEnabled } from './model-bar.js'; import { getSelectedModel, isThinkEnabled } from './model-bar.js';
import { import {
renderMessages, appendAssistantPlaceholder, appendSystemMessage, renderMessages, appendAssistantPlaceholder, appendSystemMessage,
updateLastAssistantMessage, clearMessages updateLastAssistantMessage, clearMessages, safeMarkdown
} from './chat-area.js'; } from './chat-area.js';
import { showToast } from './toast.js'; import { showToast } from './toast.js';
import { checkConnection } from './header.js'; import { checkConnection } from './header.js';
@@ -23,7 +23,13 @@ export function initInputArea() {
imagePreviewEl = document.querySelector('#imagePreview'); imagePreviewEl = document.querySelector('#imagePreview');
// 发送 // 发送
btnSendEl.addEventListener('click', sendMessage); btnSendEl.addEventListener('click', () => {
if (state.get(KEYS.IS_STREAMING)) {
stopGeneration();
} else {
sendMessage();
}
});
chatInputEl.addEventListener('keydown', (e) => { chatInputEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
@@ -93,13 +99,31 @@ function renderImagePreviews() {
function updateSendButton(streaming) { function updateSendButton(streaming) {
if (streaming) { if (streaming) {
btnSendEl.innerHTML = '<div class="spinner"></div>'; // 流式时显示停止按钮
btnSendEl.classList.add('disabled'); btnSendEl.innerHTML = `<svg viewBox="0 0 24 24" fill="currentColor">
<rect x="6" y="6" width="12" height="12" rx="2"/>
</svg>`;
btnSendEl.classList.add('stop-btn');
btnSendEl.classList.remove('disabled');
btnSendEl.title = '停止生成';
} else { } else {
// 恢复发送按钮
btnSendEl.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> btnSendEl.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/> <line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>`; </svg>`;
btnSendEl.classList.remove('stop-btn');
btnSendEl.classList.remove('disabled'); 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) { if (chunk.done) {
finalStats = { eval_count: chunk.eval_count, total_duration: chunk.total_duration }; finalStats = { eval_count: chunk.eval_count, total_duration: chunk.total_duration };
} }
}); }, abortController);
const assistantMsg = { const assistantMsg = {
role: 'assistant', role: 'assistant',
@@ -228,13 +252,49 @@ export async function sendMessage() {
} catch (err) { } catch (err) {
console.error('[InputArea] 流式聊天错误:', 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) + '<p><code>[已停止]</code></p>';
} else {
if (contentDiv) contentDiv.innerHTML = '<p><code>[已停止]</code></p>';
}
appendSystemMessage('⏹ 已停止生成');
await saveCurrentSession();
state.set(KEYS.IS_STREAMING, false);
updateSendButton(false);
state.set(KEYS.ABORT_CONTROLLER, null);
return;
}
const placeholder = document.querySelector('#messagesContainer .message.assistant:last-child'); const placeholder = document.querySelector('#messagesContainer .message.assistant:last-child');
const loadingDots = placeholder?.querySelector('.loading-dots'); const loadingDots = placeholder?.querySelector('.loading-dots');
const loadingText = placeholder?.querySelector('.loading-text'); const loadingText = placeholder?.querySelector('.loading-text');
if (loadingDots) loadingDots.remove(); if (loadingDots) loadingDots.remove();
if (loadingText) loadingText.remove(); if (loadingText) loadingText.remove();
const assistantContent = ''; // 已在 catch 作用域外不可用,取 DOM 值
const existingContent = placeholder?.querySelector('.msg-content')?.textContent || ''; const existingContent = placeholder?.querySelector('.msg-content')?.textContent || '';
if (existingContent) { if (existingContent) {
+19 -3
View File
@@ -69,8 +69,10 @@ export class OllamaAPI {
* 使用 ReadableStream + TextDecoder 解析 NDJSON 流 * 使用 ReadableStream + TextDecoder 解析 NDJSON 流
* Ollama 流式响应格式 (NDJSON): * Ollama 流式响应格式 (NDJSON):
* {"model":"xxx","message":{"role":"assistant","content":"Hello"},"done":false} * {"model":"xxx","message":{"role":"assistant","content":"Hello"},"done":false}
*
* @param {AbortController} [abortController] - 可选,用于强制终止流
*/ */
async chatStream(params, onChunk) { async chatStream(params, onChunk, abortController) {
const body = { const body = {
model: params.model, model: params.model,
messages: params.messages, messages: params.messages,
@@ -81,15 +83,29 @@ export class OllamaAPI {
...(params.options && { options: params.options }) ...(params.options && { options: params.options })
}; };
const response = await this._request('/api/chat', { const fetchOptions = {
method: 'POST', method: 'POST',
body: JSON.stringify(body) body: JSON.stringify(body)
}); };
// 注入 abort 信号,同时中止 fetch 和 reader
if (abortController) {
fetchOptions.signal = abortController.signal;
}
const response = await this._request('/api/chat', fetchOptions);
const reader = response.body.getReader(); const reader = response.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buffer = ''; let buffer = '';
// 如果外部触发 abort,立即取消 reader(双重保险:fetch abort + reader.cancel
if (abortController) {
abortController.signal.addEventListener('abort', () => {
reader.cancel();
}, { once: true });
}
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) break; if (done) break;