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.
@@ -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
- install(chatService) {
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
- buildRequest(messages, config) {
14
- // 将内部消息格式转为 OpenAI 格式
15
- const apiMessages = messages.map(msg => {
16
- const base = { role: msg.role, content: msg.content };
17
- if (msg.role === 'assistant' && msg.toolCalls) {
18
- base.tool_calls = msg.toolCalls;
19
- }
20
- if (msg.role === 'tool' && msg.toolCallId) {
21
- base.tool_call_id = msg.toolCallId;
22
- }
23
- return base;
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
- return {
27
- model: config.model,
80
+ const requestBody = {
81
+ model,
28
82
  messages: apiMessages,
29
- temperature: config.temperature ?? 0.7,
30
- max_tokens: config.maxTokens ?? 2000,
31
- stream: false // 流式请求会在 stream 方法中单独处理
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
- async send(requestBody, config) {
36
- const response = await fetch(config.apiUrl || 'https://api.openai.com/v1/chat/completions', {
37
- method: 'POST',
38
- headers: {
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
- body: JSON.stringify(requestBody)
43
- });
44
- if (!response.ok) {
45
- const error = await response.text();
46
- throw new Error(`API 请求失败: ${response.status} ${error}`);
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
- const choice = apiResponse.choices[0];
54
- const msg = choice.message;
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
- internal.toolCalls = msg.tool_calls;
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
- async stream(requestBody, config, onProgress, onDone) {
70
- // 流式请求需要设置 stream: true
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 response = await fetch(config.apiUrl || 'https://api.openai.com/v1/chat/completions', {
73
- method: 'POST',
74
- headers: {
75
- 'Content-Type': 'application/json',
76
- 'Authorization': `Bearer ${config.apiKey}`,
77
- 'Accept': 'text/event-stream'
78
- },
79
- body: JSON.stringify(streamBody)
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
- const error = await response.text();
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 || dataLine === '[DONE]') continue;
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
- const update = { role: delta.role || 'assistant', content: delta.content || '' };
107
- if (delta.tool_calls) update.toolCalls = delta.tool_calls;
108
- if (delta.reasoning_content) update.reasoningContent = delta.reasoning_content;
109
-
110
- if (update.content) accumulated.content += update.content;
111
- if (update.toolCalls) accumulated.toolCalls = update.toolCalls;
112
- if (update.reasoningContent) accumulated.reasoningContent = update.reasoningContent;
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('解析流式数据块失败:', e, dataLine);
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
+ }