@aalis/plugin-llm-ollama 0.13.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,10 +1,13 @@
1
- import { LLMCapabilities, llm, metered, requestedThinking } from '@aalis/api-llm';
1
+ import { finishReasonOf, IDLE_TIMEOUT_MAX_MS, idleTimeout, LLMCapabilities, llm, metered, requestedThinking, } from '@aalis/api-llm';
2
2
  import { processService } from '@aalis/api-process';
3
3
  import { config, definePlugin, events, lifecycle, logger, optional, provide, } from '@aalis/core';
4
4
  import { configError, defineConfig, missingConfigError, parseConfig } from '@aalis/schema-config';
5
+ import { TextError, textClauses, textOf, toSource } from '@aalis/schema-i18n';
5
6
  import { prepareLLMMessages, toLLMRole } from '@aalis/schema-message';
6
7
  import { safeFetch } from '@aalis/util-network-guard';
7
8
  import { truncateChars } from '@aalis/util-text-normalize';
9
+ import { Agent } from 'undici';
10
+ import { t } from './texts.js';
8
11
  // ===== 插件元数据 =====
9
12
  /** 远程图片/音频下载硬上限(附件缓存默认 10MiB 且可配;此处是缓存失败回退直下时的兜底硬顶)。 */
10
13
  const MAX_REMOTE_BINARY_BYTES = 20 * 1024 * 1024;
@@ -15,51 +18,45 @@ function bodyExcerpt(body) {
15
18
  return truncateChars(body.replace(/\s+/g, ' ').trim(), ERROR_BODY_MAX_CHARS, '…');
16
19
  }
17
20
  /**
18
- * 读配置时校验 baseUrl,解析不了的与带用户名或密码的抛配置错误。带凭据的 URL fetch 拒发,凭据还会随报错与
19
- * 条目名称(WebUI 的模型下拉)显示出来;解析不了的无从判断有没有凭据,请求同样发不出去。消息不带 URL
21
+ * 读配置时校验 baseUrl,解析不了的、带查询串或片段的与带用户名或密码的抛配置错误。插件在 baseUrl 后面拼路径,带查询串或
22
+ * 片段时路径落进查询串或片段,请求地址是坏的;末尾只有一个 ? 时解析后查询串为空,拼出来照样是坏的,所以按原文判。带凭据
23
+ * 的 URL fetch 拒发,凭据还会随报错与条目名称(WebUI 的模型下拉)显示出来;解析不了的无从判断有没有凭据,请求同样发不
24
+ * 出去。消息不带 URL
20
25
  */
21
26
  function checkBaseUrl(baseUrl) {
22
27
  if (!URL.canParse(baseUrl)) {
23
- throw configError('baseUrl 不是有效的 URL,需写成完整地址,如 http://localhost:11434');
28
+ throw configError(t('baseUrlInvalid'));
29
+ }
30
+ if (/[?#]/.test(baseUrl)) {
31
+ throw configError(t('baseUrlQuery'));
24
32
  }
25
33
  const { username, password } = new URL(baseUrl);
26
34
  if (username || password) {
27
- throw configError('baseUrl 不能带用户名或密码(user:pass@),本插件不支持带凭据访问 Ollama');
35
+ throw configError(t('baseUrlCredentials'));
28
36
  }
29
37
  }
30
38
  /**
31
- * 错误信息与日志里显示的 URL:去掉查询串。没有查询串时原样返回,保留配置里的写法。带用户名或密码的 baseUrl
32
- * 读配置时已拒绝(见 checkBaseUrl)
33
- */
34
- function redactUrl(url) {
35
- const parsed = new URL(url);
36
- if (!parsed.search)
37
- return url;
38
- parsed.search = '';
39
- return parsed.href;
40
- }
41
- /**
42
- * 错误消息连同底层原因,写成一行。fetch 网络失败的消息固定是「fetch failed」,真实原因(DNS、拒绝连接、TLS)在
43
- * cause 上;连 localhost 时两个地址族都失败,cause 是消息为空的 AggregateError,原因在它的子错误上
39
+ * 错误连同底层原因,写成一行(日志取 toSource)。fetch 网络失败的消息固定是「fetch failed」,真实原因(DNS、拒绝连接、TLS)
40
+ * 在 cause 上;连 localhost 时两个地址族都失败,cause 是消息为空的 AggregateError,原因在它的子错误上
44
41
  */
45
42
  function describeError(err) {
46
43
  const cause = err instanceof Error ? err.cause : undefined;
47
44
  const reasons = cause instanceof AggregateError ? cause.errors : cause instanceof Error ? [cause] : [];
48
45
  const detail = reasons.map(e => (e instanceof Error ? e.message : String(e))).join('; ');
49
- return `${err instanceof Error ? err.message : String(err)}${detail ? ` ← ${detail}` : ''}`;
46
+ return detail ? t('withCause', { error: textOf(err), detail }) : textOf(err);
50
47
  }
51
48
  /** 常见状态码的一句提示 */
52
49
  function statusHint(status) {
53
50
  if (status === 401 || status === 403)
54
- return '密钥无效或没有权限';
51
+ return t('hintAuth');
55
52
  if (status === 402)
56
- return '余额不足或需要付费';
53
+ return t('hintPayment');
57
54
  if (status === 404)
58
- return '模型或地址不对';
55
+ return t('hintNotFound');
59
56
  if (status === 429)
60
- return '请求过多或额度不足';
57
+ return t('hintRateLimit');
61
58
  if (status >= 500)
62
- return '上游服务故障';
59
+ return t('hintUpstream');
63
60
  return undefined;
64
61
  }
65
62
  /**
@@ -85,7 +82,10 @@ function upstreamMessage(body) {
85
82
  function httpErrorMessage(provider, status, body) {
86
83
  const hint = statusHint(status);
87
84
  const message = upstreamMessage(body);
88
- return `${provider} API 错误 (${status})${hint ? `:${hint}` : ''};${message ? `上游说明:${message}` : '详情见日志'}`;
85
+ return textClauses([
86
+ hint ? t('apiErrorHint', { provider, status, hint }) : t('apiError', { provider, status }),
87
+ message ? t('upstreamMessage', { message }) : t('seeLogs'),
88
+ ]);
89
89
  }
90
90
  /**
91
91
  * 流式读取响应体并限额:Content-Length 头超限即拒;流式累计超限即断,返回 null。
@@ -119,68 +119,75 @@ export async function readBodyCapped(res, maxBytes) {
119
119
  }
120
120
  /** 报告了思考能力的模型允许的档位:开关型,翻成 think 开关 */
121
121
  const THINKING_LEVELS = [
122
- { id: 'off', label: '关' },
123
- { id: 'on', label: '开' },
122
+ { id: 'off', label: t('levelOff') },
123
+ { id: 'on', label: t('levelOn') },
124
124
  ];
125
125
  const configSchema = defineConfig({
126
126
  baseUrl: {
127
127
  type: 'string',
128
- label: 'Ollama 地址',
128
+ label: t('baseUrlLabel'),
129
129
  default: 'http://localhost:11434',
130
130
  onInvalid: 'error',
131
- description: '本地 Ollama 服务的 HTTP 地址',
131
+ description: t('baseUrlDescription'),
132
132
  },
133
133
  customModels: {
134
134
  type: 'textarea',
135
- label: '自定义模型',
135
+ label: t('customModelsLabel'),
136
136
  default: '',
137
- description: '手动添加的模型名称(每行一个或逗号分隔)。用于补充自动发现列表中未出现的模型。与自动发现重复时会提示去重。',
137
+ description: t('customModelsDescription'),
138
138
  },
139
139
  discoverModels: {
140
140
  type: 'boolean',
141
- label: '自动发现模型',
141
+ label: t('discoverModelsLabel'),
142
142
  default: true,
143
- description: '启动时请求 /api/tags 发现已安装的模型,WebUI 可刷新模型列表。服务不提供 /api/tags 时关闭:不发发现请求,只注册 customModels(此时必填),也不支持刷新。',
143
+ description: t('discoverModelsDescription'),
144
144
  },
145
145
  modelCapabilities: {
146
146
  type: 'textarea',
147
- label: '单模型能力覆盖',
147
+ label: t('modelCapabilitiesLabel'),
148
148
  default: '',
149
- description: '强制覆盖某模型的能力(优先级最高,高于 /api/show 自动探测与家族表),与 adapter 默认能力取并集。\n格式:`<modelId>: <cap1>,<cap2>,...`,每行一条。如:nemotron3:33b: chat,vision,tool_calling',
149
+ description: t('modelCapabilitiesDescription'),
150
150
  },
151
151
  providerCapabilities: {
152
152
  type: 'string',
153
- label: '适配器默认能力(逗号分隔)',
153
+ label: t('providerCapabilitiesLabel'),
154
154
  default: '',
155
- description: '兜底默认能力:仅当某模型既无法从 Ollama /api/show 探测、又不在内置家族表时才使用。能力现已自动探测,通常留空即可(填了反而可能给不支持的模型乱标能力)。例:chat,tool_calling,streaming',
155
+ description: t('providerCapabilitiesDescription'),
156
156
  },
157
157
  timeout: {
158
158
  type: 'number',
159
- label: '请求超时 (秒)',
159
+ label: t('timeoutLabel'),
160
160
  default: 120,
161
- description: 'LLM 请求超时时间(秒)。大模型或长上下文建议适当调大。0 = 不限制。',
161
+ description: t('timeoutDescription'),
162
162
  },
163
- temperature: { type: 'number', label: '温度', default: 0.7, description: '0-2,越高越随机' },
163
+ temperature: { type: 'number', label: t('temperatureLabel'), default: 0.7, description: t('temperatureDescription') },
164
164
  maxTokens: {
165
165
  type: 'number',
166
- label: '最大 Token',
166
+ label: t('maxTokensLabel'),
167
167
  default: 4096,
168
- description: '单次回复最大生成 token 数(num_predict)',
168
+ min: 1,
169
+ integer: true,
170
+ onInvalid: 'error',
171
+ description: t('maxTokensDescription'),
172
+ },
173
+ contextLength: {
174
+ type: 'number',
175
+ label: t('contextLengthLabel'),
176
+ default: 8192,
177
+ description: t('contextLengthDescription'),
169
178
  },
170
- contextLength: { type: 'number', label: '上下文长度', default: 8192, description: '模型上下文窗口大小(num_ctx)' },
171
179
  keepAlive: {
172
180
  type: 'string',
173
- label: '模型保活时间',
181
+ label: t('keepAliveLabel'),
174
182
  default: '5m',
175
- description: '模型在显存中保留的时间,如 5m、1h、0(立即卸载)',
183
+ description: t('keepAliveDescription'),
176
184
  },
177
185
  thinking: {
178
186
  type: 'select',
179
- label: '默认思考档位',
187
+ label: t('thinkingLabel'),
180
188
  default: 'on',
181
189
  options: THINKING_LEVELS.map(level => ({ label: level.label, value: level.id })),
182
- description: '报告了思考能力的模型(或单模型能力覆盖里写了 thinking 的)在请求没指定档位时用这一档,会话设置的「深度思考」压过它。' +
183
- '没有思考能力的模型不发 think 参数。',
190
+ description: t('thinkingDescription'),
184
191
  },
185
192
  });
186
193
  /**
@@ -245,6 +252,7 @@ function extractThinkTags(text) {
245
252
  }
246
253
  // ===== Ollama 客户端(共享底层 fetch 封装,多个 ModelHandle 复用) =====
247
254
  class OllamaClient {
255
+ activation;
248
256
  baseUrl;
249
257
  timeout;
250
258
  temperature;
@@ -253,10 +261,16 @@ class OllamaClient {
253
261
  keepAlive;
254
262
  logger;
255
263
  proc;
256
- constructor(config, logger, proc) {
264
+ /**
265
+ * 生成请求的连接池:不设等响应头与读响应体的时限(默认各 300 秒),timeout 是唯一的时限。dispatcher 是 Node 自带 fetch
266
+ * 认的扩展字段,DOM 的 RequestInit 里没有(测试的类型检查带 DOM),带它的选项按 RequestInit 断言
267
+ */
268
+ dispatcher = new Agent({ headersTimeout: 0, bodyTimeout: 0 });
269
+ constructor(config, logger, proc, activation) {
270
+ this.activation = activation;
257
271
  this.baseUrl = config.baseUrl.replace(/\/$/, '');
258
- // schema 中 timeout 单位为「秒」,存储为毫秒;0 视为不限制 → 用一个非常大的值
259
- this.timeout = config.timeout && config.timeout > 0 ? config.timeout * 1000 : 2_147_483_647;
272
+ // schema 中 timeout 单位为「秒」,存储为毫秒;0 或负数不限,超过定时器上限按上限计,报错里的秒数是生效的值(见 idleTimeout)
273
+ this.timeout = Math.min(config.timeout * 1000, IDLE_TIMEOUT_MAX_MS);
260
274
  this.temperature = config.temperature;
261
275
  this.maxTokens = config.maxTokens;
262
276
  this.contextLength = config.contextLength;
@@ -264,25 +278,48 @@ class OllamaClient {
264
278
  this.logger = logger;
265
279
  this.proc = proc;
266
280
  }
281
+ /**
282
+ * 停用时关掉生成请求的连接池。在飞的请求不用等:它们的信号合着本插件这次激活的(见 signalOf),停用开始时已经中止;
283
+ * 停用之后发来的请求由入口的 refuseIfClosed 拒绝(报插件已停用或重载)
284
+ */
285
+ close() {
286
+ void this.dispatcher.close();
287
+ }
267
288
  /** 对话请求的非 2xx 应答:截断后的响应体记 warn,抛出的错误见 httpErrorMessage */
268
289
  apiError(status, body) {
269
290
  this.logger.warn(`Ollama API 错误 (${status}): ${bodyExcerpt(body)}`);
270
- return new Error(httpErrorMessage('Ollama', status, body));
291
+ return new TextError(httpErrorMessage('Ollama', status, body));
292
+ }
293
+ /** 本插件已停用或重载(停用之后还拿着旧句柄发来的请求):不发,报插件已停用或重载、这次请求没有发出 */
294
+ refuseIfClosed() {
295
+ if (this.activation.aborted)
296
+ throw new TextError(t('pluginClosed'));
297
+ }
298
+ /** 这次请求的中止信号:调用方的与本插件这次激活的合起来,调用方没中止、本插件停用或重载时请求同样停下 */
299
+ signalOf(request) {
300
+ return request.signal ? AbortSignal.any([request.signal, this.activation]) : this.activation;
301
+ }
302
+ /** 这次请求已中止时抛出:调用方中止的抛它的中止原因,本插件停用或重载的报中途停下 */
303
+ throwIfAborted(request) {
304
+ request.signal?.throwIfAborted();
305
+ if (this.activation.aborted)
306
+ throw new TextError(t('pluginStopped'));
271
307
  }
272
308
  /**
273
- * 对话请求没拿到完整应答(fetch 或读响应体时抛出):原始错误连同原因记 warn,超时与连不上各换成一句说明,其它错误
274
- * 原样返回。调用方经 request.signal 中止时原样返回、不记日志,agent 按中止收尾
309
+ * 对话请求没拿到完整应答(fetch 或读响应体时抛出):原始错误连同原因记 warn,超时与连不上各换成一句说明,其它错误原样
310
+ * 返回。调用方经 request.signal 中止时原样返回、不记日志,agent 按中止收尾;本插件停用或重载时中止的报中途停下,不记日志
275
311
  */
276
312
  requestError(err, request, startedAt) {
277
313
  if (request.signal?.aborted)
278
314
  return err;
279
- this.logger.warn(`Ollama 请求失败 (耗时 ${Date.now() - startedAt}ms): ${describeError(err)}`);
315
+ if (this.activation.aborted)
316
+ return new TextError(t('pluginStopped'));
317
+ this.logger.warn(`Ollama 请求失败 (耗时 ${Date.now() - startedAt}ms): ${toSource(describeError(err))}`);
280
318
  if (err instanceof DOMException && err.name === 'TimeoutError') {
281
- return new Error(`Ollama 请求超时:${this.timeout / 1000} 秒内没有完成,可在配置里调大 timeout`);
282
- }
283
- if (err instanceof TypeError && err.message === 'fetch failed') {
284
- return new Error('Ollama 连不上服务:检查 baseUrl 与网络,详情见日志');
319
+ return new TextError(t('requestTimeout', { seconds: this.timeout / 1000 }));
285
320
  }
321
+ if (err instanceof TypeError && err.message === 'fetch failed')
322
+ return new TextError(t('unreachable'));
286
323
  return err;
287
324
  }
288
325
  /** 对话应答按 JSON 解析;不是 JSON 时截断后的应答体记 warn,抛出一行说明 */
@@ -292,7 +329,7 @@ class OllamaClient {
292
329
  }
293
330
  catch {
294
331
  this.logger.warn(`Ollama 应答不是 JSON (${status}): ${bodyExcerpt(text)}`);
295
- throw new Error(`Ollama 应答不是 JSON (${status});详情见日志`);
332
+ throw new TextError(t('notJson', { status }));
296
333
  }
297
334
  }
298
335
  /**
@@ -319,17 +356,17 @@ class OllamaClient {
319
356
  data = JSON.parse(text);
320
357
  }
321
358
  catch {
322
- throw new Error(`响应不是 JSON: ${bodyExcerpt(text)}`);
359
+ throw new TextError(t('discoveryNotJson', { excerpt: bodyExcerpt(text) }));
323
360
  }
324
361
  const list = data?.models;
325
362
  if (!Array.isArray(list) || !list.every(m => typeof m?.name === 'string')) {
326
- throw new Error(`响应不是模型列表(需要 models 数组,每项带字符串 name): ${bodyExcerpt(JSON.stringify(data))}`);
363
+ throw new TextError(t('discoveryNotList', { excerpt: bodyExcerpt(JSON.stringify(data)) }));
327
364
  }
328
365
  return list.map(m => m.name);
329
366
  }
330
367
  catch (err) {
331
368
  signal?.throwIfAborted();
332
- throw new Error(`模型发现失败 ${redactUrl(url)}: ${describeError(err)}`, { cause: err });
369
+ throw new TextError(t('discoveryFailed', { url, error: describeError(err) }), { cause: err });
333
370
  }
334
371
  }
335
372
  /**
@@ -360,7 +397,7 @@ class OllamaClient {
360
397
  * [系统通知]/[跨会话委派] 等前缀。level 是这次的思考档位,不能调的模型没有
361
398
  */
362
399
  async buildRequestBody(model, request, level, stream) {
363
- const messages = await Promise.all(prepareLLMMessages(request.messages).map(m => this.toOllamaMessage(m, request.requireImages === true)));
400
+ const messages = await Promise.all(prepareLLMMessages(request.messages).map(m => this.toOllamaMessage(m, request)));
364
401
  const tools = request.tools?.map(t => this.toOllamaTool(t));
365
402
  const body = {
366
403
  model,
@@ -382,15 +419,13 @@ class OllamaClient {
382
419
  return { body, messages, tools };
383
420
  }
384
421
  async chat(model, request, level) {
422
+ this.refuseIfClosed();
385
423
  // 包含音频输入 → 改走 OpenAI 兼容的 /v1/chat/completions(/api/chat 不支持 audios)
386
424
  if (request.messages.some(m => m.audios && m.audios.length > 0)) {
387
425
  return this.chatOpenAIWithAudio(model, request, level);
388
426
  }
389
427
  const { body, messages, tools } = await this.buildRequestBody(model, request, level, false);
390
428
  this.logger.debug(`请求 Ollama${level ? ` (思考 ${level})` : ''}: ${body.model}, ${messages.length} 条消息, ${tools?.length ?? 0} 个工具`);
391
- const signals = [AbortSignal.timeout(this.timeout)];
392
- if (request.signal)
393
- signals.push(request.signal);
394
429
  const startedAt = Date.now();
395
430
  let response;
396
431
  let text;
@@ -399,7 +434,8 @@ class OllamaClient {
399
434
  method: 'POST',
400
435
  headers: { 'Content-Type': 'application/json' },
401
436
  body: JSON.stringify(body),
402
- signal: AbortSignal.any(signals),
437
+ signal: idleTimeout(this.timeout, this.signalOf(request)).signal,
438
+ dispatcher: this.dispatcher,
403
439
  });
404
440
  text = await response.text();
405
441
  }
@@ -409,14 +445,15 @@ class OllamaClient {
409
445
  if (!response.ok)
410
446
  throw this.apiError(response.status, text);
411
447
  const data = this.parseJsonBody(response.status, text);
412
- // 优先使用原生 thinking 字段(Ollama think API),回退到 <think> 标签解析
448
+ // 发了 think 时思考由 Ollama 管、走原生 thinking 字段,正文照原样;没发时才把正文里 <think> 标签之间的内容当思考
413
449
  const nativeThinking = data.message.thinking || '';
414
450
  const rawContent = data.message.content || '';
415
- const { reasoning: tagReasoning, content: cleanContent } = extractThinkTags(rawContent);
451
+ const { reasoning: tagReasoning, content: cleanContent } = level === undefined ? extractThinkTags(rawContent) : { reasoning: '', content: rawContent };
416
452
  const allReasoning = [nativeThinking, tagReasoning].filter(Boolean).join('');
417
453
  const result = {
418
454
  content: cleanContent || null,
419
455
  reasoningContent: allReasoning || null,
456
+ finishReason: finishReasonOf(data.done_reason),
420
457
  };
421
458
  const toolCalls = finalizeToolCalls(data.message.tool_calls);
422
459
  if (toolCalls)
@@ -433,6 +470,7 @@ class OllamaClient {
433
470
  return result;
434
471
  }
435
472
  async *chatStream(model, request, level) {
473
+ this.refuseIfClosed();
436
474
  // 包含音频输入 → 回退为非流式(OpenAI compat 路径不支持 SSE 交互)后以单 chunk 交付。
437
475
  if (request.messages.some(m => m.audios && m.audios.length > 0)) {
438
476
  const r = await this.chatOpenAIWithAudio(model, request, level);
@@ -441,14 +479,25 @@ class OllamaClient {
441
479
  reasoningDelta: r.reasoningContent ?? '',
442
480
  usage: r.usage,
443
481
  done: true,
482
+ finishReason: r.finishReason,
444
483
  };
445
484
  return;
446
485
  }
447
486
  const { body, messages } = await this.buildRequestBody(model, request, level, true);
448
487
  this.logger.debug(`流式请求 Ollama${level ? ` (思考 ${level})` : ''}: ${body.model}, ${messages.length} 条消息`);
449
- const signals = [AbortSignal.timeout(this.timeout)];
450
- if (request.signal)
451
- signals.push(request.signal);
488
+ // 交出一段期间不计时:调用方处理一段的时间不算停顿
489
+ const timer = idleTimeout(this.timeout, this.signalOf(request));
490
+ for await (const chunk of this.readStream(body, request, timer, level === undefined)) {
491
+ timer.pause();
492
+ yield chunk;
493
+ timer.resume();
494
+ }
495
+ }
496
+ /**
497
+ * 发出流式请求并逐段解析,timer 管这次请求的停顿超时。parseThinkTags 为真(这次没发 think)时把正文里 <think> 标签之间的
498
+ * 内容当思考交出,为假时正文照原样交出
499
+ */
500
+ async *readStream(body, request, timer, parseThinkTags) {
452
501
  const reqStart = Date.now();
453
502
  const slowConnectTimer = setTimeout(() => {
454
503
  this.logger.warn(`Ollama 连接慢:已等待 15s 仍未收到响应头 (url=${this.baseUrl}/api/chat)`);
@@ -459,7 +508,8 @@ class OllamaClient {
459
508
  method: 'POST',
460
509
  headers: { 'Content-Type': 'application/json' },
461
510
  body: JSON.stringify(body),
462
- signal: AbortSignal.any(signals),
511
+ signal: timer.signal,
512
+ dispatcher: this.dispatcher,
463
513
  });
464
514
  }
465
515
  catch (err) {
@@ -475,7 +525,7 @@ class OllamaClient {
475
525
  throw this.apiError(response.status, errorText);
476
526
  }
477
527
  if (!response.body) {
478
- throw new Error('Ollama API 返回了空的响应体,无法进行流式读取');
528
+ throw new TextError(t('emptyBody'));
479
529
  }
480
530
  const reader = response.body.getReader();
481
531
  const decoder = new TextDecoder();
@@ -510,6 +560,9 @@ class OllamaClient {
510
560
  continue;
511
561
  try {
512
562
  const data = JSON.parse(trimmed);
563
+ // 带生成内容的帧重新计时:正文(含按标签解析出的思考)、原生思考、工具调用
564
+ if (data.message?.content || data.message?.thinking || data.message?.tool_calls?.length)
565
+ timer.touch();
513
566
  // 累积工具调用
514
567
  if (data.message?.tool_calls) {
515
568
  toolCallBuffers.push(...data.message.tool_calls);
@@ -536,7 +589,7 @@ class OllamaClient {
536
589
  tagBuffer = '';
537
590
  }
538
591
  // 最后一个 chunk
539
- const chunk = { done: true };
592
+ const chunk = { done: true, finishReason: finishReasonOf(data.done_reason) };
540
593
  const toolCalls = finalizeToolCalls(toolCallBuffers);
541
594
  if (toolCalls)
542
595
  chunk.toolCalls = toolCalls;
@@ -552,11 +605,15 @@ class OllamaClient {
552
605
  yield chunk;
553
606
  return;
554
607
  }
555
- // 原生 thinking 字段(Ollama think API)—— 优先级高于 <think> 标签解析
608
+ // 原生 thinking 字段(Ollama think API)
556
609
  if (data.message?.thinking) {
557
610
  yield { reasoningDelta: data.message.thinking };
558
611
  }
559
- if (data.message?.content) {
612
+ // 发了 think:思考由 Ollama 管,正文照原样交出
613
+ if (data.message?.content && !parseThinkTags) {
614
+ yield { contentDelta: data.message.content };
615
+ }
616
+ else if (data.message?.content) {
560
617
  // 解析 <think> / </think> 标签,将内部内容路由为 reasoningDelta
561
618
  let text = tagBuffer + data.message.content;
562
619
  tagBuffer = '';
@@ -629,8 +686,8 @@ class OllamaClient {
629
686
  finally {
630
687
  clearTimeout(streamStallTimer);
631
688
  // cancel 而非仅 releaseLock:中止/提前退出时要主动关闭响应体。releaseLock 只是放锁,
632
- // 底层流仍开着——被打断的生成会让 undici 挂着半读的响应与套接字,直到请求超时兜底
633
- // 才释放(本地慢生成 + lane 中止高频后,这类残留一挂就是整个超时窗)。
689
+ // 底层流仍开着——被打断的生成会让 undici 一直持有半读的响应与套接字,直到请求超时兜底
690
+ // 才释放(本地慢生成时回合常被停下,这类残留一挂就是整个超时窗)。
634
691
  // cancel 自带放锁。三条退出路径的语义:读尽 EOF(done:true)→ 流已 closed,cancel 是
635
692
  // 真 no-op;收到终帧即 return(未读到 EOF)与消费方提前 break → cancel 中止残余响应体,
636
693
  // 这正是本修复要的「立即交还套接字」,代价是该连接不回 keep-alive 池。
@@ -648,19 +705,22 @@ class OllamaClient {
648
705
  * 支持 data URI、HTTP(S) URL、纯 base64、文件路径。返回 null 表示图片无法获取。
649
706
  * 所有返回都会去除空白字符(Ollama 校验时不容忍 base64 内的换行/空格,
650
707
  * 否则会返回 `illegal base64 data at input byte N` 错误)。
708
+ * 网址的下载随这次请求的中止信号(见 signalOf)停下,中止时按 throwIfAborted 抛出。
651
709
  */
652
- async resolveImage(img) {
710
+ async resolveImage(img, request) {
653
711
  const sanitize = (b64) => b64.replace(/[\s\r\n]+/g, '');
654
712
  const trimmed = img.trim();
655
713
  // data URI → 提取 base64(兼容多参数格式如 data:image/png;charset=utf-8;base64,...)
656
714
  const dataMatch = trimmed.match(/^data:[^,]*;base64,(.+)$/);
657
715
  if (dataMatch)
658
716
  return sanitize(dataMatch[1]);
659
- // HTTP(S) URL → 下载并转 base64(流式限额:超限即断,防超大/恶意资源撑爆内存——
717
+ // HTTP(S) URL → 下载并转 base64(流式限额:超限即断,防超大/恶意资源耗尽内存——
660
718
  // 附件缓存侧有 maxBytes 上限,但缓存失败回退原 URL 时会走到这里,此前这一侧不限量)
661
719
  if (/^https?:\/\//i.test(trimmed)) {
662
720
  try {
663
- const res = await safeFetch(trimmed, { signal: AbortSignal.timeout(30000) });
721
+ const res = await safeFetch(trimmed, {
722
+ signal: AbortSignal.any([AbortSignal.timeout(30000), this.signalOf(request)]),
723
+ });
664
724
  if (!res.ok) {
665
725
  this.logger.warn(`下载图片失败 (${res.status}): ${trimmed}`);
666
726
  return null;
@@ -673,6 +733,8 @@ class OllamaClient {
673
733
  return buf.toString('base64');
674
734
  }
675
735
  catch (err) {
736
+ // 中止不是图片取不到:这次调用随之停下
737
+ this.throwIfAborted(request);
676
738
  this.logger.warn(`下载图片异常: ${trimmed}`, err);
677
739
  return null;
678
740
  }
@@ -699,15 +761,15 @@ class OllamaClient {
699
761
  * `requireImages` 为真时一张都拿不到就抛:视觉识别这类调用里图片就是全部内容,
700
762
  * 省掉它降级成纯文本,模型只看得到 prompt 里的占位文字,会照着编出一段描述,
701
763
  * 而调用方拿到非空内容便记成识别成功——失败被伪装成幻觉,比报错难查得多。
702
- * 为假时(顺手带图)保持宽松,warn 后按剩余的继续,不打断这一轮。
764
+ * 为假时(同时带图)保持宽松,warn 后按剩余的继续,不打断这一轮。
703
765
  */
704
- async resolveImages(images, requireImages) {
705
- const resolved = await Promise.all(images.map(img => this.resolveImage(img)));
766
+ async resolveImages(images, request) {
767
+ const resolved = await Promise.all(images.map(img => this.resolveImage(img, request)));
706
768
  const valid = resolved.filter((r) => r !== null);
707
769
  if (valid.length === images.length)
708
770
  return valid;
709
- if (valid.length === 0 && requireImages) {
710
- throw new Error(`图片全部获取失败(共 ${images.length} 张),拒绝降级为纯文本请求`);
771
+ if (valid.length === 0 && request.requireImages === true) {
772
+ throw new TextError(t('imagesUnavailable', { count: images.length }));
711
773
  }
712
774
  this.logger.warn(`图片获取失败 ${images.length - valid.length}/${images.length} 张,按剩余的继续`);
713
775
  return valid;
@@ -717,7 +779,7 @@ class OllamaClient {
717
779
  * Ollama 的图片通过 images 字段传递 base64 数据(或 URL)
718
780
  * 工具调用结果通过 role: tool 传递
719
781
  */
720
- async toOllamaMessage(msg, requireImages = false) {
782
+ async toOllamaMessage(msg, request) {
721
783
  // 调用方已经 prepareLLMMessages 处理过:role 已是 WellKnownRole,自定义 role / kind
722
784
  // 对应的前缀已拼接进 content。这里只需透传。
723
785
  const ollamaMsg = {
@@ -730,7 +792,7 @@ class OllamaClient {
730
792
  }
731
793
  // 多模态:Ollama 支持 images 字段(base64 或文件路径)
732
794
  if (msg.images && msg.images.length > 0 && msg.role === 'user') {
733
- const valid = await this.resolveImages(msg.images, requireImages);
795
+ const valid = await this.resolveImages(msg.images, request);
734
796
  if (valid.length > 0)
735
797
  ollamaMsg.images = valid;
736
798
  }
@@ -768,7 +830,7 @@ class OllamaClient {
768
830
  // Modality order:Ollama 官方 best practice 要求 image/audio content
769
831
  // 必须在 text 之前。
770
832
  if (m.images && m.images.length > 0 && m.role === 'user') {
771
- for (const b64 of await this.resolveImages(m.images, request.requireImages === true)) {
833
+ for (const b64 of await this.resolveImages(m.images, request)) {
772
834
  blocks.push({ type: 'image_url', image_url: { url: `data:image/png;base64,${b64}` } });
773
835
  }
774
836
  }
@@ -814,9 +876,6 @@ class OllamaClient {
814
876
  if (audioCount > 0) {
815
877
  this.logger.info(`[ollama-audio] 发送 ${audioCount} 段音频,合计 ${(totalAudioBytes / 1024).toFixed(1)}KB`);
816
878
  }
817
- const signals = [AbortSignal.timeout(this.timeout)];
818
- if (request.signal)
819
- signals.push(request.signal);
820
879
  const httpT0 = Date.now();
821
880
  let resp;
822
881
  let respText;
@@ -825,7 +884,8 @@ class OllamaClient {
825
884
  method: 'POST',
826
885
  headers: { 'Content-Type': 'application/json' },
827
886
  body: JSON.stringify(body),
828
- signal: AbortSignal.any(signals),
887
+ signal: idleTimeout(this.timeout, this.signalOf(request)).signal,
888
+ dispatcher: this.dispatcher,
829
889
  });
830
890
  respText = await resp.text();
831
891
  }
@@ -834,30 +894,25 @@ class OllamaClient {
834
894
  }
835
895
  if (!resp.ok) {
836
896
  this.logger.warn(`[ollama-audio] HTTP ${resp.status} 失败 ${Date.now() - httpT0}ms: ${bodyExcerpt(respText)}`);
837
- const message = httpErrorMessage('Ollama', resp.status, respText);
838
- // 诊断提示:ollama runner 把 input_audio 当 image 解码失败时报 "image: unknown format",
839
- // 99% 是模型本身不支持 audio modality(如 Nemotron-3 是纯文本/纯推理,
840
- // 多模态版 Nemotron-Nano-VL 也只有 vision)。提示用户换 gemma3n / qwen2.5-omni
841
- // 等明确支持 audio 的多模态模型。
842
- const lowerErr = respText.toLowerCase();
843
- if (lowerErr.includes('image: unknown format') || lowerErr.includes('unknown format')) {
844
- throw new Error(`${message}\n` +
845
- `[诊断] 模型 "${model}" 很可能不支持 audio modality(ollama runner 把 input_audio 当 image 解码失败)。` +
846
- `请改用明确支持音频的模型(如 gemma3n、qwen2.5-omni)。`);
847
- }
848
- throw new Error(message);
897
+ throw new TextError(httpErrorMessage('Ollama', resp.status, respText));
849
898
  }
850
899
  const data = this.parseJsonBody(resp.status, respText);
851
900
  const rawContent = data.choices?.[0]?.message?.content ?? '';
852
- const finishReason = data.choices?.[0]?.finish_reason ?? '?';
853
- const text = rawContent.trim();
901
+ const finishReason = data.choices?.[0]?.finish_reason;
902
+ // 与 /api/chat 同一条规则:这次没管思考(level 为 undefined)时才把正文里 <think> 标签之间的内容当思考
903
+ const { reasoning, content } = level === undefined ? extractThinkTags(rawContent) : { reasoning: '', content: rawContent };
904
+ const text = content.trim();
854
905
  if (audioCount > 0) {
855
- this.logger.info(`[ollama-audio] ${model} HTTP ${Date.now() - httpT0}ms, finish=${finishReason}, ` +
906
+ this.logger.info(`[ollama-audio] ${model} HTTP ${Date.now() - httpT0}ms, finish=${finishReason ?? '?'}, ` +
856
907
  `raw=${rawContent.length}字 trim=${text.length}字, ` +
857
908
  `tokens prompt=${data.usage?.prompt_tokens ?? '?'} completion=${data.usage?.completion_tokens ?? '?'}` +
858
909
  (rawContent.length > 0 ? `, 原文="${rawContent.replace(/\n/g, ' ')}"` : ' [模型返回空字符串]'));
859
910
  }
860
- const result = { content: text || null, reasoningContent: null };
911
+ const result = {
912
+ content: text || null,
913
+ reasoningContent: reasoning || null,
914
+ finishReason: finishReasonOf(finishReason),
915
+ };
861
916
  if (data.usage) {
862
917
  result.usage = {
863
918
  promptTokens: data.usage.prompt_tokens ?? 0,
@@ -868,32 +923,30 @@ class OllamaClient {
868
923
  return result;
869
924
  }
870
925
  }
871
- /** 去掉 base64 音频的 data: 前缀,返回纯 payload。 */
872
- function stripAudioDataPrefix(s) {
873
- const m = s.match(/^data:[^;]+;base64,(.+)$/);
874
- return m ? m[1] : s;
875
- }
876
926
  /**
877
- * 将 Aalis 传过来的 audio payload(可能带 data: 前缀也可能不带)解析为
878
- * OpenAI input_audio 需要的 `{ data, format }`。format 推断优先级:
879
- * data URL 的 mime 后缀 → 默认 wav。
927
+ * data URL 的音频类型(audio/ 之后的部分)→ OpenAI 兼容接口 input_audio 的 format。只收 wav 与 mp3:Ollama 的兼容接口不看
928
+ * format、只把 data 按 base64 解码(ollama 仓的 openai/openai.go),之后按文件头认音频,只认 wav 与 mp3(llm/media.go 的
929
+ * AudioFormat;mlxrunner/model/audio/audio.go 的 Decode 只认 wav)
930
+ */
931
+ const AUDIO_FORMATS = new Map([
932
+ ['mpeg', 'mp3'],
933
+ ['mp3', 'mp3'],
934
+ ['wav', 'wav'],
935
+ ['x-wav', 'wav'],
936
+ ['wave', 'wav'],
937
+ ]);
938
+ /**
939
+ * 把 `data:audio/<类型>[;参数];base64,…` 解析为 OpenAI input_audio 需要的 `{ data, format }`。format 只按写明的类型映射;
940
+ * 认不出的类型与不是 data URL 的(本地路径、链接、裸 base64)在发请求前报错:猜成 wav 时上游只回一句解码失败,看不出是格式的事
880
941
  */
881
942
  function decodeAudioForOpenAI(payload) {
882
- const m = payload.match(/^data:audio\/([^;]+);base64,(.+)$/);
883
- if (m) {
884
- const fmt = m[1].toLowerCase();
885
- const norm = fmt === 'mpeg' || fmt === 'mp3'
886
- ? 'mp3'
887
- : fmt === 'x-wav' || fmt === 'wave' || fmt === 'wav'
888
- ? 'wav'
889
- : fmt === 'ogg' || fmt === 'oga' || fmt === 'opus'
890
- ? 'ogg'
891
- : fmt === 'm4a' || fmt === 'mp4' || fmt === 'aac'
892
- ? 'm4a'
893
- : 'wav';
894
- return { data: m[2], format: norm };
895
- }
896
- return { data: stripAudioDataPrefix(payload), format: 'wav' };
943
+ const m = payload.match(/^data:audio\/([^;,]+)(?:;[^;,]+)*;base64,(.+)$/);
944
+ if (!m)
945
+ throw new TextError(t('audioDataUrlOnly'));
946
+ const format = AUDIO_FORMATS.get(m[1].toLowerCase());
947
+ if (!format)
948
+ throw new TextError(t('audioFormatUnsupported', { format: m[1] }));
949
+ return { data: m[2], format };
897
950
  }
898
951
  function safeParseJSON(str) {
899
952
  try {
@@ -1106,11 +1159,11 @@ async function start({ config, logger, lifecycle, provide, events, proc }) {
1106
1159
  const providerCapabilities = parseProviderCapabilities(cfg.providerCapabilities);
1107
1160
  checkBaseUrl(cfg.baseUrl);
1108
1161
  if (!cfg.discoverModels && customModels.length === 0) {
1109
- throw missingConfigError('customModels', '关闭 discoverModels 时必填');
1162
+ throw missingConfigError('customModels', t('customModelsRequired'));
1110
1163
  }
1111
- const client = new OllamaClient(cfg, logger.use, proc.use);
1164
+ const client = new OllamaClient(cfg, logger.use, proc.use, lifecycle.use.signal);
1165
+ lifecycle.use.onDispose(() => client.close(), '关闭生成请求的连接池');
1112
1166
  const baseLabel = `Ollama (${cfg.baseUrl.replace(/^https?:\/\//, '')})`;
1113
- const shownBaseUrl = redactUrl(cfg.baseUrl);
1114
1167
  // 已注册 model entry 的句柄表:modelId → 该 entry 的退订
1115
1168
  const registered = new Map();
1116
1169
  // 同 provider 下所有 OllamaModelHandle 共享同一份 refresh。关闭模型发现时不提供:没有可重新发现的列表
@@ -1160,7 +1213,7 @@ async function start({ config, logger, lifecycle, provide, events, proc }) {
1160
1213
  let discoveryError;
1161
1214
  const remoteIds = cfg.discoverModels
1162
1215
  ? await client.fetchRemoteModelIds(lifecycle.use.signal).catch((err) => {
1163
- discoveryError = err instanceof Error ? err.message : String(err);
1216
+ discoveryError = textOf(err);
1164
1217
  return [];
1165
1218
  })
1166
1219
  : [];
@@ -1170,11 +1223,11 @@ async function start({ config, logger, lifecycle, provide, events, proc }) {
1170
1223
  // 提示写在原因之前:原因里可能带着一段响应体
1171
1224
  if (initialIds.length === 0) {
1172
1225
  throw configError(discoveryError
1173
- ? `取不到 Ollama 的模型列表,没有可注册的模型:确认 Ollama 在运行、baseUrl 写对后重载本插件,或在 customModels 里写明要用的模型。原因:${discoveryError}`
1174
- : `Ollama 已连接 ${shownBaseUrl},但未发现任何可用模型;先用 ollama pull 下载模型,或在 customModels 里写明要用的模型`);
1226
+ ? t('noModelsDiscoveryFailed', { error: discoveryError })
1227
+ : t('noModels', { baseUrl: cfg.baseUrl }));
1175
1228
  }
1176
1229
  if (discoveryError)
1177
- logger.use.warn(`启动时只注册 customModels 里的模型;${discoveryError}`);
1230
+ logger.use.warn(`启动时只注册 customModels 里的模型;${toSource(discoveryError)}`);
1178
1231
  // 并行查每个模型的真实能力(顺序保留→注册顺序稳定→优先级稳定);失败者回退家族表。
1179
1232
  const detectedCaps = await Promise.all(initialIds.map(id => client.fetchModelCapabilities(id, lifecycle.use.signal)));
1180
1233
  lifecycle.use.signal.throwIfAborted();
@@ -1182,13 +1235,13 @@ async function start({ config, logger, lifecycle, provide, events, proc }) {
1182
1235
  registerOne(initialIds[i], detectedCaps[i]);
1183
1236
  // 模型都被当作非对话模型跳过(如只装了嵌入模型)时同样抛配置错误。只有 /api/show 答复过才会跳过,所以是已连接
1184
1237
  if (registered.size === 0) {
1185
- throw configError(`Ollama 已连接 ${shownBaseUrl},但没有可用的对话模型:${initialIds.join(', ')} 都没有报告对话能力(如嵌入模型);先用 ollama pull 下载对话模型`);
1238
+ throw configError(t('noChatModels', { baseUrl: cfg.baseUrl, models: initialIds.join(', ') }));
1186
1239
  }
1187
1240
  logger.use.info(!cfg.discoverModels
1188
- ? `Ollama 未开启模型发现: ${shownBaseUrl},注册 customModels 里的 ${registered.size} 个 model entry`
1241
+ ? `Ollama 未开启模型发现: ${cfg.baseUrl},注册 customModels 里的 ${registered.size} 个 model entry`
1189
1242
  : discoveryError
1190
- ? `Ollama 模型发现失败: ${shownBaseUrl},注册 customModels 里的 ${registered.size} 个 model entry`
1191
- : `Ollama 已连接: ${shownBaseUrl},注册 ${registered.size} 个 model entry`);
1243
+ ? `Ollama 模型发现失败: ${cfg.baseUrl},注册 customModels 里的 ${registered.size} 个 model entry`
1244
+ : `Ollama 已连接: ${cfg.baseUrl},注册 ${registered.size} 个 model entry`);
1192
1245
  /**
1193
1246
  * 重新发现并按差异增删条目(WebUI 触发,无需重启插件)。与初次注册一样随停用或停机中止:每次 await 之后自己查,
1194
1247
  * 中止即抛出,不再增删条目。模型发现失败同样抛出(WebUI 据此报错)、不增删条目:按空列表处理会把自动发现的