@aalis/plugin-llm-ollama 0.12.1 → 0.14.0

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 } from '@aalis/api-llm';
2
- import { createProcessGateway, processService } from '@aalis/api-process';
3
- import { config, definePlugin, lifecycle, logger, optional, provide } from '@aalis/core';
1
+ import { finishReasonOf, IDLE_TIMEOUT_MAX_MS, idleTimeout, LLMCapabilities, llm, metered, requestedThinking, } from '@aalis/api-llm';
2
+ import { processService } from '@aalis/api-process';
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, errors } 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。
@@ -117,63 +117,77 @@ export async function readBodyCapped(res, maxBytes) {
117
117
  }
118
118
  return Buffer.concat(chunks);
119
119
  }
120
+ /** 报告了思考能力的模型允许的档位:开关型,翻成 think 开关 */
121
+ const THINKING_LEVELS = [
122
+ { id: 'off', label: t('levelOff') },
123
+ { id: 'on', label: t('levelOn') },
124
+ ];
120
125
  const configSchema = defineConfig({
121
126
  baseUrl: {
122
127
  type: 'string',
123
- label: 'Ollama 地址',
128
+ label: t('baseUrlLabel'),
124
129
  default: 'http://localhost:11434',
125
130
  onInvalid: 'error',
126
- description: '本地 Ollama 服务的 HTTP 地址',
131
+ description: t('baseUrlDescription'),
127
132
  },
128
133
  customModels: {
129
134
  type: 'textarea',
130
- label: '自定义模型',
135
+ label: t('customModelsLabel'),
131
136
  default: '',
132
- description: '手动添加的模型名称(每行一个或逗号分隔)。用于补充自动发现列表中未出现的模型。与自动发现重复时会提示去重。',
137
+ description: t('customModelsDescription'),
133
138
  },
134
139
  discoverModels: {
135
140
  type: 'boolean',
136
- label: '自动发现模型',
141
+ label: t('discoverModelsLabel'),
137
142
  default: true,
138
- description: '启动时请求 /api/tags 发现已安装的模型,WebUI 可刷新模型列表。服务不提供 /api/tags 时关闭:不发发现请求,只注册 customModels(此时必填),也不支持刷新。',
143
+ description: t('discoverModelsDescription'),
139
144
  },
140
145
  modelCapabilities: {
141
146
  type: 'textarea',
142
- label: '单模型能力覆盖',
147
+ label: t('modelCapabilitiesLabel'),
143
148
  default: '',
144
- description: '强制覆盖某模型的能力(优先级最高,高于 /api/show 自动探测与家族表),与 adapter 默认能力取并集。\n格式:`<modelId>: <cap1>,<cap2>,...`,每行一条。如:nemotron3:33b: chat,vision,tool_calling',
149
+ description: t('modelCapabilitiesDescription'),
145
150
  },
146
151
  providerCapabilities: {
147
152
  type: 'string',
148
- label: '适配器默认能力(逗号分隔)',
153
+ label: t('providerCapabilitiesLabel'),
149
154
  default: '',
150
- description: '兜底默认能力:仅当某模型既无法从 Ollama /api/show 探测、又不在内置家族表时才使用。能力现已自动探测,通常留空即可(填了反而可能给不支持的模型乱标能力)。例:chat,tool_calling,streaming',
155
+ description: t('providerCapabilitiesDescription'),
151
156
  },
152
157
  timeout: {
153
158
  type: 'number',
154
- label: '请求超时 (秒)',
159
+ label: t('timeoutLabel'),
155
160
  default: 120,
156
- description: 'LLM 请求超时时间(秒)。大模型或长上下文建议适当调大。0 = 不限制。',
161
+ description: t('timeoutDescription'),
157
162
  },
158
- temperature: { type: 'number', label: '温度', default: 0.7, description: '0-2,越高越随机' },
163
+ temperature: { type: 'number', label: t('temperatureLabel'), default: 0.7, description: t('temperatureDescription') },
159
164
  maxTokens: {
160
165
  type: 'number',
161
- label: '最大 Token',
166
+ label: t('maxTokensLabel'),
162
167
  default: 4096,
163
- 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'),
164
178
  },
165
- contextLength: { type: 'number', label: '上下文长度', default: 8192, description: '模型上下文窗口大小(num_ctx)' },
166
179
  keepAlive: {
167
180
  type: 'string',
168
- label: '模型保活时间',
181
+ label: t('keepAliveLabel'),
169
182
  default: '5m',
170
- description: '模型在显存中保留的时间,如 5m、1h、0(立即卸载)',
183
+ description: t('keepAliveDescription'),
171
184
  },
172
185
  thinking: {
173
- type: 'boolean',
174
- label: '启用思考',
175
- default: true,
176
- description: '为支持思考的模型启用扩展思考(think 参数)。无 thinking 能力的模型该参数无效。',
186
+ type: 'select',
187
+ label: t('thinkingLabel'),
188
+ default: 'on',
189
+ options: THINKING_LEVELS.map(level => ({ label: level.label, value: level.id })),
190
+ description: t('thinkingDescription'),
177
191
  },
178
192
  });
179
193
  /**
@@ -246,10 +260,15 @@ class OllamaClient {
246
260
  keepAlive;
247
261
  logger;
248
262
  proc;
263
+ /**
264
+ * 生成请求的连接池:不设等响应头与读响应体的时限(默认各 300 秒),timeout 是唯一的时限。dispatcher 是 Node 自带 fetch
265
+ * 认的扩展字段,DOM 的 RequestInit 里没有(测试的类型检查带 DOM),带它的选项按 RequestInit 断言
266
+ */
267
+ dispatcher = new Agent({ headersTimeout: 0, bodyTimeout: 0 });
249
268
  constructor(config, logger, proc) {
250
269
  this.baseUrl = config.baseUrl.replace(/\/$/, '');
251
- // schema 中 timeout 单位为「秒」,存储为毫秒;0 视为不限制 → 用一个非常大的值
252
- this.timeout = config.timeout && config.timeout > 0 ? config.timeout * 1000 : 2_147_483_647;
270
+ // schema 中 timeout 单位为「秒」,存储为毫秒;0 或负数不限,超过定时器上限按上限计,报错里的秒数是生效的值(见 idleTimeout)
271
+ this.timeout = Math.min(config.timeout * 1000, IDLE_TIMEOUT_MAX_MS);
253
272
  this.temperature = config.temperature;
254
273
  this.maxTokens = config.maxTokens;
255
274
  this.contextLength = config.contextLength;
@@ -257,24 +276,31 @@ class OllamaClient {
257
276
  this.logger = logger;
258
277
  this.proc = proc;
259
278
  }
279
+ /** 停用时关掉生成请求的连接池,之后的请求被拒(报插件已停用或重载)。不等在飞的请求读完:它们是调用方的,等它们会让重载卡到回复写完 */
280
+ close() {
281
+ void this.dispatcher.close();
282
+ }
260
283
  /** 对话请求的非 2xx 应答:截断后的响应体记 warn,抛出的错误见 httpErrorMessage */
261
284
  apiError(status, body) {
262
285
  this.logger.warn(`Ollama API 错误 (${status}): ${bodyExcerpt(body)}`);
263
- return new Error(httpErrorMessage('Ollama', status, body));
286
+ return new TextError(httpErrorMessage('Ollama', status, body));
264
287
  }
265
288
  /**
266
- * 对话请求没拿到完整应答(fetch 或读响应体时抛出):原始错误连同原因记 warn,超时与连不上各换成一句说明,其它错误
267
- * 原样返回。调用方经 request.signal 中止时原样返回、不记日志,agent 按中止收尾
289
+ * 对话请求没拿到完整应答(fetch 或读响应体时抛出):原始错误连同原因记 warn,超时、连不上与连接池已关闭(插件停用或重载)
290
+ * 各换成一句说明,其它错误原样返回。调用方经 request.signal 中止时原样返回、不记日志,agent 按中止收尾
268
291
  */
269
292
  requestError(err, request, startedAt) {
270
293
  if (request.signal?.aborted)
271
294
  return err;
272
- this.logger.warn(`Ollama 请求失败 (耗时 ${Date.now() - startedAt}ms): ${describeError(err)}`);
295
+ this.logger.warn(`Ollama 请求失败 (耗时 ${Date.now() - startedAt}ms): ${toSource(describeError(err))}`);
273
296
  if (err instanceof DOMException && err.name === 'TimeoutError') {
274
- return new Error(`Ollama 请求超时:${this.timeout / 1000} 秒内没有完成,可在配置里调大 timeout`);
297
+ return new TextError(t('requestTimeout', { seconds: this.timeout / 1000 }));
275
298
  }
276
299
  if (err instanceof TypeError && err.message === 'fetch failed') {
277
- return new Error('Ollama 连不上服务:检查 baseUrl 与网络,详情见日志');
300
+ if (err.cause instanceof errors.ClientClosedError || err.cause instanceof errors.ClientDestroyedError) {
301
+ return new TextError(t('pluginClosed'));
302
+ }
303
+ return new TextError(t('unreachable'));
278
304
  }
279
305
  return err;
280
306
  }
@@ -285,7 +311,7 @@ class OllamaClient {
285
311
  }
286
312
  catch {
287
313
  this.logger.warn(`Ollama 应答不是 JSON (${status}): ${bodyExcerpt(text)}`);
288
- throw new Error(`Ollama 应答不是 JSON (${status});详情见日志`);
314
+ throw new TextError(t('notJson', { status }));
289
315
  }
290
316
  }
291
317
  /**
@@ -312,17 +338,17 @@ class OllamaClient {
312
338
  data = JSON.parse(text);
313
339
  }
314
340
  catch {
315
- throw new Error(`响应不是 JSON: ${bodyExcerpt(text)}`);
341
+ throw new TextError(t('discoveryNotJson', { excerpt: bodyExcerpt(text) }));
316
342
  }
317
343
  const list = data?.models;
318
344
  if (!Array.isArray(list) || !list.every(m => typeof m?.name === 'string')) {
319
- throw new Error(`响应不是模型列表(需要 models 数组,每项带字符串 name): ${bodyExcerpt(JSON.stringify(data))}`);
345
+ throw new TextError(t('discoveryNotList', { excerpt: bodyExcerpt(JSON.stringify(data)) }));
320
346
  }
321
347
  return list.map(m => m.name);
322
348
  }
323
349
  catch (err) {
324
350
  signal?.throwIfAborted();
325
- throw new Error(`模型发现失败 ${redactUrl(url)}: ${describeError(err)}`, { cause: err });
351
+ throw new TextError(t('discoveryFailed', { url, error: describeError(err) }), { cause: err });
326
352
  }
327
353
  }
328
354
  /**
@@ -350,9 +376,9 @@ class OllamaClient {
350
376
  /**
351
377
  * chat / chatStream 共用的 /api/chat 请求体(两侧只差 stream 标志;分开写曾让两侧漂移)。
352
378
  * 先走 prepareLLMMessages(归一 role + 拼 kind/自定义 role 内容前缀),否则丢
353
- * [系统通知]/[跨会话委派] 等前缀。
379
+ * [系统通知]/[跨会话委派] 等前缀。level 是这次的思考档位,不能调的模型没有
354
380
  */
355
- async buildRequestBody(model, request, defaultThinking, stream) {
381
+ async buildRequestBody(model, request, level, stream) {
356
382
  const messages = await Promise.all(prepareLLMMessages(request.messages).map(m => this.toOllamaMessage(m, request.requireImages === true)));
357
383
  const tools = request.tools?.map(t => this.toOllamaTool(t));
358
384
  const body = {
@@ -369,24 +395,18 @@ class OllamaClient {
369
395
  if (tools && tools.length > 0) {
370
396
  body.tools = tools;
371
397
  }
372
- // 启用原生思考模式(Ollama API think 参数)
373
- // 调用方可通过 request.think === false 显式关闭
374
- // 必须显式传 think:false 才能关闭 gemma4:31b 等原生 thinking 模型的思考;
375
- // 仅省略字段会被模型默认启用思考,导致 content 为空。
376
- const shouldThink = request.think !== undefined ? request.think : defaultThinking;
377
- body.think = shouldThink;
378
- return { body, messages, tools, shouldThink };
398
+ // 能思考的模型总是明说 think:gemma4:31b 等原生思考模型省略字段会默认思考,content 可能为空。没有思考能力的模型不发
399
+ if (level !== undefined)
400
+ body.think = level === 'on';
401
+ return { body, messages, tools };
379
402
  }
380
- async chat(model, request, defaultThinking) {
403
+ async chat(model, request, level) {
381
404
  // 包含音频输入 → 改走 OpenAI 兼容的 /v1/chat/completions(/api/chat 不支持 audios)
382
405
  if (request.messages.some(m => m.audios && m.audios.length > 0)) {
383
- return this.chatOpenAIWithAudio(model, request);
406
+ return this.chatOpenAIWithAudio(model, request, level);
384
407
  }
385
- const { body, messages, tools, shouldThink } = await this.buildRequestBody(model, request, defaultThinking, false);
386
- this.logger.debug(`请求 Ollama${shouldThink ? ' [think]' : ''}: ${body.model}, ${messages.length} 条消息, ${tools?.length ?? 0} 个工具`);
387
- const signals = [AbortSignal.timeout(this.timeout)];
388
- if (request.signal)
389
- signals.push(request.signal);
408
+ const { body, messages, tools } = await this.buildRequestBody(model, request, level, false);
409
+ this.logger.debug(`请求 Ollama${level ? ` (思考 ${level})` : ''}: ${body.model}, ${messages.length} 条消息, ${tools?.length ?? 0} 个工具`);
390
410
  const startedAt = Date.now();
391
411
  let response;
392
412
  let text;
@@ -395,7 +415,8 @@ class OllamaClient {
395
415
  method: 'POST',
396
416
  headers: { 'Content-Type': 'application/json' },
397
417
  body: JSON.stringify(body),
398
- signal: AbortSignal.any(signals),
418
+ signal: idleTimeout(this.timeout, request.signal).signal,
419
+ dispatcher: this.dispatcher,
399
420
  });
400
421
  text = await response.text();
401
422
  }
@@ -405,14 +426,15 @@ class OllamaClient {
405
426
  if (!response.ok)
406
427
  throw this.apiError(response.status, text);
407
428
  const data = this.parseJsonBody(response.status, text);
408
- // 优先使用原生 thinking 字段(Ollama think API),回退到 <think> 标签解析
429
+ // 发了 think 时思考由 Ollama 管、走原生 thinking 字段,正文照原样;没发时才把正文里 <think> 标签之间的内容当思考
409
430
  const nativeThinking = data.message.thinking || '';
410
431
  const rawContent = data.message.content || '';
411
- const { reasoning: tagReasoning, content: cleanContent } = extractThinkTags(rawContent);
432
+ const { reasoning: tagReasoning, content: cleanContent } = level === undefined ? extractThinkTags(rawContent) : { reasoning: '', content: rawContent };
412
433
  const allReasoning = [nativeThinking, tagReasoning].filter(Boolean).join('');
413
434
  const result = {
414
435
  content: cleanContent || null,
415
436
  reasoningContent: allReasoning || null,
437
+ finishReason: finishReasonOf(data.done_reason),
416
438
  };
417
439
  const toolCalls = finalizeToolCalls(data.message.tool_calls);
418
440
  if (toolCalls)
@@ -428,23 +450,34 @@ class OllamaClient {
428
450
  }
429
451
  return result;
430
452
  }
431
- async *chatStream(model, request, defaultThinking) {
453
+ async *chatStream(model, request, level) {
432
454
  // 包含音频输入 → 回退为非流式(OpenAI compat 路径不支持 SSE 交互)后以单 chunk 交付。
433
455
  if (request.messages.some(m => m.audios && m.audios.length > 0)) {
434
- const r = await this.chatOpenAIWithAudio(model, request);
456
+ const r = await this.chatOpenAIWithAudio(model, request, level);
435
457
  yield {
436
458
  contentDelta: r.content ?? '',
437
459
  reasoningDelta: r.reasoningContent ?? '',
438
460
  usage: r.usage,
439
461
  done: true,
462
+ finishReason: r.finishReason,
440
463
  };
441
464
  return;
442
465
  }
443
- const { body, messages, shouldThink } = await this.buildRequestBody(model, request, defaultThinking, true);
444
- this.logger.debug(`流式请求 Ollama${shouldThink ? ' [think]' : ''}: ${body.model}, ${messages.length} 条消息`);
445
- const signals = [AbortSignal.timeout(this.timeout)];
446
- if (request.signal)
447
- signals.push(request.signal);
466
+ const { body, messages } = await this.buildRequestBody(model, request, level, true);
467
+ this.logger.debug(`流式请求 Ollama${level ? ` (思考 ${level})` : ''}: ${body.model}, ${messages.length} 条消息`);
468
+ // 交出一段期间不计时:调用方处理一段的时间不算停顿
469
+ const timer = idleTimeout(this.timeout, request.signal);
470
+ for await (const chunk of this.readStream(body, request, timer, level === undefined)) {
471
+ timer.pause();
472
+ yield chunk;
473
+ timer.resume();
474
+ }
475
+ }
476
+ /**
477
+ * 发出流式请求并逐段解析,timer 管这次请求的停顿超时。parseThinkTags 为真(这次没发 think)时把正文里 <think> 标签之间的
478
+ * 内容当思考交出,为假时正文照原样交出
479
+ */
480
+ async *readStream(body, request, timer, parseThinkTags) {
448
481
  const reqStart = Date.now();
449
482
  const slowConnectTimer = setTimeout(() => {
450
483
  this.logger.warn(`Ollama 连接慢:已等待 15s 仍未收到响应头 (url=${this.baseUrl}/api/chat)`);
@@ -455,7 +488,8 @@ class OllamaClient {
455
488
  method: 'POST',
456
489
  headers: { 'Content-Type': 'application/json' },
457
490
  body: JSON.stringify(body),
458
- signal: AbortSignal.any(signals),
491
+ signal: timer.signal,
492
+ dispatcher: this.dispatcher,
459
493
  });
460
494
  }
461
495
  catch (err) {
@@ -471,7 +505,7 @@ class OllamaClient {
471
505
  throw this.apiError(response.status, errorText);
472
506
  }
473
507
  if (!response.body) {
474
- throw new Error('Ollama API 返回了空的响应体,无法进行流式读取');
508
+ throw new TextError(t('emptyBody'));
475
509
  }
476
510
  const reader = response.body.getReader();
477
511
  const decoder = new TextDecoder();
@@ -506,6 +540,9 @@ class OllamaClient {
506
540
  continue;
507
541
  try {
508
542
  const data = JSON.parse(trimmed);
543
+ // 带生成内容的帧重新计时:正文(含按标签解析出的思考)、原生思考、工具调用
544
+ if (data.message?.content || data.message?.thinking || data.message?.tool_calls?.length)
545
+ timer.touch();
509
546
  // 累积工具调用
510
547
  if (data.message?.tool_calls) {
511
548
  toolCallBuffers.push(...data.message.tool_calls);
@@ -532,7 +569,7 @@ class OllamaClient {
532
569
  tagBuffer = '';
533
570
  }
534
571
  // 最后一个 chunk
535
- const chunk = { done: true };
572
+ const chunk = { done: true, finishReason: finishReasonOf(data.done_reason) };
536
573
  const toolCalls = finalizeToolCalls(toolCallBuffers);
537
574
  if (toolCalls)
538
575
  chunk.toolCalls = toolCalls;
@@ -548,11 +585,15 @@ class OllamaClient {
548
585
  yield chunk;
549
586
  return;
550
587
  }
551
- // 原生 thinking 字段(Ollama think API)—— 优先级高于 <think> 标签解析
588
+ // 原生 thinking 字段(Ollama think API)
552
589
  if (data.message?.thinking) {
553
590
  yield { reasoningDelta: data.message.thinking };
554
591
  }
555
- if (data.message?.content) {
592
+ // 发了 think:思考由 Ollama 管,正文照原样交出
593
+ if (data.message?.content && !parseThinkTags) {
594
+ yield { contentDelta: data.message.content };
595
+ }
596
+ else if (data.message?.content) {
556
597
  // 解析 <think> / </think> 标签,将内部内容路由为 reasoningDelta
557
598
  let text = tagBuffer + data.message.content;
558
599
  tagBuffer = '';
@@ -626,7 +667,7 @@ class OllamaClient {
626
667
  clearTimeout(streamStallTimer);
627
668
  // cancel 而非仅 releaseLock:中止/提前退出时要主动关闭响应体。releaseLock 只是放锁,
628
669
  // 底层流仍开着——被打断的生成会让 undici 挂着半读的响应与套接字,直到请求超时兜底
629
- // 才释放(本地慢生成 + lane 中止高频后,这类残留一挂就是整个超时窗)。
670
+ // 才释放(本地慢生成时回合常被停下,这类残留一挂就是整个超时窗)。
630
671
  // cancel 自带放锁。三条退出路径的语义:读尽 EOF(done:true)→ 流已 closed,cancel 是
631
672
  // 真 no-op;收到终帧即 return(未读到 EOF)与消费方提前 break → cancel 中止残余响应体,
632
673
  // 这正是本修复要的「立即交还套接字」,代价是该连接不回 keep-alive 池。
@@ -703,7 +744,7 @@ class OllamaClient {
703
744
  if (valid.length === images.length)
704
745
  return valid;
705
746
  if (valid.length === 0 && requireImages) {
706
- throw new Error(`图片全部获取失败(共 ${images.length} 张),拒绝降级为纯文本请求`);
747
+ throw new TextError(t('imagesUnavailable', { count: images.length }));
707
748
  }
708
749
  this.logger.warn(`图片获取失败 ${images.length - valid.length}/${images.length} 张,按剩余的继续`);
709
750
  return valid;
@@ -756,7 +797,7 @@ class OllamaClient {
756
797
  * audios 字段时,/api/chat 原生路径不支持,必须走这里。
757
798
  * 文本与工具调用等其它能力仍由 chat() 走原生路径。
758
799
  */
759
- async chatOpenAIWithAudio(model, request) {
800
+ async chatOpenAIWithAudio(model, request, level) {
760
801
  // 将 Aalis Message 转为 OpenAI multimodal content blocks
761
802
  const oaiMessages = await Promise.all(prepareLLMMessages(request.messages).map(async (m) => {
762
803
  const role = toLLMRole(m.role);
@@ -788,12 +829,10 @@ class OllamaClient {
788
829
  max_tokens: request.maxTokens ?? this.maxTokens,
789
830
  temperature: request.temperature ?? this.temperature,
790
831
  };
791
- // Ollama 0.20+ thinking 控制:OpenAI 兼容路径只认 reasoning_effort,
792
- // 不认 /api/chat 的 think 字段。think=false → reasoning_effort: "none"
793
- // 节省 ~5-8x completion tokens(实测 935 → 155)。
794
- if (request.think === false) {
832
+ // Ollama 0.20+ 的兼容路径只认 reasoning_effort、不认 /api/chat 的 think:关 → reasoning_effort: "none",
833
+ // 节省 ~5-8x completion tokens(实测 935 → 155);开按模型默认,不发
834
+ if (level === 'off')
795
835
  body.reasoning_effort = 'none';
796
- }
797
836
  this.logger.debug(`请求 Ollama (OpenAI compat, audio): ${model}, ${oaiMessages.length} 条消息`);
798
837
  // 统计本次发送的音频载荷大小,便于诊断模型是否真的收到了音频
799
838
  let totalAudioBytes = 0;
@@ -812,9 +851,6 @@ class OllamaClient {
812
851
  if (audioCount > 0) {
813
852
  this.logger.info(`[ollama-audio] 发送 ${audioCount} 段音频,合计 ${(totalAudioBytes / 1024).toFixed(1)}KB`);
814
853
  }
815
- const signals = [AbortSignal.timeout(this.timeout)];
816
- if (request.signal)
817
- signals.push(request.signal);
818
854
  const httpT0 = Date.now();
819
855
  let resp;
820
856
  let respText;
@@ -823,7 +859,8 @@ class OllamaClient {
823
859
  method: 'POST',
824
860
  headers: { 'Content-Type': 'application/json' },
825
861
  body: JSON.stringify(body),
826
- signal: AbortSignal.any(signals),
862
+ signal: idleTimeout(this.timeout, request.signal).signal,
863
+ dispatcher: this.dispatcher,
827
864
  });
828
865
  respText = await resp.text();
829
866
  }
@@ -832,30 +869,25 @@ class OllamaClient {
832
869
  }
833
870
  if (!resp.ok) {
834
871
  this.logger.warn(`[ollama-audio] HTTP ${resp.status} 失败 ${Date.now() - httpT0}ms: ${bodyExcerpt(respText)}`);
835
- const message = httpErrorMessage('Ollama', resp.status, respText);
836
- // 诊断提示:ollama runner 把 input_audio 当 image 解码失败时报 "image: unknown format",
837
- // 99% 是模型本身不支持 audio modality(如 Nemotron-3 是纯文本/纯推理,
838
- // 多模态版 Nemotron-Nano-VL 也只有 vision)。提示用户换 gemma3n / qwen2.5-omni
839
- // 等明确支持 audio 的多模态模型。
840
- const lowerErr = respText.toLowerCase();
841
- if (lowerErr.includes('image: unknown format') || lowerErr.includes('unknown format')) {
842
- throw new Error(`${message}\n` +
843
- `[诊断] 模型 "${model}" 很可能不支持 audio modality(ollama runner 把 input_audio 当 image 解码失败)。` +
844
- `请改用明确支持音频的模型(如 gemma3n、qwen2.5-omni)。`);
845
- }
846
- throw new Error(message);
872
+ throw new TextError(httpErrorMessage('Ollama', resp.status, respText));
847
873
  }
848
874
  const data = this.parseJsonBody(resp.status, respText);
849
875
  const rawContent = data.choices?.[0]?.message?.content ?? '';
850
- const finishReason = data.choices?.[0]?.finish_reason ?? '?';
851
- const text = rawContent.trim();
876
+ const finishReason = data.choices?.[0]?.finish_reason;
877
+ // 与 /api/chat 同一条规则:这次没管思考(level 为 undefined)时才把正文里 <think> 标签之间的内容当思考
878
+ const { reasoning, content } = level === undefined ? extractThinkTags(rawContent) : { reasoning: '', content: rawContent };
879
+ const text = content.trim();
852
880
  if (audioCount > 0) {
853
- this.logger.info(`[ollama-audio] ${model} HTTP ${Date.now() - httpT0}ms, finish=${finishReason}, ` +
881
+ this.logger.info(`[ollama-audio] ${model} HTTP ${Date.now() - httpT0}ms, finish=${finishReason ?? '?'}, ` +
854
882
  `raw=${rawContent.length}字 trim=${text.length}字, ` +
855
883
  `tokens prompt=${data.usage?.prompt_tokens ?? '?'} completion=${data.usage?.completion_tokens ?? '?'}` +
856
884
  (rawContent.length > 0 ? `, 原文="${rawContent.replace(/\n/g, ' ')}"` : ' [模型返回空字符串]'));
857
885
  }
858
- const result = { content: text || null, reasoningContent: null };
886
+ const result = {
887
+ content: text || null,
888
+ reasoningContent: reasoning || null,
889
+ finishReason: finishReasonOf(finishReason),
890
+ };
859
891
  if (data.usage) {
860
892
  result.usage = {
861
893
  promptTokens: data.usage.prompt_tokens ?? 0,
@@ -866,32 +898,30 @@ class OllamaClient {
866
898
  return result;
867
899
  }
868
900
  }
869
- /** 去掉 base64 音频的 data: 前缀,返回纯 payload。 */
870
- function stripAudioDataPrefix(s) {
871
- const m = s.match(/^data:[^;]+;base64,(.+)$/);
872
- return m ? m[1] : s;
873
- }
874
901
  /**
875
- * 将 Aalis 传过来的 audio payload(可能带 data: 前缀也可能不带)解析为
876
- * OpenAI input_audio 需要的 `{ data, format }`。format 推断优先级:
877
- * data URL 的 mime 后缀 → 默认 wav。
902
+ * data URL 的音频类型(audio/ 之后的部分)→ OpenAI 兼容接口 input_audio 的 format。只收 wav 与 mp3:Ollama 的兼容接口不看
903
+ * format、只把 data 按 base64 解码(ollama 仓的 openai/openai.go),之后按文件头认音频,只认 wav 与 mp3(llm/media.go 的
904
+ * AudioFormat;mlxrunner/model/audio/audio.go 的 Decode 只认 wav)
905
+ */
906
+ const AUDIO_FORMATS = new Map([
907
+ ['mpeg', 'mp3'],
908
+ ['mp3', 'mp3'],
909
+ ['wav', 'wav'],
910
+ ['x-wav', 'wav'],
911
+ ['wave', 'wav'],
912
+ ]);
913
+ /**
914
+ * 把 `data:audio/<类型>[;参数];base64,…` 解析为 OpenAI input_audio 需要的 `{ data, format }`。format 只按写明的类型映射;
915
+ * 认不出的类型与不是 data URL 的(本地路径、链接、裸 base64)在发请求前报错:猜成 wav 时上游只回一句解码失败,看不出是格式的事
878
916
  */
879
917
  function decodeAudioForOpenAI(payload) {
880
- const m = payload.match(/^data:audio\/([^;]+);base64,(.+)$/);
881
- if (m) {
882
- const fmt = m[1].toLowerCase();
883
- const norm = fmt === 'mpeg' || fmt === 'mp3'
884
- ? 'mp3'
885
- : fmt === 'x-wav' || fmt === 'wave' || fmt === 'wav'
886
- ? 'wav'
887
- : fmt === 'ogg' || fmt === 'oga' || fmt === 'opus'
888
- ? 'ogg'
889
- : fmt === 'm4a' || fmt === 'mp4' || fmt === 'aac'
890
- ? 'm4a'
891
- : 'wav';
892
- return { data: m[2], format: norm };
893
- }
894
- return { data: stripAudioDataPrefix(payload), format: 'wav' };
918
+ const m = payload.match(/^data:audio\/([^;,]+)(?:;[^;,]+)*;base64,(.+)$/);
919
+ if (!m)
920
+ throw new TextError(t('audioDataUrlOnly'));
921
+ const format = AUDIO_FORMATS.get(m[1].toLowerCase());
922
+ if (!format)
923
+ throw new TextError(t('audioFormatUnsupported', { format: m[1] }));
924
+ return { data: m[2], format };
895
925
  }
896
926
  function safeParseJSON(str) {
897
927
  try {
@@ -903,6 +933,8 @@ function safeParseJSON(str) {
903
933
  }
904
934
  // ===== 模型能力映射 =====
905
935
  const { Chat, ToolCalling, Streaming, Vision, Audio, Thinking } = LLMCapabilities;
936
+ // 能力探测(/api/show)失败时的回退。前缀匹配按表序取第一个命中,更具体的前缀排在更短的之前(qwen3-coder 在 qwen3 之前)。
937
+ // 原生思考的族标 Thinking:探测失败时照样声明档位、明说 think,省略字段它们会默认思考
906
938
  const MODEL_CAPABILITIES = {
907
939
  'llama3.1': [Chat, ToolCalling, Streaming],
908
940
  'llama3.2': [Chat, ToolCalling, Streaming],
@@ -911,12 +943,13 @@ const MODEL_CAPABILITIES = {
911
943
  'llava-llama3': [Chat, Streaming, Vision],
912
944
  gemma2: [Chat, Streaming],
913
945
  gemma3: [Chat, ToolCalling, Streaming, Vision],
914
- gemma4: [Chat, ToolCalling, Streaming, Vision],
946
+ gemma4: [Chat, ToolCalling, Streaming, Vision, Thinking],
915
947
  'qwen2.5': [Chat, ToolCalling, Streaming],
916
948
  'qwen2.5-coder': [Chat, ToolCalling, Streaming],
917
- qwen3: [Chat, ToolCalling, Streaming],
949
+ 'qwen3-coder': [Chat, ToolCalling, Streaming],
950
+ qwen3: [Chat, ToolCalling, Streaming, Thinking],
918
951
  mistral: [Chat, ToolCalling, Streaming],
919
- 'deepseek-r1': [Chat, Streaming],
952
+ 'deepseek-r1': [Chat, Streaming, Thinking],
920
953
  phi4: [Chat, Streaming],
921
954
  'command-r': [Chat, ToolCalling, Streaming],
922
955
  };
@@ -1053,12 +1086,14 @@ class OllamaModelHandle {
1053
1086
  providerId;
1054
1087
  contextLength;
1055
1088
  maxOutputTokens;
1056
- defaultThinking;
1057
1089
  capabilities;
1090
+ thinking;
1058
1091
  refresh;
1059
- constructor(client, id, providerId, contextLength, maxOutputTokens, defaultThinking,
1092
+ constructor(client, id, providerId, contextLength, maxOutputTokens,
1060
1093
  /** 该 model 的能力元数据(供 media 发现/下拉展示读取,非 DI 选择机制)。 */
1061
1094
  capabilities,
1095
+ /** 能思考的模型才有:默认档取实例配置 */
1096
+ thinking,
1062
1097
  /** Provider 级共享的 refresh 闭包;webui 按 providerId 找到该 provider 的任一 entry 调一次即可。关闭模型发现时没有 */
1063
1098
  refresh) {
1064
1099
  this.client = client;
@@ -1066,18 +1101,22 @@ class OllamaModelHandle {
1066
1101
  this.providerId = providerId;
1067
1102
  this.contextLength = contextLength;
1068
1103
  this.maxOutputTokens = maxOutputTokens;
1069
- this.defaultThinking = defaultThinking;
1070
1104
  this.capabilities = capabilities;
1105
+ this.thinking = thinking;
1071
1106
  this.refresh = refresh;
1072
1107
  }
1073
- chat(request) {
1074
- return this.client.chat(this.id, request, this.defaultThinking);
1108
+ async chat(request) {
1109
+ return this.client.chat(this.id, request, this.levelOf(request));
1110
+ }
1111
+ async *chatStream(request) {
1112
+ yield* this.client.chatStream(this.id, request, this.levelOf(request));
1075
1113
  }
1076
- chatStream(request) {
1077
- return this.client.chatStream(this.id, request, this.defaultThinking);
1114
+ /** 这次的档位:请求带了按请求,没带按默认档;不能调的模型没有 */
1115
+ levelOf(request) {
1116
+ return requestedThinking(this, request) ?? this.thinking?.default;
1078
1117
  }
1079
1118
  }
1080
- const uses = { config, logger, lifecycle, provide, proc: optional(processService) };
1119
+ const uses = { config, logger, lifecycle, provide, events, proc: optional(processService) };
1081
1120
  export default definePlugin({
1082
1121
  name: '@aalis/plugin-llm-ollama',
1083
1122
  displayName: 'Ollama',
@@ -1088,18 +1127,18 @@ export default definePlugin({
1088
1127
  uses,
1089
1128
  apply: start,
1090
1129
  });
1091
- async function start({ config, logger, lifecycle, provide, proc }) {
1092
- const cfg = parseConfig(configSchema, config, logger);
1130
+ async function start({ config, logger, lifecycle, provide, events, proc }) {
1131
+ const cfg = parseConfig(configSchema, config.use, logger.use);
1093
1132
  const customModels = parseCustomModels(cfg.customModels);
1094
1133
  const modelCapabilities = parseModelCapabilities(cfg.modelCapabilities);
1095
1134
  const providerCapabilities = parseProviderCapabilities(cfg.providerCapabilities);
1096
1135
  checkBaseUrl(cfg.baseUrl);
1097
1136
  if (!cfg.discoverModels && customModels.length === 0) {
1098
- throw missingConfigError('customModels', '关闭 discoverModels 时必填');
1137
+ throw missingConfigError('customModels', t('customModelsRequired'));
1099
1138
  }
1100
- const client = new OllamaClient(cfg, logger, createProcessGateway(proc));
1139
+ const client = new OllamaClient(cfg, logger.use, proc.use);
1140
+ lifecycle.use.onDispose(() => client.close(), '关闭生成请求的连接池');
1101
1141
  const baseLabel = `Ollama (${cfg.baseUrl.replace(/^https?:\/\//, '')})`;
1102
- const shownBaseUrl = redactUrl(cfg.baseUrl);
1103
1142
  // 已注册 model entry 的句柄表:modelId → 该 entry 的退订
1104
1143
  const registered = new Map();
1105
1144
  // 同 provider 下所有 OllamaModelHandle 共享同一份 refresh。关闭模型发现时不提供:没有可重新发现的列表
@@ -1110,13 +1149,13 @@ async function start({ config, logger, lifecycle, provide, proc }) {
1110
1149
  return false;
1111
1150
  const capabilities = resolveCapabilities(modelId, modelCapabilities.get(modelId), providerCapabilities, detected);
1112
1151
  if (capabilities.length === 0) {
1113
- logger.debug(`跳过 model entry "${modelId}": /api/show 未报告对话能力(embedding 等非对话模型)`);
1152
+ logger.use.debug(`跳过 model entry "${modelId}": /api/show 未报告对话能力(embedding 等非对话模型)`);
1114
1153
  return false;
1115
1154
  }
1116
- const handle = new OllamaModelHandle(client, modelId, lifecycle.id, cfg.contextLength, cfg.maxTokens, cfg.thinking, capabilities, refresh);
1117
- const dispose = provide(llm, handle, {
1155
+ const handle = new OllamaModelHandle(client, modelId, lifecycle.use.id, cfg.contextLength, cfg.maxTokens, capabilities, capabilities.includes(Thinking) ? { levels: THINKING_LEVELS, default: cfg.thinking } : undefined, refresh);
1156
+ const dispose = provide.use(llm, metered(handle, events.use), {
1118
1157
  label: `${baseLabel} / ${modelId}`,
1119
- entryId: `${lifecycle.id}/${modelId}`,
1158
+ entryId: `${lifecycle.use.id}/${modelId}`,
1120
1159
  });
1121
1160
  registered.set(modelId, dispose);
1122
1161
  return true;
@@ -1129,7 +1168,7 @@ async function start({ config, logger, lifecycle, provide, proc }) {
1129
1168
  d();
1130
1169
  }
1131
1170
  catch (err) {
1132
- logger.warn(`卸载 model entry "${modelId}" 失败: ${err}`);
1171
+ logger.use.warn(`卸载 model entry "${modelId}" 失败: ${err}`);
1133
1172
  }
1134
1173
  registered.delete(modelId);
1135
1174
  }
@@ -1138,7 +1177,7 @@ async function start({ config, logger, lifecycle, provide, proc }) {
1138
1177
  const remoteSet = new Set(remoteIds);
1139
1178
  for (const cm of customModels) {
1140
1179
  if (remoteSet.has(cm)) {
1141
- logger.warn(`自定义模型 "${cm}" 与自动发现的模型重复,请在配置中去重`);
1180
+ logger.use.warn(`自定义模型 "${cm}" 与自动发现的模型重复,请在配置中去重`);
1142
1181
  }
1143
1182
  }
1144
1183
  return [...remoteIds, ...customModels.filter(id => !remoteSet.has(id))];
@@ -1148,51 +1187,51 @@ async function start({ config, logger, lifecycle, provide, proc }) {
1148
1187
  // 发现失败只留消息:消息里已带 URL 与原因(cause 也内联在内),err 交给 logger 会按因果链把原因再记一遍
1149
1188
  let discoveryError;
1150
1189
  const remoteIds = cfg.discoverModels
1151
- ? await client.fetchRemoteModelIds(lifecycle.signal).catch((err) => {
1152
- discoveryError = err instanceof Error ? err.message : String(err);
1190
+ ? await client.fetchRemoteModelIds(lifecycle.use.signal).catch((err) => {
1191
+ discoveryError = textOf(err);
1153
1192
  return [];
1154
1193
  })
1155
1194
  : [];
1156
- lifecycle.signal.throwIfAborted();
1195
+ lifecycle.use.signal.throwIfAborted();
1157
1196
  const initialIds = withCustomModels(remoteIds);
1158
1197
  // 一个模型都没有时抛配置错误,原因成为实例的错误信息;否则只剩 core 的「声明 provides [llm] 但未实际注册」。
1159
1198
  // 提示写在原因之前:原因里可能带着一段响应体
1160
1199
  if (initialIds.length === 0) {
1161
1200
  throw configError(discoveryError
1162
- ? `未配置 customModels,没有可注册的模型;${discoveryError}`
1163
- : `Ollama 已连接 ${shownBaseUrl},但未发现任何可用模型;先用 ollama pull 下载模型,或在 customModels 里写明要用的模型`);
1201
+ ? t('noModelsDiscoveryFailed', { error: discoveryError })
1202
+ : t('noModels', { baseUrl: cfg.baseUrl }));
1164
1203
  }
1165
1204
  if (discoveryError)
1166
- logger.warn(`启动时只注册 customModels 里的模型;${discoveryError}`);
1205
+ logger.use.warn(`启动时只注册 customModels 里的模型;${toSource(discoveryError)}`);
1167
1206
  // 并行查每个模型的真实能力(顺序保留→注册顺序稳定→优先级稳定);失败者回退家族表。
1168
- const detectedCaps = await Promise.all(initialIds.map(id => client.fetchModelCapabilities(id, lifecycle.signal)));
1169
- lifecycle.signal.throwIfAborted();
1207
+ const detectedCaps = await Promise.all(initialIds.map(id => client.fetchModelCapabilities(id, lifecycle.use.signal)));
1208
+ lifecycle.use.signal.throwIfAborted();
1170
1209
  for (let i = 0; i < initialIds.length; i++)
1171
1210
  registerOne(initialIds[i], detectedCaps[i]);
1172
1211
  // 模型都被当作非对话模型跳过(如只装了嵌入模型)时同样抛配置错误。只有 /api/show 答复过才会跳过,所以是已连接
1173
1212
  if (registered.size === 0) {
1174
- throw configError(`Ollama 已连接 ${shownBaseUrl},但没有可用的对话模型:${initialIds.join(', ')} 都没有报告对话能力(如嵌入模型);先用 ollama pull 下载对话模型`);
1213
+ throw configError(t('noChatModels', { baseUrl: cfg.baseUrl, models: initialIds.join(', ') }));
1175
1214
  }
1176
- logger.info(!cfg.discoverModels
1177
- ? `Ollama 未开启模型发现: ${shownBaseUrl},注册 customModels 里的 ${registered.size} 个 model entry`
1215
+ logger.use.info(!cfg.discoverModels
1216
+ ? `Ollama 未开启模型发现: ${cfg.baseUrl},注册 customModels 里的 ${registered.size} 个 model entry`
1178
1217
  : discoveryError
1179
- ? `Ollama 模型发现失败: ${shownBaseUrl},注册 customModels 里的 ${registered.size} 个 model entry`
1180
- : `Ollama 已连接: ${shownBaseUrl},注册 ${registered.size} 个 model entry`);
1218
+ ? `Ollama 模型发现失败: ${cfg.baseUrl},注册 customModels 里的 ${registered.size} 个 model entry`
1219
+ : `Ollama 已连接: ${cfg.baseUrl},注册 ${registered.size} 个 model entry`);
1181
1220
  /**
1182
1221
  * 重新发现并按差异增删条目(WebUI 触发,无需重启插件)。与初次注册一样随停用或停机中止:每次 await 之后自己查,
1183
1222
  * 中止即抛出,不再增删条目。模型发现失败同样抛出(WebUI 据此报错)、不增删条目:按空列表处理会把自动发现的
1184
1223
  * 条目全部注销
1185
1224
  */
1186
1225
  async function refreshModels() {
1187
- const next = withCustomModels(await client.fetchRemoteModelIds(lifecycle.signal));
1188
- lifecycle.signal.throwIfAborted();
1226
+ const next = withCustomModels(await client.fetchRemoteModelIds(lifecycle.use.signal));
1227
+ lifecycle.use.signal.throwIfAborted();
1189
1228
  const nextSet = new Set(next);
1190
1229
  const added = [];
1191
1230
  const removed = [];
1192
1231
  // 新模型的能力并行探测(与初次注册一致),再按发现顺序登记
1193
1232
  const fresh = next.filter(id => !registered.has(id));
1194
- const detectedCaps = await Promise.all(fresh.map(id => client.fetchModelCapabilities(id, lifecycle.signal)));
1195
- lifecycle.signal.throwIfAborted();
1233
+ const detectedCaps = await Promise.all(fresh.map(id => client.fetchModelCapabilities(id, lifecycle.use.signal)));
1234
+ lifecycle.use.signal.throwIfAborted();
1196
1235
  for (let i = 0; i < fresh.length; i++) {
1197
1236
  // 非对话模型被跳过、等能力探测期间并发的刷新已登记,都不算本次新增
1198
1237
  if (registerOne(fresh[i], detectedCaps[i]))
@@ -1205,10 +1244,10 @@ async function start({ config, logger, lifecycle, provide, proc }) {
1205
1244
  }
1206
1245
  }
1207
1246
  if (added.length || removed.length) {
1208
- logger.info(`Ollama 模型列表已刷新: +${added.length} (${added.join(',') || '-'}) / -${removed.length} (${removed.join(',') || '-'}) / 现共 ${registered.size}`);
1247
+ logger.use.info(`Ollama 模型列表已刷新: +${added.length} (${added.join(',') || '-'}) / -${removed.length} (${removed.join(',') || '-'}) / 现共 ${registered.size}`);
1209
1248
  }
1210
1249
  else {
1211
- logger.debug(`Ollama 模型列表已刷新: 无变化 (共 ${registered.size})`);
1250
+ logger.use.debug(`Ollama 模型列表已刷新: 无变化 (共 ${registered.size})`);
1212
1251
  }
1213
1252
  return { added, removed, total: registered.size };
1214
1253
  }