hydro-ai-helper 1.8.5
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/.eslintrc.json +31 -0
- package/.github/workflows/sync-releases-to-gitee.yml +119 -0
- package/.github/workflows/sync-to-gitee.yml +53 -0
- package/.vercelignore +31 -0
- package/CONTRIBUTING.md +165 -0
- package/README.md +273 -0
- package/TROUBLESHOOTING.md +355 -0
- package/api/badge-active.ts +88 -0
- package/api/badge-installs.ts +75 -0
- package/api/report.ts +242 -0
- package/assets/screenshots/1.png +0 -0
- package/assets/screenshots/2.png +0 -0
- package/assets/screenshots/3.png +0 -0
- package/assets/screenshots/4.png +0 -0
- package/assets/screenshots/5.png +0 -0
- package/assets/screenshots/6.png +0 -0
- package/assets/screenshots/7.png +0 -0
- package/dist/__tests__/__mocks__/hydrooj.js +29 -0
- package/dist/__tests__/__mocks__/hydrooj.js.map +1 -0
- package/dist/constants/jailbreakRules.js +29 -0
- package/dist/constants/jailbreakRules.js.map +1 -0
- package/dist/constants/permissions.js +42 -0
- package/dist/constants/permissions.js.map +1 -0
- package/dist/handlers/adminConfigHandler.js +347 -0
- package/dist/handlers/adminConfigHandler.js.map +1 -0
- package/dist/handlers/adminHandler.js +378 -0
- package/dist/handlers/adminHandler.js.map +1 -0
- package/dist/handlers/analyticsHandler.js +433 -0
- package/dist/handlers/analyticsHandler.js.map +1 -0
- package/dist/handlers/dashboardHandler.js +22 -0
- package/dist/handlers/dashboardHandler.js.map +1 -0
- package/dist/handlers/exportHandler.js +118 -0
- package/dist/handlers/exportHandler.js.map +1 -0
- package/dist/handlers/studentHandler.js +430 -0
- package/dist/handlers/studentHandler.js.map +1 -0
- package/dist/handlers/teacherHandler.js +246 -0
- package/dist/handlers/teacherHandler.js.map +1 -0
- package/dist/handlers/testHandler.js +27 -0
- package/dist/handlers/testHandler.js.map +1 -0
- package/dist/handlers/updateHandler.js +84 -0
- package/dist/handlers/updateHandler.js.map +1 -0
- package/dist/handlers/versionHandler.js +61 -0
- package/dist/handlers/versionHandler.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +151 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/crypto.js +110 -0
- package/dist/lib/crypto.js.map +1 -0
- package/dist/lib/httpHelpers.js +61 -0
- package/dist/lib/httpHelpers.js.map +1 -0
- package/dist/lib/queryHelpers.js +81 -0
- package/dist/lib/queryHelpers.js.map +1 -0
- package/dist/models/aiConfig.js +255 -0
- package/dist/models/aiConfig.js.map +1 -0
- package/dist/models/conversation.js +209 -0
- package/dist/models/conversation.js.map +1 -0
- package/dist/models/jailbreakLog.js +68 -0
- package/dist/models/jailbreakLog.js.map +1 -0
- package/dist/models/message.js +174 -0
- package/dist/models/message.js.map +1 -0
- package/dist/models/pluginInstall.js +95 -0
- package/dist/models/pluginInstall.js.map +1 -0
- package/dist/models/rateLimitRecord.js +54 -0
- package/dist/models/rateLimitRecord.js.map +1 -0
- package/dist/models/versionCache.js +89 -0
- package/dist/models/versionCache.js.map +1 -0
- package/dist/services/effectivenessService.js +131 -0
- package/dist/services/effectivenessService.js.map +1 -0
- package/dist/services/exportService.js +152 -0
- package/dist/services/exportService.js.map +1 -0
- package/dist/services/migrationService.js +90 -0
- package/dist/services/migrationService.js.map +1 -0
- package/dist/services/openaiClient.js +412 -0
- package/dist/services/openaiClient.js.map +1 -0
- package/dist/services/promptService.js +467 -0
- package/dist/services/promptService.js.map +1 -0
- package/dist/services/rateLimitService.js +108 -0
- package/dist/services/rateLimitService.js.map +1 -0
- package/dist/services/telemetryService.js +173 -0
- package/dist/services/telemetryService.js.map +1 -0
- package/dist/services/updateService.js +388 -0
- package/dist/services/updateService.js.map +1 -0
- package/dist/services/versionService.js +289 -0
- package/dist/services/versionService.js.map +1 -0
- package/dist/types/hydrooj.js +7 -0
- package/dist/types/hydrooj.js.map +1 -0
- package/dist/types/mongo.js +7 -0
- package/dist/types/mongo.js.map +1 -0
- package/dist/utils/domainHelper.js +16 -0
- package/dist/utils/domainHelper.js.map +1 -0
- package/dist/utils/mongo.js +16 -0
- package/dist/utils/mongo.js.map +1 -0
- package/frontend/admin/ConfigPanel.tsx +1379 -0
- package/frontend/admin/VersionBadge.tsx +655 -0
- package/frontend/ai_helper.page.tsx +52 -0
- package/frontend/ai_helper_admin_config.page.tsx +47 -0
- package/frontend/ai_helper_analytics.page.tsx +52 -0
- package/frontend/ai_helper_conversation_detail.page.tsx +55 -0
- package/frontend/ai_helper_conversations.page.tsx +56 -0
- package/frontend/components/AIHelperDashboard.tsx +125 -0
- package/frontend/problem_detail.page.tsx +395 -0
- package/frontend/student/AIAssistantPanel.tsx +1839 -0
- package/frontend/student/AIChatPanel.tsx +944 -0
- package/frontend/student/ThreeColumnLayout.tsx +240 -0
- package/frontend/teacher/AnalyticsPage.tsx +614 -0
- package/frontend/teacher/ConversationDetail.tsx +504 -0
- package/frontend/teacher/ConversationList.tsx +568 -0
- package/frontend/teacher/ExportDialog.tsx +291 -0
- package/frontend/test.page.ts +88 -0
- package/frontend/utils/domainUtils.ts +37 -0
- package/index.js +6 -0
- package/jest.config.js +17 -0
- package/lib/mongodb.ts +60 -0
- package/locales/en.yaml +4 -0
- package/locales/zh.yaml +77 -0
- package/package.json +48 -0
- package/public/.gitkeep +0 -0
- package/public/README.md +1 -0
- package/src/__tests__/__mocks__/hydrooj.ts +27 -0
- package/src/__tests__/services/exportService.test.ts +156 -0
- package/src/__tests__/services/promptService.test.ts +141 -0
- package/src/__tests__/services/rateLimitService.test.ts +145 -0
- package/src/__tests__/utils/domainHelper.test.ts +47 -0
- package/src/constants/jailbreakRules.ts +28 -0
- package/src/constants/permissions.ts +43 -0
- package/src/handlers/adminConfigHandler.ts +363 -0
- package/src/handlers/adminHandler.ts +427 -0
- package/src/handlers/analyticsHandler.ts +517 -0
- package/src/handlers/dashboardHandler.ts +20 -0
- package/src/handlers/exportHandler.ts +134 -0
- package/src/handlers/studentHandler.ts +483 -0
- package/src/handlers/teacherHandler.ts +317 -0
- package/src/handlers/testHandler.ts +25 -0
- package/src/handlers/updateHandler.ts +85 -0
- package/src/handlers/versionHandler.ts +62 -0
- package/src/index.ts +198 -0
- package/src/lib/crypto.ts +125 -0
- package/src/lib/httpHelpers.ts +75 -0
- package/src/lib/queryHelpers.ts +108 -0
- package/src/models/aiConfig.ts +375 -0
- package/src/models/conversation.ts +328 -0
- package/src/models/jailbreakLog.ts +108 -0
- package/src/models/message.ts +246 -0
- package/src/models/pluginInstall.ts +138 -0
- package/src/models/rateLimitRecord.ts +81 -0
- package/src/models/versionCache.ts +127 -0
- package/src/services/effectivenessService.ts +161 -0
- package/src/services/exportService.ts +191 -0
- package/src/services/migrationService.ts +111 -0
- package/src/services/openaiClient.ts +548 -0
- package/src/services/promptService.ts +573 -0
- package/src/services/rateLimitService.ts +122 -0
- package/src/services/telemetryService.ts +218 -0
- package/src/services/updateService.ts +441 -0
- package/src/services/versionService.ts +351 -0
- package/src/types/hydrooj-extensions.d.ts +55 -0
- package/src/types/hydrooj.ts +38 -0
- package/src/types/mongo.ts +23 -0
- package/src/utils/domainHelper.ts +16 -0
- package/src/utils/mongo.ts +15 -0
- package/templates/ai-helper/admin_config.html +8 -0
- package/templates/ai-helper/analytics.html +8 -0
- package/templates/ai-helper/dashboard.html +8 -0
- package/templates/ai-helper/teacher_conversation_detail.html +8 -0
- package/templates/ai-helper/teacher_conversations.html +8 -0
- package/tests/integration/README.md +86 -0
- package/tests/integration/curl/01-admin-config-set.sh +56 -0
- package/tests/integration/curl/02-student-chat.sh +67 -0
- package/tests/integration/curl/03-teacher-list.sh +52 -0
- package/tests/integration/curl/04-teacher-detail.sh +37 -0
- package/tests/integration/curl/05-export.sh +49 -0
- package/tests/integration/curl/06-analytics.sh +43 -0
- package/tests/performance/README.md +85 -0
- package/tests/performance/data/prepare-sample-data.md +57 -0
- package/tests/performance/scripts/export-csv-k6.js +41 -0
- package/tests/performance/scripts/student-chat-k6.js +44 -0
- package/tests/performance/scripts/teacher-conversations-k6.js +33 -0
- package/tsconfig.json +42 -0
- package/tsconfig.vercel.json +27 -0
- package/types/hydrooj/index.d.ts +73 -0
- package/vercel.json +3 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crypto 加解密工具
|
|
3
|
+
*
|
|
4
|
+
* 使用 AES-256-GCM 加密 API Key 等敏感信息
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'crypto';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 加密密钥(32 字节)
|
|
11
|
+
* 从环境变量读取,若不存在则使用默认值(仅开发环境)
|
|
12
|
+
*/
|
|
13
|
+
function getEncryptionKey(): Buffer {
|
|
14
|
+
const keyRaw = process.env.ENCRYPTION_KEY || 'dev-encryption-key-please-change-me-32';
|
|
15
|
+
|
|
16
|
+
// 如果环境变量不存在,打印警告(但不打印密钥本身)
|
|
17
|
+
if (!process.env.ENCRYPTION_KEY) {
|
|
18
|
+
console.warn('[Crypto] ⚠️ ENCRYPTION_KEY 环境变量未设置,使用默认密钥(仅开发环境)');
|
|
19
|
+
console.warn('[Crypto] ⚠️ 生产环境请务必设置 ENCRYPTION_KEY 环境变量!');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// 使用 SHA-256 哈希生成固定 32 字节密钥
|
|
23
|
+
return createHash('sha256').update(keyRaw).digest();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const ENCRYPTION_KEY = getEncryptionKey();
|
|
27
|
+
const ALGORITHM = 'aes-256-gcm';
|
|
28
|
+
const IV_LENGTH = 12; // GCM 推荐 12 字节 IV
|
|
29
|
+
const AUTH_TAG_LENGTH = 16; // GCM 认证标签长度
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 加密文本
|
|
33
|
+
* @param text 明文
|
|
34
|
+
* @returns Base64 编码的密文(包含 IV + AuthTag + Encrypted)
|
|
35
|
+
*/
|
|
36
|
+
export function encrypt(text: string): string {
|
|
37
|
+
if (!text) {
|
|
38
|
+
throw new Error('加密失败:输入文本不能为空');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
// 生成随机 IV
|
|
43
|
+
const iv = randomBytes(IV_LENGTH);
|
|
44
|
+
|
|
45
|
+
// 创建加密器
|
|
46
|
+
const cipher = createCipheriv(ALGORITHM, ENCRYPTION_KEY, iv);
|
|
47
|
+
|
|
48
|
+
// 加密数据
|
|
49
|
+
const encrypted = Buffer.concat([
|
|
50
|
+
cipher.update(text, 'utf8'),
|
|
51
|
+
cipher.final()
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
// 获取认证标签
|
|
55
|
+
const authTag = cipher.getAuthTag();
|
|
56
|
+
|
|
57
|
+
// 拼接: IV(12) + AuthTag(16) + Encrypted(N)
|
|
58
|
+
const combined = Buffer.concat([iv, authTag, encrypted]);
|
|
59
|
+
|
|
60
|
+
// 返回 Base64 编码
|
|
61
|
+
return combined.toString('base64');
|
|
62
|
+
} catch (error) {
|
|
63
|
+
throw new Error(`加密失败: ${error instanceof Error ? error.message : String(error)}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 解密文本
|
|
69
|
+
* @param cipherText Base64 编码的密文
|
|
70
|
+
* @returns 解密后的明文
|
|
71
|
+
*/
|
|
72
|
+
export function decrypt(cipherText: string): string {
|
|
73
|
+
if (!cipherText) {
|
|
74
|
+
throw new Error('解密失败:输入密文不能为空');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
// 解码 Base64
|
|
79
|
+
const combined = Buffer.from(cipherText, 'base64');
|
|
80
|
+
|
|
81
|
+
// 分离 IV + AuthTag + Encrypted
|
|
82
|
+
const iv = combined.subarray(0, IV_LENGTH);
|
|
83
|
+
const authTag = combined.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
|
|
84
|
+
const encrypted = combined.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
|
|
85
|
+
|
|
86
|
+
// 创建解密器
|
|
87
|
+
const decipher = createDecipheriv(ALGORITHM, ENCRYPTION_KEY, iv);
|
|
88
|
+
decipher.setAuthTag(authTag);
|
|
89
|
+
|
|
90
|
+
// 解密数据
|
|
91
|
+
const decrypted = Buffer.concat([
|
|
92
|
+
decipher.update(encrypted),
|
|
93
|
+
decipher.final()
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
return decrypted.toString('utf8');
|
|
97
|
+
} catch (error) {
|
|
98
|
+
// 解密失败可能是密钥错误或数据损坏
|
|
99
|
+
throw new Error(`解密失败: ${error instanceof Error ? error.message : String(error)}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* API Key 脱敏显示
|
|
105
|
+
* @param apiKeyPlain 明文 API Key
|
|
106
|
+
* @returns 脱敏后的字符串(如 "sk-12****abcd")
|
|
107
|
+
*/
|
|
108
|
+
export function maskApiKey(apiKeyPlain: string): string {
|
|
109
|
+
if (!apiKeyPlain) {
|
|
110
|
+
return '';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const len = apiKeyPlain.length;
|
|
114
|
+
|
|
115
|
+
// 如果长度 <= 8,简单返回 ****
|
|
116
|
+
if (len <= 8) {
|
|
117
|
+
return '****';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 保留前 4 和后 4 位,中间用 **** 替换
|
|
121
|
+
const start = apiKeyPlain.slice(0, 4);
|
|
122
|
+
const end = apiKeyPlain.slice(-4);
|
|
123
|
+
|
|
124
|
+
return `${start}****${end}`;
|
|
125
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP 响应工具函数
|
|
3
|
+
* 提供统一的响应格式和错误处理
|
|
4
|
+
*
|
|
5
|
+
* @module lib/httpHelpers
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Handler } from 'hydrooj';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 标准错误响应接口
|
|
12
|
+
*/
|
|
13
|
+
export interface ErrorResponse {
|
|
14
|
+
error: {
|
|
15
|
+
code: string;
|
|
16
|
+
message: string;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 设置 JSON 响应
|
|
22
|
+
* 统一设置响应类型和状态码
|
|
23
|
+
*
|
|
24
|
+
* @param handler Handler 实例
|
|
25
|
+
* @param data 响应数据
|
|
26
|
+
* @param status HTTP 状态码,默认 200
|
|
27
|
+
*/
|
|
28
|
+
export function setJsonResponse(handler: Handler, data: unknown, status: number = 200): void {
|
|
29
|
+
handler.response.status = status;
|
|
30
|
+
handler.response.body = data;
|
|
31
|
+
handler.response.type = 'application/json';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 设置错误响应
|
|
36
|
+
* 统一的错误响应格式
|
|
37
|
+
*
|
|
38
|
+
* @param handler Handler 实例
|
|
39
|
+
* @param code 错误代码,如 'INVALID_INPUT'
|
|
40
|
+
* @param message 错误信息
|
|
41
|
+
* @param status HTTP 状态码,默认 400
|
|
42
|
+
*/
|
|
43
|
+
export function setErrorResponse(
|
|
44
|
+
handler: Handler,
|
|
45
|
+
code: string,
|
|
46
|
+
message: string,
|
|
47
|
+
status: number = 400
|
|
48
|
+
): void {
|
|
49
|
+
setJsonResponse(handler, { error: { code, message } }, status);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 设置模板响应(HTML 页面)
|
|
54
|
+
* 统一设置模板和数据
|
|
55
|
+
*
|
|
56
|
+
* @param handler Handler 实例
|
|
57
|
+
* @param template 模板路径
|
|
58
|
+
* @param data 模板数据
|
|
59
|
+
*/
|
|
60
|
+
export function setTemplateResponse(handler: Handler, template: string, data: unknown = {}): void {
|
|
61
|
+
handler.response.template = template;
|
|
62
|
+
handler.response.body = data;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 判断请求是否期望 JSON 响应
|
|
67
|
+
* 根据 Accept 头部判断
|
|
68
|
+
*
|
|
69
|
+
* @param handler Handler 实例
|
|
70
|
+
* @returns 是否期望 JSON
|
|
71
|
+
*/
|
|
72
|
+
export function expectsJson(handler: Handler): boolean {
|
|
73
|
+
const accept = handler.request.headers.accept || '';
|
|
74
|
+
return accept.includes('application/json');
|
|
75
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 查询参数解析工具
|
|
3
|
+
* 提供统一的查询参数解析和验证
|
|
4
|
+
*
|
|
5
|
+
* @module lib/queryHelpers
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 分页参数接口
|
|
10
|
+
*/
|
|
11
|
+
export interface PaginationParams {
|
|
12
|
+
page: number;
|
|
13
|
+
limit: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 日期范围参数接口
|
|
18
|
+
*/
|
|
19
|
+
export interface DateRangeParams {
|
|
20
|
+
startDate?: Date;
|
|
21
|
+
endDate?: Date;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 解析分页参数
|
|
26
|
+
* 从查询参数中提取并验证分页参数
|
|
27
|
+
*
|
|
28
|
+
* @param pageStr 页码字符串,默认 '1'
|
|
29
|
+
* @param limitStr 每页数量字符串,默认 '50'
|
|
30
|
+
* @param maxLimit 最大每页数量,默认 100
|
|
31
|
+
* @returns 解析后的分页参数
|
|
32
|
+
*/
|
|
33
|
+
export function parsePaginationParams(
|
|
34
|
+
pageStr: string = '1',
|
|
35
|
+
limitStr: string = '50',
|
|
36
|
+
maxLimit: number = 100
|
|
37
|
+
): PaginationParams {
|
|
38
|
+
const page = Math.max(1, parseInt(pageStr, 10) || 1);
|
|
39
|
+
const limit = Math.min(maxLimit, Math.max(1, parseInt(limitStr, 10) || 50));
|
|
40
|
+
|
|
41
|
+
return { page, limit };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 解析日期范围参数
|
|
46
|
+
* 从查询参数中提取并验证日期范围
|
|
47
|
+
*
|
|
48
|
+
* @param startDateStr 开始日期字符串 (ISO 8601 格式)
|
|
49
|
+
* @param endDateStr 结束日期字符串 (ISO 8601 格式)
|
|
50
|
+
* @returns 解析后的日期范围参数
|
|
51
|
+
*/
|
|
52
|
+
export function parseDateRangeParams(
|
|
53
|
+
startDateStr?: string,
|
|
54
|
+
endDateStr?: string
|
|
55
|
+
): DateRangeParams {
|
|
56
|
+
const result: DateRangeParams = {};
|
|
57
|
+
|
|
58
|
+
if (startDateStr) {
|
|
59
|
+
try {
|
|
60
|
+
const date = new Date(startDateStr);
|
|
61
|
+
if (!isNaN(date.getTime())) {
|
|
62
|
+
result.startDate = date;
|
|
63
|
+
}
|
|
64
|
+
} catch (err) {
|
|
65
|
+
// 忽略无效日期
|
|
66
|
+
console.error('[queryHelpers] Invalid startDate:', startDateStr);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (endDateStr) {
|
|
71
|
+
try {
|
|
72
|
+
const date = new Date(endDateStr);
|
|
73
|
+
if (!isNaN(date.getTime())) {
|
|
74
|
+
result.endDate = date;
|
|
75
|
+
}
|
|
76
|
+
} catch (err) {
|
|
77
|
+
// 忽略无效日期
|
|
78
|
+
console.error('[queryHelpers] Invalid endDate:', endDateStr);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 构建筛选条件对象
|
|
87
|
+
* 从查询参数中构建筛选条件,过滤掉空值
|
|
88
|
+
*
|
|
89
|
+
* @param params 原始查询参数对象
|
|
90
|
+
* @param allowedFields 允许的字段列表
|
|
91
|
+
* @returns 筛选条件对象
|
|
92
|
+
*/
|
|
93
|
+
export function buildFilters<T extends Record<string, unknown>>(
|
|
94
|
+
params: Record<string, unknown>,
|
|
95
|
+
allowedFields: string[]
|
|
96
|
+
): Partial<T> {
|
|
97
|
+
const filters: Partial<T> = {};
|
|
98
|
+
|
|
99
|
+
for (const field of allowedFields) {
|
|
100
|
+
const value = params[field];
|
|
101
|
+
if (value !== undefined && value !== null && value !== '') {
|
|
102
|
+
// @ts-expect-error - 动态字段赋值
|
|
103
|
+
filters[field] = value;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return filters;
|
|
108
|
+
}
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI Config Model - AI 服务配置数据模型
|
|
3
|
+
*
|
|
4
|
+
* 管理全局 AI 服务配置(API Key、模型名称等)
|
|
5
|
+
* 约定:数据库中最多只有一条配置记录(固定 ID = 'default')
|
|
6
|
+
*
|
|
7
|
+
* v2 新增:支持多 API 端点配置、模型自动获取、Fallback 机制
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Db, Collection } from 'mongodb';
|
|
11
|
+
import { randomUUID } from 'crypto';
|
|
12
|
+
|
|
13
|
+
/** 当前配置版本号 */
|
|
14
|
+
export const CURRENT_CONFIG_VERSION = 2;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* API 端点配置
|
|
18
|
+
*/
|
|
19
|
+
export interface APIEndpoint {
|
|
20
|
+
id: string; // 唯一标识 (UUID)
|
|
21
|
+
name: string; // 显示名称
|
|
22
|
+
apiBaseUrl: string; // API Base URL
|
|
23
|
+
apiKeyEncrypted: string; // 加密后的 API Key
|
|
24
|
+
models: string[]; // 可用模型列表
|
|
25
|
+
modelsLastFetched?: Date; // 模型列表最后获取时间
|
|
26
|
+
enabled: boolean; // 是否启用
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 选中的模型(按 fallback 顺序排列)
|
|
31
|
+
*/
|
|
32
|
+
export interface SelectedModel {
|
|
33
|
+
endpointId: string; // 端点 ID
|
|
34
|
+
modelName: string; // 模型名称
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* AI 配置接口 (v2)
|
|
39
|
+
*/
|
|
40
|
+
export interface AIConfig {
|
|
41
|
+
_id: string; // 固定为 'default'
|
|
42
|
+
configVersion: number; // 配置版本号,用于迁移检测
|
|
43
|
+
endpoints: APIEndpoint[]; // API 端点列表
|
|
44
|
+
selectedModels: SelectedModel[]; // 选中的模型(��� fallback 顺序)
|
|
45
|
+
rateLimitPerMinute: number; // 频率限制(每分钟最大请求数)
|
|
46
|
+
timeoutSeconds: number; // 超时时间(秒)
|
|
47
|
+
systemPromptTemplate?: string; // 系统提示词模板(可选)
|
|
48
|
+
extraJailbreakPatternsText?: string; // 自定义越狱规则(多行文本)
|
|
49
|
+
updatedAt: Date; // 最后更新时间
|
|
50
|
+
// 保留旧字段用于向后兼容(迁移完成后可能为空)
|
|
51
|
+
apiBaseUrl?: string;
|
|
52
|
+
modelName?: string;
|
|
53
|
+
apiKeyEncrypted?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 旧版配置接口(用于迁移检测)
|
|
58
|
+
*/
|
|
59
|
+
interface LegacyAIConfig {
|
|
60
|
+
_id: string;
|
|
61
|
+
apiBaseUrl: string;
|
|
62
|
+
modelName: string;
|
|
63
|
+
apiKeyEncrypted: string;
|
|
64
|
+
rateLimitPerMinute: number;
|
|
65
|
+
timeoutSeconds: number;
|
|
66
|
+
systemPromptTemplate?: string;
|
|
67
|
+
extraJailbreakPatternsText?: string;
|
|
68
|
+
updatedAt: Date;
|
|
69
|
+
configVersion?: number;
|
|
70
|
+
endpoints?: APIEndpoint[];
|
|
71
|
+
selectedModels?: SelectedModel[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* AI Config Model 操作类
|
|
76
|
+
* 封装 AI 配置的 CRUD 操作
|
|
77
|
+
*/
|
|
78
|
+
export class AIConfigModel {
|
|
79
|
+
private collection: Collection<AIConfig>;
|
|
80
|
+
private readonly FIXED_ID = 'default'; // 固定配置记录 ID
|
|
81
|
+
|
|
82
|
+
constructor(db: Db) {
|
|
83
|
+
this.collection = db.collection<AIConfig>('ai_config');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 确保索引已创建
|
|
88
|
+
* (单条记录无需复杂索引,仅用于一致性)
|
|
89
|
+
*/
|
|
90
|
+
async ensureIndexes(): Promise<void> {
|
|
91
|
+
// 创建 _id 索引(MongoDB 自动创建,此处仅占位)
|
|
92
|
+
console.log('[AIConfigModel] Collection initialized');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 获取当前配置(自动执行懒迁移)
|
|
97
|
+
* @returns 配置对象或 null(若尚未配置)
|
|
98
|
+
*/
|
|
99
|
+
async getConfig(): Promise<AIConfig | null> {
|
|
100
|
+
const rawConfig = await this.collection.findOne({ _id: this.FIXED_ID });
|
|
101
|
+
|
|
102
|
+
if (!rawConfig) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 懒迁移:检测并迁移旧版配置
|
|
107
|
+
if (this.needsMigration(rawConfig as unknown as LegacyAIConfig)) {
|
|
108
|
+
const migratedConfig = this.migrateFromLegacy(rawConfig as unknown as LegacyAIConfig);
|
|
109
|
+
// 持久化迁移后的配置
|
|
110
|
+
await this.collection.updateOne(
|
|
111
|
+
{ _id: this.FIXED_ID },
|
|
112
|
+
{ $set: migratedConfig }
|
|
113
|
+
);
|
|
114
|
+
return migratedConfig;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return rawConfig;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 检测是否需要迁移
|
|
122
|
+
*/
|
|
123
|
+
private needsMigration(config: LegacyAIConfig): boolean {
|
|
124
|
+
// 无 configVersion 或版本低于当前版本需要迁移
|
|
125
|
+
return !config.configVersion || config.configVersion < CURRENT_CONFIG_VERSION;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 从旧版配置迁移到新版
|
|
130
|
+
*/
|
|
131
|
+
private migrateFromLegacy(legacy: LegacyAIConfig): AIConfig {
|
|
132
|
+
console.log('[AIConfigModel] Migrating from legacy config to v2...');
|
|
133
|
+
|
|
134
|
+
// 如果已有 endpoints,规范化并保留现有数据
|
|
135
|
+
if (legacy.endpoints && legacy.endpoints.length > 0) {
|
|
136
|
+
const normalizedEndpoints = legacy.endpoints.map((endpoint, index) => ({
|
|
137
|
+
id: endpoint.id || randomUUID(),
|
|
138
|
+
name: endpoint.name || `端点 ${index + 1}`,
|
|
139
|
+
apiBaseUrl: endpoint.apiBaseUrl || legacy.apiBaseUrl || '',
|
|
140
|
+
apiKeyEncrypted: endpoint.apiKeyEncrypted || legacy.apiKeyEncrypted || '',
|
|
141
|
+
models: Array.isArray(endpoint.models) ? endpoint.models : [],
|
|
142
|
+
modelsLastFetched: endpoint.modelsLastFetched,
|
|
143
|
+
enabled: endpoint.enabled !== false,
|
|
144
|
+
}));
|
|
145
|
+
|
|
146
|
+
const endpointIds = new Set(normalizedEndpoints.map(ep => ep.id));
|
|
147
|
+
let selectedModels = (legacy.selectedModels || []).filter(sm => endpointIds.has(sm.endpointId));
|
|
148
|
+
|
|
149
|
+
// 如果没有有效的 selectedModels,尝试从旧配置或端点模型列表推导
|
|
150
|
+
if (selectedModels.length === 0) {
|
|
151
|
+
if (legacy.modelName && normalizedEndpoints[0]) {
|
|
152
|
+
selectedModels = [{ endpointId: normalizedEndpoints[0].id, modelName: legacy.modelName }];
|
|
153
|
+
} else {
|
|
154
|
+
selectedModels = normalizedEndpoints
|
|
155
|
+
.filter(ep => ep.models.length > 0)
|
|
156
|
+
.map(ep => ({ endpointId: ep.id, modelName: ep.models[0] }));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
...legacy,
|
|
162
|
+
configVersion: CURRENT_CONFIG_VERSION,
|
|
163
|
+
endpoints: normalizedEndpoints,
|
|
164
|
+
selectedModels,
|
|
165
|
+
} as AIConfig;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 从旧字段创建默认端点
|
|
169
|
+
const defaultEndpoint: APIEndpoint = {
|
|
170
|
+
id: randomUUID(),
|
|
171
|
+
name: '默认端点',
|
|
172
|
+
apiBaseUrl: legacy.apiBaseUrl || '',
|
|
173
|
+
apiKeyEncrypted: legacy.apiKeyEncrypted || '',
|
|
174
|
+
models: legacy.modelName ? [legacy.modelName] : [],
|
|
175
|
+
modelsLastFetched: undefined,
|
|
176
|
+
enabled: true,
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// 创建默认选中模型
|
|
180
|
+
const selectedModels: SelectedModel[] = legacy.modelName
|
|
181
|
+
? [{ endpointId: defaultEndpoint.id, modelName: legacy.modelName }]
|
|
182
|
+
: [];
|
|
183
|
+
|
|
184
|
+
const migratedConfig: AIConfig = {
|
|
185
|
+
_id: legacy._id,
|
|
186
|
+
configVersion: CURRENT_CONFIG_VERSION,
|
|
187
|
+
endpoints: legacy.apiBaseUrl ? [defaultEndpoint] : [],
|
|
188
|
+
selectedModels,
|
|
189
|
+
rateLimitPerMinute: legacy.rateLimitPerMinute,
|
|
190
|
+
timeoutSeconds: legacy.timeoutSeconds,
|
|
191
|
+
systemPromptTemplate: legacy.systemPromptTemplate,
|
|
192
|
+
extraJailbreakPatternsText: legacy.extraJailbreakPatternsText,
|
|
193
|
+
updatedAt: legacy.updatedAt,
|
|
194
|
+
// 保留旧字段便于回滚
|
|
195
|
+
apiBaseUrl: legacy.apiBaseUrl,
|
|
196
|
+
modelName: legacy.modelName,
|
|
197
|
+
apiKeyEncrypted: legacy.apiKeyEncrypted,
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
console.log('[AIConfigModel] Migration complete. Created endpoint:', defaultEndpoint.id);
|
|
201
|
+
return migratedConfig;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* 更新配置(若不存在则创建)
|
|
206
|
+
* @param partial 要更新的配置字段(部分更新)
|
|
207
|
+
* @returns 更新后的配置对象
|
|
208
|
+
*/
|
|
209
|
+
async updateConfig(partial: Partial<Omit<AIConfig, '_id' | 'updatedAt'>>): Promise<AIConfig> {
|
|
210
|
+
const now = new Date();
|
|
211
|
+
|
|
212
|
+
// 确保 configVersion 为最新
|
|
213
|
+
const updateData = {
|
|
214
|
+
...partial,
|
|
215
|
+
configVersion: CURRENT_CONFIG_VERSION,
|
|
216
|
+
updatedAt: now
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
// 使用 upsert 更新或创建配置
|
|
220
|
+
await this.collection.updateOne(
|
|
221
|
+
{ _id: this.FIXED_ID },
|
|
222
|
+
{ $set: updateData },
|
|
223
|
+
{ upsert: true }
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
// 查询更新后的配置
|
|
227
|
+
const config = await this.collection.findOne({ _id: this.FIXED_ID });
|
|
228
|
+
|
|
229
|
+
if (!config) {
|
|
230
|
+
throw new Error('配置更新失败:无法读取更新后的配置');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return config;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* 添加新端点
|
|
238
|
+
*/
|
|
239
|
+
async addEndpoint(endpoint: Omit<APIEndpoint, 'id'>): Promise<APIEndpoint> {
|
|
240
|
+
const config = await this.getConfig();
|
|
241
|
+
const newEndpoint: APIEndpoint = {
|
|
242
|
+
...endpoint,
|
|
243
|
+
id: randomUUID(),
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const endpoints = config?.endpoints || [];
|
|
247
|
+
endpoints.push(newEndpoint);
|
|
248
|
+
|
|
249
|
+
await this.updateConfig({ endpoints });
|
|
250
|
+
return newEndpoint;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* 更新端点
|
|
255
|
+
*/
|
|
256
|
+
async updateEndpoint(endpointId: string, updates: Partial<Omit<APIEndpoint, 'id'>>): Promise<void> {
|
|
257
|
+
const config = await this.getConfig();
|
|
258
|
+
if (!config) {
|
|
259
|
+
throw new Error('配置不存在');
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const endpoints = config.endpoints.map(ep =>
|
|
263
|
+
ep.id === endpointId ? { ...ep, ...updates } : ep
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
await this.updateConfig({ endpoints });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* 删除端点
|
|
271
|
+
*/
|
|
272
|
+
async deleteEndpoint(endpointId: string): Promise<void> {
|
|
273
|
+
const config = await this.getConfig();
|
|
274
|
+
if (!config) {
|
|
275
|
+
throw new Error('配置不存在');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const endpoints = config.endpoints.filter(ep => ep.id !== endpointId);
|
|
279
|
+
// 同时移除引用该端点的 selectedModels
|
|
280
|
+
const selectedModels = config.selectedModels.filter(sm => sm.endpointId !== endpointId);
|
|
281
|
+
|
|
282
|
+
await this.updateConfig({ endpoints, selectedModels });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* 更新选中的模型列表
|
|
287
|
+
*/
|
|
288
|
+
async updateSelectedModels(selectedModels: SelectedModel[]): Promise<void> {
|
|
289
|
+
await this.updateConfig({ selectedModels });
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* 根据端点 ID 获取端点配置
|
|
294
|
+
*/
|
|
295
|
+
async getEndpointById(endpointId: string): Promise<APIEndpoint | null> {
|
|
296
|
+
const config = await this.getConfig();
|
|
297
|
+
if (!config) {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
return config.endpoints.find(ep => ep.id === endpointId) || null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* 删除配置(用于测试或重置)
|
|
305
|
+
*/
|
|
306
|
+
async deleteConfig(): Promise<void> {
|
|
307
|
+
await this.collection.deleteOne({ _id: this.FIXED_ID });
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* 初始化默认配置(若不存在)
|
|
312
|
+
* @param defaults 默认配置值
|
|
313
|
+
*/
|
|
314
|
+
async initializeDefaultConfig(defaults: Omit<AIConfig, '_id' | 'updatedAt' | 'configVersion' | 'endpoints' | 'selectedModels'>): Promise<void> {
|
|
315
|
+
const existing = await this.getConfig();
|
|
316
|
+
|
|
317
|
+
if (!existing) {
|
|
318
|
+
await this.collection.insertOne({
|
|
319
|
+
_id: this.FIXED_ID,
|
|
320
|
+
configVersion: CURRENT_CONFIG_VERSION,
|
|
321
|
+
endpoints: [],
|
|
322
|
+
selectedModels: [],
|
|
323
|
+
...defaults,
|
|
324
|
+
updatedAt: new Date()
|
|
325
|
+
} as AIConfig);
|
|
326
|
+
|
|
327
|
+
console.log('[AIConfigModel] Default config initialized');
|
|
328
|
+
} else {
|
|
329
|
+
console.log('[AIConfigModel] Config already exists, skipping initialization');
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* 获取解析后的模型配置(用于 MultiModelClient)
|
|
335
|
+
* 返回按 fallback 顺序排列的完整模型配置
|
|
336
|
+
*/
|
|
337
|
+
async getResolvedModelConfigs(): Promise<Array<{
|
|
338
|
+
endpointId: string;
|
|
339
|
+
endpointName: string;
|
|
340
|
+
apiBaseUrl: string;
|
|
341
|
+
apiKeyEncrypted: string;
|
|
342
|
+
modelName: string;
|
|
343
|
+
timeoutSeconds: number;
|
|
344
|
+
}>> {
|
|
345
|
+
const config = await this.getConfig();
|
|
346
|
+
if (!config) {
|
|
347
|
+
return [];
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const results: Array<{
|
|
351
|
+
endpointId: string;
|
|
352
|
+
endpointName: string;
|
|
353
|
+
apiBaseUrl: string;
|
|
354
|
+
apiKeyEncrypted: string;
|
|
355
|
+
modelName: string;
|
|
356
|
+
timeoutSeconds: number;
|
|
357
|
+
}> = [];
|
|
358
|
+
|
|
359
|
+
for (const selected of config.selectedModels) {
|
|
360
|
+
const endpoint = config.endpoints.find(ep => ep.id === selected.endpointId);
|
|
361
|
+
if (endpoint && endpoint.enabled !== false) {
|
|
362
|
+
results.push({
|
|
363
|
+
endpointId: endpoint.id,
|
|
364
|
+
endpointName: endpoint.name,
|
|
365
|
+
apiBaseUrl: endpoint.apiBaseUrl,
|
|
366
|
+
apiKeyEncrypted: endpoint.apiKeyEncrypted,
|
|
367
|
+
modelName: selected.modelName,
|
|
368
|
+
timeoutSeconds: config.timeoutSeconds,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return results;
|
|
374
|
+
}
|
|
375
|
+
}
|