@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.
Files changed (94) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +131 -497
  3. package/lib/agent.d.ts +54 -6
  4. package/lib/agent.d.ts.map +1 -1
  5. package/lib/agent.js +468 -116
  6. package/lib/agent.js.map +1 -1
  7. package/lib/compaction.d.ts +132 -0
  8. package/lib/compaction.d.ts.map +1 -0
  9. package/lib/compaction.js +370 -0
  10. package/lib/compaction.js.map +1 -0
  11. package/lib/context-manager.d.ts.map +1 -1
  12. package/lib/context-manager.js +10 -3
  13. package/lib/context-manager.js.map +1 -1
  14. package/lib/conversation-memory.d.ts +192 -0
  15. package/lib/conversation-memory.d.ts.map +1 -0
  16. package/lib/conversation-memory.js +619 -0
  17. package/lib/conversation-memory.js.map +1 -0
  18. package/lib/index.d.ts +25 -163
  19. package/lib/index.d.ts.map +1 -1
  20. package/lib/index.js +24 -1122
  21. package/lib/index.js.map +1 -1
  22. package/lib/output.d.ts +93 -0
  23. package/lib/output.d.ts.map +1 -0
  24. package/lib/output.js +176 -0
  25. package/lib/output.js.map +1 -0
  26. package/lib/providers/anthropic.d.ts +7 -0
  27. package/lib/providers/anthropic.d.ts.map +1 -1
  28. package/lib/providers/anthropic.js +5 -0
  29. package/lib/providers/anthropic.js.map +1 -1
  30. package/lib/providers/ollama.d.ts +10 -0
  31. package/lib/providers/ollama.d.ts.map +1 -1
  32. package/lib/providers/ollama.js +19 -4
  33. package/lib/providers/ollama.js.map +1 -1
  34. package/lib/providers/openai.d.ts +7 -0
  35. package/lib/providers/openai.d.ts.map +1 -1
  36. package/lib/providers/openai.js +11 -0
  37. package/lib/providers/openai.js.map +1 -1
  38. package/lib/rate-limiter.d.ts +38 -0
  39. package/lib/rate-limiter.d.ts.map +1 -0
  40. package/lib/rate-limiter.js +86 -0
  41. package/lib/rate-limiter.js.map +1 -0
  42. package/lib/session.d.ts +7 -0
  43. package/lib/session.d.ts.map +1 -1
  44. package/lib/session.js +47 -18
  45. package/lib/session.js.map +1 -1
  46. package/lib/storage.d.ts +68 -0
  47. package/lib/storage.d.ts.map +1 -0
  48. package/lib/storage.js +105 -0
  49. package/lib/storage.js.map +1 -0
  50. package/lib/tone-detector.d.ts +19 -0
  51. package/lib/tone-detector.d.ts.map +1 -0
  52. package/lib/tone-detector.js +72 -0
  53. package/lib/tone-detector.js.map +1 -0
  54. package/lib/types.d.ts +84 -8
  55. package/lib/types.d.ts.map +1 -1
  56. package/package.json +13 -42
  57. package/src/agent.ts +518 -135
  58. package/src/compaction.ts +529 -0
  59. package/src/context-manager.ts +10 -9
  60. package/src/conversation-memory.ts +816 -0
  61. package/src/index.ts +121 -1406
  62. package/src/output.ts +261 -0
  63. package/src/providers/anthropic.ts +4 -0
  64. package/src/providers/ollama.ts +23 -4
  65. package/src/providers/openai.ts +8 -1
  66. package/src/rate-limiter.ts +129 -0
  67. package/src/session.ts +47 -18
  68. package/src/storage.ts +135 -0
  69. package/src/tone-detector.ts +89 -0
  70. package/src/types.ts +95 -6
  71. package/tests/agent.test.ts +123 -70
  72. package/tests/compaction.test.ts +310 -0
  73. package/tests/context-manager.test.ts +73 -47
  74. package/tests/conversation-memory.test.ts +128 -0
  75. package/tests/output.test.ts +128 -0
  76. package/tests/providers.test.ts +574 -0
  77. package/tests/rate-limiter.test.ts +108 -0
  78. package/tests/session.test.ts +139 -48
  79. package/tests/setup.ts +82 -240
  80. package/tests/storage.test.ts +224 -0
  81. package/tests/tone-detector.test.ts +80 -0
  82. package/tsconfig.json +4 -5
  83. package/vitest.setup.ts +1 -0
  84. package/TOOLS.md +0 -294
  85. package/lib/tools.d.ts +0 -45
  86. package/lib/tools.d.ts.map +0 -1
  87. package/lib/tools.js +0 -194
  88. package/lib/tools.js.map +0 -1
  89. package/src/tools.ts +0 -205
  90. package/tests/ai-trigger.test.ts +0 -369
  91. package/tests/integration.test.ts +0 -596
  92. package/tests/providers.integration.test.ts +0 -227
  93. package/tests/tool.test.ts +0 -800
  94. 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
+ * - ![alt](url) → 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: ![alt](url) ──
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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> {
@@ -3,7 +3,7 @@
3
3
  * 支持本地 Ollama 模型
4
4
  */
5
5
 
6
- import { Logger } from '@zhin.js/core';
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
  }
@@ -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/core';
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, // 7 天过期
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: record.config || { provider: 'openai' },
220
- messages: record.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, { session_id: session.id });
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);