my-ai-chat-framework 2.0.0 → 3.0.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/CHANGELOG.md +65 -0
- package/LICENSE +20 -20
- package/README.md +329 -45
- package/README_ZH.md +375 -0
- package/dist/my-ai-chat-framework.browser.es.js +1581 -207
- package/dist/my-ai-chat-framework.browser.es.js.map +1 -1
- package/dist/my-ai-chat-framework.browser.umd.js +1594 -211
- package/dist/my-ai-chat-framework.browser.umd.js.map +1 -1
- package/dist/my-ai-chat-framework.node.cjs.js +1594 -211
- package/dist/my-ai-chat-framework.node.cjs.js.map +1 -1
- package/docs/DEVELOPER.md +479 -0
- package/package.json +26 -3
- package/src/adapters/openai.js +289 -64
- package/src/core/ChatService.js +652 -110
- package/src/core/Errors.js +60 -0
- package/src/core/EventEmitter.js +19 -0
- package/src/core/MessageStore.js +66 -0
- package/src/core/Pipeline.js +48 -0
- package/src/core/SystemPromptStore.js +118 -0
- package/src/index.js +12 -14
- package/src/plugins/model-registry.js +223 -0
- package/src/plugins/tool-calling.js +229 -105
- package/src/utils/MessageFormatter.js +204 -0
- package/src/utils/typeCheck.js +12 -0
- package/src/utils/url.js +18 -0
- package/.env +0 -15
- package/test.js +0 -107
package/src/adapters/openai.js
CHANGED
|
@@ -2,93 +2,263 @@
|
|
|
2
2
|
* OpenAI 兼容 API 适配器
|
|
3
3
|
* 支持 DeepSeek 等完全兼容 OpenAI 接口的服务
|
|
4
4
|
*/
|
|
5
|
+
|
|
6
|
+
import { joinUrl } from "../utils/url.js";
|
|
7
|
+
import { isString } from "../utils/typeCheck.js";
|
|
8
|
+
import { APIError, NetworkError, ParsingError } from '../core/Errors.js';
|
|
9
|
+
import { MessageFormatter } from '../utils/MessageFormatter.js';
|
|
10
|
+
|
|
11
|
+
// 属于"运输层"的配置键:工厂实例持有,请求时覆盖 config 中同名项
|
|
12
|
+
const TRANSPORT_KEYS = ['apiKey', 'baseUrl', 'apiUrl', 'path', 'headers'];
|
|
13
|
+
|
|
5
14
|
export const openaiAdapter = {
|
|
6
15
|
name: 'openai',
|
|
7
16
|
|
|
8
|
-
|
|
9
|
-
|
|
17
|
+
/**
|
|
18
|
+
* 安装适配器。
|
|
19
|
+
* - 单例(openaiAdapter):options 无意义,配置从 chat.config 读取(平铺兼容路径)
|
|
20
|
+
* - 工厂实例(createOpenAIAdapter):options 并入实例自持的运输配置
|
|
21
|
+
*/
|
|
22
|
+
install(chatService, options = {}) {
|
|
23
|
+
if (this._transport) this._transport = { ...this._transport, ...options };
|
|
10
24
|
chatService.setAdapter(this);
|
|
11
25
|
},
|
|
12
26
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
23
|
-
|
|
27
|
+
/**
|
|
28
|
+
* 实例自持配置(运输层)覆盖调用方传入的 config 同名项。
|
|
29
|
+
* modelParams 特殊处理:深度合并(config 优先,transport 兜底)——
|
|
30
|
+
* 避免工厂实例的默认参数覆盖掉会话/请求级覆盖。
|
|
31
|
+
*/
|
|
32
|
+
_resolveConfig(config) {
|
|
33
|
+
const transport = this._transport || {};
|
|
34
|
+
const resolved = { ...config, ...transport };
|
|
35
|
+
if (transport.modelParams) {
|
|
36
|
+
resolved.modelParams = { ...transport.modelParams, ...(config.modelParams || {}) };
|
|
37
|
+
}
|
|
38
|
+
return resolved;
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
/** 工厂实例可提供给会话层的"请求默认参数"(非运输键的部分) */
|
|
42
|
+
getRequestDefaults() {
|
|
43
|
+
if (!this._transport) return {};
|
|
44
|
+
const defaults = {};
|
|
45
|
+
for (const [key, value] of Object.entries(this._transport)) {
|
|
46
|
+
if (!TRANSPORT_KEYS.includes(key) && value !== undefined) defaults[key] = value;
|
|
47
|
+
}
|
|
48
|
+
return defaults;
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 构建 OpenAI 格式的请求体
|
|
53
|
+
* @param {Array} messages - 内部消息列表
|
|
54
|
+
* @param {Object} config - 合并后的请求参数(含 model/temperature 等)
|
|
55
|
+
* @param {Array} [systemPrompts=[]] - 启用的 system prompt 列表
|
|
56
|
+
* @returns {Object} 请求体
|
|
57
|
+
*/
|
|
58
|
+
buildRequest(messages, config, systemPrompts = []) {
|
|
59
|
+
config = this._resolveConfig(config);
|
|
60
|
+
const model = config.model || config.modelParams?.model;
|
|
61
|
+
if (!model) {
|
|
62
|
+
throw new Error('Missing required config: model (either at top level or in modelParams)');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const mp = config.modelParams || {};
|
|
66
|
+
const temperature = mp.temperature ?? config.temperature ?? 0.7;
|
|
67
|
+
const maxTokens = mp.maxTokens ?? config.maxTokens ?? 2000;
|
|
68
|
+
const reasoningEffort = mp.reasoningEffort ?? config.reasoningEffort;
|
|
69
|
+
|
|
70
|
+
// 用 MessageFormatter 统一处理:system 置顶、消息过滤、字段映射、图片解析
|
|
71
|
+
// format 由 config.messageFormat 指定(默认 'openai')
|
|
72
|
+
const apiMessages = MessageFormatter.format({
|
|
73
|
+
messages,
|
|
74
|
+
systemPrompts,
|
|
75
|
+
capabilities: config.capabilities || {},
|
|
76
|
+
resolveImage: config.resolveImage,
|
|
77
|
+
format: config.messageFormat
|
|
24
78
|
});
|
|
25
79
|
|
|
26
|
-
|
|
27
|
-
model
|
|
80
|
+
const requestBody = {
|
|
81
|
+
model,
|
|
28
82
|
messages: apiMessages,
|
|
29
|
-
temperature
|
|
30
|
-
max_tokens:
|
|
31
|
-
stream: false
|
|
83
|
+
temperature,
|
|
84
|
+
max_tokens: maxTokens,
|
|
85
|
+
stream: false
|
|
32
86
|
};
|
|
87
|
+
|
|
88
|
+
// 硬编码常用参数:驼峰命名,适配器负责转 API 格式
|
|
89
|
+
if (mp.topP !== undefined) requestBody.top_p = mp.topP;
|
|
90
|
+
if (mp.frequencyPenalty !== undefined) requestBody.frequency_penalty = mp.frequencyPenalty;
|
|
91
|
+
if (mp.presencePenalty !== undefined) requestBody.presence_penalty = mp.presencePenalty;
|
|
92
|
+
if (mp.stop !== undefined) requestBody.stop = mp.stop;
|
|
93
|
+
if (mp.responseFormat !== undefined) requestBody.response_format = mp.responseFormat;
|
|
94
|
+
if (mp.seed !== undefined) requestBody.seed = mp.seed;
|
|
95
|
+
|
|
96
|
+
if (config.tools && Array.isArray(config.tools) && config.tools.length > 0) {
|
|
97
|
+
requestBody.tools = config.tools;
|
|
98
|
+
requestBody.tool_choice = 'auto';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (reasoningEffort !== undefined) {
|
|
102
|
+
requestBody.reasoning_effort = reasoningEffort;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// modelParams 中未被硬编码处理的字段,透传到请求体
|
|
106
|
+
const consumedKeys = new Set([
|
|
107
|
+
'model', 'temperature', 'maxTokens', 'reasoningEffort',
|
|
108
|
+
'topP', 'frequencyPenalty', 'presencePenalty',
|
|
109
|
+
'stop', 'responseFormat', 'seed'
|
|
110
|
+
]);
|
|
111
|
+
for (const [key, value] of Object.entries(mp)) {
|
|
112
|
+
if (!consumedKeys.has(key) && value !== undefined) {
|
|
113
|
+
requestBody[key] = value;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return requestBody;
|
|
33
118
|
},
|
|
34
119
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
120
|
+
/**
|
|
121
|
+
* 处理URL相关配置,将其转换为完整请求URL
|
|
122
|
+
* @param {Object} config - 配置参数
|
|
123
|
+
* @returns {String} 实际请求地址
|
|
124
|
+
*/
|
|
125
|
+
_getUrl(config) {
|
|
126
|
+
config = config || {};
|
|
127
|
+
const { apiUrl, baseUrl, path } = config;
|
|
128
|
+
// 默认 path 没有 v1 因为 baseUrl 一般包括版本号
|
|
129
|
+
const defaultPath = '/chat/completions';
|
|
130
|
+
|
|
131
|
+
if (apiUrl && isString(apiUrl)) {
|
|
132
|
+
return apiUrl;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (baseUrl && isString(baseUrl)) {
|
|
136
|
+
const finalPath = (path && isString(path)) ? path : defaultPath;
|
|
137
|
+
return joinUrl(baseUrl, finalPath);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return 'https://api.openai.com/v1/chat/completions';
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 非流式发送
|
|
145
|
+
* @param {Object} requestBody
|
|
146
|
+
* @param {Object} config
|
|
147
|
+
* @param {Object} [options] — { signal, headers: {...} }
|
|
148
|
+
*/
|
|
149
|
+
async send(requestBody, config, options = {}) {
|
|
150
|
+
try {
|
|
151
|
+
config = this._resolveConfig(config);
|
|
152
|
+
const url = this._getUrl(config);
|
|
153
|
+
|
|
154
|
+
const headers = {
|
|
39
155
|
'Content-Type': 'application/json',
|
|
40
|
-
'Authorization': `Bearer ${config.apiKey}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
|
|
156
|
+
'Authorization': `Bearer ${config.apiKey}`,
|
|
157
|
+
...(config.headers || {}),
|
|
158
|
+
...(options.headers || {})
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const response = await fetch(url, {
|
|
162
|
+
method: 'POST',
|
|
163
|
+
headers,
|
|
164
|
+
body: JSON.stringify(requestBody),
|
|
165
|
+
signal: options.signal
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
if (!response.ok) {
|
|
169
|
+
await this.handleErrorResponse(response);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const data = await response.json();
|
|
173
|
+
return data;
|
|
174
|
+
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (error instanceof APIError) throw error;
|
|
177
|
+
if (error.name === 'AbortError') throw error;
|
|
178
|
+
|
|
179
|
+
if (error.name === 'TypeError' && error.message.includes('fetch')) {
|
|
180
|
+
throw new NetworkError(`Request failed: ${error.message}`, error);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
throw error;
|
|
47
184
|
}
|
|
48
|
-
const data = await response.json();
|
|
49
|
-
return data;
|
|
50
185
|
},
|
|
51
186
|
|
|
187
|
+
/**
|
|
188
|
+
* 解析 API 响应为内部消息格式
|
|
189
|
+
*/
|
|
52
190
|
parseResponse(apiResponse) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
191
|
+
// 检查响应结构是否有效
|
|
192
|
+
if (!apiResponse || !apiResponse.choices || !apiResponse.choices[0] || !apiResponse.choices[0].message) {
|
|
193
|
+
throw new ParsingError('Invalid API response: missing choices or message', apiResponse, JSON.stringify(apiResponse));
|
|
194
|
+
}
|
|
195
|
+
// 将 API 响应转换为内部消息格式
|
|
196
|
+
const msg = apiResponse.choices[0].message;
|
|
56
197
|
const internal = {
|
|
57
198
|
role: msg.role,
|
|
58
199
|
content: msg.content || '',
|
|
59
200
|
};
|
|
60
|
-
if (msg.tool_calls)
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
if (msg.reasoning_content) {
|
|
64
|
-
internal.reasoningContent = msg.reasoning_content;
|
|
65
|
-
}
|
|
201
|
+
if (msg.tool_calls) internal.toolCalls = msg.tool_calls;
|
|
202
|
+
if (msg.reasoning_content) internal.reasoningContent = msg.reasoning_content;
|
|
66
203
|
return internal;
|
|
67
204
|
},
|
|
68
205
|
|
|
69
|
-
|
|
70
|
-
|
|
206
|
+
/**
|
|
207
|
+
* 流式发送
|
|
208
|
+
* @param {Object} requestBody
|
|
209
|
+
* @param {Object} config
|
|
210
|
+
* @param {Function} onProgress — 接收累积快照 { content, reasoningContent, toolCalls }
|
|
211
|
+
* @param {Function} onDone — 接收最终消息对象
|
|
212
|
+
* @param {Object} [options] — { signal, headers: {...} }
|
|
213
|
+
*/
|
|
214
|
+
async stream(requestBody, config, onProgress, onDone, options = {}) {
|
|
215
|
+
config = this._resolveConfig(config);
|
|
71
216
|
const streamBody = { ...requestBody, stream: true };
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
217
|
+
const url = this._getUrl(config);
|
|
218
|
+
let response;
|
|
219
|
+
|
|
220
|
+
const headers = {
|
|
221
|
+
'Content-Type': 'application/json',
|
|
222
|
+
'Authorization': `Bearer ${config.apiKey}`,
|
|
223
|
+
'Accept': 'text/event-stream',
|
|
224
|
+
...(config.headers || {}),
|
|
225
|
+
...(options.headers || {})
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
response = await fetch(url, {
|
|
230
|
+
method: 'POST',
|
|
231
|
+
headers,
|
|
232
|
+
body: JSON.stringify(streamBody),
|
|
233
|
+
signal: options.signal
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
} catch (error) {
|
|
237
|
+
if (error.name === 'AbortError') throw error;
|
|
238
|
+
|
|
239
|
+
if (error.name === 'TypeError' && error.message.includes('fetch')) {
|
|
240
|
+
throw new NetworkError(`Stream request failed: ${error.message}`, error);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
throw error;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// 处理非 2xx 响应
|
|
81
247
|
if (!response.ok) {
|
|
82
|
-
|
|
83
|
-
throw new Error(`流式请求失败: ${response.status} ${error}`);
|
|
248
|
+
await this.handleErrorResponse(response);
|
|
84
249
|
}
|
|
85
250
|
|
|
251
|
+
// 解析流式响应
|
|
86
252
|
const reader = response.body.getReader();
|
|
87
253
|
const decoder = new TextDecoder();
|
|
88
254
|
let buffer = '';
|
|
89
|
-
let accumulated = { role: 'assistant', content: '' };
|
|
255
|
+
let accumulated = { role: 'assistant', content: '', reasoningContent: '' };
|
|
256
|
+
let reachedDone = false; // 是否收到 [DONE]
|
|
90
257
|
|
|
91
258
|
while (true) {
|
|
259
|
+
// 用户调用 abort() 后 signal.aborted 为 true,跳出循环
|
|
260
|
+
if (options.signal?.aborted) break;
|
|
261
|
+
|
|
92
262
|
const { done, value } = await reader.read();
|
|
93
263
|
if (done) break;
|
|
94
264
|
buffer += decoder.decode(value, { stream: true });
|
|
@@ -97,28 +267,45 @@ export const openaiAdapter = {
|
|
|
97
267
|
|
|
98
268
|
for (const line of lines) {
|
|
99
269
|
const dataLine = line.replace(/^data: /, '').trim();
|
|
100
|
-
if (!dataLine
|
|
270
|
+
if (!dataLine) continue;
|
|
271
|
+
if (dataLine === '[DONE]') { reachedDone = true; break; }
|
|
101
272
|
try {
|
|
102
273
|
const chunk = JSON.parse(dataLine);
|
|
103
274
|
const delta = chunk.choices?.[0]?.delta;
|
|
104
275
|
if (!delta) continue;
|
|
105
276
|
|
|
106
|
-
|
|
107
|
-
if (delta.tool_calls)
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
277
|
+
if (delta.content) accumulated.content += delta.content;
|
|
278
|
+
if (delta.tool_calls) {
|
|
279
|
+
if (!accumulated.toolCalls) accumulated.toolCalls = [];
|
|
280
|
+
for (const toolCallDelta of delta.tool_calls) {
|
|
281
|
+
const index = toolCallDelta.index;
|
|
282
|
+
if (!accumulated.toolCalls[index]) {
|
|
283
|
+
accumulated.toolCalls[index] = {
|
|
284
|
+
id: '',
|
|
285
|
+
type: 'function',
|
|
286
|
+
function: { name: '', arguments: '' }
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
if (toolCallDelta.id) accumulated.toolCalls[index].id = toolCallDelta.id;
|
|
290
|
+
if (toolCallDelta.type) accumulated.toolCalls[index].type = toolCallDelta.type;
|
|
291
|
+
if (toolCallDelta.function?.name) {
|
|
292
|
+
accumulated.toolCalls[index].function.name += toolCallDelta.function.name;
|
|
293
|
+
}
|
|
294
|
+
if (toolCallDelta.function?.arguments) {
|
|
295
|
+
accumulated.toolCalls[index].function.arguments += toolCallDelta.function.arguments;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (delta.reasoning_content) accumulated.reasoningContent += delta.reasoning_content;
|
|
113
300
|
|
|
301
|
+
// 传出累积快照,ChatService 自行处理占位消息更新
|
|
114
302
|
onProgress({ ...accumulated });
|
|
115
303
|
} catch (e) {
|
|
116
|
-
console.warn('
|
|
304
|
+
console.warn('stream parsing failed :', e, dataLine);
|
|
117
305
|
}
|
|
118
306
|
}
|
|
119
307
|
}
|
|
120
308
|
|
|
121
|
-
// 流结束,最终消息
|
|
122
309
|
const finalMessage = {
|
|
123
310
|
role: 'assistant',
|
|
124
311
|
content: accumulated.content,
|
|
@@ -126,5 +313,43 @@ export const openaiAdapter = {
|
|
|
126
313
|
reasoningContent: accumulated.reasoningContent
|
|
127
314
|
};
|
|
128
315
|
onDone(finalMessage);
|
|
316
|
+
},
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* 处理对话请求的非 2xx 响应,抛出 APIError,包含状态码、错误消息和原始响应文本
|
|
320
|
+
* @param {Response} response - fetch API 的 Response 对象
|
|
321
|
+
* @returns {Promise<never>} 抛出错误
|
|
322
|
+
*/
|
|
323
|
+
async handleErrorResponse(response) {
|
|
324
|
+
let errorText = await response.text();
|
|
325
|
+
let errorMessage = `HTTP ${response.status}`;
|
|
326
|
+
// 尝试从响应体中解析 JSON 格式的错误信息
|
|
327
|
+
try {
|
|
328
|
+
const errorJson = JSON.parse(errorText);
|
|
329
|
+
errorMessage = errorJson.error?.message || errorJson.message || errorText.slice(0, 200);
|
|
330
|
+
} catch (e) {
|
|
331
|
+
// 如果解析失败,使用原始文本(已截断)
|
|
332
|
+
errorMessage = errorText.length > 200 ? errorText.slice(0, 200) + '...' : errorText;
|
|
333
|
+
}
|
|
334
|
+
throw new APIError(errorMessage, response.status, null, errorText);
|
|
129
335
|
}
|
|
336
|
+
|
|
130
337
|
};
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* 创建 OpenAI 兼容适配器实例(工厂)。
|
|
341
|
+
*
|
|
342
|
+
* 与单例 openaiAdapter 的区别:每个实例自持一份运输配置(apiKey/baseUrl/apiUrl/path/headers),
|
|
343
|
+
* 互不干扰——解决"同一适配器装到多个 ChatService 互相覆盖"的单例陷阱。
|
|
344
|
+
*
|
|
345
|
+
* 用法:
|
|
346
|
+
* const adapter = createOpenAIAdapter({ apiKey, baseUrl, modelParams: { temperature: 0.8 } });
|
|
347
|
+
* const chat = new ChatService({ adapter, model: 'deepseek-chat' });
|
|
348
|
+
* chat.use(adapter); // 或直接 new ChatService({ adapter })
|
|
349
|
+
*
|
|
350
|
+
* 非运输键(model/modelParams/messageFormat/resolveImage/capabilities 等)会作为
|
|
351
|
+
* "请求默认参数"供会话层合并,单次请求仍可覆盖。
|
|
352
|
+
*/
|
|
353
|
+
export function createOpenAIAdapter(options = {}) {
|
|
354
|
+
return { ...openaiAdapter, _transport: { ...options } };
|
|
355
|
+
}
|