my-ai-chat-framework 2.7.0 → 4.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.
@@ -5,32 +5,68 @@
5
5
 
6
6
  import { joinUrl } from "../utils/url.js";
7
7
  import { isString } from "../utils/typeCheck.js";
8
- import { APIError, NetworkError, ParsingError } from '../core/Errors.js';
8
+ import { APIError, NetworkError, ParsingError, ConfigurationError } from '../core/Errors.js';
9
9
  import { MessageFormatter } from '../utils/MessageFormatter.js';
10
10
 
11
+ // 属于"运输层"的配置键:工厂实例持有,请求时覆盖 config 中同名项
12
+ const TRANSPORT_KEYS = ['apiKey', 'baseUrl', 'apiUrl', 'path', 'headers'];
13
+
11
14
  export const openaiAdapter = {
12
15
  name: 'openai',
13
16
 
14
- install(chatService) {
17
+ /**
18
+ * 安装适配器。
19
+ * - 单例(openaiAdapter):options 无意义,配置从 chat.config 读取(平铺兼容路径)
20
+ * - 工厂实例(createOpenAIAdapter):options 并入实例自持的运输配置
21
+ */
22
+ install(chatService, options = {}) {
23
+ if (this._transport) this._transport = { ...this._transport, ...options };
15
24
  chatService.setAdapter(this);
16
25
  },
17
26
 
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
+
18
51
  /**
19
52
  * 构建 OpenAI 格式的请求体
20
53
  * @param {Array} messages - 内部消息列表
21
- * @param {Object} config - 完整配置对象
54
+ * @param {Object} config - 合并后的请求参数(含 model/temperature 等)
22
55
  * @param {Array} [systemPrompts=[]] - 启用的 system prompt 列表
23
56
  * @returns {Object} 请求体
24
57
  */
25
58
  buildRequest(messages, config, systemPrompts = []) {
59
+ config = this._resolveConfig(config);
26
60
  const model = config.model || config.modelParams?.model;
27
61
  if (!model) {
28
- throw new Error('Missing required config: model (either at top level or in modelParams)');
62
+ throw new ConfigurationError('Missing required config: model (either at top level or in modelParams)');
29
63
  }
30
64
 
31
- const temperature = config.modelParams?.temperature ?? config.temperature ?? 0.7;
32
- const maxTokens = config.modelParams?.maxTokens ?? config.maxTokens ?? 2000;
33
- const reasoningEffort = config.modelParams?.reasoningEffort ?? config.reasoningEffort;
65
+ const mp = config.modelParams || {};
66
+ // v4.0:**不再提供默认值** —— 没传就不放进请求体,由 API 用自己的默认
67
+ const temperature = mp.temperature ?? config.temperature;
68
+ const maxTokens = mp.maxTokens ?? config.maxTokens;
69
+ const reasoningEffort = mp.reasoningEffort ?? config.reasoningEffort;
34
70
 
35
71
  // 用 MessageFormatter 统一处理:system 置顶、消息过滤、字段映射、图片解析
36
72
  // format 由 config.messageFormat 指定(默认 'openai')
@@ -45,20 +81,42 @@ export const openaiAdapter = {
45
81
  const requestBody = {
46
82
  model,
47
83
  messages: apiMessages,
48
- temperature,
49
- max_tokens: maxTokens,
50
84
  stream: false
51
85
  };
52
86
 
87
+ // v4.0:可选参数只在"显式传了"时才放进请求体(不替使用者决定要什么默认值)
88
+ if (temperature !== undefined) requestBody.temperature = temperature;
89
+ if (maxTokens !== undefined) requestBody.max_tokens = maxTokens;
90
+
91
+ // 硬编码常用参数:驼峰命名,适配器负责转 API 格式
92
+ if (mp.topP !== undefined) requestBody.top_p = mp.topP;
93
+ if (mp.frequencyPenalty !== undefined) requestBody.frequency_penalty = mp.frequencyPenalty;
94
+ if (mp.presencePenalty !== undefined) requestBody.presence_penalty = mp.presencePenalty;
95
+ if (mp.stop !== undefined) requestBody.stop = mp.stop;
96
+ if (mp.responseFormat !== undefined) requestBody.response_format = mp.responseFormat;
97
+ if (mp.seed !== undefined) requestBody.seed = mp.seed;
98
+
53
99
  if (config.tools && Array.isArray(config.tools) && config.tools.length > 0) {
54
100
  requestBody.tools = config.tools;
55
101
  requestBody.tool_choice = 'auto';
56
102
  }
57
103
 
58
- if (reasoningEffort && model === 'deepseek-reasoner') {
104
+ if (reasoningEffort !== undefined) {
59
105
  requestBody.reasoning_effort = reasoningEffort;
60
106
  }
61
107
 
108
+ // modelParams 中未被硬编码处理的字段,透传到请求体
109
+ const consumedKeys = new Set([
110
+ 'model', 'temperature', 'maxTokens', 'reasoningEffort',
111
+ 'topP', 'frequencyPenalty', 'presencePenalty',
112
+ 'stop', 'responseFormat', 'seed'
113
+ ]);
114
+ for (const [key, value] of Object.entries(mp)) {
115
+ if (!consumedKeys.has(key) && value !== undefined) {
116
+ requestBody[key] = value;
117
+ }
118
+ }
119
+
62
120
  return requestBody;
63
121
  },
64
122
 
@@ -93,6 +151,7 @@ export const openaiAdapter = {
93
151
  */
94
152
  async send(requestBody, config, options = {}) {
95
153
  try {
154
+ config = this._resolveConfig(config);
96
155
  const url = this._getUrl(config);
97
156
 
98
157
  const headers = {
@@ -156,6 +215,7 @@ export const openaiAdapter = {
156
215
  * @param {Object} [options] — { signal, headers: {...} }
157
216
  */
158
217
  async stream(requestBody, config, onProgress, onDone, options = {}) {
218
+ config = this._resolveConfig(config);
159
219
  const streamBody = { ...requestBody, stream: true };
160
220
  const url = this._getUrl(config);
161
221
  let response;
@@ -205,20 +265,37 @@ export const openaiAdapter = {
205
265
  const { done, value } = await reader.read();
206
266
  if (done) break;
207
267
  buffer += decoder.decode(value, { stream: true });
208
- const lines = buffer.split(/\n\n/);
209
- buffer = lines.pop();
210
268
 
211
- for (const line of lines) {
212
- const dataLine = line.replace(/^data: /, '').trim();
213
- if (!dataLine) continue;
214
- if (dataLine === '[DONE]') { reachedDone = true; break; }
269
+ // ===== v4.0:稳健的 SSE 事件解析 =====
270
+ // 支持:\r\n 换行、`data:`(无空格)、多行 data(按 SSE 规范用 \n 连接)、注释行
271
+ let sepMatch;
272
+ const EVENT_SEP = /\r?\n\r?\n/;
273
+ while ((sepMatch = EVENT_SEP.exec(buffer)) !== null) {
274
+ const rawEvent = buffer.slice(0, sepMatch.index);
275
+ buffer = buffer.slice(sepMatch.index + sepMatch[0].length);
276
+
277
+ const dataPayload = rawEvent
278
+ .split(/\r?\n/)
279
+ .filter(l => l.startsWith('data:'))
280
+ .map(l => l.slice(5).replace(/^ /, ''))
281
+ .join('\n');
282
+ if (!dataPayload) continue;
283
+ if (dataPayload.trim() === '[DONE]') { reachedDone = true; break; }
284
+
215
285
  try {
216
- const chunk = JSON.parse(dataLine);
286
+ const chunk = JSON.parse(dataPayload);
217
287
  const delta = chunk.choices?.[0]?.delta;
218
288
  if (!delta) continue;
219
289
 
220
- if (delta.content) accumulated.content += delta.content;
290
+ // v4.0:本次 chunk 的"增量"(onProgress 第二个参数)
291
+ const deltaInfo = { content: '', reasoningContent: '', toolCalls: null };
292
+
293
+ if (delta.content) {
294
+ accumulated.content += delta.content;
295
+ deltaInfo.content = delta.content;
296
+ }
221
297
  if (delta.tool_calls) {
298
+ const newCalls = [];
222
299
  if (!accumulated.toolCalls) accumulated.toolCalls = [];
223
300
  for (const toolCallDelta of delta.tool_calls) {
224
301
  const index = toolCallDelta.index;
@@ -237,16 +314,22 @@ export const openaiAdapter = {
237
314
  if (toolCallDelta.function?.arguments) {
238
315
  accumulated.toolCalls[index].function.arguments += toolCallDelta.function.arguments;
239
316
  }
317
+ newCalls.push({ index, delta: toolCallDelta });
240
318
  }
319
+ deltaInfo.toolCalls = newCalls;
320
+ }
321
+ if (delta.reasoning_content) {
322
+ accumulated.reasoningContent += delta.reasoning_content;
323
+ deltaInfo.reasoningContent = delta.reasoning_content;
241
324
  }
242
- if (delta.reasoning_content) accumulated.reasoningContent += delta.reasoning_content;
243
325
 
244
- // 传出累积快照,ChatService 自行处理占位消息更新
245
- onProgress({ ...accumulated });
326
+ // onProgress(snap, delta):snap = 全量累积快照;delta = 本次新增片段
327
+ onProgress({ ...accumulated }, deltaInfo);
246
328
  } catch (e) {
247
- console.warn('stream parsing failed :', e, dataLine);
329
+ console.warn('stream parsing failed :', e, dataPayload);
248
330
  }
249
331
  }
332
+ if (reachedDone) break;
250
333
  }
251
334
 
252
335
  const finalMessage = {
@@ -277,4 +360,22 @@ export const openaiAdapter = {
277
360
  throw new APIError(errorMessage, response.status, null, errorText);
278
361
  }
279
362
 
280
- };
363
+ };
364
+
365
+ /**
366
+ * 创建 OpenAI 兼容适配器实例(工厂)。
367
+ *
368
+ * 与单例 openaiAdapter 的区别:每个实例自持一份运输配置(apiKey/baseUrl/apiUrl/path/headers),
369
+ * 互不干扰——解决"同一适配器装到多个 ChatService 互相覆盖"的单例陷阱。
370
+ *
371
+ * 用法:
372
+ * const adapter = createOpenAIAdapter({ apiKey, baseUrl, modelParams: { temperature: 0.8 } });
373
+ * const chat = new ChatService({ adapter, model: 'deepseek-chat' });
374
+ * chat.use(adapter); // 或直接 new ChatService({ adapter })
375
+ *
376
+ * 非运输键(model/modelParams/messageFormat/resolveImage/capabilities 等)会作为
377
+ * "请求默认参数"供会话层合并,单次请求仍可覆盖。
378
+ */
379
+ export function createOpenAIAdapter(options = {}) {
380
+ return { ...openaiAdapter, _transport: { ...options } };
381
+ }