feat: 强制停止AI回复 - 真正中止Ollama接口请求
- ollama-api.js: chatStream 新增 AbortController 参数,同时中止 fetch 和 reader - input-area.js: 发送按钮流式时变为停止按钮(红色方块),点击立即 abort - abort 时保留已生成内容,标记 [已停止] - 停止按钮带脉冲动画,视觉反馈明确 - 真正的接口级别终止:fetch abort + reader.cancel 双重保险
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 = '<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 {
|
||||
// 恢复发送按钮
|
||||
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"/>
|
||||
</svg>`;
|
||||
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) + '<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 loadingDots = placeholder?.querySelector('.loading-dots');
|
||||
const loadingText = placeholder?.querySelector('.loading-text');
|
||||
if (loadingDots) loadingDots.remove();
|
||||
if (loadingText) loadingText.remove();
|
||||
|
||||
const assistantContent = ''; // 已在 catch 作用域外不可用,取 DOM 值
|
||||
const existingContent = placeholder?.querySelector('.msg-content')?.textContent || '';
|
||||
|
||||
if (existingContent) {
|
||||
|
||||
+19
-3
@@ -69,8 +69,10 @@ export class OllamaAPI {
|
||||
* 使用 ReadableStream + TextDecoder 解析 NDJSON 流
|
||||
* Ollama 流式响应格式 (NDJSON):
|
||||
* {"model":"xxx","message":{"role":"assistant","content":"Hello"},"done":false}
|
||||
*
|
||||
* @param {AbortController} [abortController] - 可选,用于强制终止流
|
||||
*/
|
||||
async chatStream(params, onChunk) {
|
||||
async chatStream(params, onChunk, abortController) {
|
||||
const body = {
|
||||
model: params.model,
|
||||
messages: params.messages,
|
||||
@@ -81,15 +83,29 @@ export class OllamaAPI {
|
||||
...(params.options && { options: params.options })
|
||||
};
|
||||
|
||||
const response = await this._request('/api/chat', {
|
||||
const fetchOptions = {
|
||||
method: 'POST',
|
||||
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 decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
// 如果外部触发 abort,立即取消 reader(双重保险:fetch abort + reader.cancel)
|
||||
if (abortController) {
|
||||
abortController.signal.addEventListener('abort', () => {
|
||||
reader.cancel();
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
Reference in New Issue
Block a user