Files
metona-ollama-desktop/js/ollama-api.js
T
thzxx f80dd7f390 feat: 强制停止AI回复 - 真正中止Ollama接口请求
- ollama-api.js: chatStream 新增 AbortController 参数,同时中止 fetch 和 reader
- input-area.js: 发送按钮流式时变为停止按钮(红色方块),点击立即 abort
- abort 时保留已生成内容,标记 [已停止]
- 停止按钮带脉冲动画,视觉反馈明确
- 真正的接口级别终止:fetch abort + reader.cancel 双重保险
2026-04-03 12:31:04 +08:00

186 lines
5.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Ollama API 客户端封装
*
* 基于 Fetch API 与 Ollama REST 接口交互
* 支持:流式聊天、模型管理、连接检测
*
* API 文档:https://docs.ollama.com/api
*/
export class OllamaAPI {
constructor(baseUrl = 'http://127.0.0.1:11434') {
this.baseUrl = baseUrl.replace(/\/+$/, '');
}
/** 通用请求方法 */
async _request(path, options = {}) {
const url = `${this.baseUrl}${path}`;
const config = {
...options,
headers: { 'Content-Type': 'application/json', ...options.headers }
};
const response = await fetch(url, config);
if (!response.ok) {
const errorBody = await response.text().catch(() => '');
throw new Error(`Ollama API 错误 ${response.status}: ${errorBody || response.statusText}`);
}
return response;
}
/** JSON 请求快捷方法 */
async _json(path, body = null) {
const options = body ? { method: 'POST', body: JSON.stringify(body) } : {};
const response = await this._request(path, options);
return response.json();
}
// ── 模型管理 ──
/** 获取已安装模型列表 */
async listModels() { return this._json('/api/tags'); }
/** 获取运行中的模型列表 */
async psModels() { return this._json('/api/ps'); }
/** 获取 Ollama 版本 */
async getVersion() { return this._json('/api/version'); }
/** 查看模型详情 */
async showModel(model) { return this._json('/api/show', { model }); }
// ── 聊天 API ──
/** 非流式聊天 */
async chat(params) {
const body = {
model: params.model,
messages: params.messages,
stream: false,
...(params.think !== undefined && { think: params.think }),
...(params.system && { system: params.system }),
...(params.keep_alive !== undefined && { keep_alive: params.keep_alive }),
...(params.options && { options: params.options })
};
return this._json('/api/chat', body);
}
/**
* 流式聊天
* 使用 ReadableStream + TextDecoder 解析 NDJSON 流
* Ollama 流式响应格式 (NDJSON):
* {"model":"xxx","message":{"role":"assistant","content":"Hello"},"done":false}
*
* @param {AbortController} [abortController] - 可选,用于强制终止流
*/
async chatStream(params, onChunk, abortController) {
const body = {
model: params.model,
messages: params.messages,
stream: true,
...(params.think !== undefined && { think: params.think }),
...(params.system && { system: params.system }),
...(params.keep_alive !== undefined && { keep_alive: params.keep_alive }),
...(params.options && { options: params.options })
};
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;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
try {
const chunk = JSON.parse(line);
if (onChunk) onChunk(chunk);
if (chunk.done) {
reader.cancel();
return;
}
} catch (err) {
console.warn('[OllamaAPI] 流式数据解析警告:', err.message);
}
}
}
if (buffer.trim()) {
try {
const chunk = JSON.parse(buffer);
if (onChunk) onChunk(chunk);
} catch { /* 忽略 */ }
}
}
/** 流式生成(单轮) */
async generateStream(params, onChunk) {
const body = {
model: params.model,
prompt: params.prompt,
stream: true,
...(params.images && { images: params.images }),
...(params.options && { options: params.options })
};
const response = await this._request('/api/generate', {
method: 'POST',
body: JSON.stringify(body)
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
try {
const chunk = JSON.parse(line);
if (onChunk) onChunk(chunk);
if (chunk.done) {
reader.cancel();
return;
}
} catch { /* 忽略不完整数据 */ }
}
}
}
/** 生成嵌入向量 */
async embed(model, input) {
return this._json('/api/embed', { model, input });
}
}