This commit is contained in:
2025-04-05 14:48:01 +08:00
parent a225bd4f16
commit 2ff8590ceb
23 changed files with 1079 additions and 98 deletions

View File

@@ -1,5 +1,21 @@
import { app } from './app.ts';
import { useConfig } from '@kevisual/use-config/env';
const config = useConfig();
app
.route({
path: 'auth',
key: 'auth',
id: 'auth',
})
.define(async (ctx) => {
ctx.state.tokenUser = {
id: 'abcdefff',
username: 'root',
};
ctx.query.token = config.ROOT_TEST_TOKEN;
})
.addTo(app);
app
.route({
@@ -11,6 +27,4 @@ app
})
.addTo(app);
const config = useConfig();
console.log('run demo: http://localhost:' + config.PORT + '/api/router?path=demo&key=demo');

View File

@@ -1,10 +1,7 @@
import { useConfig } from '@kevisual/use-config/env';
import { useContextKey } from '@kevisual/use-config/context';
import { Redis } from 'ioredis';
export const redis = useContextKey('redis', () => {
return new Redis();
});
import { app } from './index.ts'; // 开发环境
import { app } from './app.ts';
import './demo-route.ts';
import './routes/index.ts';
const config = useConfig();

View File

@@ -1,4 +1,4 @@
import { app } from './app.ts';
import './demo-route.ts';
import './routes/index.ts';
export { app };
export { app };

View File

@@ -1,3 +1,16 @@
import { useContextKey } from '@kevisual/use-config/context';
import { Redis } from 'ioredis';
export const redis = useContextKey('redis', () => {
const redis = new Redis({
host: 'localhost',
port: 6379,
});
return redis;
});
export const redis = useContextKey('redis');
const checkConnection = async () => {
const res = await redis.ping();
console.log('redis ping', res);
};
// checkConnection();

12
src/modules/query.ts Normal file
View File

@@ -0,0 +1,12 @@
import { Query } from '@kevisual/query/query';
import { QueryConfig } from '@kevisual/query-config';
import { config } from './config.ts';
const baseURL = new URL(config.path, config.host);
export const query = new Query({
url: baseURL.toString(),
});
export const queryConfig = new QueryConfig({
query: query as any,
});

30
src/modules/sequelize.ts Normal file
View File

@@ -0,0 +1,30 @@
import { Sequelize } from 'sequelize';
import { useConfig } from '@kevisual/use-config/env';
export const config = useConfig() as any;
export type PostgresConfig = {
postgres: {
username: string;
password: string;
host: string;
port: number;
database: string;
};
};
if (!config.POSTGRES_PASSWORD || !config.POSTGRES_USER) {
console.error('postgres config is required password and user');
process.exit(1);
}
const postgresConfig = {
username: config.POSTGRES_USER,
password: config.POSTGRES_PASSWORD,
host: config.POSTGRES_HOST || 'localhost',
port: parseInt(config.POSTGRES_PORT || '5432'),
database: config.POSTGRES_DB || 'postgres',
};
// connect to db
export const sequelize = new Sequelize({
dialect: 'postgres',
...postgresConfig,
// logging: false,
});

View File

@@ -1,7 +1,4 @@
import OpenAI from 'openai';
import { APIPromise } from 'openai/core.mjs';
import { ChatCompletionChunk } from 'openai/resources.mjs';
import { Stream } from 'openai/streaming.mjs';
export type ChatMessage = OpenAI.Chat.Completions.ChatCompletionMessageParam;
export type ChatMessageOptions = Partial<OpenAI.Chat.Completions.ChatCompletionCreateParams>;

View File

@@ -1,14 +1,14 @@
import { AES, enc } from 'crypto-js';
import CryptoJS from 'crypto-js';
// 加密函数
export function encryptAES(plainText: string, secretKey: string) {
return AES.encrypt(plainText, secretKey).toString();
return CryptoJS.AES.encrypt(plainText, secretKey).toString();
}
// 解密函数
export function decryptAES(cipherText: string, secretKey: string) {
const bytes = AES.decrypt(cipherText, secretKey);
return bytes.toString(enc.Utf8);
const bytes = CryptoJS.AES.decrypt(cipherText, secretKey);
return bytes.toString(CryptoJS.enc.Utf8);
}
type AIModel = {
@@ -25,9 +25,13 @@ type AIModel = {
*/
group: string;
/**
* 每日限制
* 每日请求频率限制
*/
dayLimit?: number;
/**
* 总的token限制
*/
tokenLimit?: number;
};
type SecretKey = {
@@ -56,6 +60,7 @@ export type ProviderResult = {
group: string;
apiKey: string;
dayLimit?: number;
tokenLimit?: number;
baseURL?: string;
/**
* 解密密钥
@@ -68,6 +73,12 @@ export type AIConfig = {
description?: string;
models: AIModel[];
secretKeys: SecretKey[];
filter?: {
objectKey: string;
type: 'array' | 'object';
operate: 'removeAttribute' | 'remove';
attribute: string[];
}[];
};
export class AIConfigParser {
private config: AIConfig;

View File

@@ -1,11 +1,165 @@
import { app } from '@/app.ts';
import { ChatServices } from './services/chat-services.ts';
import { ChatConfigServices } from './services/chat-config-srevices.ts';
import { AiChatHistoryModel } from './models/ai-chat-history.ts';
app
.route({
path: 'ai',
key: 'chat',
middleware: ['auth'],
})
.define(async () => {
//
.define(async (ctx) => {
const data = ctx.query.data || {};
const { id, messages = [], options = {}, title, hook, getFull = false } = data;
let { username, model, group } = ctx.query;
const tokenUser = ctx.state.tokenUser || {};
const tokenUsername = tokenUser.username;
let aiChatHistory: AiChatHistoryModel;
if (id) {
aiChatHistory = await AiChatHistoryModel.findByPk(id);
if (!aiChatHistory) {
ctx.throw(400, 'aiChatHistory not found');
}
if (aiChatHistory.uid !== tokenUser.uid) {
ctx.throw(403, 'not permission');
}
username = username || aiChatHistory.username;
model = model || aiChatHistory.model;
group = group || aiChatHistory.group;
} else {
username = username || tokenUsername;
}
if (!Array.isArray(messages)) {
ctx.throw(400, 'chat messages is not array');
}
// 初始化服务
const chatServices = await ChatServices.createServices({
owner: username,
model,
group,
username: tokenUsername,
});
const chatConfigServices = new ChatConfigServices(username, tokenUsername);
await chatConfigServices.checkUserCanChat(tokenUsername);
await chatServices.checkCanChat();
const pickMessages = await chatServices.chatMessagePick(messages);
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,
});
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,
};
if (hook) {
needUpdateData.data = {
...aiChatHistory.data,
hook,
};
}
await AiChatHistoryModel.update(needUpdateData, { where: { id: aiChatHistory.id } });
ctx.body = {
message: newMessage[newMessage.length - 1],
aiChatHistory: getFull || !id ? aiChatHistory : undefined,
};
})
.addTo(app);
// http://localhost:4010/api/router?path=ai&key=question&question="1 and 1 equals"
app
.route({
path: 'ai',
key: 'question',
middleware: ['auth'],
isDebug: true,
})
.define(async (ctx) => {
const data = ctx.query;
const model = data.model || 'qwq:latest';
const group = data.group || 'ollama';
const tokenUser = ctx.state.tokenUser;
const chatServices = await ChatServices.createServices({
owner: data.username || 'root',
model,
group,
username: tokenUser.username,
});
const res = await chatServices.chat([
{
role: 'user',
content: data.question,
},
]);
ctx.body = res;
})
.addTo(app);
// http://localhost:4010/api/router?path=ai&key=get-model-list
app
.route({
path: 'ai',
key: 'get-model-list',
middleware: ['auth'],
})
.define(async (ctx) => {
const username = ctx.query.username || 'root';
const tokenUser = ctx.state.tokenUser;
const isSameUser = username === tokenUser.username;
const configObject: Record<string, any> = {};
const services = new ChatConfigServices(username, tokenUser.username);
const res = await services.getChatConfig(true, ctx.query.token);
configObject[username] = res;
if (!isSameUser) {
const selfServices = new ChatConfigServices(tokenUser.username, tokenUser.username);
const selfRes = await selfServices.getChatConfig(true, ctx.query.token);
configObject['self'] = selfRes;
} else {
configObject['self'] = res;
}
ctx.body = configObject;
})
.addTo(app);
app
.route({
path: 'ai',
key: 'get-chat-usage',
description: '获取chat使用情况',
middleware: ['auth'],
})
.define(async (ctx) => {
const tokenUser = ctx.state.tokenUser || {};
const username = tokenUser.username;
const services = new ChatConfigServices('root', username);
const chatServices = await ChatServices.createServices({ owner: username, username });
const rootUsage = await services.getUserChatLimit(username);
const selfUsage = await chatServices.getChatLimit();
ctx.body = {
rootUsage,
selfUsage,
};
})
.addTo(app);

View File

@@ -0,0 +1,23 @@
import { app } from '@/app.ts';
import { AiChatHistoryModel } from './models/ai-chat-history.ts';
app
.route({
path: 'ai',
key: 'get-chat-list',
description: '获取chat列表',
middleware: ['auth'],
})
.define(async (ctx) => {
const tokenUser = ctx.state.tokenUser;
const aiChatList = await AiChatHistoryModel.findAll({
where: {
uid: tokenUser.uid,
},
order: [['createdAt', 'DESC']],
});
ctx.body = {
list: aiChatList,
};
})
.addTo(app);

View File

@@ -0,0 +1,99 @@
import { sequelize } from '@/modules/sequelize.ts';
import { DataTypes, Model } from 'sequelize';
export type AiChatHistory = Partial<InstanceType<typeof AiChatHistoryModel>>;
export type ChastHistoryMessage = {
role: string;
content: string;
name: string;
id?: string;
createdAt?: number;
updatedAt?: number;
hide?: boolean;
noUse?: boolean;
};
type AiChatHistoryData = {
hook?: {
[key: string]: any;
};
};
export class AiChatHistoryModel extends Model {
declare id: string;
declare username: string;
declare model: string;
declare group: string;
declare title: string;
declare messages: ChastHistoryMessage[];
declare uid: string;
declare data: AiChatHistoryData;
declare prompt_tokens: number;
declare total_tokens: number;
declare completion_tokens: number;
declare createdAt: Date;
declare updatedAt: Date;
}
AiChatHistoryModel.init(
{
id: {
type: DataTypes.UUID,
primaryKey: true,
defaultValue: DataTypes.UUIDV4,
},
username: {
type: DataTypes.STRING,
allowNull: false,
},
model: {
type: DataTypes.STRING,
allowNull: false,
},
group: {
type: DataTypes.STRING,
allowNull: false,
},
title: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: '',
},
messages: {
type: DataTypes.JSONB,
allowNull: false,
defaultValue: [],
},
prompt_tokens: {
type: DataTypes.INTEGER,
defaultValue: 0,
},
total_tokens: {
type: DataTypes.INTEGER,
defaultValue: 0,
},
completion_tokens: {
type: DataTypes.INTEGER,
defaultValue: 0,
},
data: {
type: DataTypes.JSONB,
defaultValue: {},
},
uid: {
type: DataTypes.UUID,
allowNull: true,
},
},
{
sequelize,
tableName: 'kv_ai_chat_history',
paranoid: false,
},
);
AiChatHistoryModel.sync({ alter: true, logging: false }).catch((e) => {
console.error('AiChatHistoryModel sync', e);
});

View File

@@ -0,0 +1,128 @@
import 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';
export class ChatConfigServices {
cachePrefix = 'ai:chat:config';
// 使用谁的模型
owner: string;
// 使用者
username: string;
/**
* username 是使用的模型的用户名,使用谁的模型
* @param username
*/
constructor(owner: string, username: string, token?: string) {
this.owner = owner;
this.username = username;
}
getKey() {
return `${this.cachePrefix}:${this.owner}`;
}
/**
* 获取chat配置
* @param needClearSecret 是否需要清除secret 默认false
* @returns
*/
async getChatConfig(needClearSecret = false, token?: string) {
const key = this.getKey();
const cache = await redis.get(key);
let modelConfig = null;
if (cache) {
modelConfig = JSON.parse(cache);
}
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`);
} else {
const res = await queryConfig.getConfigByKey('ai.json', { token });
if (res.code === 200 && res.data?.data) {
modelConfig = res.data.data;
} else {
throw new CustomError(400, 'get config failed');
}
}
}
if (!modelConfig) {
throw new CustomError(`${this.owner} modelConfig is null`);
}
if (!cache) {
const cacheTime = 60 * 60 * 24 * 40; // 1天
await redis.set(key, JSON.stringify(modelConfig), 'EX', cacheTime);
}
if (needClearSecret) {
modelConfig = this.filterApiKey(modelConfig);
}
return modelConfig;
}
async filterApiKey(chatConfig: AIConfig) {
// 过滤掉secret中的所有apiKey移除掉并返回chatConfig
const { secretKeys, ...rest } = chatConfig;
return {
...rest,
secretKeys: secretKeys.map((item) => {
return {
...item,
apiKey: undefined,
decryptKey: undefined,
};
}),
};
}
/**
* 获取和检测当前用户的额度, 当使用 root 账号的时候,才需要检测
* username是当前使用用户
* @param username
*/
async checkUserCanChat(username: string) {
if (this.owner !== 'root') return true;
const maxToken = 100000;
const userCacheKey = `${this.cachePrefix}:root:chat-limit:${username}`;
const cache = await redis.get(userCacheKey);
if (cache) {
const cacheData = JSON.parse(cache);
if (cacheData.token >= maxToken) {
throw new CustomError(400, 'use root account token limit exceeded');
}
}
return true;
}
/**
* 获取用户的使用情况
* username是当前使用用户
* @param username
* @returns
*/
async getUserChatLimit(username: string) {
if (this.owner !== 'root') return;
const userCacheKey = `${this.cachePrefix}:root:chat-limit:${username}`;
const cache = await redis.get(userCacheKey);
if (cache) {
const cacheData = JSON.parse(cache);
return cacheData;
}
return {
token: 0,
day: 0,
};
}
/**
* 更新用户的使用情况
* username是当前使用用户
* @param username
* @param token
*/
async updateUserChatLimit(username: string, token: number) {
if (this.owner !== 'root') return;
const userCacheKey = `${this.cachePrefix}:root:chat-limit:${username}`;
const cache = await redis.get(userCacheKey);
if (cache) {
const cacheData = JSON.parse(cache);
cacheData.token = cacheData.token + token;
await redis.set(userCacheKey, JSON.stringify(cacheData), 'EX', 60 * 60 * 24 * 30); // 30天
} else {
await redis.set(userCacheKey, JSON.stringify({ token }), 'EX', 60 * 60 * 24 * 30); // 30天
}
}
}

View File

@@ -1,9 +1,15 @@
import { AIConfigParser, ProviderResult } from '@/provider/utils/parse-config.ts';
import { ProviderManager, ChatMessage, BaseChat } from '@/provider/index.ts';
import { getChatConfig } from '@/modules/chat-config.ts';
import { ProviderManager, ChatMessage, BaseChat, ChatMessageOptions } from '@/provider/index.ts';
import { redis } from '@/modules/db.ts';
import { CustomError } from '@kevisual/router';
import { ChatConfigServices } from './chat-config-srevices.ts';
import { pick } from 'lodash-es';
import { ChastHistoryMessage } from '../models/ai-chat-history.ts';
import { nanoid } from '@/utils/uuid.ts';
import dayjs from 'dayjs';
export type ChatServicesConfig = {
username: string;
owner: string;
model: string;
group: string;
decryptKey?: string;
@@ -13,7 +19,7 @@ export class ChatServices {
/**
* 用户名
*/
username: string;
owner: string;
/**
* 模型
*/
@@ -32,7 +38,7 @@ export class ChatServices {
modelConfig?: ProviderResult;
chatProvider?: BaseChat;
constructor(opts: ChatServicesConfig) {
this.username = opts.username;
this.owner = opts.owner;
this.model = opts.model;
this.group = opts.group;
this.decryptKey = opts.decryptKey;
@@ -41,8 +47,8 @@ export class ChatServices {
* 初始化
* @returns
*/
async init() {
const config = await this.getConfig();
async init(username: string) {
const config = await this.getConfig(username);
const aiConfigParser = new AIConfigParser(config);
const model = this.model;
const group = this.group;
@@ -52,24 +58,30 @@ export class ChatServices {
const apiKey = await aiConfigParser.getSecretKey({
getCache: async (key) => {
const cache = await redis.get(that.wrapperKey(key));
return cache;
return cache ? JSON.parse(cache) : null;
},
setCache: async (key, value) => {
await redis.set(that.wrapperKey(key), value);
await redis.set(that.wrapperKey(key), JSON.stringify(value), 'EX', 60 * 60 * 24 * 1); // 1天
},
});
that.modelConfig = { ...providerResult, apiKey };
return that.modelConfig;
}
async wrapperKey(key: string) {
const username = this.username;
return `${this.cachePrefix}${username}:${key}`;
/**
* 包装key , 默认了username
* @param key
* @returns
*/
wrapperKey(key: string) {
const owner = this.owner;
return `${this.cachePrefix}${owner}:${key}`;
}
async getConfig() {
return getChatConfig();
async getConfig(username: string) {
const services = new ChatConfigServices(this.owner, username);
return services.getChatConfig();
}
async chat(messages: ChatMessage[]) {
async chat(messages: ChatMessage[], options?: ChatMessageOptions) {
const { model, provider, apiKey, baseURL } = this.modelConfig;
const providerManager = await ProviderManager.createProvider({
provider: provider,
@@ -78,14 +90,111 @@ export class ChatServices {
baseURL: baseURL,
});
this.chatProvider = providerManager;
const result = await providerManager.chat(messages);
const result = await providerManager.chat(messages, options);
return result;
}
static async createServices(opts: Partial<ChatServicesConfig>) {
const username = opts.username || 'root';
const model = opts.model || 'deepseek-r1-250120';
async createTitle(messages: ChastHistoryMessage[]) {
return nanoid();
}
/**
* 过滤消息只保留对话需要的内容name,role,content
* @param messages
* @returns
*/
async chatMessagePick(messages: ChastHistoryMessage[]) {
let newMessages = messages.filter((item) => !item.hide && !item.noUse);
return newMessages.map((item) => pick(item, ['role', 'content', 'name'])) as ChatMessage[];
}
async createNewMessage(messages: ChastHistoryMessage[]) {
return messages.map((item) => {
if (!item.id) {
item.id = 'chat' + nanoid();
item.createdAt = Date.now();
item.updatedAt = Date.now();
}
return item;
});
}
static async createServices(opts: Partial<ChatServicesConfig> & { username: string }) {
const owner = opts.owner || 'root';
const model = opts.model || 'deepseek-chat';
const group = opts.group || 'deepseek';
const decryptKey = opts.decryptKey;
return new ChatServices({ username, model, group, decryptKey });
const chatServices = new ChatServices({ owner, model, group, decryptKey });
await chatServices.init(opts.username);
return chatServices;
}
/**
* 检查模型的余量
* @returns
*/
async checkCanChat() {
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');
}
}
return true;
}
/**
* 获取模型的使用情况
* @returns
*/
async getChatLimit() {
const { modelConfig } = this;
const { group, model } = modelConfig;
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);
return cacheData;
}
return [
{
group: group,
model: model,
token: 0,
[today]: 0,
},
];
}
/**
* 更新模型的使用情况
* @param token
*/
async updateChatLimit(token: number) {
const { modelConfig } = this;
const { group, model } = modelConfig;
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;
} else {
cacheData.push({ group, model, token: token, [today]: 1 });
}
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天
}
}
}

2
src/routes/index.ts Normal file
View File

@@ -0,0 +1,2 @@
import './ai-chat/index.ts';
import './ai-chat/list.ts';

View File

@@ -0,0 +1,9 @@
import { encryptAES, decryptAES } from '../../provider/utils/parse-config.ts';
const plainx = process.env.API_KEY;
const decryptKey = process.env.DECRYPT_KEY;
const encrypt = encryptAES(plainx, decryptKey);
console.log('encrypt', encrypt);
const decrypt = decryptAES(encrypt, decryptKey);
console.log(decrypt);

8
src/utils/uuid.ts Normal file
View File

@@ -0,0 +1,8 @@
import { customAlphabet } from 'nanoid';
export const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
export const nanoid = customAlphabet(alphabet, 16);
export function uuid() {
return nanoid();
}