diff --git a/package.json b/package.json index b36f3ab..590cc36 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "type": "system-app" }, "files": [ - "dist" + "dist", + "types" ], "scripts": { "watch": "rollup -c rollup.config.mjs -w", @@ -29,7 +30,8 @@ "dependencies": { "@kevisual/cache": "^0.0.2", "@kevisual/permission": "^0.0.1", - "@kevisual/router": "0.0.10" + "@kevisual/router": "0.0.10", + "pino-pretty": "^13.0.0" }, "devDependencies": { "@kevisual/code-center-module": "0.0.18", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d3f2c45..5562649 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@kevisual/router': specifier: 0.0.10 version: 0.0.10 + pino-pretty: + specifier: ^13.0.0 + version: 13.0.0 devDependencies: '@kevisual/code-center-module': specifier: 0.0.18 @@ -1197,6 +1200,9 @@ packages: colorette@1.4.0: resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==} + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -1291,6 +1297,9 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + dateformat@4.6.3: + resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + dayjs@1.11.13: resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==} @@ -1400,6 +1409,9 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + end-of-stream@1.4.4: + resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} + engine.io-parser@5.2.3: resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} engines: {node: '>=10.0.0'} @@ -1523,6 +1535,9 @@ packages: fast-content-type-parse@2.0.1: resolution: {integrity: sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q==} + fast-copy@3.0.2: + resolution: {integrity: sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1537,6 +1552,9 @@ packages: resolution: {integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==} engines: {node: '>=6'} + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fast-uri@3.0.6: resolution: {integrity: sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==} @@ -1736,6 +1754,9 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + help-me@5.0.0: + resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + hexoid@2.0.0: resolution: {integrity: sha512-qlspKUK7IlSQv2o+5I7yhUd7TxlOG2Vr5LTa3ve2XSNVKAL/n/u/7KLvKmFNimomDIKvZFXWHv0T12mv7rT8Aw==} engines: {node: '>=8'} @@ -2326,6 +2347,10 @@ packages: pino-abstract-transport@2.0.0: resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} + pino-pretty@13.0.0: + resolution: {integrity: sha512-cQBBIVG3YajgoUjo1FdKVRX6t9XPxwB9lcNJVD5GCnNM4Y6T12YYx8c6zEejxQsU0wrg9TwmDulcE9LR7qcJqA==} + hasBin: true + pino-std-serializers@7.0.0: resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==} @@ -2433,6 +2458,9 @@ packages: pstree.remy@1.1.8: resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} + pump@3.0.2: + resolution: {integrity: sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -2579,6 +2607,9 @@ packages: sax@1.4.1: resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} + secure-json-parse@2.7.0: + resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} + selfsigned@2.4.1: resolution: {integrity: sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==} engines: {node: '>=10'} @@ -2789,6 +2820,10 @@ packages: resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} engines: {node: '>=18'} + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + stubborn-fs@1.2.5: resolution: {integrity: sha512-H2N9c26eXjzL/S/K+i/RHHcFanE74dptvvjM8iwzwbVcWY/zjBbgRqF3K0DY4+OD+uTTASTBvDoxPDaPN02D7g==} @@ -4244,6 +4279,8 @@ snapshots: colorette@1.4.0: {} + colorette@2.0.20: {} + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -4337,6 +4374,8 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + dateformat@4.6.3: {} + dayjs@1.11.13: {} dayjs@1.8.36: {} @@ -4443,6 +4482,10 @@ snapshots: emoji-regex@9.2.2: {} + end-of-stream@1.4.4: + dependencies: + once: 1.4.0 + engine.io-parser@5.2.3: {} engine.io@6.6.4: @@ -4651,6 +4694,8 @@ snapshots: fast-content-type-parse@2.0.1: {} + fast-copy@3.0.2: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.3: @@ -4665,6 +4710,8 @@ snapshots: fast-redact@3.5.0: {} + fast-safe-stringify@2.1.1: {} + fast-uri@3.0.6: {} fastq@1.19.1: @@ -4885,6 +4932,8 @@ snapshots: dependencies: function-bind: 1.1.2 + help-me@5.0.0: {} + hexoid@2.0.0: {} http-proxy-agent@7.0.2: @@ -5514,6 +5563,22 @@ snapshots: dependencies: split2: 4.2.0 + pino-pretty@13.0.0: + dependencies: + colorette: 2.0.20 + dateformat: 4.6.3 + fast-copy: 3.0.2 + fast-safe-stringify: 2.1.1 + help-me: 5.0.0 + joycon: 3.1.1 + minimist: 1.2.8 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 2.0.0 + pump: 3.0.2 + secure-json-parse: 2.7.0 + sonic-boom: 4.2.0 + strip-json-comments: 3.1.1 + pino-std-serializers@7.0.0: {} pino@9.6.0: @@ -5664,6 +5729,11 @@ snapshots: pstree.remy@1.1.8: {} + pump@3.0.2: + dependencies: + end-of-stream: 1.4.4 + once: 1.4.0 + punycode@2.3.1: {} queue-microtask@1.2.3: {} @@ -5845,6 +5915,8 @@ snapshots: sax@1.4.1: {} + secure-json-parse@2.7.0: {} + selfsigned@2.4.1: dependencies: '@types/node-forge': 1.3.11 @@ -6082,6 +6154,8 @@ snapshots: strip-final-newline@4.0.0: {} + strip-json-comments@3.1.1: {} + stubborn-fs@1.2.5: {} sucrase@3.35.0: diff --git a/rollup.config.mjs b/rollup.config.mjs index 04898d3..4ce8a3c 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -71,6 +71,9 @@ const config = { 'sequelize', // 数据库 orm 'ioredis', // redis 'pg', // pg + 'pino', // pino + 'pino-pretty', // pino-pretty + ], }; export default config; diff --git a/src/logger/index.ts b/src/logger/index.ts new file mode 100644 index 0000000..1f6fe32 --- /dev/null +++ b/src/logger/index.ts @@ -0,0 +1,37 @@ +import { pino } from 'pino'; +import { useConfig } from '@kevisual/use-config/env'; + +const config = useConfig(); + +export const logger = pino({ + level: config.LOG_LEVEL || 'info', + transport: { + target: 'pino-pretty', + options: { + colorize: true, + translateTime: 'SYS:standard', + ignore: 'pid,hostname', + }, + }, + serializers: { + error: pino.stdSerializers.err, + req: pino.stdSerializers.req, + res: pino.stdSerializers.res, + }, + base: { + app: 'ai-chat', + env: process.env.NODE_ENV || 'development', + }, +}); + +export const logError = (message: string, data?: any) => logger.error({ data }, message); +export const logWarning = (message: string, data?: any) => logger.warn({ data }, message); +export const logInfo = (message: string, data?: any) => logger.info({ data }, message); +export const logDebug = (message: string, data?: any) => logger.debug({ data }, message); + +export const log = { + error: logError, + warn: logWarning, + info: logInfo, + debug: logDebug, +}; diff --git a/src/provider/chat-adapter/custom.ts b/src/provider/chat-adapter/custom.ts index ffae8df..1d29255 100644 --- a/src/provider/chat-adapter/custom.ts +++ b/src/provider/chat-adapter/custom.ts @@ -7,6 +7,7 @@ export type OllamaOptions = BaseChatOptions; */ export class Custom extends BaseChat { constructor(options: OllamaOptions) { - super(options); + const baseURL = options.baseURL || 'https://api.deepseek.com/v1/'; + super({ ...(options as BaseChatOptions), baseURL: baseURL }); } } diff --git a/src/provider/chat-adapter/deepseek.ts b/src/provider/chat-adapter/deepseek.ts index 4508708..6313567 100644 --- a/src/provider/chat-adapter/deepseek.ts +++ b/src/provider/chat-adapter/deepseek.ts @@ -3,6 +3,7 @@ import { BaseChat, BaseChatOptions } from '../core/chat.ts'; export type DeepSeekOptions = Partial; export class DeepSeek extends BaseChat { constructor(options: DeepSeekOptions) { - super({ baseURL: 'https://api.deepseek.com/v1/', ...options } as any); + const baseURL = options.baseURL || 'https://api.deepseek.com/v1/'; + super({ ...(options as BaseChatOptions), baseURL: baseURL }); } } diff --git a/src/provider/chat-adapter/model-scope.ts b/src/provider/chat-adapter/model-scope.ts index 4bb8b04..2980a23 100644 --- a/src/provider/chat-adapter/model-scope.ts +++ b/src/provider/chat-adapter/model-scope.ts @@ -4,6 +4,7 @@ import { BaseChat, BaseChatOptions } from '../core/chat.ts'; export type ModelScopeOptions = Partial; export class ModelScope extends BaseChat { constructor(options: ModelScopeOptions) { - super({ baseURL: 'https://api-inference.modelscope.cn/v1/', ...options } as any); + const baseURL = options.baseURL || 'https://api-inference.modelscope.cn/v1/'; + super({ ...options, baseURL: baseURL } as any); } } diff --git a/src/provider/chat-adapter/ollama.ts b/src/provider/chat-adapter/ollama.ts index bb4e792..66733d2 100644 --- a/src/provider/chat-adapter/ollama.ts +++ b/src/provider/chat-adapter/ollama.ts @@ -21,7 +21,8 @@ type OllamaModel = { }; export class Ollama extends BaseChat { constructor(options: OllamaOptions) { - super({ baseURL: 'http://localhost:11434/v1', ...(options as BaseChatOptions) }); + const baseURL = options.baseURL || 'http://localhost:11434/v1'; + super({ ...(options as BaseChatOptions), baseURL: baseURL }); } async chat(messages: ChatMessage[], options?: ChatMessageOptions) { const res = await super.chat(messages, options); diff --git a/src/provider/chat-adapter/siliconflow.ts b/src/provider/chat-adapter/siliconflow.ts index 7948e5f..5685e34 100644 --- a/src/provider/chat-adapter/siliconflow.ts +++ b/src/provider/chat-adapter/siliconflow.ts @@ -25,7 +25,8 @@ type SiliconFlowUsageResponse = { }; export class SiliconFlow extends BaseChat { constructor(options: SiliconFlowOptions) { - super({ baseURL: 'https://api.siliconflow.com/v1', ...(options as BaseChatOptions) }); + const baseURL = options.baseURL || 'https://api.siliconflow.com/v1'; + super({ ...(options as BaseChatOptions), baseURL: baseURL }); } async getUsageInfo(): Promise { return this.openai.get('/user/info'); diff --git a/src/provider/chat-adapter/volces.ts b/src/provider/chat-adapter/volces.ts index 7ffe707..ba12fc7 100644 --- a/src/provider/chat-adapter/volces.ts +++ b/src/provider/chat-adapter/volces.ts @@ -3,6 +3,7 @@ import { BaseChat, BaseChatOptions } from '../core/chat.ts'; export type VolcesOptions = Partial; export class Volces extends BaseChat { constructor(options: VolcesOptions) { - super({ baseURL: 'https://ark.cn-beijing.volces.com/api/v3/', ...options } as any); + const baseURL = options.baseURL || 'https://ark.cn-beijing.volces.com/api/v3/'; + super({ ...(options as BaseChatOptions), baseURL: baseURL }); } } diff --git a/src/provider/core/type.ts b/src/provider/core/type.ts index 8866ca3..01db2d6 100644 --- a/src/provider/core/type.ts +++ b/src/provider/core/type.ts @@ -1,6 +1,6 @@ import OpenAI from 'openai'; -export type ChatMessage = OpenAI.Chat.Completions.ChatCompletionMessageParam; +export type ChatMessage = OpenAI.Chat.Completions.ChatCompletionMessageParam ; export type ChatMessageOptions = Partial; export type ChatMessageComplete = OpenAI.Chat.Completions.ChatCompletion; export type ChatMessageStream = OpenAI.Chat.Completions.ChatCompletion; diff --git a/src/provider/index.ts b/src/provider/index.ts index c21bb04..d77c050 100644 --- a/src/provider/index.ts +++ b/src/provider/index.ts @@ -40,6 +40,8 @@ export class ProviderManager { if (!Provider) { throw new Error(`Provider ${provider} not found`); } + console.log('pm', 'Provider', ProviderMap[provider]); + this.provider = new Provider({ model, apiKey, diff --git a/src/provider/utils/ai-config-type.ts b/src/provider/utils/ai-config-type.ts new file mode 100644 index 0000000..a4ed0c6 --- /dev/null +++ b/src/provider/utils/ai-config-type.ts @@ -0,0 +1,52 @@ +import type { Permission } from '@kevisual/permission'; + +export type AIModel = { + /** + * 提供商 + */ + provider: string; + /** + * 模型名称 + */ + model: string; + /** + * 模型组 + */ + group: string; + /** + * 每日请求频率限制 + */ + dayLimit?: number; + /** + * 总的token限制 + */ + tokenLimit?: number; +}; +export type SecretKey = { + /** + * 组 + */ + group: string; + /** + * API密钥 + */ + apiKey: string; + /** + * 解密密钥 + */ + decryptKey?: string; +}; + +export type AIConfig = { + title?: string; + description?: string; + models: AIModel[]; + secretKeys: SecretKey[]; + permission?: Permission; + filter?: { + objectKey: string; + type: 'array' | 'object'; + operate: 'removeAttribute' | 'remove'; + attribute: string[]; + }[]; +}; diff --git a/src/provider/utils/parse-config.ts b/src/provider/utils/parse-config.ts index 62a7b05..05859cf 100644 --- a/src/provider/utils/parse-config.ts +++ b/src/provider/utils/parse-config.ts @@ -88,7 +88,11 @@ export class AIConfigParser { constructor(config: AIConfig) { this.config = config; } - + /** + * 获取模型配置 + * @param opts + * @returns + */ getProvider(opts: GetProviderOpts): ProviderResult { const { model, group, decryptKey } = opts; const modelConfig = this.config.models.find((m) => m.model === model && m.group === group); @@ -117,16 +121,17 @@ export class AIConfigParser { this.result = mergeConfig; return mergeConfig; } - - async getSecretKey({ - getCache, - setCache, - providerResult, - }: { + /** + * 获取解密密钥 + * @param opts + * @returns + */ + async getSecretKey(opts?: { getCache?: (key: string) => Promise; setCache?: (key: string, value: string) => Promise; providerResult?: ProviderResult; }) { + const { getCache, setCache, providerResult } = opts || {}; const { apiKey, decryptKey, group = '', model } = providerResult || this.result; const cacheKey = `${group}--${model}`; if (!decryptKey) { @@ -144,11 +149,38 @@ export class AIConfigParser { } return secretKey; } + /** + * 加密 + * @param plainText + * @param secretKey + * @returns + */ encrypt(plainText: string, secretKey: string) { return encryptAES(plainText, secretKey); } - + /** + * 解密 + * @param cipherText + * @param secretKey + * @returns + */ decrypt(cipherText: string, secretKey: string) { return decryptAES(cipherText, secretKey); } + + /** + * 获取模型配置 + * @returns + */ + getSelectOpts() { + const { models, secretKeys = [] } = this.config; + + return models.map((model) => { + const selectOpts = secretKeys.find((m) => m.group === model.group); + return { + ...model, + ...selectOpts, + }; + }); + } } diff --git a/src/routes/ai-chat/cache.ts b/src/routes/ai-chat/cache.ts new file mode 100644 index 0000000..80887a7 --- /dev/null +++ b/src/routes/ai-chat/cache.ts @@ -0,0 +1,43 @@ +import { app } from '@/app.ts'; +import { ChatConfigServices } from './services/chat-config-srevices.ts'; +import { log } from '@/logger/index.ts'; +import { ChatServices } from './services/chat-services.ts'; + +/** + * 清除缓存 + */ +// https://localhost:4000/api/router?path=ai&key=clear-cache +app + .route({ + path: 'ai', + key: 'clear-cache', + description: '清除缓存', + middleware: ['auth'], + }) + .define(async (ctx) => { + const tokenUser = ctx.state.tokenUser; + const username = tokenUser.username; + const services = new ChatConfigServices(username, username); + await services.clearCache(); + log.info('清除缓存成功', { username }); + ctx.body = 'success'; + }) + .addTo(app); + +app + .route({ + path: 'ai', + key: 'clear-chat-limit', + description: '清除chat使用情况', + middleware: ['auth'], + }) + .define(async (ctx) => { + const tokenUser = ctx.state.tokenUser; + const username = tokenUser.username; + const cache = await ChatServices.clearChatLimit(username); + log.debug('清除chat使用情况成功', { username, cache }); + ctx.body = { + cache, + }; + }) + .addTo(app); diff --git a/src/routes/ai-chat/index.ts b/src/routes/ai-chat/index.ts index 695f3de..42034bc 100644 --- a/src/routes/ai-chat/index.ts +++ b/src/routes/ai-chat/index.ts @@ -3,6 +3,8 @@ import { ChatServices } from './services/chat-services.ts'; import { ChatConfigServices } from './services/chat-config-srevices.ts'; import { AiChatHistoryModel } from './models/ai-chat-history.ts'; import { UserPermission } from '@kevisual/permission'; +import { AIConfigParser } from '@/provider/utils/parse-config.ts'; +import { log } from '@/logger/index.ts'; app .route({ path: 'ai', @@ -11,10 +13,13 @@ app }) .define(async (ctx) => { const data = ctx.query.data || {}; - const { id, messages = [], options = {}, title, hook, getFull = false } = data; - let { username, model, group } = ctx.query; + const { id, messages = [], title, type } = data; + const hook = data.data?.hook; + + let { username, model, group, getFull = false } = ctx.query; const tokenUser = ctx.state.tokenUser || {}; const tokenUsername = tokenUser.username; + const options = ctx.query.options || {}; let aiChatHistory: AiChatHistoryModel; if (id) { aiChatHistory = await AiChatHistoryModel.findByPk(id); @@ -58,46 +63,65 @@ app if (pickMessages.length === 0) { ctx.throw(400, 'chat messages is empty'); } - const res = await chatServices.chat(pickMessages, options); if (!aiChatHistory) { aiChatHistory = await AiChatHistoryModel.create({ username, model, group, title, + type: type || 'keep', }); if (!title) { // TODO: 创建标题 } } - const message = res.choices[0].message; - const newMessage = await chatServices.createNewMessage([...messages, message]); - - const usage = chatServices.chatProvider.getChatUsage(); - await chatServices.updateChatLimit(usage.total_tokens); - await chatConfigServices.updateUserChatLimit(tokenUsername, usage.total_tokens); - - const needUpdateData: any = { - messages: newMessage, - prompt_tokens: aiChatHistory.prompt_tokens + usage.prompt_tokens, - completion_tokens: aiChatHistory.completion_tokens + usage.completion_tokens, - total_tokens: aiChatHistory.total_tokens + usage.total_tokens, - version: aiChatHistory.version + 1, - model: model, - group: group, - username: username, - }; - if (hook) { - needUpdateData.data = { - ...aiChatHistory.data, - hook, - }; + let message; + try { + const res = await chatServices.chat(pickMessages, options); + message = res.choices[0].message; + } catch (error) { + log.error('chat error', { + errorMessage: error.message, + }); + ctx.throw(500, error.message); + } + try { + const newMessage = await chatServices.createNewMessage([...messages, message]); + + const usage = chatServices.chatProvider.getChatUsage(); + await chatServices.updateChatLimit(usage.total_tokens); + await chatConfigServices.updateUserChatLimit(tokenUsername, usage.total_tokens); + + const needUpdateData: any = { + messages: newMessage, + prompt_tokens: aiChatHistory.prompt_tokens + usage.prompt_tokens, + completion_tokens: aiChatHistory.completion_tokens + usage.completion_tokens, + total_tokens: aiChatHistory.total_tokens + usage.total_tokens, + version: aiChatHistory.version + 1, + model: model, + group: group, + username: username, + }; + if (hook) { + needUpdateData.data = { + ...aiChatHistory.data, + hook, + }; + } + if (type) { + needUpdateData.type = type; + } + await AiChatHistoryModel.update(needUpdateData, { where: { id: aiChatHistory.id } }); + ctx.body = { + message: newMessage[newMessage.length - 1], + updatedAt: aiChatHistory.updatedAt, + version: aiChatHistory.version, + aiChatHistory: getFull || !id ? aiChatHistory : undefined, + }; + } catch (error) { + console.error('create new message error', error); + ctx.throw(500, error.message); } - await AiChatHistoryModel.update(needUpdateData, { where: { id: aiChatHistory.id } }); - ctx.body = { - message: newMessage[newMessage.length - 1], - aiChatHistory: getFull || !id ? aiChatHistory : undefined, - }; }) .addTo(app); @@ -136,50 +160,69 @@ app path: 'ai', key: 'get-model-list', middleware: ['auth'], + description: '获取模型列表', + isDebug: true, }) .define(async (ctx) => { const username = ctx.query.username || 'root'; const tokenUser = ctx.state.tokenUser; const usernames = ctx.query.data?.usernames || []; + const keepSecret = ctx.query.keepSecret || false; const tokenUsername = tokenUser.username; const isSameUser = username === tokenUser.username; const configArray: any[] = []; - const services = new ChatConfigServices(username, tokenUser.username); - const res = await services.getChatConfig(true, ctx.query.token); - configArray.push({ - username, - config: res, - }); - if (!isSameUser) { - const selfServices = new ChatConfigServices(tokenUser.username, tokenUser.username); - const selfRes = await selfServices.getChatConfig(true, ctx.query.token); - configArray.push({ - username: tokenUser.username, - self: true, - config: selfRes, - }); - } - for (const username of usernames) { + try { const services = new ChatConfigServices(username, tokenUser.username); - const res = await services.getChatConfig(true, ctx.query.token); - const aiConfig = services.aiConfig; - const permission = new UserPermission({ permission: aiConfig.permission, owner: username }); - const checkPermission = permission.checkPermissionSuccess({ username: tokenUsername, password: '-----------------' }); - if (!checkPermission.success) { - // ctx.throw(403, `[${username}] ${checkPermission.message}`); + const res = await services.getChatConfig(services.isOwner && keepSecret, ctx.query.token); + const selectOpts = await services.getSelectOpts(res); + configArray.push({ + username, + config: res, + selectOpts, + self: isSameUser, + }); + if (!isSameUser) { + const selfServices = new ChatConfigServices(tokenUser.username, tokenUser.username); + const selfRes = await selfServices.getChatConfig(services.isOwner && keepSecret, ctx.query.token); + const selfSelectOpts = await selfServices.getSelectOpts(selfRes); configArray.push({ - username, - config: null, - error: checkPermission.message, - }); - } else { - configArray.push({ - username, - config: res, + username: tokenUser.username, + self: true, + config: selfRes, + selectOpts: selfSelectOpts, }); } + for (const username of usernames) { + const services = new ChatConfigServices(username, tokenUser.username); + const res = await services.getChatConfig(services.isOwner && keepSecret, ctx.query.token); + const aiConfig = services.aiConfig; + const permission = new UserPermission({ permission: aiConfig.permission, owner: username }); + const checkPermission = permission.checkPermissionSuccess({ username: tokenUsername, password: '-----------------' }); + if (!checkPermission.success) { + configArray.push({ + username, + config: null, + error: checkPermission.message, + }); + } else { + const selectOpts = await services.getSelectOpts(res); + configArray.push({ + username, + config: res, + selectOpts, + self: username === tokenUser.username, + }); + } + } + ctx.body = { list: configArray }; + } catch (error) { + log.error('get model list error', { + username, + errorMessage: error.message, + errorStack: error.stack, + }); + ctx.throw(500, error.message); } - ctx.body = configArray; }) .addTo(app); diff --git a/src/routes/ai-chat/list.ts b/src/routes/ai-chat/list.ts index cca9dd2..08c4bbb 100644 --- a/src/routes/ai-chat/list.ts +++ b/src/routes/ai-chat/list.ts @@ -10,9 +10,11 @@ app }) .define(async (ctx) => { const tokenUser = ctx.state.tokenUser; + const type = ctx.query.type || 'keep'; const aiChatList = await AiChatHistoryModel.findAll({ where: { uid: tokenUser.id, + type, }, order: [['updatedAt', 'DESC']], }); diff --git a/src/routes/ai-chat/models/ai-chat-history.ts b/src/routes/ai-chat/models/ai-chat-history.ts index f0a4f80..4e1777f 100644 --- a/src/routes/ai-chat/models/ai-chat-history.ts +++ b/src/routes/ai-chat/models/ai-chat-history.ts @@ -34,6 +34,7 @@ export class AiChatHistoryModel extends Model { declare completion_tokens: number; declare version: number; + declare type: string; declare createdAt: Date; declare updatedAt: Date; @@ -87,6 +88,11 @@ AiChatHistoryModel.init( type: DataTypes.JSONB, defaultValue: {}, }, + type: { + type: DataTypes.STRING, + allowNull: false, + defaultValue: 'keep', // keep 保留 temp 临时 + }, version: { type: DataTypes.INTEGER, defaultValue: 0, diff --git a/src/routes/ai-chat/services/chat-config-srevices.ts b/src/routes/ai-chat/services/chat-config-srevices.ts index 746e618..3e7e51d 100644 --- a/src/routes/ai-chat/services/chat-config-srevices.ts +++ b/src/routes/ai-chat/services/chat-config-srevices.ts @@ -1,7 +1,8 @@ -import type { AIConfig } from '@/provider/utils/parse-config.ts'; +import { AIConfigParser, type AIConfig } from '@/provider/utils/parse-config.ts'; import { redis } from '@/modules/db.ts'; import { CustomError } from '@kevisual/router'; import { queryConfig } from '@/modules/query.ts'; +import { log } from '@/logger/index.ts'; export class ChatConfigServices { cachePrefix = 'ai:chat:config'; // 使用谁的模型 @@ -9,7 +10,7 @@ export class ChatConfigServices { // 使用者 username: string; aiConfig?: AIConfig; - + isOwner: boolean; /** * username 是使用的模型的用户名,使用谁的模型 * @param username @@ -17,16 +18,17 @@ export class ChatConfigServices { constructor(owner: string, username: string, token?: string) { this.owner = owner; this.username = username; + this.isOwner = owner === username; } getKey() { return `${this.cachePrefix}:${this.owner}`; } /** * 获取chat配置 - * @param needClearSecret 是否需要清除secret 默认false + * @param keepSecret 是否需要清除secret 默认 不清除 为true * @returns */ - async getChatConfig(needClearSecret = false, token?: string) { + async getChatConfig(keepSecret = true, token?: string) { const key = this.getKey(); const cache = await redis.get(key); let modelConfig = null; @@ -35,7 +37,9 @@ export class ChatConfigServices { } if (!modelConfig) { if (this.owner !== this.username) { - throw new CustomError(`the owner [${this.owner}] config, [${this.username}] not permission to init config, only owner can init config, place connect owner`); + throw new CustomError( + `the owner [${this.owner}] config, [${this.username}] not permission to init config, only owner can init config, place connect owner`, + ); } else { const res = await queryConfig.getConfigByKey('ai.json', { token }); if (res.code === 200 && res.data?.data) { @@ -53,14 +57,26 @@ export class ChatConfigServices { await redis.set(key, JSON.stringify(modelConfig), 'EX', cacheTime); } this.aiConfig = modelConfig; - if (needClearSecret) { + if (!keepSecret) { modelConfig = this.filterApiKey(modelConfig); } return modelConfig; } + async clearCache() { + const key = this.getKey(); + await redis.set(key, JSON.stringify({}), 'EX', 1); + } + /** + * 获取模型配置 + * @returns + */ + async getSelectOpts(config?: AIConfig) { + const aiConfigParser = new AIConfigParser(config || this.aiConfig); + return aiConfigParser.getSelectOpts(); + } async filterApiKey(chatConfig: AIConfig) { // 过滤掉secret中的所有apiKey,移除掉并返回chatConfig - const { secretKeys, ...rest } = chatConfig; + const { secretKeys = [], ...rest } = chatConfig; return { ...rest, secretKeys: secretKeys.map((item) => { @@ -128,4 +144,9 @@ export class ChatConfigServices { await redis.set(userCacheKey, JSON.stringify({ token }), 'EX', 60 * 60 * 24 * 30); // 30天 } } + async clearChatLimit() { + if (this.owner !== 'root') return; + // const userCacheKey = `${this.cachePrefix}:root:chat-limit:${this.username}`; + // await redis.del(userCacheKey); + } } diff --git a/src/routes/ai-chat/services/chat-services.ts b/src/routes/ai-chat/services/chat-services.ts index 774bc21..f02a75a 100644 --- a/src/routes/ai-chat/services/chat-services.ts +++ b/src/routes/ai-chat/services/chat-services.ts @@ -7,6 +7,7 @@ import { pick } from 'lodash-es'; import { ChastHistoryMessage } from '../models/ai-chat-history.ts'; import { nanoid } from '@/utils/uuid.ts'; import dayjs from 'dayjs'; +import { log } from '@/logger/index.ts'; export type ChatServicesConfig = { owner: string; @@ -78,22 +79,49 @@ export class ChatServices { const owner = this.owner; return `${this.cachePrefix}${owner}:${key}`; } + static chatLimitKey(owner: string, key = 'chat-limit') { + return `ai-chat:model:${owner}:${key}`; + } + static async clearChatLimit(owner: string) { + const key = ChatServices.chatLimitKey(owner); + const cache = await redis.get(key); + if (cache) { + await redis.expire(key, 2); + } + return cache; + } async getConfig(username: string) { const services = new ChatConfigServices(this.owner, username); return services.getChatConfig(); } - async chat(messages: ChatMessage[], options?: ChatMessageOptions) { + async chat(messages: ChatMessage[], options?: ChatMessageOptions, customOptions?: { clearThink?: boolean }) { const { model, provider, apiKey, baseURL } = this.modelConfig; - const providerManager = await ProviderManager.createProvider({ - provider: provider, - model: model, - apiKey: apiKey, - baseURL: baseURL, - }); - this.chatProvider = providerManager; - const result = await providerManager.chat(messages, options); - return result; + try { + const providerManager = await ProviderManager.createProvider({ + provider: provider, + model: model, + apiKey: apiKey, + baseURL: baseURL, + }); + this.chatProvider = providerManager; + const result = await providerManager.chat(messages, options); + const { clearThink = true } = customOptions || {}; + if (clearThink) { + result.choices[0].message.content = result.choices[0].message.content.replace(/[\s\S]*?<\/think>/g, ''); + } + return result; + } catch (error) { + log.error('chat error', { + errorMessage: error.message, + errorStack: error.stack, + provider, + model, + apiKey, + baseURL, + }); + throw error; + } } async createTitle(messages: ChastHistoryMessage[]) { return nanoid(); @@ -135,21 +163,31 @@ export class ChatServices { const { modelConfig } = this; const { tokenLimit, dayLimit, group, model } = modelConfig; const key = this.wrapperKey(`chat-limit`); - const cache = await redis.get(key); - if (cache) { - const cacheData = JSON.parse(cache); - const today = dayjs().format('YYYY-MM-DD'); - const current = cacheData.find((item) => item.group === group && item.model === model); - const day = current[today] || 0; - const token = current.token || 0; - if (tokenLimit && token >= tokenLimit) { - throw new CustomError(400, 'token limit exceeded'); - } - if (dayLimit && day >= dayLimit) { - throw new CustomError(400, 'day limit exceeded'); + try { + const cache = await redis.get(key); + if (cache) { + const cacheData = JSON.parse(cache); + const today = dayjs().format('YYYY-MM-DD'); + log.debug('checkCanChat', { cacheData }); + let current = cacheData.find((item) => item.group === group && item.model === model); + if (current) { + const day = current[today] || 0; + const token = current.token || 0; + if (tokenLimit && token >= tokenLimit) { + throw new CustomError(400, 'token limit exceeded'); + } + if (dayLimit && day >= dayLimit) { + throw new CustomError(400, 'day limit exceeded'); + } + } } + return true; + } catch (error) { + console.error('checkCanChat error', error); + // 如果获取失败,则设置一个空的缓存,2秒后删除 + await redis.set(key, '', 'EX', 2); // 2秒 + throw new CustomError(500, 'checkCanChat error, please try again later'); } - return true; } /** * 获取模型的使用情况 @@ -184,19 +222,27 @@ export class ChatServices { const key = this.wrapperKey(`chat-limit`); const cache = await redis.get(key); const today = dayjs().format('YYYY-MM-DD'); - if (cache) { - const cacheData = JSON.parse(cache); - const current = cacheData.find((item) => item.group === group && item.model === model); - if (current) { - const day = current[today] || 0; - current[today] = day + 1; - current.token = current.token + token; + try { + if (cache) { + const cacheData = JSON.parse(cache); + const current = cacheData.find((item) => item.group === group && item.model === model); + if (current) { + const day = current[today] || 0; + current[today] = day + 1; + current.token = current.token + token; + } else { + cacheData.push({ group, model, token: token, [today]: 1 }); + } + await redis.set(key, JSON.stringify(cacheData), 'EX', 60 * 60 * 24 * 30); // 30天 } else { - cacheData.push({ group, model, token: token, [today]: 1 }); + const cacheData = { group, model, token: token, [today]: 1 }; + await redis.set(key, JSON.stringify([cacheData]), 'EX', 60 * 60 * 24 * 30); // 30天 } - await redis.set(key, JSON.stringify(cacheData), 'EX', 60 * 60 * 24 * 30); // 30天 - } else { - await redis.set(key, JSON.stringify({ group, model, token: token, [today]: 1 }), 'EX', 60 * 60 * 24 * 30); // 30天 + } catch (error) { + console.error('updateChatLimit error', error); + // 如果更新失败,则设置一个空的缓存,2秒后删除 + await redis.set(key, '', 'EX', 2); // 2秒 + throw new CustomError(500, 'updateChatLimit error, please try again later'); } } } diff --git a/src/routes/index.ts b/src/routes/index.ts index 24dda09..989a03e 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,2 +1,3 @@ import './ai-chat/index.ts'; import './ai-chat/list.ts'; +import './ai-chat/cache.ts'; \ No newline at end of file diff --git a/src/test/model-scope/index.ts b/src/test/model-scope/index.ts new file mode 100644 index 0000000..c5a6b7e --- /dev/null +++ b/src/test/model-scope/index.ts @@ -0,0 +1,26 @@ +import { ModelScope } from '../../provider/chat-adapter/model-scope.ts'; +import { logInfo } from '../../logger/index.ts'; +import util from 'util'; +import { config } from 'dotenv'; +config(); + +const chat = new ModelScope({ + apiKey: process.env.MODEL_SCOPE_API_KEY, + model: 'Qwen/Qwen2.5-Coder-32B-Instruct', +}); + +// chat.chat([{ role: 'user', content: 'Hello, world! 1 + 1 equals ?' }]); +const chatMessage = [{ role: 'user', content: 'Hello, world! 1 + 1 equals ?' }]; + +const main = async () => { + const res = await chat.test(); + logInfo('test', res); +}; + +// main(); +const mainChat = async () => { + const res = await chat.chat(chatMessage as any); + logInfo('chat', res); +}; + +mainChat(); diff --git a/src/test/provider/index.ts b/src/test/provider/index.ts new file mode 100644 index 0000000..c596b2a --- /dev/null +++ b/src/test/provider/index.ts @@ -0,0 +1,6 @@ +import { ProviderManager } from '../../provider/index.ts'; + +const providerConfig = { provider: 'ModelScope', model: 'Qwen/Qwen2.5-Coder-32B-Instruct', apiKey: 'a4cc0e94-3633-4374-85a6-06f455e17bea' }; +const provider = await ProviderManager.createProvider(providerConfig); +const result = await provider.chat([{ role: 'user', content: '你好' }]); +console.log(result);