my-ai-chat-framework 3.0.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.
@@ -13,6 +13,8 @@
13
13
  * }
14
14
  */
15
15
 
16
+ import { ValidationError } from './Errors.js';
17
+
16
18
  export class SystemPromptStore {
17
19
  constructor() {
18
20
  this._prompts = [];
@@ -27,7 +29,7 @@ export class SystemPromptStore {
27
29
  */
28
30
  add(content, enabled = true) {
29
31
  if (!content || typeof content !== 'string' || !content.trim()) {
30
- throw new Error('[SystemPromptStore] content 必须是非空字符串');
32
+ throw new ValidationError('[SystemPromptStore] content 必须是非空字符串');
31
33
  }
32
34
  const record = {
33
35
  id: `sys_${Date.now()}_${++this._idCounter}`,
@@ -46,7 +48,7 @@ export class SystemPromptStore {
46
48
  */
47
49
  remove(index) {
48
50
  if (index < 0 || index >= this._prompts.length) {
49
- throw new Error(`[SystemPromptStore] 索引越界: ${index}`);
51
+ throw new ValidationError(`[SystemPromptStore] 索引越界: ${index}`);
50
52
  }
51
53
  return this._prompts.splice(index, 1)[0];
52
54
  }
@@ -58,7 +60,7 @@ export class SystemPromptStore {
58
60
  */
59
61
  toggle(index) {
60
62
  if (index < 0 || index >= this._prompts.length) {
61
- throw new Error(`[SystemPromptStore] 索引越界: ${index}`);
63
+ throw new ValidationError(`[SystemPromptStore] 索引越界: ${index}`);
62
64
  }
63
65
  this._prompts[index].enabled = !this._prompts[index].enabled;
64
66
  return this._prompts[index].enabled;
@@ -71,10 +73,10 @@ export class SystemPromptStore {
71
73
  */
72
74
  update(index, content) {
73
75
  if (index < 0 || index >= this._prompts.length) {
74
- throw new Error(`[SystemPromptStore] 索引越界: ${index}`);
76
+ throw new ValidationError(`[SystemPromptStore] 索引越界: ${index}`);
75
77
  }
76
78
  if (!content || typeof content !== 'string' || !content.trim()) {
77
- throw new Error('[SystemPromptStore] content 必须是非空字符串');
79
+ throw new ValidationError('[SystemPromptStore] content 必须是非空字符串');
78
80
  }
79
81
  this._prompts[index].content = content.trim();
80
82
  this._prompts[index].timestamp = Date.now();
package/src/index.js CHANGED
@@ -5,9 +5,10 @@ export { EventEmitter } from './core/EventEmitter.js';
5
5
  export { openaiAdapter, createOpenAIAdapter } from './adapters/openai.js';
6
6
  export { toolCallingPlugin, createToolCallingPlugin } from './plugins/tool-calling.js';
7
7
  export { modelRegistryPlugin, createModelRegistryPlugin } from './plugins/model-registry.js';
8
+ export { continuationPlugin, createContinuationPlugin } from './plugins/continuation.js';
8
9
  export { MessageFormatter } from './utils/MessageFormatter.js';
9
10
  export { assembleMessages } from './utils/MessageFormatter.js';
10
11
 
11
- export { APIError, NetworkError, ConfigurationError, ParsingError } from './core/Errors.js';
12
+ export { APIError, NetworkError, ConfigurationError, ValidationError, ParsingError } from './core/Errors.js';
12
13
  export { assertAdapter } from './core/ChatService.js';
13
14
  export { Pipeline } from './core/Pipeline.js';
@@ -0,0 +1,156 @@
1
+ /**
2
+ * continuation 插件(v4.0)
3
+ *
4
+ * 「续写 / 临时消息」这套玩法住在这里 —— 核心不认识它,装上才有:
5
+ * 1. 续写检测(替换核心的 autoContinue 步骤):底部若有带 prefix 标记的消息,就把结果合并回它
6
+ * 2. ephemeral 过滤器:临时消息只有"位于底部连续段"时才发给 API
7
+ * 3. continueLast / continueLastStream 两个 API
8
+ * 4. uninstall():把自己装的东西全部还原(步骤、过滤器、方法)
9
+ *
10
+ * 用法:
11
+ * import { createContinuationPlugin } from 'my-ai-chat-framework';
12
+ * chat.use(createContinuationPlugin({ autoContinue: true }));
13
+ *
14
+ * // 模拟思维链:注入一条临时引导,结果合并回上一条 assistant,不留痕
15
+ * chat.messages.addOnceAssistant('(内心:他肯定又要熬夜)');
16
+ * await chat.stream('在吗');
17
+ */
18
+
19
+ import { MessageFormatter } from '../utils/MessageFormatter.js';
20
+ import { ValidationError } from '../core/Errors.js';
21
+
22
+ /**
23
+ * ephemeral 规则:只有底部连续的临时消息保留,其余丢弃(与 v3.x 核心内置行为一致)。
24
+ * 导出以便使用者直接注册到 MessageFormatter(不使用插件时也能用这条规则)。
25
+ */
26
+ export function ephemeralFilterRule(msg, { index, messages }) {
27
+ if (!msg._ephemeral) return msg;
28
+ let start = messages.length;
29
+ for (let i = messages.length - 1; i >= 0; i--) {
30
+ if (messages[i]._ephemeral) start = i;
31
+ else break;
32
+ }
33
+ return index >= start ? msg : null;
34
+ }
35
+
36
+ /**
37
+ * 找续写目标:最后一条必须是 assistant 或临时消息(否则抛错)。
38
+ * @param {object} chat ChatService 实例
39
+ */
40
+ export function prepareContinue(chat) {
41
+ const msgs = chat.messages.getAll();
42
+ const last = msgs[msgs.length - 1];
43
+ if (last && (last.role === 'assistant' || last._ephemeral)) return last;
44
+ throw new ValidationError('最后一条消息不是 assistant,无法续写');
45
+ }
46
+
47
+ /** 续写结束后的"转正" */
48
+ function finalize(chat, targetId) {
49
+ chat.messages.update(targetId, { _complete: true, prefix: undefined, _ephemeral: false });
50
+ }
51
+
52
+ /**
53
+ * 创建续写插件实例(工厂)
54
+ * @param {Object} [options] — { autoContinue?: boolean } 是否自动识别底部 prefix 消息并合并(默认 false)
55
+ */
56
+ export function createContinuationPlugin(options = {}) {
57
+ return {
58
+ name: 'continuation',
59
+ _options: { autoContinue: false, ...options },
60
+ _prevEphemeralFilter: undefined,
61
+ _installedOn: null,
62
+
63
+ /**
64
+ * 安装:替换续写步骤 + 接管 ephemeral 过滤 + 挂续写 API
65
+ * @param {import('../core/ChatService.js').ChatService} chat
66
+ */
67
+ install(chat) {
68
+ this._installedOn = chat;
69
+
70
+ // 0) 登记可运行时修改的配置字段(v4.0:插件自带配置,不用改核心白名单)
71
+ if (typeof chat.registerConfigKeys === 'function') {
72
+ chat.registerConfigKeys({ autoContinue: (v) => typeof v === 'boolean' });
73
+ }
74
+
75
+ // 1) 续写检测(用 v3.1 的出口替换核心的 autoContinue 步骤)
76
+ chat.replaceStage('autoContinue', (ctx) => {
77
+ // 运行时 `chat.updateConfig({ autoContinue })` 优先,其次取安装时的工厂 options
78
+ const enabled = chat.config.autoContinue ?? this._options.autoContinue;
79
+ if (!enabled) return; // 默认关:不替用户做选择
80
+ if (ctx.mergeToEntry) return;
81
+ const last = ctx.messages.getLast();
82
+ if (last && last.prefix && (last.role === 'assistant' || last._ephemeral)) {
83
+ ctx.mergeToEntry = last.id;
84
+ }
85
+ });
86
+
87
+ // 2) 接管 ephemeral 过滤(同名覆盖;保存旧函数,卸载时还原)
88
+ this._prevEphemeralFilter = MessageFormatter.registerFilter('ephemeral', ephemeralFilterRule, { priority: 100 });
89
+
90
+ // 3) 续写 API(通过 chat.plugins.continuation 暴露,v4.0 命名空间)
91
+ const continueLast = async () => {
92
+ const target = prepareContinue(chat);
93
+ chat.messages.update(target.id, { prefix: true });
94
+ try {
95
+ await chat.sendExisting(undefined, { mergeToEntry: target.id });
96
+ finalize(chat, target.id);
97
+ return target;
98
+ } catch (err) {
99
+ finalize(chat, target.id);
100
+ if (err.name === 'AbortError') {
101
+ chat.emit('aborted', { timestamp: Date.now() });
102
+ return target;
103
+ }
104
+ throw err;
105
+ }
106
+ };
107
+
108
+ const continueLastStream = async (onProgress, onDone) => {
109
+ const target = prepareContinue(chat);
110
+ chat.messages.update(target.id, { prefix: true });
111
+ const baseLen = (target.content || '').length;
112
+ try {
113
+ await chat.sendExistingStream(
114
+ undefined,
115
+ { mergeToEntry: target.id },
116
+ (chunk) => { if (onProgress) onProgress({ ...chunk, content: (chunk.content || '').slice(baseLen) }); },
117
+ (final) => { if (onDone) onDone(final); }
118
+ );
119
+ finalize(chat, target.id);
120
+ return target;
121
+ } catch (err) {
122
+ finalize(chat, target.id);
123
+ if (err.name === 'AbortError') {
124
+ chat.emit('aborted', { timestamp: Date.now() });
125
+ return target;
126
+ }
127
+ throw err;
128
+ }
129
+ };
130
+
131
+ // 交给 use() 挂到 chat.plugins['continuation']
132
+ return { continueLast, continueLastStream, uninstall: () => this.uninstall(chat) };
133
+ },
134
+
135
+ /**
136
+ * 卸载:恢复被替换的步骤、还原过滤器、移除挂载的方法
137
+ * @param {import('../core/ChatService.js').ChatService} [chat]
138
+ */
139
+ uninstall(chat = this._installedOn) {
140
+ if (!chat) return chat;
141
+ if (typeof chat.restoreStage === 'function') chat.restoreStage('autoContinue');
142
+ if (this._prevEphemeralFilter) {
143
+ MessageFormatter.registerFilter('ephemeral', this._prevEphemeralFilter, { priority: 100 });
144
+ } else {
145
+ MessageFormatter.unregisterFilter('ephemeral');
146
+ }
147
+ if (chat.plugins && chat.plugins['continuation']) delete chat.plugins['continuation'];
148
+ if (typeof chat.unregisterConfigKeys === 'function') chat.unregisterConfigKeys(['autoContinue']);
149
+ this._installedOn = null;
150
+ return chat;
151
+ }
152
+ };
153
+ }
154
+
155
+ /** 兼容用单例(多实例场景请用工厂) */
156
+ export const continuationPlugin = createContinuationPlugin();
@@ -23,6 +23,8 @@
23
23
  * chat.registerModel('my-custom-model', { toolCalling: true });
24
24
  */
25
25
 
26
+ import { ValidationError } from '../core/Errors.js';
27
+
26
28
  // ====== 内置模型能力表 ======
27
29
  // 数据来源:各厂商官方文档(2026-05 快照),用户可通过 registerModel() 覆盖
28
30
  const BUILTIN_MODELS = {
@@ -149,9 +151,9 @@ export function createModelRegistryPlugin(options = {}) {
149
151
  * @param {Object} capabilities — 能力标签对象(部分字段即可,未提供的取默认值)
150
152
  * @returns {ChatService}
151
153
  */
152
- chatService.registerModel = (name, capabilities = {}) => {
154
+ const registerModel = (name, capabilities = {}) => {
153
155
  if (!name || typeof name !== 'string' || !name.trim()) {
154
- throw new Error('[model-registry] 模型名称必须是非空字符串');
156
+ throw new ValidationError('[model-registry] 模型名称必须是非空字符串');
155
157
  }
156
158
  const merged = { ...DEFAULT_CAPABILITIES, ...capabilities };
157
159
  this._registry.set(name.trim(), merged);
@@ -166,7 +168,7 @@ export function createModelRegistryPlugin(options = {}) {
166
168
  * 列出所有已注册的模型名称
167
169
  * @returns {Array<string>}
168
170
  */
169
- chatService.listModels = () => {
171
+ const listModels = () => {
170
172
  return [...this._registry.keys()];
171
173
  };
172
174
 
@@ -198,6 +200,17 @@ export function createModelRegistryPlugin(options = {}) {
198
200
  }
199
201
  }
200
202
  });
203
+
204
+ // 交给 use() 挂到 chat.plugins['model-registry'](v4.0 命名空间)
205
+ return { registerModel, listModels, uninstall: (c) => this.uninstall(c) };
206
+ },
207
+
208
+ /** 卸载:移除能力同步车间 + 事件监听 + 命名空间条目 */
209
+ uninstall(chat = this.chat) {
210
+ if (!chat) return chat;
211
+ chat.unpipe?.('model-registry-caps');
212
+ if (chat.plugins && chat.plugins['model-registry']) delete chat.plugins['model-registry'];
213
+ return chat;
201
214
  },
202
215
 
203
216
  // ---- 内部方法 ----
@@ -18,12 +18,15 @@ export const toolCallingPlugin = createToolCallingPlugin();
18
18
 
19
19
  /**
20
20
  * 创建工具调用插件实例(工厂)。
21
- * @param {Object} [options] — { timeout, maxIterations }
21
+ * @param {Object} [options] — { timeout?, maxIterations? }
22
+ * - 两者**默认都不限制**:不传就是"循环到没有工具调用为止 / 不超时"
23
+ * - 想限制才传(这是使用者的选择,不是框架替你做的决定)
22
24
  */
23
25
  export function createToolCallingPlugin(options = {}) {
24
26
  return {
25
27
  name: 'tool-calling',
26
- maxIterations: options.maxIterations || 5, // 最大工具调用循环次数,防止无限循环
28
+ // 默认不限制循环轮数(旧版硬编码 5 轮,属于"框架替用户做决定",v4.0 移除)
29
+ maxIterations: options.maxIterations ?? Infinity,
27
30
  _options: { ...options },
28
31
  _tools: new Map(), // 工具名 -> { executor, description }
29
32
  _toolDefs: [], // OpenAI 格式的工具定义(按注册顺序)
@@ -55,21 +58,26 @@ export function createToolCallingPlugin(options = {}) {
55
58
  * @param {Object} parameters - JSON Schema 参数定义(可选,默认为空对象)
56
59
  * @returns {ChatService} 返回 chatService 实例,支持链式调用
57
60
  */
58
- chatService.registerTool = (name, description, executor, parameters = {}) => {
61
+ const registerTool = (name, description, executor, parameters = {}) => {
59
62
  // 保存执行器
60
63
  this._tools.set(name, { executor, description });
61
64
 
62
65
  // 构建 OpenAI 兼容的工具定义
66
+ // 参数写法:{ 参数名: { type, description, required? } }
67
+ // → 必填项收集到顶层 required 数组;properties 里不残留 required 字段(标准 JSON Schema 要求)
68
+ const properties = {};
69
+ const requiredKeys = [];
70
+ for (const [key, def] of Object.entries(parameters || {})) {
71
+ const { required: isRequired, ...schema } = def || {};
72
+ properties[key] = schema;
73
+ if (isRequired) requiredKeys.push(key);
74
+ }
63
75
  const toolDefinition = {
64
76
  type: 'function',
65
77
  function: {
66
78
  name,
67
79
  description,
68
- parameters: {
69
- type: 'object',
70
- properties: parameters,
71
- required: Object.keys(parameters).filter(key => parameters[key]?.required)
72
- }
80
+ parameters: { type: 'object', properties, required: requiredKeys }
73
81
  }
74
82
  };
75
83
  // 避免重复添加相同工具
@@ -93,6 +101,18 @@ export function createToolCallingPlugin(options = {}) {
93
101
  );
94
102
  }
95
103
  });
104
+
105
+ // 交给 use() 挂到 chat.plugins['tool-calling'](v4.0 命名空间)
106
+ return { registerTool, tools: this._tools, uninstall: (c) => this.uninstall(c) };
107
+ },
108
+
109
+ /** 卸载:移除注入车间(工具定义注入 + 工具循环) */
110
+ uninstall(chat) {
111
+ if (!chat) return chat;
112
+ chat.unpipe?.('tool-calling-inject');
113
+ chat.unpipe?.('tool-calling-loop');
114
+ if (chat.plugins && chat.plugins['tool-calling']) delete chat.plugins['tool-calling'];
115
+ return chat;
96
116
  },
97
117
 
98
118
  /**
@@ -162,19 +182,27 @@ export function createToolCallingPlugin(options = {}) {
162
182
  }
163
183
 
164
184
  try {
165
- // 工具执行超时控制(工厂 options.timeout 优先,兼容旧 config.toolTimeout)
166
- const toolTimeout = self._options.timeout ?? chat.config.toolTimeout;
167
- let execPromise = tool.executor(args);
168
- if (toolTimeout && typeof toolTimeout === 'number' && toolTimeout > 0) {
185
+ // 工具执行超时:只在工厂 options.timeout 显式传入时生效(不传 = 不限制)
186
+ const toolTimeout = self._options.timeout;
187
+ let result;
188
+ if (typeof toolTimeout === 'number' && toolTimeout > 0) {
189
+ const controller = new AbortController(); // 通知支持 signal 的工具去清理
169
190
  const timeoutErr = new Error(`工具 ${toolName} 执行超时 (${toolTimeout}ms)`);
170
191
  timeoutErr.name = 'ToolTimeoutError';
171
- const timeoutPromise = new Promise((_, reject) =>
172
- setTimeout(() => reject(timeoutErr), toolTimeout)
173
- );
174
- execPromise = Promise.race([execPromise, timeoutPromise]);
192
+ let timer;
193
+ const timeoutPromise = new Promise((_, reject) => {
194
+ timer = setTimeout(() => { controller.abort(); reject(timeoutErr); }, toolTimeout);
195
+ });
196
+ const execPromise = Promise.resolve().then(() => tool.executor(args, { signal: controller.signal }));
197
+ execPromise.catch(() => {}); // 超时后工具的后续结果不再冒泡为 unhandled
198
+ try {
199
+ result = await Promise.race([execPromise, timeoutPromise]);
200
+ } finally {
201
+ clearTimeout(timer); // 工具先完成时不留悬挂的 timer(旧版泄漏点)
202
+ }
203
+ } else {
204
+ result = await tool.executor(args);
175
205
  }
176
-
177
- const result = await execPromise;
178
206
  chat.emit('tool-success', {
179
207
  toolName,
180
208
  result,
@@ -1,29 +1,27 @@
1
1
  /**
2
- * MessageFormatter — 可注册的消息格式转换器
2
+ * MessageFormatter — 可注册的消息格式转换器 + 可注册的过滤器链(v4.0)
3
3
  *
4
4
  * 职责:
5
- * 1. 内置 OpenA I兼容格式的转换逻辑
5
+ * 1. 内置 OpenAI 兼容格式的转换逻辑
6
6
  * 2. 支持 register(name, fn) 注册自定义格式(如 Anthropic、Gemini 等)
7
- * 3. 统一处理 capabilities 过滤(reasoning、vision 等)
8
- * 4. 所有适配器通过此工具获取 API 消息数组,消除重复代码
7
+ * 3. v4.0:消息"保留 / 丢弃"规则改为可注册的过滤器链(registerFilter)
8
+ * —— 原来硬编码在格式里的 ephemeral / system / 空内容规则,现在都是过滤器;
9
+ * 业务插件(如 continuation)可以同名覆盖内置过滤器,接管规则。
9
10
  *
10
11
  * 用法:
11
12
  * import { MessageFormatter } from 'my-ai-chat-framework';
12
13
  *
13
14
  * // 使用内置格式
14
- * const msgs = MessageFormatter.format({
15
- * messages, systemPrompts, capabilities, resolveImage
16
- * }); // 默认 'openai'
15
+ * const msgs = MessageFormatter.format({ messages, systemPrompts, capabilities, resolveImage });
17
16
  *
18
- * // 注册自定义格式
19
- * MessageFormatter.register('anthropic', ({ messages, systemPrompts, capabilities }) => {
20
- * // 返回 Anthropic 格式的消息数组
21
- * });
17
+ * // 注册自己的过滤器(决定哪些消息进入请求)
18
+ * MessageFormatter.registerFilter('my-rule', (msg, ctx) => msg.role === 'system' ? null : msg);
22
19
  */
23
20
 
24
21
  import { isString } from './typeCheck.js';
22
+ import { ValidationError } from '../core/Errors.js';
25
23
 
26
- // ====== 内置格式 ======
24
+ // ====== 内置工具 ======
27
25
 
28
26
  const IS_URL = /^https?:\/\//i;
29
27
 
@@ -47,38 +45,59 @@ function buildMultimodalContent(textContent, images, resolveImage) {
47
45
  return content;
48
46
  }
49
47
 
50
- /** OpenAI 兼容格式 */
48
+ // ====== 内置过滤器(通用清理规则,无业务含义)======
49
+
50
+ /**
51
+ * ephemeral 规则:临时消息只有"位于底部连续段"时才保留,其余丢弃。
52
+ * (v3.x 时硬编码在 toOpenAI 里;v4.0 起是过滤器,插件可同名覆盖)
53
+ */
54
+ function filterEphemeral(msg, { index, messages }) {
55
+ if (!msg._ephemeral) return msg;
56
+ let ephemStart = messages.length;
57
+ for (let i = messages.length - 1; i >= 0; i--) {
58
+ if (messages[i]._ephemeral) ephemStart = i;
59
+ else break;
60
+ }
61
+ return index >= ephemStart ? msg : null;
62
+ }
63
+
64
+ /** system 角色消息不进对话数组(system 由 systemPrompts 统一置顶) */
65
+ function filterSystemDrop(msg) {
66
+ if (msg.role === 'system' && !msg._ephemeral) return null;
67
+ return msg;
68
+ }
69
+
70
+ /** 空内容清理:无文本、无图片的消息丢弃(tool、带 toolCalls 的 assistant、prefix 空消息例外) */
71
+ function filterEmptyDrop(msg) {
72
+ if (msg.role === 'system' || msg.role === 'tool') return msg;
73
+ if (msg.role === 'assistant' && msg.toolCalls?.length) return msg;
74
+ const hasText = msg.content && isString(msg.content) && msg.content.trim();
75
+ const hasImages = Array.isArray(msg.images) && msg.images.length > 0;
76
+ if (!hasText && !hasImages) {
77
+ if (msg.role === 'assistant' && msg.prefix) return msg;
78
+ return null;
79
+ }
80
+ return msg;
81
+ }
82
+
83
+ // ====== 内置格式:OpenAI 兼容 ======
84
+
51
85
  function toOpenAI({ messages, systemPrompts, capabilities, resolveImage }) {
52
86
  const rImg = typeof resolveImage === 'function' ? resolveImage : defaultResolveImage;
53
87
  const result = [];
54
88
 
55
- // 1) system 置顶
89
+ // 1) system 置顶(来自 SystemPromptStore)
56
90
  for (const sp of systemPrompts) {
57
91
  if (sp?.content && isString(sp.content) && sp.content.trim()) {
58
92
  result.push({ role: 'system', content: sp.content.trim() });
59
93
  }
60
94
  }
61
95
 
62
- // 2) 识别底部连续 ephemeral(只保留这些临时消息,其他 ephemeral 忽略)
63
- let ephemEnd = messages.length;
64
- for (let i = messages.length - 1; i >= 0; i--) {
65
- if (messages[i]._ephemeral) ephemEnd = i;
66
- else break;
67
- }
68
-
69
- // 3) 过滤 + 字段映射
70
- for (let i = 0; i < messages.length; i++) {
71
- const msg = messages[i];
72
-
73
- // ephemeral 消息:仅底部连续的保留,其余跳过
74
- if (msg._ephemeral) {
75
- if (i < ephemEnd) continue;
76
- // 底部 ephemeral 按原始 role 处理,不加 _ephemeral 标记
77
- }
78
-
79
- // 非底部 ephemeral 的正常消息仍保留原有过滤
80
- if (msg.role === 'system' && !msg._ephemeral) continue;
96
+ // 2) 跑过滤器链 → 决定"哪些消息进入请求"(规则可插拔)
97
+ const kept = MessageFormatter._runFilters(messages, capabilities || {});
81
98
 
99
+ // 3) 字段映射(过滤后的消息 → API 格式)
100
+ for (const msg of kept) {
82
101
  if (msg.role === 'tool') {
83
102
  result.push({ role: 'tool', content: msg.content || '', tool_call_id: msg.toolCallId });
84
103
  continue;
@@ -87,22 +106,19 @@ function toOpenAI({ messages, systemPrompts, capabilities, resolveImage }) {
87
106
  if (msg.role === 'assistant' && msg.toolCalls?.length) {
88
107
  const entry = { role: 'assistant', content: msg.content || '', tool_calls: msg.toolCalls };
89
108
  if (msg.prefix) entry.prefix = true;
90
- if (capabilities?.reasoning && msg.reasoningContent) {
91
- entry.reasoning_content = msg.reasoningContent;
92
- }
109
+ if (capabilities?.reasoning && msg.reasoningContent) entry.reasoning_content = msg.reasoningContent;
93
110
  result.push(entry);
94
111
  continue;
95
112
  }
96
113
 
97
- // 常规消息
98
114
  const hasText = msg.content && isString(msg.content) && msg.content.trim();
99
115
  const hasImages = msg.images && Array.isArray(msg.images) && msg.images.length > 0;
116
+
117
+ // 走到这里说明是被过滤器"特意保留"的空消息(prefix 续写占位)
100
118
  if (!hasText && !hasImages) {
101
119
  if (msg.role === 'assistant' && msg.prefix) {
102
120
  const prefixEntry = { role: 'assistant', content: '', prefix: true };
103
- if (capabilities?.reasoning && msg.reasoningContent) {
104
- prefixEntry.reasoning_content = msg.reasoningContent;
105
- }
121
+ if (capabilities?.reasoning && msg.reasoningContent) prefixEntry.reasoning_content = msg.reasoningContent;
106
122
  result.push(prefixEntry);
107
123
  }
108
124
  continue;
@@ -112,7 +128,7 @@ function toOpenAI({ messages, systemPrompts, capabilities, resolveImage }) {
112
128
 
113
129
  if (hasImages) {
114
130
  if (!capabilities?.vision) {
115
- throw new Error(
131
+ throw new ValidationError(
116
132
  '[MessageFormatter] 消息包含图片但模型不支持视觉(capabilities.vision=false)。' +
117
133
  '请切换模型或移除图片。'
118
134
  );
@@ -123,7 +139,6 @@ function toOpenAI({ messages, systemPrompts, capabilities, resolveImage }) {
123
139
  }
124
140
 
125
141
  if (msg.prefix) entry.prefix = true;
126
- // 常规 assistant 不回传 reasoningContent——API 自己会重新推理
127
142
  result.push(entry);
128
143
  }
129
144
 
@@ -137,6 +152,10 @@ export const MessageFormatter = {
137
152
  ['openai', toOpenAI]
138
153
  ]),
139
154
 
155
+ /** 过滤器表:name → { name, fn, priority, order } */
156
+ _filters: new Map(),
157
+ _filterOrder: 0,
158
+
140
159
  /**
141
160
  * 注册自定义格式
142
161
  * @param {string} name — 格式名称(如 'anthropic', 'gemini')
@@ -144,10 +163,10 @@ export const MessageFormatter = {
144
163
  */
145
164
  register(name, fn) {
146
165
  if (!name || typeof name !== 'string' || !name.trim()) {
147
- throw new Error('[MessageFormatter] 格式名称必须是非空字符串');
166
+ throw new ValidationError('[MessageFormatter] 格式名称必须是非空字符串');
148
167
  }
149
168
  if (typeof fn !== 'function') {
150
- throw new Error('[MessageFormatter] 转换函数必须是 function');
169
+ throw new ValidationError('[MessageFormatter] 转换函数必须是 function');
151
170
  }
152
171
  this._formats.set(name.trim(), fn);
153
172
  },
@@ -158,7 +177,7 @@ export const MessageFormatter = {
158
177
  */
159
178
  unregister(name) {
160
179
  if (name === 'openai') {
161
- throw new Error('[MessageFormatter] 内置格式 "openai" 不可移除');
180
+ throw new ValidationError('[MessageFormatter] 内置格式 "openai" 不可移除');
162
181
  }
163
182
  this._formats.delete(name);
164
183
  },
@@ -171,6 +190,73 @@ export const MessageFormatter = {
171
190
  return [...this._formats.keys()];
172
191
  },
173
192
 
193
+ // ===== 过滤器 API(v4.0)=====
194
+
195
+ /**
196
+ * 注册一个消息过滤器(决定消息是否进入请求、允许改写字段)。
197
+ * 同名会**覆盖**(返回被覆盖的旧函数),因此插件可以接管内置规则。
198
+ * @param {string} name
199
+ * @param {(msg: Object, ctx: {index:number,total:number,messages:Array,capabilities:Object}) => Object|null} fn
200
+ * 返回消息 = 保留(可改字段 / 返回新对象);返回 null = 丢弃
201
+ * @param {{priority?: number}} [options] 数字越小越先执行,默认 100
202
+ * @returns {Function|undefined} 被覆盖的旧过滤器(没有则 undefined)
203
+ */
204
+ registerFilter(name, fn, options = {}) {
205
+ if (!name || typeof name !== 'string' || !name.trim()) {
206
+ throw new ValidationError('[MessageFormatter] 过滤器名称必须是非空字符串');
207
+ }
208
+ if (typeof fn !== 'function') {
209
+ throw new ValidationError('[MessageFormatter] 过滤器必须是 function');
210
+ }
211
+ const key = name.trim();
212
+ const priority = typeof options.priority === 'number' ? options.priority : 100;
213
+ const prev = this._filters.get(key);
214
+ this._filters.set(key, { name: key, fn, priority, order: ++this._filterOrder });
215
+ return prev ? prev.fn : undefined;
216
+ },
217
+
218
+ /**
219
+ * 移除过滤器
220
+ * @param {string} name
221
+ * @returns {boolean} 是否移除成功
222
+ */
223
+ unregisterFilter(name) {
224
+ return this._filters.delete(name);
225
+ },
226
+
227
+ /**
228
+ * 列出过滤器(按执行顺序)
229
+ * @returns {Array<{name: string, priority: number}>}
230
+ */
231
+ listFilters() {
232
+ return [...this._filters.values()]
233
+ .sort((a, b) => a.priority - b.priority || a.order - b.order)
234
+ .map(f => ({ name: f.name, priority: f.priority }));
235
+ },
236
+
237
+ /**
238
+ * 跑过滤器链(内部使用)
239
+ * @param {Array} messages
240
+ * @param {Object} capabilities
241
+ * @returns {Array} 保留的消息
242
+ */
243
+ _runFilters(messages, capabilities = {}) {
244
+ if (this._filters.size === 0) return [...messages];
245
+ const list = [...this._filters.values()].sort((a, b) => a.priority - b.priority || a.order - b.order);
246
+ const out = [];
247
+ for (let i = 0; i < messages.length; i++) {
248
+ let msg = messages[i];
249
+ const ctx = { index: i, total: messages.length, messages, capabilities };
250
+ let dropped = false;
251
+ for (const f of list) {
252
+ msg = f.fn(msg, ctx);
253
+ if (msg === null || msg === undefined) { dropped = true; break; }
254
+ }
255
+ if (!dropped) out.push(msg);
256
+ }
257
+ return out;
258
+ },
259
+
174
260
  /**
175
261
  * 按指定格式转换消息
176
262
  * @param {Object} options
@@ -185,7 +271,7 @@ export const MessageFormatter = {
185
271
  const formatName = options.format || 'openai';
186
272
  const fn = this._formats.get(formatName);
187
273
  if (!fn) {
188
- throw new Error(
274
+ throw new ValidationError(
189
275
  `[MessageFormatter] 未知格式 "${formatName}"。可用格式: ${[...this._formats.keys()].join(', ')}`
190
276
  );
191
277
  }
@@ -198,6 +284,12 @@ export const MessageFormatter = {
198
284
  }
199
285
  };
200
286
 
287
+ // ====== 内置过滤器注册 ======
288
+ // v4.0:核心只保留「通用清理」规则(system 置顶分流、空内容清理)。
289
+ // ephemeral(临时消息)属于玩法规则,由 continuation 插件注册 —— 见 src/plugins/continuation.js。
290
+ MessageFormatter.registerFilter('system-drop', filterSystemDrop, { priority: 900 });
291
+ MessageFormatter.registerFilter('empty-drop', filterEmptyDrop, { priority: 910 });
292
+
201
293
  // 向后兼容:保留 assembleMessages 函数
202
294
  export function assembleMessages(options = {}) {
203
295
  return MessageFormatter.format({ ...options, format: 'openai' });