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;
|
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;
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user