@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.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +223 -184
- package/dist/index.js.map +1 -1
- package/dist/locales/en.d.ts +56 -0
- package/dist/locales/en.d.ts.map +1 -0
- package/dist/locales/en.js +56 -0
- package/dist/locales/en.js.map +1 -0
- package/dist/texts.d.ts +111 -0
- package/dist/texts.d.ts.map +1 -0
- package/dist/texts.js +59 -0
- package/dist/texts.js.map +1 -0
- package/package.json +13 -10
package/dist/index.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import { LLMCapabilities, llm } from '@aalis/api-llm';
|
|
2
|
-
import {
|
|
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
|
|
19
|
-
*
|
|
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('
|
|
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('
|
|
35
|
+
throw configError(t('baseUrlCredentials'));
|
|
28
36
|
}
|
|
29
37
|
}
|
|
30
38
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
|
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
|
|
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: '
|
|
128
|
+
label: t('baseUrlLabel'),
|
|
124
129
|
default: 'http://localhost:11434',
|
|
125
130
|
onInvalid: 'error',
|
|
126
|
-
description: '
|
|
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: '
|
|
143
|
+
description: t('discoverModelsDescription'),
|
|
139
144
|
},
|
|
140
145
|
modelCapabilities: {
|
|
141
146
|
type: 'textarea',
|
|
142
|
-
label: '
|
|
147
|
+
label: t('modelCapabilitiesLabel'),
|
|
143
148
|
default: '',
|
|
144
|
-
description: '
|
|
149
|
+
description: t('modelCapabilitiesDescription'),
|
|
145
150
|
},
|
|
146
151
|
providerCapabilities: {
|
|
147
152
|
type: 'string',
|
|
148
|
-
label: '
|
|
153
|
+
label: t('providerCapabilitiesLabel'),
|
|
149
154
|
default: '',
|
|
150
|
-
description: '
|
|
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: '
|
|
161
|
+
description: t('timeoutDescription'),
|
|
157
162
|
},
|
|
158
|
-
temperature: { type: 'number', label: '
|
|
163
|
+
temperature: { type: 'number', label: t('temperatureLabel'), default: 0.7, description: t('temperatureDescription') },
|
|
159
164
|
maxTokens: {
|
|
160
165
|
type: 'number',
|
|
161
|
-
label: '
|
|
166
|
+
label: t('maxTokensLabel'),
|
|
162
167
|
default: 4096,
|
|
163
|
-
|
|
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: '
|
|
183
|
+
description: t('keepAliveDescription'),
|
|
171
184
|
},
|
|
172
185
|
thinking: {
|
|
173
|
-
type: '
|
|
174
|
-
label: '
|
|
175
|
-
default:
|
|
176
|
-
|
|
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 =
|
|
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
|
|
286
|
+
return new TextError(httpErrorMessage('Ollama', status, body));
|
|
264
287
|
}
|
|
265
288
|
/**
|
|
266
|
-
* 对话请求没拿到完整应答(fetch 或读响应体时抛出):原始错误连同原因记 warn
|
|
267
|
-
*
|
|
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
|
|
297
|
+
return new TextError(t('requestTimeout', { seconds: this.timeout / 1000 }));
|
|
275
298
|
}
|
|
276
299
|
if (err instanceof TypeError && err.message === 'fetch failed') {
|
|
277
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
//
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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,
|
|
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
|
|
386
|
-
this.logger.debug(`请求 Ollama${
|
|
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:
|
|
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
|
-
//
|
|
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,
|
|
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
|
|
444
|
-
this.logger.debug(`流式请求 Ollama${
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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:
|
|
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
|
|
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
|
|
588
|
+
// 原生 thinking 字段(Ollama think API)
|
|
552
589
|
if (data.message?.thinking) {
|
|
553
590
|
yield { reasoningDelta: data.message.thinking };
|
|
554
591
|
}
|
|
555
|
-
|
|
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
|
-
//
|
|
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
|
|
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+
|
|
792
|
-
//
|
|
793
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|
|
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
|
-
*
|
|
876
|
-
*
|
|
877
|
-
*
|
|
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\/([
|
|
881
|
-
if (m)
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
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,
|
|
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.
|
|
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
|
-
|
|
1077
|
-
|
|
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', '
|
|
1137
|
+
throw missingConfigError('customModels', t('customModelsRequired'));
|
|
1099
1138
|
}
|
|
1100
|
-
const client = new OllamaClient(cfg, logger,
|
|
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
|
|
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 =
|
|
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
|
-
?
|
|
1163
|
-
:
|
|
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(
|
|
1213
|
+
throw configError(t('noChatModels', { baseUrl: cfg.baseUrl, models: initialIds.join(', ') }));
|
|
1175
1214
|
}
|
|
1176
|
-
logger.info(!cfg.discoverModels
|
|
1177
|
-
? `Ollama 未开启模型发现: ${
|
|
1215
|
+
logger.use.info(!cfg.discoverModels
|
|
1216
|
+
? `Ollama 未开启模型发现: ${cfg.baseUrl},注册 customModels 里的 ${registered.size} 个 model entry`
|
|
1178
1217
|
: discoveryError
|
|
1179
|
-
? `Ollama 模型发现失败: ${
|
|
1180
|
-
: `Ollama 已连接: ${
|
|
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
|
}
|