@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.
Files changed (61) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/lib/agent.d.ts +4 -129
  3. package/lib/agent.d.ts.map +1 -1
  4. package/lib/agent.js +3 -733
  5. package/lib/agent.js.map +1 -1
  6. package/lib/compaction.d.ts +3 -129
  7. package/lib/compaction.d.ts.map +1 -1
  8. package/lib/compaction.js +2 -367
  9. package/lib/compaction.js.map +1 -1
  10. package/lib/context-manager.d.ts +3 -210
  11. package/lib/context-manager.d.ts.map +1 -1
  12. package/lib/context-manager.js +2 -310
  13. package/lib/context-manager.js.map +1 -1
  14. package/lib/conversation-memory.d.ts +3 -189
  15. package/lib/conversation-memory.d.ts.map +1 -1
  16. package/lib/conversation-memory.js +2 -616
  17. package/lib/conversation-memory.js.map +1 -1
  18. package/lib/init/create-zhin-agent.d.ts.map +1 -1
  19. package/lib/init/create-zhin-agent.js +1 -3
  20. package/lib/init/create-zhin-agent.js.map +1 -1
  21. package/lib/init/register-management-tools.js +3 -3
  22. package/lib/output.d.ts +3 -90
  23. package/lib/output.d.ts.map +1 -1
  24. package/lib/output.js +2 -173
  25. package/lib/output.js.map +1 -1
  26. package/lib/rate-limiter.d.ts +3 -35
  27. package/lib/rate-limiter.d.ts.map +1 -1
  28. package/lib/rate-limiter.js +2 -83
  29. package/lib/rate-limiter.js.map +1 -1
  30. package/lib/session.d.ts +3 -190
  31. package/lib/session.d.ts.map +1 -1
  32. package/lib/session.js +2 -462
  33. package/lib/session.js.map +1 -1
  34. package/lib/storage.d.ts +3 -65
  35. package/lib/storage.d.ts.map +1 -1
  36. package/lib/storage.js +2 -102
  37. package/lib/storage.js.map +1 -1
  38. package/lib/tone-detector.d.ts +3 -16
  39. package/lib/tone-detector.d.ts.map +1 -1
  40. package/lib/tone-detector.js +2 -69
  41. package/lib/tone-detector.js.map +1 -1
  42. package/package.json +3 -2
  43. package/src/agent.ts +4 -852
  44. package/src/compaction.ts +27 -528
  45. package/src/context-manager.ts +14 -439
  46. package/src/conversation-memory.ts +3 -814
  47. package/src/init/create-zhin-agent.ts +1 -3
  48. package/src/init/register-management-tools.ts +3 -3
  49. package/src/output.ts +14 -260
  50. package/src/rate-limiter.ts +3 -127
  51. package/src/session.ts +12 -565
  52. package/src/storage.ts +8 -134
  53. package/src/tone-detector.ts +3 -87
  54. package/tests/ai/setup.ts +20 -84
  55. package/tests/ai/agent.test.ts +0 -565
  56. package/tests/ai/context-manager.test.ts +0 -413
  57. package/tests/ai/conversation-memory.test.ts +0 -128
  58. package/tests/ai/output.test.ts +0 -128
  59. package/tests/ai/rate-limiter.test.ts +0 -108
  60. package/tests/ai/session.test.ts +0 -334
  61. 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 + Heartbeat)
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('ai.models')
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('ai.clear')
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('ai.health')
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 - 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
- }
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';
@@ -1,129 +1,5 @@
1
1
  /**
2
- * RateLimiter AI 请求频率限制
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
- import { Logger } from '@zhin.js/core';
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';