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.
- package/CHANGELOG.md +52 -0
- package/README.md +3 -3
- package/README_ZH.md +22 -5
- package/dist/my-ai-chat-framework.browser.es.js +599 -292
- package/dist/my-ai-chat-framework.browser.es.js.map +1 -1
- package/dist/my-ai-chat-framework.browser.umd.js +601 -291
- package/dist/my-ai-chat-framework.browser.umd.js.map +1 -1
- package/dist/my-ai-chat-framework.node.cjs.js +601 -291
- package/dist/my-ai-chat-framework.node.cjs.js.map +1 -1
- package/docs/DEVELOPER.md +100 -10
- package/docs/README.md +33 -0
- package/package.json +1 -1
- package/src/adapters/openai.js +44 -18
- package/src/core/ChatService.js +250 -141
- package/src/core/Errors.js +13 -0
- package/src/core/SystemPromptStore.js +7 -5
- package/src/index.js +2 -1
- package/src/plugins/continuation.js +156 -0
- package/src/plugins/model-registry.js +16 -3
- package/src/plugins/tool-calling.js +46 -18
- package/src/utils/MessageFormatter.js +138 -46
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
76
|
+
throw new ValidationError(`[SystemPromptStore] 索引越界: ${index}`);
|
|
75
77
|
}
|
|
76
78
|
if (!content || typeof content !== 'string' || !content.trim()) {
|
|
77
|
-
throw new
|
|
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
|
-
|
|
154
|
+
const registerModel = (name, capabilities = {}) => {
|
|
153
155
|
if (!name || typeof name !== 'string' || !name.trim()) {
|
|
154
|
-
throw new
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
166
|
-
const toolTimeout = self._options.timeout
|
|
167
|
-
let
|
|
168
|
-
if (
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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. 内置
|
|
5
|
+
* 1. 内置 OpenAI 兼容格式的转换逻辑
|
|
6
6
|
* 2. 支持 register(name, fn) 注册自定义格式(如 Anthropic、Gemini 等)
|
|
7
|
-
* 3.
|
|
8
|
-
*
|
|
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.
|
|
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
|
-
|
|
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)
|
|
63
|
-
|
|
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
|
|
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
|
|
166
|
+
throw new ValidationError('[MessageFormatter] 格式名称必须是非空字符串');
|
|
148
167
|
}
|
|
149
168
|
if (typeof fn !== 'function') {
|
|
150
|
-
throw new
|
|
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
|
|
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
|
|
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' });
|