@zhin.js/ai 1.0.0 → 1.0.2
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 +13 -0
- package/README.md +131 -497
- package/lib/agent.d.ts +54 -6
- package/lib/agent.d.ts.map +1 -1
- package/lib/agent.js +468 -116
- package/lib/agent.js.map +1 -1
- package/lib/compaction.d.ts +132 -0
- package/lib/compaction.d.ts.map +1 -0
- package/lib/compaction.js +370 -0
- package/lib/compaction.js.map +1 -0
- package/lib/context-manager.d.ts.map +1 -1
- package/lib/context-manager.js +10 -3
- package/lib/context-manager.js.map +1 -1
- package/lib/conversation-memory.d.ts +192 -0
- package/lib/conversation-memory.d.ts.map +1 -0
- package/lib/conversation-memory.js +619 -0
- package/lib/conversation-memory.js.map +1 -0
- package/lib/index.d.ts +25 -163
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +24 -1122
- package/lib/index.js.map +1 -1
- package/lib/output.d.ts +93 -0
- package/lib/output.d.ts.map +1 -0
- package/lib/output.js +176 -0
- package/lib/output.js.map +1 -0
- package/lib/providers/anthropic.d.ts +7 -0
- package/lib/providers/anthropic.d.ts.map +1 -1
- package/lib/providers/anthropic.js +5 -0
- package/lib/providers/anthropic.js.map +1 -1
- package/lib/providers/ollama.d.ts +10 -0
- package/lib/providers/ollama.d.ts.map +1 -1
- package/lib/providers/ollama.js +19 -4
- package/lib/providers/ollama.js.map +1 -1
- package/lib/providers/openai.d.ts +7 -0
- package/lib/providers/openai.d.ts.map +1 -1
- package/lib/providers/openai.js +11 -0
- package/lib/providers/openai.js.map +1 -1
- package/lib/rate-limiter.d.ts +38 -0
- package/lib/rate-limiter.d.ts.map +1 -0
- package/lib/rate-limiter.js +86 -0
- package/lib/rate-limiter.js.map +1 -0
- package/lib/session.d.ts +7 -0
- package/lib/session.d.ts.map +1 -1
- package/lib/session.js +47 -18
- package/lib/session.js.map +1 -1
- package/lib/storage.d.ts +68 -0
- package/lib/storage.d.ts.map +1 -0
- package/lib/storage.js +105 -0
- package/lib/storage.js.map +1 -0
- package/lib/tone-detector.d.ts +19 -0
- package/lib/tone-detector.d.ts.map +1 -0
- package/lib/tone-detector.js +72 -0
- package/lib/tone-detector.js.map +1 -0
- package/lib/types.d.ts +84 -8
- package/lib/types.d.ts.map +1 -1
- package/package.json +13 -42
- package/src/agent.ts +518 -135
- package/src/compaction.ts +529 -0
- package/src/context-manager.ts +10 -9
- package/src/conversation-memory.ts +816 -0
- package/src/index.ts +121 -1406
- package/src/output.ts +261 -0
- package/src/providers/anthropic.ts +4 -0
- package/src/providers/ollama.ts +23 -4
- package/src/providers/openai.ts +8 -1
- package/src/rate-limiter.ts +129 -0
- package/src/session.ts +47 -18
- package/src/storage.ts +135 -0
- package/src/tone-detector.ts +89 -0
- package/src/types.ts +95 -6
- package/tests/agent.test.ts +123 -70
- package/tests/compaction.test.ts +310 -0
- package/tests/context-manager.test.ts +73 -47
- package/tests/conversation-memory.test.ts +128 -0
- package/tests/output.test.ts +128 -0
- package/tests/providers.test.ts +574 -0
- package/tests/rate-limiter.test.ts +108 -0
- package/tests/session.test.ts +139 -48
- package/tests/setup.ts +82 -240
- package/tests/storage.test.ts +224 -0
- package/tests/tone-detector.test.ts +80 -0
- package/tsconfig.json +4 -5
- package/vitest.setup.ts +1 -0
- package/TOOLS.md +0 -294
- package/lib/tools.d.ts +0 -45
- package/lib/tools.d.ts.map +0 -1
- package/lib/tools.js +0 -194
- package/lib/tools.js.map +0 -1
- package/src/tools.ts +0 -205
- package/tests/ai-trigger.test.ts +0 -369
- package/tests/integration.test.ts +0 -596
- package/tests/providers.integration.test.ts +0 -227
- package/tests/tool.test.ts +0 -800
- package/tests/tools-builtin.test.ts +0 -346
package/src/output.ts
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zhin.js/ai - Multi-modal Output Pipeline
|
|
3
|
+
*
|
|
4
|
+
* 统一输出类型系统:AI 回复 → OutputElement[] → Adapter 渲染
|
|
5
|
+
*
|
|
6
|
+
* 支持的输出类型:
|
|
7
|
+
* - text: 纯文本 / markdown
|
|
8
|
+
* - image: 图片 URL 或 base64
|
|
9
|
+
* - audio: 音频 URL 或 base64
|
|
10
|
+
* - video: 视频 URL
|
|
11
|
+
* - card: 结构化卡片 (标题 + 正文 + 字段 + 按钮)
|
|
12
|
+
* - file: 文件附件
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// OutputElement 类型定义
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
export interface TextElement {
|
|
20
|
+
type: 'text';
|
|
21
|
+
content: string;
|
|
22
|
+
/** markdown / plain */
|
|
23
|
+
format?: 'markdown' | 'plain';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ImageElement {
|
|
27
|
+
type: 'image';
|
|
28
|
+
url: string;
|
|
29
|
+
/** base64 数据 (url 不可用时的后备) */
|
|
30
|
+
base64?: string;
|
|
31
|
+
alt?: string;
|
|
32
|
+
width?: number;
|
|
33
|
+
height?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface AudioElement {
|
|
37
|
+
type: 'audio';
|
|
38
|
+
url: string;
|
|
39
|
+
base64?: string;
|
|
40
|
+
duration?: number;
|
|
41
|
+
/** 语音转文字的后备文本 */
|
|
42
|
+
fallbackText?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface VideoElement {
|
|
46
|
+
type: 'video';
|
|
47
|
+
url: string;
|
|
48
|
+
coverUrl?: string;
|
|
49
|
+
duration?: number;
|
|
50
|
+
fallbackText?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface CardField {
|
|
54
|
+
label: string;
|
|
55
|
+
value: string;
|
|
56
|
+
inline?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface CardButton {
|
|
60
|
+
text: string;
|
|
61
|
+
/** 点击后发送的命令 */
|
|
62
|
+
command?: string;
|
|
63
|
+
/** 点击后打开的 URL */
|
|
64
|
+
url?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface CardElement {
|
|
68
|
+
type: 'card';
|
|
69
|
+
title: string;
|
|
70
|
+
description?: string;
|
|
71
|
+
fields?: CardField[];
|
|
72
|
+
imageUrl?: string;
|
|
73
|
+
buttons?: CardButton[];
|
|
74
|
+
color?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface FileElement {
|
|
78
|
+
type: 'file';
|
|
79
|
+
url: string;
|
|
80
|
+
name: string;
|
|
81
|
+
size?: number;
|
|
82
|
+
mimeType?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type OutputElement =
|
|
86
|
+
| TextElement
|
|
87
|
+
| ImageElement
|
|
88
|
+
| AudioElement
|
|
89
|
+
| VideoElement
|
|
90
|
+
| CardElement
|
|
91
|
+
| FileElement;
|
|
92
|
+
|
|
93
|
+
// ============================================================================
|
|
94
|
+
// 输出解析器 — 将 AI 原始回复转换为 OutputElement[]
|
|
95
|
+
// ============================================================================
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 从 AI 回复文本中解析出结构化的 OutputElement[]
|
|
99
|
+
*
|
|
100
|
+
* 识别规则:
|
|
101
|
+
* -  → ImageElement
|
|
102
|
+
* - [audio](url) → AudioElement
|
|
103
|
+
* - [video](url) → VideoElement
|
|
104
|
+
* - ```card ... ``` → CardElement (JSON)
|
|
105
|
+
* - [file:name](url) → FileElement
|
|
106
|
+
* - 其余文本 → TextElement
|
|
107
|
+
*/
|
|
108
|
+
export function parseOutput(raw: string): OutputElement[] {
|
|
109
|
+
if (!raw || !raw.trim()) return [{ type: 'text', content: '', format: 'plain' }];
|
|
110
|
+
|
|
111
|
+
const elements: OutputElement[] = [];
|
|
112
|
+
let remaining = raw;
|
|
113
|
+
|
|
114
|
+
// eslint-disable-next-line no-constant-condition
|
|
115
|
+
while (remaining.length > 0) {
|
|
116
|
+
// ── 尝试匹配 card 代码块 ──
|
|
117
|
+
const cardMatch = remaining.match(/^([\s\S]*?)```card\s*\n([\s\S]*?)```/);
|
|
118
|
+
if (cardMatch) {
|
|
119
|
+
// 前缀文本
|
|
120
|
+
if (cardMatch[1].trim()) {
|
|
121
|
+
elements.push({ type: 'text', content: cardMatch[1].trim(), format: 'markdown' });
|
|
122
|
+
}
|
|
123
|
+
// 解析卡片 JSON
|
|
124
|
+
try {
|
|
125
|
+
const cardData = JSON.parse(cardMatch[2]);
|
|
126
|
+
elements.push({ ...cardData, type: 'card' });
|
|
127
|
+
} catch {
|
|
128
|
+
elements.push({ type: 'text', content: cardMatch[2], format: 'plain' });
|
|
129
|
+
}
|
|
130
|
+
remaining = remaining.slice(cardMatch[0].length);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── 尝试匹配 image:  ──
|
|
135
|
+
const imgMatch = remaining.match(/^([\s\S]*?)!\[([^\]]*)\]\(([^)]+)\)/);
|
|
136
|
+
if (imgMatch && imgMatch[1].length < 500) {
|
|
137
|
+
if (imgMatch[1].trim()) {
|
|
138
|
+
elements.push({ type: 'text', content: imgMatch[1].trim(), format: 'markdown' });
|
|
139
|
+
}
|
|
140
|
+
elements.push({ type: 'image', url: imgMatch[3], alt: imgMatch[2] || undefined });
|
|
141
|
+
remaining = remaining.slice(imgMatch[0].length);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── 尝试匹配 audio: [audio](url) ──
|
|
146
|
+
const audioMatch = remaining.match(/^([\s\S]*?)\[audio\]\(([^)]+)\)/i);
|
|
147
|
+
if (audioMatch && audioMatch[1].length < 500) {
|
|
148
|
+
if (audioMatch[1].trim()) {
|
|
149
|
+
elements.push({ type: 'text', content: audioMatch[1].trim(), format: 'markdown' });
|
|
150
|
+
}
|
|
151
|
+
elements.push({ type: 'audio', url: audioMatch[2] });
|
|
152
|
+
remaining = remaining.slice(audioMatch[0].length);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── 尝试匹配 video: [video](url) ──
|
|
157
|
+
const videoMatch = remaining.match(/^([\s\S]*?)\[video\]\(([^)]+)\)/i);
|
|
158
|
+
if (videoMatch && videoMatch[1].length < 500) {
|
|
159
|
+
if (videoMatch[1].trim()) {
|
|
160
|
+
elements.push({ type: 'text', content: videoMatch[1].trim(), format: 'markdown' });
|
|
161
|
+
}
|
|
162
|
+
elements.push({ type: 'video', url: videoMatch[2] });
|
|
163
|
+
remaining = remaining.slice(videoMatch[0].length);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── 尝试匹配 file: [file:name](url) ──
|
|
168
|
+
const fileMatch = remaining.match(/^([\s\S]*?)\[file:([^\]]+)\]\(([^)]+)\)/i);
|
|
169
|
+
if (fileMatch && fileMatch[1].length < 500) {
|
|
170
|
+
if (fileMatch[1].trim()) {
|
|
171
|
+
elements.push({ type: 'text', content: fileMatch[1].trim(), format: 'markdown' });
|
|
172
|
+
}
|
|
173
|
+
elements.push({ type: 'file', url: fileMatch[3], name: fileMatch[2] });
|
|
174
|
+
remaining = remaining.slice(fileMatch[0].length);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── 无匹配 → 全部作为文本 ──
|
|
179
|
+
elements.push({ type: 'text', content: remaining.trim(), format: 'markdown' });
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return elements.length > 0 ? elements : [{ type: 'text', content: raw, format: 'plain' }];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ============================================================================
|
|
187
|
+
// OutputElement 渲染工具
|
|
188
|
+
// ============================================================================
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* 将 OutputElement[] 降级为纯文本(用于不支持富媒体的平台)
|
|
192
|
+
*/
|
|
193
|
+
export function renderToPlainText(elements: OutputElement[]): string {
|
|
194
|
+
return elements.map(el => {
|
|
195
|
+
switch (el.type) {
|
|
196
|
+
case 'text':
|
|
197
|
+
return el.content;
|
|
198
|
+
case 'image':
|
|
199
|
+
return el.alt ? `[图片: ${el.alt}]` : `[图片: ${el.url}]`;
|
|
200
|
+
case 'audio':
|
|
201
|
+
return el.fallbackText || `[音频: ${el.url}]`;
|
|
202
|
+
case 'video':
|
|
203
|
+
return el.fallbackText || `[视频: ${el.url}]`;
|
|
204
|
+
case 'card': {
|
|
205
|
+
const parts = [el.title];
|
|
206
|
+
if (el.description) parts.push(el.description);
|
|
207
|
+
if (el.fields?.length) {
|
|
208
|
+
for (const f of el.fields) parts.push(`${f.label}: ${f.value}`);
|
|
209
|
+
}
|
|
210
|
+
return parts.join('\n');
|
|
211
|
+
}
|
|
212
|
+
case 'file':
|
|
213
|
+
return `[文件: ${el.name}] ${el.url}`;
|
|
214
|
+
default:
|
|
215
|
+
return '';
|
|
216
|
+
}
|
|
217
|
+
}).filter(Boolean).join('\n');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* 将 OutputElement[] 渲染为 Satori 兼容的 HTML 片段
|
|
222
|
+
*/
|
|
223
|
+
export function renderToSatori(elements: OutputElement[]): string {
|
|
224
|
+
return elements.map(el => {
|
|
225
|
+
switch (el.type) {
|
|
226
|
+
case 'text':
|
|
227
|
+
return `<p>${escapeHtml(el.content)}</p>`;
|
|
228
|
+
case 'image':
|
|
229
|
+
return `<img src="${escapeHtml(el.url)}"${el.alt ? ` alt="${escapeHtml(el.alt)}"` : ''}/>`;
|
|
230
|
+
case 'audio':
|
|
231
|
+
return el.fallbackText
|
|
232
|
+
? `<p>${escapeHtml(el.fallbackText)}</p>`
|
|
233
|
+
: `<audio src="${escapeHtml(el.url)}"/>`;
|
|
234
|
+
case 'video':
|
|
235
|
+
return el.fallbackText
|
|
236
|
+
? `<p>${escapeHtml(el.fallbackText)}</p>`
|
|
237
|
+
: `<video src="${escapeHtml(el.url)}"/>`;
|
|
238
|
+
case 'card': {
|
|
239
|
+
let html = `<div class="card">`;
|
|
240
|
+
html += `<h3>${escapeHtml(el.title)}</h3>`;
|
|
241
|
+
if (el.description) html += `<p>${escapeHtml(el.description)}</p>`;
|
|
242
|
+
if (el.imageUrl) html += `<img src="${escapeHtml(el.imageUrl)}"/>`;
|
|
243
|
+
if (el.fields?.length) {
|
|
244
|
+
for (const f of el.fields) {
|
|
245
|
+
html += `<p><b>${escapeHtml(f.label)}</b>: ${escapeHtml(f.value)}</p>`;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
html += `</div>`;
|
|
249
|
+
return html;
|
|
250
|
+
}
|
|
251
|
+
case 'file':
|
|
252
|
+
return `<a href="${escapeHtml(el.url)}">${escapeHtml(el.name)}</a>`;
|
|
253
|
+
default:
|
|
254
|
+
return '';
|
|
255
|
+
}
|
|
256
|
+
}).filter(Boolean).join('\n');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function escapeHtml(s: string): string {
|
|
260
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
261
|
+
}
|
|
@@ -178,14 +178,18 @@ export class AnthropicProvider extends BaseProvider {
|
|
|
178
178
|
'claude-3-sonnet-20240229',
|
|
179
179
|
'claude-3-haiku-20240307',
|
|
180
180
|
];
|
|
181
|
+
contextWindow: number;
|
|
182
|
+
capabilities = { vision: true, streaming: true, toolCalling: true, thinking: false };
|
|
181
183
|
|
|
182
184
|
private baseUrl: string;
|
|
183
185
|
private anthropicVersion: string;
|
|
184
186
|
|
|
185
187
|
constructor(config: AnthropicConfig = {}) {
|
|
186
188
|
super(config);
|
|
189
|
+
this.contextWindow = config.contextWindow ?? 200000;
|
|
187
190
|
this.baseUrl = config.baseUrl || 'https://api.anthropic.com';
|
|
188
191
|
this.anthropicVersion = config.anthropicVersion || '2023-06-01';
|
|
192
|
+
if (config.models?.length) this.models = config.models;
|
|
189
193
|
}
|
|
190
194
|
|
|
191
195
|
protected async fetch<T>(url: string, options: RequestInit & { json?: any } = {}): Promise<T> {
|
package/src/providers/ollama.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* 支持本地 Ollama 模型
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { Logger } from '@zhin.js/
|
|
6
|
+
import { Logger } from '@zhin.js/logger';
|
|
7
7
|
import { BaseProvider } from './base.js';
|
|
8
8
|
import type {
|
|
9
9
|
ProviderConfig,
|
|
@@ -19,6 +19,8 @@ const logger = new Logger(null, 'Ollama');
|
|
|
19
19
|
export interface OllamaConfig extends ProviderConfig {
|
|
20
20
|
host?: string;
|
|
21
21
|
models?: string[];
|
|
22
|
+
/** Ollama 上下文窗口大小(token 数),默认 32768。影响多轮对话和技能指令的保持能力 */
|
|
23
|
+
num_ctx?: number;
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
/**
|
|
@@ -81,13 +83,17 @@ function toOllamaTools(tools?: ToolDefinition[]): any[] | undefined {
|
|
|
81
83
|
export class OllamaProvider extends BaseProvider {
|
|
82
84
|
name = 'ollama';
|
|
83
85
|
models: string[];
|
|
86
|
+
contextWindow: number;
|
|
87
|
+
capabilities = { vision: true, streaming: true, toolCalling: true, thinking: true };
|
|
84
88
|
|
|
85
89
|
private host: string;
|
|
90
|
+
private numCtx: number;
|
|
86
91
|
|
|
87
92
|
constructor(config: OllamaConfig = {}) {
|
|
88
93
|
super(config);
|
|
89
94
|
this.host = config.host || config.baseUrl || 'http://localhost:11434';
|
|
90
|
-
|
|
95
|
+
this.numCtx = config.contextWindow ?? config.num_ctx ?? 32768;
|
|
96
|
+
this.contextWindow = this.numCtx;
|
|
91
97
|
this.models = config.models?.length ? config.models : [
|
|
92
98
|
'llama3.3',
|
|
93
99
|
'llama3.2',
|
|
@@ -113,9 +119,16 @@ export class OllamaProvider extends BaseProvider {
|
|
|
113
119
|
model: request.model,
|
|
114
120
|
messages,
|
|
115
121
|
stream: false,
|
|
116
|
-
options: {
|
|
122
|
+
options: {
|
|
123
|
+
num_ctx: this.numCtx,
|
|
124
|
+
},
|
|
117
125
|
};
|
|
118
126
|
|
|
127
|
+
// think 参数:控制 qwen3 等模型的思考模式
|
|
128
|
+
if (request.think !== undefined) {
|
|
129
|
+
ollamaRequest.think = request.think;
|
|
130
|
+
}
|
|
131
|
+
|
|
119
132
|
if (request.temperature !== undefined) {
|
|
120
133
|
ollamaRequest.options.temperature = request.temperature;
|
|
121
134
|
}
|
|
@@ -181,9 +194,15 @@ export class OllamaProvider extends BaseProvider {
|
|
|
181
194
|
model: request.model,
|
|
182
195
|
messages,
|
|
183
196
|
stream: true,
|
|
184
|
-
options: {
|
|
197
|
+
options: {
|
|
198
|
+
num_ctx: this.numCtx,
|
|
199
|
+
},
|
|
185
200
|
};
|
|
186
201
|
|
|
202
|
+
if (request.think !== undefined) {
|
|
203
|
+
ollamaRequest.think = request.think;
|
|
204
|
+
}
|
|
205
|
+
|
|
187
206
|
if (request.temperature !== undefined) {
|
|
188
207
|
ollamaRequest.options.temperature = request.temperature;
|
|
189
208
|
}
|
package/src/providers/openai.ts
CHANGED
|
@@ -28,13 +28,17 @@ export class OpenAIProvider extends BaseProvider {
|
|
|
28
28
|
'o1-preview',
|
|
29
29
|
'o3-mini',
|
|
30
30
|
];
|
|
31
|
+
contextWindow: number;
|
|
32
|
+
capabilities = { vision: true, streaming: true, toolCalling: true, thinking: false };
|
|
31
33
|
|
|
32
34
|
private baseUrl: string;
|
|
33
35
|
|
|
34
36
|
constructor(config: OpenAIConfig = {}) {
|
|
35
37
|
super(config);
|
|
38
|
+
this.contextWindow = config.contextWindow ?? 128000;
|
|
36
39
|
this.baseUrl = config.baseUrl || 'https://api.openai.com/v1';
|
|
37
|
-
|
|
40
|
+
if (config.models?.length) this.models = config.models;
|
|
41
|
+
|
|
38
42
|
if (config.organization) {
|
|
39
43
|
this.config.headers = {
|
|
40
44
|
...this.config.headers,
|
|
@@ -111,6 +115,7 @@ export class DeepSeekProvider extends OpenAIProvider {
|
|
|
111
115
|
...config,
|
|
112
116
|
baseUrl: config.baseUrl || 'https://api.deepseek.com/v1',
|
|
113
117
|
});
|
|
118
|
+
if (config.models?.length) this.models = config.models;
|
|
114
119
|
}
|
|
115
120
|
|
|
116
121
|
async listModels(): Promise<string[]> {
|
|
@@ -134,6 +139,7 @@ export class MoonshotProvider extends OpenAIProvider {
|
|
|
134
139
|
...config,
|
|
135
140
|
baseUrl: config.baseUrl || 'https://api.moonshot.cn/v1',
|
|
136
141
|
});
|
|
142
|
+
if (config.models?.length) this.models = config.models;
|
|
137
143
|
}
|
|
138
144
|
|
|
139
145
|
async listModels(): Promise<string[]> {
|
|
@@ -159,6 +165,7 @@ export class ZhipuProvider extends OpenAIProvider {
|
|
|
159
165
|
...config,
|
|
160
166
|
baseUrl: config.baseUrl || 'https://open.bigmodel.cn/api/paas/v4',
|
|
161
167
|
});
|
|
168
|
+
if (config.models?.length) this.models = config.models;
|
|
162
169
|
}
|
|
163
170
|
|
|
164
171
|
async listModels(): Promise<string[]> {
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RateLimiter — AI 请求频率限制
|
|
3
|
+
*
|
|
4
|
+
* 防止单用户过度消耗 GPU/API 资源。
|
|
5
|
+
*
|
|
6
|
+
* 策略:
|
|
7
|
+
* 1. 滑动窗口速率限制 — 每分钟 N 次请求
|
|
8
|
+
* 2. 优雅降级 — 超限时返回友好提示而非静默丢弃
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Logger } from '@zhin.js/logger';
|
|
12
|
+
|
|
13
|
+
const logger = new Logger(null, 'RateLimiter');
|
|
14
|
+
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// 配置
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
export interface RateLimitConfig {
|
|
20
|
+
/** 每分钟最大请求数(默认 20) */
|
|
21
|
+
maxRequestsPerMinute?: number;
|
|
22
|
+
/** 冷却时间(秒),超限后需等待(默认 10) */
|
|
23
|
+
cooldownSeconds?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const DEFAULTS: Required<RateLimitConfig> = {
|
|
27
|
+
maxRequestsPerMinute: 20,
|
|
28
|
+
cooldownSeconds: 10,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// ============================================================================
|
|
32
|
+
// 类型
|
|
33
|
+
// ============================================================================
|
|
34
|
+
|
|
35
|
+
interface UserBucket {
|
|
36
|
+
/** 最近请求的时间戳列表(ms) */
|
|
37
|
+
timestamps: number[];
|
|
38
|
+
/** 冷却结束时间(ms),0 表示无冷却 */
|
|
39
|
+
cooldownUntil: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface RateLimitResult {
|
|
43
|
+
allowed: boolean;
|
|
44
|
+
/** 如果被拒绝,返回友好提示 */
|
|
45
|
+
message?: string;
|
|
46
|
+
/** 需等待的秒数 */
|
|
47
|
+
retryAfterSeconds?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ============================================================================
|
|
51
|
+
// RateLimiter
|
|
52
|
+
// ============================================================================
|
|
53
|
+
|
|
54
|
+
export class RateLimiter {
|
|
55
|
+
private config: Required<RateLimitConfig>;
|
|
56
|
+
private buckets: Map<string, UserBucket> = new Map();
|
|
57
|
+
private cleanupTimer?: ReturnType<typeof setInterval>;
|
|
58
|
+
|
|
59
|
+
constructor(config?: RateLimitConfig) {
|
|
60
|
+
this.config = { ...DEFAULTS, ...config };
|
|
61
|
+
// 定期清理过期的 bucket(每 5 分钟)
|
|
62
|
+
this.cleanupTimer = setInterval(() => this.cleanup(), 5 * 60 * 1000);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 检查请求是否被允许
|
|
67
|
+
*/
|
|
68
|
+
check(userId: string): RateLimitResult {
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
let bucket = this.buckets.get(userId);
|
|
71
|
+
|
|
72
|
+
if (!bucket) {
|
|
73
|
+
bucket = { timestamps: [], cooldownUntil: 0 };
|
|
74
|
+
this.buckets.set(userId, bucket);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 1. 检查冷却期
|
|
78
|
+
if (bucket.cooldownUntil > now) {
|
|
79
|
+
const waitSec = Math.ceil((bucket.cooldownUntil - now) / 1000);
|
|
80
|
+
return {
|
|
81
|
+
allowed: false,
|
|
82
|
+
message: `请稍等 ${waitSec} 秒后再发消息哦~消息太频繁啦 😊`,
|
|
83
|
+
retryAfterSeconds: waitSec,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 2. 滑动窗口:清理 1 分钟前的时间戳
|
|
88
|
+
const windowStart = now - 60_000;
|
|
89
|
+
bucket.timestamps = bucket.timestamps.filter(t => t > windowStart);
|
|
90
|
+
|
|
91
|
+
// 3. 检查速率
|
|
92
|
+
if (bucket.timestamps.length >= this.config.maxRequestsPerMinute) {
|
|
93
|
+
bucket.cooldownUntil = now + this.config.cooldownSeconds * 1000;
|
|
94
|
+
const waitSec = this.config.cooldownSeconds;
|
|
95
|
+
logger.warn(`User ${userId} rate limited: ${bucket.timestamps.length} requests in 1 min`);
|
|
96
|
+
return {
|
|
97
|
+
allowed: false,
|
|
98
|
+
message: `你发消息太快啦,请等 ${waitSec} 秒后再试~`,
|
|
99
|
+
retryAfterSeconds: waitSec,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 4. 记录这次请求
|
|
104
|
+
bucket.timestamps.push(now);
|
|
105
|
+
return { allowed: true };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 清理长期不活跃的 bucket
|
|
110
|
+
*/
|
|
111
|
+
private cleanup(): void {
|
|
112
|
+
const now = Date.now();
|
|
113
|
+
const staleThreshold = 10 * 60 * 1000; // 10 分钟
|
|
114
|
+
for (const [userId, bucket] of this.buckets) {
|
|
115
|
+
const latest = bucket.timestamps[bucket.timestamps.length - 1] ?? 0;
|
|
116
|
+
if (now - latest > staleThreshold && bucket.cooldownUntil < now) {
|
|
117
|
+
this.buckets.delete(userId);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
dispose(): void {
|
|
123
|
+
if (this.cleanupTimer) {
|
|
124
|
+
clearInterval(this.cleanupTimer);
|
|
125
|
+
this.cleanupTimer = undefined;
|
|
126
|
+
}
|
|
127
|
+
this.buckets.clear();
|
|
128
|
+
}
|
|
129
|
+
}
|
package/src/session.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* - 更长的上下文记忆能力
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { Logger } from '@zhin.js/
|
|
12
|
+
import { Logger } from '@zhin.js/logger';
|
|
13
13
|
import type { ChatMessage, SessionConfig, Session } from './types.js';
|
|
14
14
|
|
|
15
15
|
const logger = new Logger(null, 'AI-Session');
|
|
@@ -105,8 +105,8 @@ export class MemorySessionManager implements ISessionManager {
|
|
|
105
105
|
private trimMessages(session: Session): void {
|
|
106
106
|
const maxHistory = session.config.maxHistory ?? this.config.maxHistory;
|
|
107
107
|
if (session.messages.length > maxHistory) {
|
|
108
|
-
const systemMessages = session.messages.filter(m => m.role === 'system');
|
|
109
|
-
const otherMessages = session.messages.filter(m => m.role !== 'system');
|
|
108
|
+
const systemMessages = session.messages.filter((m: ChatMessage) => m.role === 'system');
|
|
109
|
+
const otherMessages = session.messages.filter((m: ChatMessage) => m.role !== 'system');
|
|
110
110
|
const keepCount = maxHistory - systemMessages.length;
|
|
111
111
|
session.messages = [...systemMessages, ...otherMessages.slice(-keepCount)];
|
|
112
112
|
}
|
|
@@ -118,7 +118,7 @@ export class MemorySessionManager implements ISessionManager {
|
|
|
118
118
|
|
|
119
119
|
setSystemPrompt(sessionId: string, prompt: string): void {
|
|
120
120
|
const session = this.get(sessionId);
|
|
121
|
-
session.messages = session.messages.filter(m => m.role !== 'system');
|
|
121
|
+
session.messages = session.messages.filter((m: ChatMessage) => m.role !== 'system');
|
|
122
122
|
session.messages.unshift({ role: 'system', content: prompt });
|
|
123
123
|
session.updatedAt = Date.now();
|
|
124
124
|
}
|
|
@@ -130,7 +130,7 @@ export class MemorySessionManager implements ISessionManager {
|
|
|
130
130
|
reset(sessionId: string): void {
|
|
131
131
|
const session = this.sessions.get(sessionId);
|
|
132
132
|
if (session) {
|
|
133
|
-
const systemMessages = session.messages.filter(m => m.role === 'system');
|
|
133
|
+
const systemMessages = session.messages.filter((m: ChatMessage) => m.role === 'system');
|
|
134
134
|
session.messages = systemMessages;
|
|
135
135
|
session.updatedAt = Date.now();
|
|
136
136
|
}
|
|
@@ -192,14 +192,17 @@ export class DatabaseSessionManager implements ISessionManager {
|
|
|
192
192
|
private saveTimer?: ReturnType<typeof setTimeout>;
|
|
193
193
|
private model: any; // 数据库模型
|
|
194
194
|
|
|
195
|
+
/** 内存缓存上限,超出时淘汰最久未访问的条目 */
|
|
196
|
+
private static readonly MAX_CACHE_SIZE = 2000;
|
|
197
|
+
|
|
195
198
|
constructor(
|
|
196
199
|
model: any,
|
|
197
200
|
config: { maxHistory?: number; expireMs?: number } = {}
|
|
198
201
|
) {
|
|
199
202
|
this.model = model;
|
|
200
203
|
this.config = {
|
|
201
|
-
maxHistory: config.maxHistory ?? 200,
|
|
202
|
-
expireMs: config.expireMs ?? 7 * 24 * 60 * 60 * 1000,
|
|
204
|
+
maxHistory: config.maxHistory ?? 200,
|
|
205
|
+
expireMs: config.expireMs ?? 7 * 24 * 60 * 60 * 1000,
|
|
203
206
|
};
|
|
204
207
|
|
|
205
208
|
// 定期清理过期会话(每小时)
|
|
@@ -211,13 +214,20 @@ export class DatabaseSessionManager implements ISessionManager {
|
|
|
211
214
|
*/
|
|
212
215
|
private async loadSession(sessionId: string): Promise<Session | null> {
|
|
213
216
|
try {
|
|
214
|
-
const records = await this.model.select({ session_id: sessionId });
|
|
217
|
+
const records = await this.model.select().where({ session_id: sessionId });
|
|
215
218
|
if (records && records.length > 0) {
|
|
216
219
|
const record = records[0] as SessionRecord;
|
|
220
|
+
// SQLite 中 json 类型存储为 TEXT,读回时需要解析
|
|
221
|
+
const messages = typeof record.messages === 'string'
|
|
222
|
+
? JSON.parse(record.messages)
|
|
223
|
+
: (record.messages || []);
|
|
224
|
+
const config = typeof record.config === 'string'
|
|
225
|
+
? JSON.parse(record.config)
|
|
226
|
+
: (record.config || { provider: 'openai' });
|
|
217
227
|
return {
|
|
218
228
|
id: record.session_id,
|
|
219
|
-
config:
|
|
220
|
-
messages:
|
|
229
|
+
config: Array.isArray(config) ? { provider: 'openai' } : config,
|
|
230
|
+
messages: Array.isArray(messages) ? messages : [],
|
|
221
231
|
createdAt: record.created_at,
|
|
222
232
|
updatedAt: record.updated_at,
|
|
223
233
|
};
|
|
@@ -250,7 +260,7 @@ export class DatabaseSessionManager implements ISessionManager {
|
|
|
250
260
|
|
|
251
261
|
for (const session of sessions) {
|
|
252
262
|
try {
|
|
253
|
-
const existing = await this.model.select({ session_id: session.id });
|
|
263
|
+
const existing = await this.model.select().where({ session_id: session.id });
|
|
254
264
|
const record: Partial<SessionRecord> = {
|
|
255
265
|
session_id: session.id,
|
|
256
266
|
messages: session.messages,
|
|
@@ -259,7 +269,7 @@ export class DatabaseSessionManager implements ISessionManager {
|
|
|
259
269
|
};
|
|
260
270
|
|
|
261
271
|
if (existing && existing.length > 0) {
|
|
262
|
-
await this.model.update(record
|
|
272
|
+
await this.model.update(record).where({ session_id: session.id });
|
|
263
273
|
} else {
|
|
264
274
|
record.created_at = session.createdAt;
|
|
265
275
|
await this.model.create(record);
|
|
@@ -279,7 +289,6 @@ export class DatabaseSessionManager implements ISessionManager {
|
|
|
279
289
|
session = await this.loadSession(sessionId) ?? undefined;
|
|
280
290
|
|
|
281
291
|
if (!session) {
|
|
282
|
-
// 创建新会话
|
|
283
292
|
session = {
|
|
284
293
|
id: sessionId,
|
|
285
294
|
config: config || { provider: 'openai' },
|
|
@@ -289,6 +298,11 @@ export class DatabaseSessionManager implements ISessionManager {
|
|
|
289
298
|
};
|
|
290
299
|
}
|
|
291
300
|
|
|
301
|
+
this.evictCacheIfNeeded();
|
|
302
|
+
this.cache.set(sessionId, session);
|
|
303
|
+
} else {
|
|
304
|
+
// LRU: 重新插入以更新 Map 的迭代顺序
|
|
305
|
+
this.cache.delete(sessionId);
|
|
292
306
|
this.cache.set(sessionId, session);
|
|
293
307
|
}
|
|
294
308
|
|
|
@@ -298,13 +312,28 @@ export class DatabaseSessionManager implements ISessionManager {
|
|
|
298
312
|
return session;
|
|
299
313
|
}
|
|
300
314
|
|
|
315
|
+
/**
|
|
316
|
+
* 当缓存超过上限时,淘汰最久未访问的条目。
|
|
317
|
+
* 利用 Map 的插入顺序(最旧的在前)。
|
|
318
|
+
*/
|
|
319
|
+
private evictCacheIfNeeded(): void {
|
|
320
|
+
while (this.cache.size >= DatabaseSessionManager.MAX_CACHE_SIZE) {
|
|
321
|
+
const oldest = this.cache.keys().next().value;
|
|
322
|
+
if (oldest !== undefined) {
|
|
323
|
+
this.cache.delete(oldest);
|
|
324
|
+
} else {
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
301
330
|
async has(sessionId: string): Promise<boolean> {
|
|
302
331
|
if (this.cache.has(sessionId)) {
|
|
303
332
|
return true;
|
|
304
333
|
}
|
|
305
334
|
|
|
306
335
|
try {
|
|
307
|
-
const records = await this.model.select({ session_id: sessionId });
|
|
336
|
+
const records = await this.model.select().where({ session_id: sessionId });
|
|
308
337
|
return records && records.length > 0;
|
|
309
338
|
} catch {
|
|
310
339
|
return false;
|
|
@@ -322,8 +351,8 @@ export class DatabaseSessionManager implements ISessionManager {
|
|
|
322
351
|
private trimMessages(session: Session): void {
|
|
323
352
|
const maxHistory = session.config.maxHistory ?? this.config.maxHistory;
|
|
324
353
|
if (session.messages.length > maxHistory) {
|
|
325
|
-
const systemMessages = session.messages.filter(m => m.role === 'system');
|
|
326
|
-
const otherMessages = session.messages.filter(m => m.role !== 'system');
|
|
354
|
+
const systemMessages = session.messages.filter((m: ChatMessage) => m.role === 'system');
|
|
355
|
+
const otherMessages = session.messages.filter((m: ChatMessage) => m.role !== 'system');
|
|
327
356
|
const keepCount = maxHistory - systemMessages.length;
|
|
328
357
|
session.messages = [...systemMessages, ...otherMessages.slice(-keepCount)];
|
|
329
358
|
}
|
|
@@ -336,7 +365,7 @@ export class DatabaseSessionManager implements ISessionManager {
|
|
|
336
365
|
|
|
337
366
|
async setSystemPrompt(sessionId: string, prompt: string): Promise<void> {
|
|
338
367
|
const session = await this.get(sessionId);
|
|
339
|
-
session.messages = session.messages.filter(m => m.role !== 'system');
|
|
368
|
+
session.messages = session.messages.filter((m: ChatMessage) => m.role !== 'system');
|
|
340
369
|
session.messages.unshift({ role: 'system', content: prompt });
|
|
341
370
|
session.updatedAt = Date.now();
|
|
342
371
|
this.schedulesSave(session);
|
|
@@ -357,7 +386,7 @@ export class DatabaseSessionManager implements ISessionManager {
|
|
|
357
386
|
|
|
358
387
|
async reset(sessionId: string): Promise<void> {
|
|
359
388
|
const session = await this.get(sessionId);
|
|
360
|
-
const systemMessages = session.messages.filter(m => m.role === 'system');
|
|
389
|
+
const systemMessages = session.messages.filter((m: ChatMessage) => m.role === 'system');
|
|
361
390
|
session.messages = systemMessages;
|
|
362
391
|
session.updatedAt = Date.now();
|
|
363
392
|
this.schedulesSave(session);
|