@zhin.js/agent 0.0.1 → 0.0.3
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 +17 -0
- package/lib/agent.d.ts +4 -129
- package/lib/agent.d.ts.map +1 -1
- package/lib/agent.js +3 -733
- package/lib/agent.js.map +1 -1
- package/lib/compaction.d.ts +3 -129
- package/lib/compaction.d.ts.map +1 -1
- package/lib/compaction.js +2 -367
- package/lib/compaction.js.map +1 -1
- package/lib/context-manager.d.ts +3 -210
- package/lib/context-manager.d.ts.map +1 -1
- package/lib/context-manager.js +2 -310
- package/lib/context-manager.js.map +1 -1
- package/lib/conversation-memory.d.ts +3 -189
- package/lib/conversation-memory.d.ts.map +1 -1
- package/lib/conversation-memory.js +2 -616
- package/lib/conversation-memory.js.map +1 -1
- package/lib/init/create-zhin-agent.d.ts.map +1 -1
- package/lib/init/create-zhin-agent.js +1 -3
- package/lib/init/create-zhin-agent.js.map +1 -1
- package/lib/init/register-management-tools.js +3 -3
- package/lib/output.d.ts +3 -90
- package/lib/output.d.ts.map +1 -1
- package/lib/output.js +2 -173
- package/lib/output.js.map +1 -1
- package/lib/rate-limiter.d.ts +3 -35
- package/lib/rate-limiter.d.ts.map +1 -1
- package/lib/rate-limiter.js +2 -83
- package/lib/rate-limiter.js.map +1 -1
- package/lib/session.d.ts +3 -190
- package/lib/session.d.ts.map +1 -1
- package/lib/session.js +2 -462
- package/lib/session.js.map +1 -1
- package/lib/storage.d.ts +3 -65
- package/lib/storage.d.ts.map +1 -1
- package/lib/storage.js +2 -102
- package/lib/storage.js.map +1 -1
- package/lib/tone-detector.d.ts +3 -16
- package/lib/tone-detector.d.ts.map +1 -1
- package/lib/tone-detector.js +2 -69
- package/lib/tone-detector.js.map +1 -1
- package/package.json +3 -2
- package/src/agent.ts +4 -852
- package/src/compaction.ts +27 -528
- package/src/context-manager.ts +14 -439
- package/src/conversation-memory.ts +3 -814
- package/src/init/create-zhin-agent.ts +1 -3
- package/src/init/register-management-tools.ts +3 -3
- package/src/output.ts +14 -260
- package/src/rate-limiter.ts +3 -127
- package/src/session.ts +12 -565
- package/src/storage.ts +8 -134
- package/src/tone-detector.ts +3 -87
- package/tests/ai/setup.ts +20 -84
- package/tests/ai/agent.test.ts +0 -565
- package/tests/ai/context-manager.test.ts +0 -413
- package/tests/ai/conversation-memory.test.ts +0 -128
- package/tests/ai/output.test.ts +0 -128
- package/tests/ai/rate-limiter.test.ts +0 -108
- package/tests/ai/session.test.ts +0 -334
- package/tests/ai/tone-detector.test.ts +0 -80
|
@@ -95,7 +95,7 @@ export function createZhinAgentContext(refs: AIServiceRefs): void {
|
|
|
95
95
|
setCronManager({ cronFeature, engine: cronEngine });
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
// Unified scheduler (at/every/cron
|
|
98
|
+
// Unified scheduler (at/every/cron)
|
|
99
99
|
const dataDir = path.join(process.cwd(), 'data');
|
|
100
100
|
const scheduler = new Scheduler({
|
|
101
101
|
storePath: path.join(dataDir, 'scheduler-jobs.json'),
|
|
@@ -108,8 +108,6 @@ export function createZhinAgentContext(refs: AIServiceRefs): void {
|
|
|
108
108
|
sceneId: 'scheduler',
|
|
109
109
|
});
|
|
110
110
|
},
|
|
111
|
-
heartbeatEnabled: true,
|
|
112
|
-
heartbeatIntervalMs: 30 * 60 * 1000,
|
|
113
111
|
});
|
|
114
112
|
setScheduler(scheduler);
|
|
115
113
|
scheduler.start().catch((e) => logger.warn('Scheduler start failed: ' + (e as Error).message));
|
|
@@ -12,7 +12,7 @@ export function registerManagementTools(): void {
|
|
|
12
12
|
useContext('ai', 'tool', (ai, toolService) => {
|
|
13
13
|
if (!ai || !toolService) return;
|
|
14
14
|
|
|
15
|
-
const listModelsTool = new ZhinTool('
|
|
15
|
+
const listModelsTool = new ZhinTool('ai_models')
|
|
16
16
|
.desc('列出所有可用的 AI 模型')
|
|
17
17
|
.keyword('模型', '可用模型', 'ai模型', 'model', 'models')
|
|
18
18
|
.tag('ai', 'management')
|
|
@@ -30,7 +30,7 @@ export function registerManagementTools(): void {
|
|
|
30
30
|
return r;
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
-
const clearSessionTool = new ZhinTool('
|
|
33
|
+
const clearSessionTool = new ZhinTool('ai_clear')
|
|
34
34
|
.desc('清除当前对话的历史记录')
|
|
35
35
|
.keyword('清除', '清空', '重置', 'clear', 'reset')
|
|
36
36
|
.tag('ai', 'session')
|
|
@@ -47,7 +47,7 @@ export function registerManagementTools(): void {
|
|
|
47
47
|
return '✅ 对话历史已清除';
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
-
const healthCheckTool = new ZhinTool('
|
|
50
|
+
const healthCheckTool = new ZhinTool('ai_health')
|
|
51
51
|
.desc('检查 AI 服务的健康状态')
|
|
52
52
|
.keyword('健康', '状态', '检查', 'health', 'status')
|
|
53
53
|
.tag('ai', 'management')
|
package/src/output.ts
CHANGED
|
@@ -1,261 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @zhin.js/ai
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
}
|
|
2
|
+
* Re-export from @zhin.js/ai for backward compatibility.
|
|
3
|
+
*/
|
|
4
|
+
export { parseOutput, renderToPlainText, renderToSatori } from '@zhin.js/ai';
|
|
5
|
+
export type {
|
|
6
|
+
TextElement,
|
|
7
|
+
ImageElement,
|
|
8
|
+
AudioElement,
|
|
9
|
+
VideoElement,
|
|
10
|
+
CardField,
|
|
11
|
+
CardButton,
|
|
12
|
+
CardElement,
|
|
13
|
+
FileElement,
|
|
14
|
+
OutputElement,
|
|
15
|
+
} from '@zhin.js/ai';
|
package/src/rate-limiter.ts
CHANGED
|
@@ -1,129 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* 防止单用户过度消耗 GPU/API 资源。
|
|
5
|
-
*
|
|
6
|
-
* 策略:
|
|
7
|
-
* 1. 滑动窗口速率限制 — 每分钟 N 次请求
|
|
8
|
-
* 2. 优雅降级 — 超限时返回友好提示而非静默丢弃
|
|
2
|
+
* Re-export from @zhin.js/ai for backward compatibility.
|
|
9
3
|
*/
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
}
|
|
4
|
+
export { RateLimiter } from '@zhin.js/ai';
|
|
5
|
+
export type { RateLimitConfig, RateLimitResult } from '@zhin.js/ai';
|