.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..f5a83cd
--- /dev/null
+++ b/README.md
@@ -0,0 +1,259 @@
+# 智谱清言智能体API转OpenAI接口
+
+这是一个将[智谱清言](https://chatglm.cn/)智能体API转换为OpenAI兼容协议的网关👋。
+
+## 特性
+
+- ✅ 支持非流式/流式响应
+- ✅ 支持对话补全
+- ✅ 支持联网搜索
+- ✅ 支持代码执行
+- ✅ 支持图像生成
+- ✅ 支持长文档解读
+- ✅ 支持多模态图像解析
+
+## API Key获取
+
+前往智谱清言智能体[创作者中心](https://chatglm.cn/developersPanel/apiSet)创建API Key,并使用`.`拼接Key与Secret为API Key,如下所示:
+
+```
+21a**********9a0.2f****************************37
+```
+
+## 对话补全
+
+对话补全接口,与openai的 [chat-completions-api](https://platform.openai.com/docs/guides/text-generation/chat-completions-api) 兼容。
+
+**POST /v1/chat/completions**
+
+header 需要设置 Authorization 头部:
+
+```
+Authorization: Bearer [API Key]
+```
+
+请求数据:
+```json
+{
+ // 必须填写您自己创建的智能体ID,否则无法调用成功
+ "model": "65d6ba38fca9900836172419",
+ // 目前多轮对话基于消息合并实现,某些场景可能导致能力下降且token最高为4096
+ // 如果您想获得原生的多轮对话体验,可以传入首轮消息获得的id,来接续上下文
+ // "conversation_id": "65f6c28546bae1f0fbb532de",
+ "messages": [
+ {
+ "role": "user",
+ "content": "你叫什么?"
+ }
+ ],
+ // 如果使用SSE流请设置为true,默认false
+ "stream": false
+}
+```
+
+响应数据:
+```json
+{
+ // 如果想获得原生多轮对话体验,此id,你可以传入到下一轮对话的conversation_id来接续上下文
+ "id": "65f6c28546bae1f0fbb532de",
+ "model": "65c046a531d3fcb034918abe",
+ "object": "chat.completion",
+ "choices": [
+ {
+ "index": 0,
+ "message": {
+ "role": "assistant",
+ "content": "我叫智谱清言,是基于智谱 AI 公司于 2023 年训练的 ChatGLM 开发的。我的任务是针对用户的问题和要求提供适当的答复和支持。"
+ },
+ "finish_reason": "stop"
+ }
+ ],
+ "usage": {
+ "prompt_tokens": 1,
+ "completion_tokens": 1,
+ "total_tokens": 2
+ },
+ "created": 1710152062
+}
+```
+
+## AI绘图
+
+对话补全接口,与openai的 [images-create-api](https://platform.openai.com/docs/api-reference/images/create) 兼容。
+
+**POST /v1/images/generations**
+
+header 需要设置 Authorization 头部:
+
+```
+Authorization: Bearer [API Key]
+```
+
+请求数据:
+```json
+{
+ // 必须填写您自己创建的智能体ID,否则无法调用成功
+ "model": "65d6ba38fca9900836172419",
+ "prompt": "一只可爱的猫"
+}
+```
+
+响应数据:
+```json
+{
+ "created": 1711507449,
+ "data": [
+ {
+ "url": "https://sfile.chatglm.cn/testpath/5e56234b-34ae-593c-ba4e-3f7ba77b5768_0.png"
+ }
+ ]
+}
+```
+
+## 文档解读
+
+提供一个可访问的文件URL或者BASE64_URL进行解析。
+
+**POST /v1/chat/completions**
+
+header 需要设置 Authorization 头部:
+
+```
+Authorization: Bearer [API Key]
+```
+
+请求数据:
+```json
+{
+ // 必须填写您自己创建的智能体ID,否则无法调用成功
+ "model": "65d6ba38fca9900836172419",
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "file",
+ "file_url": {
+ "url": "https://mj101-1317487292.cos.ap-shanghai.myqcloud.com/ai/test.pdf"
+ }
+ },
+ {
+ "type": "text",
+ "text": "文档里说了什么?"
+ }
+ ]
+ }
+ ],
+ // 如果使用SSE流请设置为true,默认false
+ "stream": false
+}
+```
+
+响应数据:
+```json
+{
+ "id": "663e4b9b91634d28460fdd74",
+ "model": "65d6ba38fca9900836172419",
+ "object": "chat.completion",
+ "choices": [
+ {
+ "index": 0,
+ "message": {
+ "role": "assistant",
+ "content": "根据文档内容,我总结如下:\n\n这是一份关于希腊罗马时期的魔法咒语和仪式的文本,包含几个魔法仪式:\n\n1. 一个涉及面包、仪式场所和特定咒语的仪式,用于使某人爱上你。\n\n2. 一个针对女神赫卡忒的召唤仪式,用来折磨某人直到她自愿来到你身边。\n\n3. 一个通过念诵爱神阿芙罗狄蒂的秘密名字,连续七天进行仪式,来赢得一个美丽女子的心。\n\n4. 一个通过燃烧没药并念诵咒语,让一个女子对你产生强烈欲望的仪式。\n\n这些仪式都带有魔法和迷信色彩,使用各种咒语和象征性行为来影响人的感情和意愿。"
+ },
+ "finish_reason": "stop"
+ }
+ ],
+ "usage": {
+ "prompt_tokens": 1,
+ "completion_tokens": 1,
+ "total_tokens": 2
+ },
+ "created": 100920
+}
+```
+
+## 图像解析
+
+提供一个可访问的图像URL或者BASE64_URL进行解析。
+
+此格式兼容 [gpt-4-vision-preview](https://platform.openai.com/docs/guides/vision) API格式,您也可以用这个格式传送文档进行解析。
+
+**POST /v1/chat/completions**
+
+header 需要设置 Authorization 头部:
+
+```
+Authorization: Bearer [API Key]
+```
+
+请求数据:
+```json
+{
+ "model": "65c046a531d3fcb034918abe",
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "image_url",
+ "image_url": {
+ "url": "http://1255881664.vod2.myqcloud.com/6a0cd388vodbj1255881664/7b97ce1d3270835009240537095/uSfDwh6ZpB0A.png"
+ }
+ },
+ {
+ "type": "text",
+ "text": "图像描述了什么?"
+ }
+ ]
+ }
+ ],
+ "stream": false
+}
+```
+
+响应数据:
+```json
+{
+ "id": "65f6c28546bae1f0fbb532de",
+ "model": "65c046a531d3fcb034918abe",
+ "object": "chat.completion",
+ "choices": [
+ {
+ "index": 0,
+ "message": {
+ "role": "assistant",
+ "content": "图片中展示的是一个蓝色背景下的logo,具体地,左边是一个由多个蓝色的圆点组成的圆形图案,右边是“智谱·AI”四个字,字体颜色为蓝色。"
+ },
+ "finish_reason": "stop"
+ }
+ ],
+ "usage": {
+ "prompt_tokens": 1,
+ "completion_tokens": 1,
+ "total_tokens": 2
+ },
+ "created": 1710670469
+}
+```
+
+## 注意事项
+
+### Nginx反代优化
+
+如果您正在使用Nginx反向代理,请添加以下配置项优化流的输出效果,优化体验感。
+
+```nginx
+# 关闭代理缓冲。当设置为off时,Nginx会立即将客户端请求发送到后端服务器,并立即将从后端服务器接收到的响应发送回客户端。
+proxy_buffering off;
+# 启用分块传输编码。分块传输编码允许服务器为动态生成的内容分块发送数据,而不需要预先知道内容的大小。
+chunked_transfer_encoding on;
+# 开启TCP_NOPUSH,这告诉Nginx在数据包发送到客户端之前,尽可能地发送数据。这通常在sendfile使用时配合使用,可以提高网络效率。
+tcp_nopush on;
+# 开启TCP_NODELAY,这告诉Nginx不延迟发送数据,立即发送小数据包。在某些情况下,这可以减少网络的延迟。
+tcp_nodelay on;
+# 设置保持连接的超时时间,这里设置为120秒。如果在这段时间内,客户端和服务器之间没有进一步的通信,连接将被关闭。
+keepalive_timeout 120;
+```
+
diff --git a/configs/dev/service.yml b/configs/dev/service.yml
new file mode 100644
index 0000000..b35b29f
--- /dev/null
+++ b/configs/dev/service.yml
@@ -0,0 +1,6 @@
+# 服务名称
+name: zhipuai-agent-to-openai
+# 服务绑定主机地址
+host: '0.0.0.0'
+# 服务绑定端口
+port: 8000
\ No newline at end of file
diff --git a/configs/dev/system.yml b/configs/dev/system.yml
new file mode 100644
index 0000000..dca6170
--- /dev/null
+++ b/configs/dev/system.yml
@@ -0,0 +1,14 @@
+# 是否开启请求日志
+requestLog: true
+# 临时目录路径
+tmpDir: ./tmp
+# 日志目录路径
+logDir: ./logs
+# 日志写入间隔(毫秒)
+logWriteInterval: 200
+# 日志文件有效期(毫秒)
+logFileExpires: 2626560000
+# 公共目录路径
+publicDir: ./public
+# 临时文件有效期(毫秒)
+tmpFileExpires: 86400000
\ No newline at end of file
diff --git a/libs.d.ts b/libs.d.ts
new file mode 100644
index 0000000..e69de29
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..5ef2897
--- /dev/null
+++ b/package.json
@@ -0,0 +1,50 @@
+{
+ "name": "zhipuai-agent-to-openai",
+ "version": "0.0.1",
+ "description": "ZhipuAI Agent To OpenAI",
+ "type": "module",
+ "main": "dist/index.js",
+ "module": "dist/index.mjs",
+ "types": "dist/index.d.ts",
+ "directories": {
+ "dist": "dist"
+ },
+ "files": [
+ "dist/"
+ ],
+ "scripts": {
+ "dev": "tsup src/index.ts --format cjs,esm --sourcemap --dts --publicDir public --watch --onSuccess \"node dist/index.js\"",
+ "start": "node dist/index.js",
+ "build": "tsup src/index.ts --format cjs,esm --sourcemap --dts --clean --publicDir public"
+ },
+ "author": "Vinlic",
+ "license": "ISC",
+ "dependencies": {
+ "axios": "^1.6.7",
+ "colors": "^1.4.0",
+ "crc-32": "^1.2.2",
+ "cron": "^3.1.6",
+ "date-fns": "^3.3.1",
+ "eventsource-parser": "^1.1.2",
+ "form-data": "^4.0.0",
+ "fs-extra": "^11.2.0",
+ "koa": "^2.15.0",
+ "koa-body": "^5.0.0",
+ "koa-bodyparser": "^4.4.1",
+ "koa-range": "^0.3.0",
+ "koa-router": "^12.0.1",
+ "koa2-cors": "^2.0.6",
+ "lodash": "^4.17.21",
+ "mime": "^4.0.1",
+ "minimist": "^1.2.8",
+ "randomstring": "^1.3.0",
+ "uuid": "^9.0.1",
+ "yaml": "^2.3.4"
+ },
+ "devDependencies": {
+ "@types/lodash": "^4.14.202",
+ "@types/mime": "^3.0.4",
+ "tsup": "^8.0.2",
+ "typescript": "^5.3.3"
+ }
+}
diff --git a/public/welcome.html b/public/welcome.html
new file mode 100644
index 0000000..cee6125
--- /dev/null
+++ b/public/welcome.html
@@ -0,0 +1,10 @@
+
+
+
+
+ 🚀 服务已启动
+
+
+ 网关已启动!
请通过支持OpenAI协议的客户端或OpenAI SDK接入!
+
+
\ No newline at end of file
diff --git a/src/api/consts/exceptions.ts b/src/api/consts/exceptions.ts
new file mode 100644
index 0000000..a54a4a5
--- /dev/null
+++ b/src/api/consts/exceptions.ts
@@ -0,0 +1,11 @@
+export default {
+ API_TEST: [-9999, 'API异常错误'],
+ API_REQUEST_PARAMS_INVALID: [-2000, '请求参数非法'],
+ API_REQUEST_FAILED: [-2001, '请求失败'],
+ API_TOKEN_EXPIRES: [-2002, 'Token已失效'],
+ API_FILE_URL_INVALID: [-2003, '远程文件URL非法'],
+ API_FILE_EXECEEDS_SIZE: [-2004, '远程文件超出大小'],
+ API_CHAT_STREAM_PUSHING: [-2005, '已有对话流正在输出'],
+ API_CONTENT_FILTERED: [-2006, '内容由于合规问题已被阻止生成'],
+ API_IMAGE_GENERATION_FAILED: [-2007, '图像生成失败']
+}
\ No newline at end of file
diff --git a/src/api/controllers/chat.ts b/src/api/controllers/chat.ts
new file mode 100644
index 0000000..d227753
--- /dev/null
+++ b/src/api/controllers/chat.ts
@@ -0,0 +1,972 @@
+import { PassThrough } from "stream";
+import path from "path";
+import _ from "lodash";
+import mime from "mime";
+import FormData from "form-data";
+import axios, { AxiosResponse } from "axios";
+
+import APIException from "@/lib/exceptions/APIException.ts";
+import EX from "@/api/consts/exceptions.ts";
+import { createParser } from "eventsource-parser";
+import logger from "@/lib/logger.ts";
+import util from "@/lib/util.ts";
+
+// 最大重试次数
+const MAX_RETRY_COUNT = 0;
+// 重试延迟
+const RETRY_DELAY = 5000;
+// 文件最大大小
+const FILE_MAX_SIZE = 100 * 1024 * 1024;
+// access_token映射
+const accessTokenMap = new Map();
+// access_token请求队列映射
+const accessTokenRequestQueueMap: Record = {};
+
+/**
+ * 请求access_token
+ *
+ * 使用refresh_token去刷新获得access_token
+ *
+ * @param apiKey API密钥
+ */
+async function requestToken(apiKey: string) {
+ if (accessTokenRequestQueueMap[apiKey])
+ return new Promise((resolve) =>
+ accessTokenRequestQueueMap[apiKey].push(resolve)
+ );
+ accessTokenRequestQueueMap[apiKey] = [];
+ const result = await (async () => {
+ const [_apiKey, apiSecret] = apiKey.split('.');
+ const result = await axios.post(
+ "https://chatglm.cn/chatglm/assistant-api/v1/get_token",
+ {
+ api_key: _apiKey,
+ api_secret: apiSecret
+ },
+ {
+ timeout: 15000,
+ validateStatus: () => true,
+ }
+ );
+ const { access_token, expires_in } = checkResult(result, apiKey);
+ return {
+ accessToken: access_token,
+ refreshTime: util.unixTimestamp() + expires_in,
+ };
+ })()
+ .then((result) => {
+ if (accessTokenRequestQueueMap[apiKey]) {
+ accessTokenRequestQueueMap[apiKey].forEach((resolve) =>
+ resolve(result)
+ );
+ delete accessTokenRequestQueueMap[apiKey];
+ }
+ logger.success(`Refresh successful`);
+ return result;
+ })
+ .catch((err) => {
+ if (accessTokenRequestQueueMap[apiKey]) {
+ accessTokenRequestQueueMap[apiKey].forEach((resolve) =>
+ resolve(err)
+ );
+ delete accessTokenRequestQueueMap[apiKey];
+ }
+ return err;
+ });
+ if (_.isError(result)) throw result;
+ return result;
+}
+
+/**
+ * 获取缓存中的access_token
+ *
+ * 避免短时间大量刷新token,未加锁,如果有并发要求还需加锁
+ *
+ * @param apiKey API密钥
+ */
+async function acquireToken(apiKey: string): Promise {
+ let result = accessTokenMap.get(apiKey);
+ if (!result) {
+ result = await requestToken(apiKey);
+ accessTokenMap.set(apiKey, result);
+ }
+ if (util.unixTimestamp() > result.refreshTime) {
+ result = await requestToken(apiKey);
+ accessTokenMap.set(apiKey, result);
+ }
+ return result.accessToken;
+}
+
+/**
+ * 同步对话补全
+*
+ * @param assistantId 智能体ID
+ * @param messages 参考gpt系列消息格式,多轮对话请完整提供上下文
+ * @param apiKey API密钥
+ * @param retryCount 重试次数
+ */
+async function createCompletion(
+ assistantId: string,
+ messages: any[],
+ apiKey: string,
+ refConvId = '',
+ retryCount = 0
+) {
+ return (async () => {
+ logger.info(messages);
+
+ // 提取引用文件URL并上传获得引用的文件ID列表
+ const refFileUrls = extractRefFileUrls(messages);
+ const refs = refFileUrls.length
+ ? await Promise.all(
+ refFileUrls.map((fileUrl) => uploadFile(fileUrl, apiKey))
+ )
+ : [];
+
+ // 如果引用对话ID不正确则重置引用
+ if (!/[0-9a-zA-Z]{24}/.test(refConvId))
+ refConvId = '';
+
+ // 请求流
+ const token = await acquireToken(apiKey);
+ const result = await axios.post(
+ "https://chatglm.cn/chatglm/assistant-api/v1/stream",
+ {
+ assistant_id: assistantId,
+ conversation_id: refConvId || undefined,
+ prompt: messagesPrepare(messages, !!refConvId),
+ file_list: refs
+ },
+ {
+ headers: {
+ Authorization: `Bearer ${token}`
+ },
+ // 120秒超时
+ timeout: 120000,
+ validateStatus: () => true,
+ responseType: "stream",
+ }
+ );
+ if (result.headers["content-type"].indexOf("text/event-stream") == -1) {
+ result.data.on("data", buffer => logger.error(buffer.toString()));
+ throw new APIException(
+ EX.API_REQUEST_FAILED,
+ `Stream response Content-Type invalid: ${result.headers["content-type"]}`
+ );
+ }
+
+ const streamStartTime = util.timestamp();
+ // 接收流为输出文本
+ const answer = await receiveStream(assistantId, result.data);
+ logger.success(
+ `Stream has completed transfer ${util.timestamp() - streamStartTime}ms`
+ );
+
+ return answer;
+ })().catch((err) => {
+ if (retryCount < MAX_RETRY_COUNT) {
+ logger.error(`Stream response error: ${err.stack}`);
+ logger.warn(`Try again after ${RETRY_DELAY / 1000}s...`);
+ return (async () => {
+ await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY));
+ return createCompletion(
+ assistantId,
+ messages,
+ apiKey,
+ refConvId,
+ retryCount + 1
+ );
+ })();
+ }
+ throw err;
+ });
+}
+
+/**
+ * 流式对话补全
+ *
+ * @param assistantId 智能体ID
+ * @param messages 参考gpt系列消息格式,多轮对话请完整提供上下文
+ * @param apiKey API密钥
+ * @param retryCount 重试次数
+ */
+async function createCompletionStream(
+ assistantId: string,
+ messages: any[],
+ apiKey: string,
+ refConvId = '',
+ retryCount = 0
+) {
+ return (async () => {
+ logger.info(messages);
+
+ // 提取引用文件URL并上传获得引用的文件ID列表
+ const refFileUrls = extractRefFileUrls(messages);
+ const refs = refFileUrls.length
+ ? await Promise.all(
+ refFileUrls.map((fileUrl) => uploadFile(fileUrl, apiKey))
+ )
+ : [];
+
+ // 如果引用对话ID不正确则重置引用
+ if (!/[0-9a-zA-Z]{24}/.test(refConvId))
+ refConvId = '';
+
+ // 请求流
+ const token = await acquireToken(apiKey);
+ const result = await axios.post(
+ "https://chatglm.cn/chatglm/assistant-api/v1/stream",
+ {
+ assistant_id: assistantId,
+ conversation_id: refConvId || undefined,
+ prompt: messagesPrepare(messages, !!refConvId),
+ file_list: refs
+ },
+ {
+ headers: {
+ Authorization: `Bearer ${token}`
+ },
+ // 120秒超时
+ timeout: 120000,
+ validateStatus: () => true,
+ responseType: "stream",
+ }
+ );
+
+ if (result.headers["content-type"].indexOf("text/event-stream") == -1) {
+ logger.error(
+ `Invalid response Content-Type:`,
+ result.headers["content-type"]
+ );
+ result.data.on("data", buffer => logger.error(buffer.toString()));
+ const transStream = new PassThrough();
+ transStream.end(
+ `data: ${JSON.stringify({
+ id: "",
+ model: assistantId,
+ object: "chat.completion.chunk",
+ choices: [
+ {
+ index: 0,
+ delta: {
+ role: "assistant",
+ content: "服务暂时不可用,第三方响应错误",
+ },
+ finish_reason: "stop",
+ },
+ ],
+ usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
+ created: util.unixTimestamp(),
+ })}\n\n`
+ );
+ return transStream;
+ }
+
+ const streamStartTime = util.timestamp();
+ // 创建转换流将消息格式转换为gpt兼容格式
+ return createTransStream(assistantId, result.data, (convId: string) => {
+ logger.success(
+ `Stream has completed transfer ${util.timestamp() - streamStartTime}ms`
+ );
+ });
+ })().catch((err) => {
+ if (retryCount < MAX_RETRY_COUNT) {
+ logger.error(`Stream response error: ${err.stack}`);
+ logger.warn(`Try again after ${RETRY_DELAY / 1000}s...`);
+ return (async () => {
+ await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY));
+ return createCompletionStream(
+ assistantId,
+ messages,
+ apiKey,
+ refConvId,
+ retryCount + 1
+ );
+ })();
+ }
+ throw err;
+ });
+}
+
+async function generateImages(
+ assistantId: string,
+ prompt: string,
+ apiKey: string,
+ retryCount = 0
+) {
+ return (async () => {
+ logger.info(prompt);
+ const messages = [
+ { role: "user", content: prompt.indexOf('画') == -1 ? `请画:${prompt}` : prompt },
+ ];
+ // 请求流
+ const token = await acquireToken(apiKey);
+ const result = await axios.post(
+ "https://chatglm.cn/chatglm/assistant-api/v1/stream",
+ {
+ assistant_id: assistantId,
+ prompt: messagesPrepare(messages)
+ },
+ {
+ headers: {
+ Authorization: `Bearer ${token}`
+ },
+ // 120秒超时
+ timeout: 120000,
+ validateStatus: () => true,
+ responseType: "stream",
+ }
+ );
+
+ if (result.headers["content-type"].indexOf("text/event-stream") == -1) {
+ logger.error(
+ `Invalid response Content-Type:`,
+ result.headers["content-type"]
+ );
+ result.data.on("data", buffer => logger.error(buffer.toString()));
+ throw new APIException(
+ EX.API_REQUEST_FAILED,
+ `Stream response Content-Type invalid: ${result.headers["content-type"]}`
+ );
+ }
+
+ const streamStartTime = util.timestamp();
+ // 接收流为输出文本
+ const { convId, imageUrls } = await receiveImages(result.data);
+ logger.success(
+ `Stream has completed transfer ${util.timestamp() - streamStartTime}ms`
+ );
+
+ if (imageUrls.length == 0)
+ throw new APIException(EX.API_IMAGE_GENERATION_FAILED);
+
+ return imageUrls;
+ })().catch((err) => {
+ if (retryCount < MAX_RETRY_COUNT) {
+ logger.error(`Stream response error: ${err.message}`);
+ logger.warn(`Try again after ${RETRY_DELAY / 1000}s...`);
+ return (async () => {
+ await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY));
+ return generateImages(assistantId, prompt, apiKey, retryCount + 1);
+ })();
+ }
+ throw err;
+ });
+}
+
+/**
+ * 提取消息中引用的文件URL
+ *
+ * @param messages 参考gpt系列消息格式,多轮对话请完整提供上下文
+ */
+function extractRefFileUrls(messages: any[]) {
+ const urls = [];
+ // 如果没有消息,则返回[]
+ if (!messages.length) {
+ return urls;
+ }
+ // 只获取最新的消息
+ const lastMessage = messages[messages.length - 1];
+ if (_.isArray(lastMessage.content)) {
+ lastMessage.content.forEach((v) => {
+ if (!_.isObject(v) || !["file", "image_url"].includes(v["type"])) return;
+ // zhipuai-agent-to-openai支持格式
+ if (
+ v["type"] == "file" &&
+ _.isObject(v["file_url"]) &&
+ _.isString(v["file_url"]["url"])
+ )
+ urls.push(v["file_url"]["url"]);
+ // 兼容gpt-4-vision-preview API格式
+ else if (
+ v["type"] == "image_url" &&
+ _.isObject(v["image_url"]) &&
+ _.isString(v["image_url"]["url"])
+ )
+ urls.push(v["image_url"]["url"]);
+ });
+ }
+ logger.info("本次请求上传:" + urls.length + "个文件");
+ return urls;
+}
+
+/**
+ * 消息预处理
+ *
+ * 由于接口只取第一条消息,此处会将多条消息合并为一条,实现多轮对话效果
+ *
+ * @param messages 参考gpt系列消息格式,多轮对话请完整提供上下文
+ * @param refs 参考文件列表
+ * @param isRefConv 是否为引用会话
+ */
+function messagesPrepare(messages: any[], isRefConv = false) {
+ let content;
+ if (isRefConv || messages.length < 2) {
+ content = messages.reduce((content, message) => {
+ if (_.isArray(message.content)) {
+ return (
+ message.content.reduce((_content, v) => {
+ if (!_.isObject(v) || v["type"] != "text") return _content;
+ return _content + (v["text"] || "") + "\n";
+ }, content)
+ );
+ }
+ return content + `${message.content}\n`;
+ }, "");
+ logger.info("\n透传内容:\n" + content);
+ }
+ else {
+ // 检查最新消息是否含有"type": "image_url"或"type": "file",如果有则注入消息
+ let latestMessage = messages[messages.length - 1];
+ let hasFileOrImage =
+ Array.isArray(latestMessage.content) &&
+ latestMessage.content.some(
+ (v) => typeof v === "object" && ["file", "image_url"].includes(v["type"])
+ );
+ if (hasFileOrImage) {
+ let newFileMessage = {
+ content: "关注用户最新发送文件和消息",
+ role: "system",
+ };
+ messages.splice(messages.length - 1, 0, newFileMessage);
+ logger.info("注入提升尾部文件注意力system prompt");
+ } else {
+ // 由于注入会导致设定污染,暂时注释
+ // let newTextMessage = {
+ // content: "关注用户最新的消息",
+ // role: "system",
+ // };
+ // messages.splice(messages.length - 1, 0, newTextMessage);
+ // logger.info("注入提升尾部消息注意力system prompt");
+ }
+ content = (
+ messages.reduce((content, message) => {
+ const role = message.role
+ .replace("system", "<|sytstem|>")
+ .replace("assistant", "<|assistant|>")
+ .replace("user", "<|user|>");
+ if (_.isArray(message.content)) {
+ return (
+ message.content.reduce((_content, v) => {
+ if (!_.isObject(v) || v["type"] != "text") return _content;
+ return _content + (`${role}\n` + v["text"] || "") + "\n";
+ }, content)
+ );
+ }
+ return (content += `${role}\n${message.content}\n`);
+ }, "") + "<|assistant|>\n"
+ )
+ // 移除MD图像URL避免幻觉
+ .replace(/\!\[.+\]\(.+\)/g, "")
+ // 移除临时路径避免在新会话引发幻觉
+ .replace(/\/mnt\/data\/.+/g, "");
+ logger.info("\n对话合并:\n" + content);
+ }
+ return content;
+}
+
+/**
+ * 预检查文件URL有效性
+ *
+ * @param fileUrl 文件URL
+ */
+async function checkFileUrl(fileUrl: string) {
+ if (util.isBASE64Data(fileUrl)) return;
+ const result = await axios.head(fileUrl, {
+ timeout: 15000,
+ validateStatus: () => true,
+ });
+ if (result.status >= 400)
+ throw new APIException(
+ EX.API_FILE_URL_INVALID,
+ `File ${fileUrl} is not valid: [${result.status}] ${result.statusText}`
+ );
+ // 检查文件大小
+ if (result.headers && result.headers["content-length"]) {
+ const fileSize = parseInt(result.headers["content-length"], 10);
+ if (fileSize > FILE_MAX_SIZE)
+ throw new APIException(
+ EX.API_FILE_EXECEEDS_SIZE,
+ `File ${fileUrl} is not valid`
+ );
+ }
+}
+
+/**
+ * 上传文件
+ *
+ * @param fileUrl 文件URL
+ * @param apiKey API密钥
+ */
+async function uploadFile(fileUrl: string, apiKey: string) {
+ // 预检查远程文件URL可用性
+ await checkFileUrl(fileUrl);
+
+ let filename, fileData, mimeType;
+ // 如果是BASE64数据则直接转换为Buffer
+ if (util.isBASE64Data(fileUrl)) {
+ mimeType = util.extractBASE64DataFormat(fileUrl);
+ const ext = mime.getExtension(mimeType);
+ filename = `${util.uuid()}.${ext}`;
+ fileData = Buffer.from(util.removeBASE64DataHeader(fileUrl), "base64");
+ }
+ // 下载文件到内存,如果您的服务器内存很小,建议考虑改造为流直传到下一个接口上,避免停留占用内存
+ else {
+ filename = path.basename(fileUrl);
+ ({ data: fileData } = await axios.get(fileUrl, {
+ responseType: "arraybuffer",
+ // 100M限制
+ maxContentLength: FILE_MAX_SIZE,
+ // 60秒超时
+ timeout: 60000,
+ }));
+ }
+
+ // 获取文件的MIME类型
+ mimeType = mimeType || mime.getType(filename);
+
+ const formData = new FormData();
+ formData.append("file", fileData, {
+ filename,
+ contentType: mimeType,
+ });
+
+ // 上传文件到目标OSS
+ const token = await acquireToken(apiKey);
+ let result = await axios.request({
+ method: "POST",
+ url: "https://chatglm.cn/chatglm/assistant-api/v1/file_upload",
+ data: formData,
+ // 100M限制
+ maxBodyLength: FILE_MAX_SIZE,
+ // 120秒超时
+ timeout: 120000,
+ headers: {
+ Authorization: `Bearer ${token}`,
+ ...formData.getHeaders(),
+ },
+ validateStatus: () => true,
+ });
+ return checkResult(result, apiKey);
+}
+
+/**
+ * 检查请求结果
+ *
+ * @param result 结果
+ */
+function checkResult(result: AxiosResponse, apiKey: string) {
+ if (!result.data) return null;
+ const { status, message, result: _result } = result.data;
+ if (!_.isFinite(status)) return result.data;
+ if (status === 0) return _result;
+ throw new APIException(EX.API_REQUEST_FAILED, `[请求失败]: ${message}`);
+}
+
+/**
+ * 从流接收完整的消息内容
+ *
+ * @param stream 消息流
+ */
+async function receiveStream(assistantId: string, stream: any): Promise {
+ return new Promise((resolve, reject) => {
+ // 消息初始化
+ const data = {
+ id: "",
+ model: assistantId,
+ object: "chat.completion",
+ choices: [
+ {
+ index: 0,
+ message: { role: "assistant", content: "" },
+ finish_reason: "stop",
+ },
+ ],
+ usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
+ created: util.unixTimestamp(),
+ };
+ let toolCall = false;
+ let codeGenerating = false;
+ let codeTemp = "";
+ let lastExecutionOutput = "";
+ let textOffset = 0;
+ let refContent = '';
+ const parser = createParser((event) => {
+ try {
+ if (event.type !== "event") return;
+ // 解析JSON
+ const result = _.attempt(() => JSON.parse(event.data));
+ if (_.isError(result))
+ throw new Error(`Stream response invalid: ${event.data}`);
+ if (!data.id && result.conversation_id)
+ data.id = result.conversation_id;
+ if (result.status == "intervene")
+ throw new APIException(EX.API_CONTENT_FILTERED);
+ if (result.status != "finish") {
+ const { status, content, meta_data } = result.message;
+ if(!content)
+ return;
+ const {
+ type,
+ text,
+ image,
+ code,
+ content: innerCcontent
+ } = content;
+ let innerStr = '';
+ if (type == "text") {
+ if (toolCall) {
+ innerStr += "\n";
+ textOffset++;
+ toolCall = false;
+ }
+ innerStr += text;
+ } else if (
+ type == "quote_result" &&
+ status == "finish" &&
+ meta_data &&
+ _.isArray(meta_data.metadata_list)
+ ) {
+ refContent = meta_data.metadata_list.reduce((meta, v) => {
+ return meta + `${v.title} - ${v.url}\n`;
+ }, refContent);
+ } else if (
+ type == "image" &&
+ _.isArray(image) &&
+ status == "finish"
+ ) {
+ const imageText =
+ image.reduce(
+ (imgs, v) =>
+ imgs +
+ (/^(http|https):\/\//.test(v.image_url)
+ ? `![图像](${v.image_url || ""})`
+ : ""),
+ ""
+ ) + "\n";
+ textOffset += imageText.length;
+ toolCall = true;
+ innerStr += imageText;
+ } else if (
+ type == "code" &&
+ status == "finish" &&
+ codeGenerating
+ ) {
+ const codeFooter = "\n```\n";
+ codeGenerating = false;
+ codeTemp = "";
+ textOffset += codeFooter.length;
+ innerStr += codeFooter;
+ } else if (type == "code") {
+ let codeHead = "";
+ if (!codeGenerating) {
+ codeGenerating = true;
+ codeHead = "```python\n";
+ }
+ const chunk = code.substring(codeTemp.length, code.length);
+ codeTemp += chunk;
+ textOffset += codeHead.length + chunk.length;
+ innerStr += codeHead + chunk;
+ } else if (
+ type == "execution_output" &&
+ _.isString(innerCcontent) &&
+ status == "finish" &&
+ lastExecutionOutput != innerCcontent
+ ) {
+ lastExecutionOutput = innerCcontent;
+ const _content = innerCcontent.replace(/^\n/, "");
+ textOffset += _content.length + 1;
+ innerStr += _content + "\n";
+ }
+ const chunk = innerStr.substring(
+ data.choices[0].message.content.length - textOffset,
+ innerStr.length
+ );
+ data.choices[0].message.content += chunk;
+ } else {
+ data.choices[0].message.content =
+ data.choices[0].message.content.replace(/【\d+†(来源|source)】/g, "") + (refContent ? `\n\n搜索结果来自:\n${refContent.replace(/\n$/, '')}` : '');
+ resolve(data);
+ }
+ } catch (err) {
+ logger.error(err);
+ reject(err);
+ }
+ });
+ // 将流数据喂给SSE转换器
+ stream.on("data", (buffer) => parser.feed(buffer.toString()));
+ stream.once("error", (err) => reject(err));
+ stream.once("close", () => resolve(data));
+ });
+}
+
+/**
+ * 创建转换流
+ *
+ * 将流格式转换为gpt兼容流格式
+ *
+ * @param assistantId 智能体ID
+ * @param stream 消息流
+ * @param endCallback 传输结束回调
+ */
+function createTransStream(assistantId: string, stream: any, endCallback?: Function) {
+ // 消息创建时间
+ const created = util.unixTimestamp();
+ // 创建转换流
+ const transStream = new PassThrough();
+ let textContent = "";
+ let toolCall = false;
+ let codeGenerating = false;
+ let codeTemp = "";
+ let lastExecutionOutput = "";
+ let textOffset = 0;
+ !transStream.closed &&
+ transStream.write(
+ `data: ${JSON.stringify({
+ id: "",
+ model: assistantId,
+ object: "chat.completion.chunk",
+ choices: [
+ {
+ index: 0,
+ delta: { role: "assistant", content: "" },
+ finish_reason: null,
+ },
+ ],
+ created,
+ })}\n\n`
+ );
+ const parser = createParser((event) => {
+ try {
+ if (event.type !== "event") return;
+ // 解析JSON
+ const result = _.attempt(() => JSON.parse(event.data));
+ if (_.isError(result))
+ throw new Error(`Stream response invalid: ${event.data}`);
+ if (result.status != "finish" && result.status != "intervene") {
+ const { status, content, meta_data } = result.message;
+ if(!content)
+ return;
+ const {
+ type,
+ text,
+ image,
+ code,
+ content: innerCcontent
+ } = content;
+ let innerStr = '';
+ if (type == "text") {
+ if (toolCall) {
+ innerStr += "\n";
+ textOffset++;
+ toolCall = false;
+ }
+ innerStr += text;
+ } else if (
+ type == "quote_result" &&
+ status == "finish" &&
+ meta_data &&
+ _.isArray(meta_data.metadata_list)
+ ) {
+ const searchText =
+ meta_data.metadata_list.reduce(
+ (meta, v) => meta + `检索 ${v.title}(${v.url}) ...`,
+ ""
+ ) + "\n";
+ textOffset += searchText.length;
+ toolCall = true;
+ innerStr += searchText;
+ } else if (
+ type == "image" &&
+ _.isArray(image) &&
+ status == "finish"
+ ) {
+ const imageText =
+ image.reduce(
+ (imgs, v) =>
+ imgs +
+ (/^(http|https):\/\//.test(v.image_url)
+ ? `![图像](${v.image_url || ""})`
+ : ""),
+ ""
+ ) + "\n";
+ textOffset += imageText.length;
+ toolCall = true;
+ innerStr += imageText;
+ } else if (
+ type == "code" &&
+ status == "finish" &&
+ codeGenerating
+ ) {
+ const codeFooter = "\n```\n";
+ codeGenerating = false;
+ codeTemp = "";
+ textOffset += codeFooter.length;
+ innerStr += codeFooter;
+ } else if (type == "code") {
+ let codeHead = "";
+ if (!codeGenerating) {
+ codeGenerating = true;
+ codeHead = "```python\n";
+ }
+ const chunk = code.substring(codeTemp.length, code.length);
+ codeTemp += chunk;
+ textOffset += codeHead.length + chunk.length;
+ innerStr += codeHead + chunk;
+ } else if (
+ type == "execution_output" &&
+ _.isString(innerCcontent) &&
+ status == "finish" &&
+ lastExecutionOutput != innerCcontent
+ ) {
+ lastExecutionOutput = innerCcontent;
+ const _content = innerCcontent.replace(/^\n/, "");
+ textOffset += _content.length + 1;
+ innerStr += _content + "\n";
+ }
+ const chunk = innerStr.substring(textContent.length - textOffset, innerStr.length);
+ if (chunk) {
+ textContent += chunk;
+ const data = `data: ${JSON.stringify({
+ id: result.conversation_id,
+ model: assistantId,
+ object: "chat.completion.chunk",
+ choices: [
+ { index: 0, delta: { content: chunk }, finish_reason: null },
+ ],
+ created,
+ })}\n\n`;
+ !transStream.closed && transStream.write(data);
+ }
+ } else {
+ const data = `data: ${JSON.stringify({
+ id: result.conversation_id,
+ model: assistantId,
+ object: "chat.completion.chunk",
+ choices: [
+ {
+ index: 0,
+ delta:
+ result.status == "intervene" &&
+ result.last_error &&
+ result.last_error.intervene_text
+ ? { content: `\n\n${result.last_error.intervene_text}` }
+ : {},
+ finish_reason: "stop",
+ },
+ ],
+ usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
+ created,
+ })}\n\n`;
+ !transStream.closed && transStream.write(data);
+ !transStream.closed && transStream.end("data: [DONE]\n\n");
+ textContent = "";
+ endCallback && endCallback(result.conversation_id);
+ }
+ } catch (err) {
+ logger.error(err);
+ !transStream.closed && transStream.end("\n\n");
+ }
+ });
+ // 将流数据喂给SSE转换器
+ stream.on("data", (buffer) => parser.feed(buffer.toString()));
+ stream.once(
+ "error",
+ () => !transStream.closed && transStream.end("data: [DONE]\n\n")
+ );
+ stream.once(
+ "close",
+ () => !transStream.closed && transStream.end("data: [DONE]\n\n")
+ );
+ return transStream;
+}
+
+/**
+ * 从流接收图像
+ *
+ * @param stream 消息流
+ */
+async function receiveImages(
+ stream: any
+): Promise<{ convId: string; imageUrls: string[] }> {
+ return new Promise((resolve, reject) => {
+ let convId = "";
+ const imageUrls = [];
+ const parser = createParser((event) => {
+ try {
+ if (event.type !== "event") return;
+ // 解析JSON
+ const result = _.attempt(() => JSON.parse(event.data));
+ if (_.isError(result))
+ throw new Error(`Stream response invalid: ${event.data}`);
+ if (!convId && result.conversation_id) convId = result.conversation_id;
+ if (result.status == "intervene")
+ throw new APIException(EX.API_CONTENT_FILTERED);
+ if (result.status != "finish") {
+ const { status, content, meta_data } = result.message;
+ if(!content)
+ return;
+ const {
+ type,
+ text,
+ image
+ } = content;
+ if (
+ type == "image" &&
+ _.isArray(image) &&
+ status == "finish"
+ ) {
+ image.forEach((value) => {
+ if (
+ !/^(http|https):\/\//.test(value.image_url) ||
+ imageUrls.indexOf(value.image_url) != -1
+ )
+ return;
+ imageUrls.push(value.image_url);
+ });
+ }
+ if (
+ type == "text" &&
+ status == "finish"
+ ) {
+ const urlPattern = /\((https?:\/\/\S+)\)/g;
+ let match;
+ while ((match = urlPattern.exec(text)) !== null) {
+ const url = match[1];
+ if (imageUrls.indexOf(url) == -1)
+ imageUrls.push(url);
+ }
+ }
+ }
+ } catch (err) {
+ logger.error(err);
+ reject(err);
+ }
+ });
+ // 将流数据喂给SSE转换器
+ stream.on("data", (buffer) => parser.feed(buffer.toString()));
+ stream.once("error", (err) => reject(err));
+ stream.once("close", () =>
+ resolve({
+ convId,
+ imageUrls,
+ })
+ );
+ });
+}
+
+/**
+ * API KEY切分
+ *
+ * @param authorization 认证字符串
+ */
+function apiKeySplit(authorization: string) {
+ return authorization.replace("Bearer ", "").split(",");
+}
+
+export default {
+ createCompletion,
+ createCompletionStream,
+ generateImages,
+ apiKeySplit,
+};
diff --git a/src/api/routes/chat.ts b/src/api/routes/chat.ts
new file mode 100644
index 0000000..bcf96f9
--- /dev/null
+++ b/src/api/routes/chat.ts
@@ -0,0 +1,37 @@
+import _ from 'lodash';
+
+import Request from '@/lib/request/Request.ts';
+import Response from '@/lib/response/Response.ts';
+import chat from '@/api/controllers/chat.ts';
+import logger from '@/lib/logger.ts';
+
+export default {
+
+ prefix: '/v1/chat',
+
+ post: {
+
+ '/completions': async (request: Request) => {
+ request
+ .validate('body.model', v => /^[a-z0-9]{24,}$/.test(v))
+ .validate('body.conversation_id', v => _.isUndefined(v) || _.isString(v))
+ .validate('body.messages', _.isArray)
+ .validate('headers.authorization', _.isString)
+ // refresh_token切分
+ const apiKeys = chat.apiKeySplit(request.headers.authorization);
+ // 随机挑选一个refresh_token
+ const apiKey = _.sample(apiKeys);
+ const { model, conversation_id: convId, messages, stream } = request.body;
+ if (stream) {
+ const stream = await chat.createCompletionStream(model, messages, apiKey, convId);
+ return new Response(stream, {
+ type: "text/event-stream"
+ });
+ }
+ else
+ return await chat.createCompletion(model, messages, apiKey, convId);
+ }
+
+ }
+
+}
\ No newline at end of file
diff --git a/src/api/routes/images.ts b/src/api/routes/images.ts
new file mode 100644
index 0000000..0dc71a1
--- /dev/null
+++ b/src/api/routes/images.ts
@@ -0,0 +1,39 @@
+import _ from "lodash";
+
+import Request from "@/lib/request/Request.ts";
+import chat from "@/api/controllers/chat.ts";
+import util from "@/lib/util.ts";
+
+export default {
+ prefix: "/v1/images",
+
+ post: {
+ "/generations": async (request: Request) => {
+ request
+ .validate("body.prompt", _.isString)
+ .validate("headers.authorization", _.isString);
+ // refresh_token切分
+ const tokens = chat.apiKeySplit(request.headers.authorization);
+ // 随机挑选一个refresh_token
+ const token = _.sample(tokens);
+ const prompt = request.body.prompt;
+ const responseFormat = _.defaultTo(request.body.response_format, "url");
+ const assistantId = /^[a-z0-9]{24,}$/.test(request.body.model) ? request.body.model : undefined
+ const imageUrls = await chat.generateImages(assistantId, prompt, token);
+ let data = [];
+ if (responseFormat == "b64_json") {
+ data = (
+ await Promise.all(imageUrls.map((url) => util.fetchFileBASE64(url)))
+ ).map((b64) => ({ b64_json: b64 }));
+ } else {
+ data = imageUrls.map((url) => ({
+ url,
+ }));
+ }
+ return {
+ created: util.unixTimestamp(),
+ data,
+ };
+ },
+ },
+};
diff --git a/src/api/routes/index.ts b/src/api/routes/index.ts
new file mode 100644
index 0000000..2b83306
--- /dev/null
+++ b/src/api/routes/index.ts
@@ -0,0 +1,27 @@
+import fs from 'fs-extra';
+
+import Response from '@/lib/response/Response.ts';
+import chat from "./chat.ts";
+import images from "./images.ts";
+import ping from "./ping.ts";
+import models from './models.ts';
+
+export default [
+ {
+ get: {
+ '/': async () => {
+ const content = await fs.readFile('public/welcome.html');
+ return new Response(content, {
+ type: 'html',
+ headers: {
+ Expires: '-1'
+ }
+ });
+ }
+ }
+ },
+ chat,
+ images,
+ ping,
+ models
+];
\ No newline at end of file
diff --git a/src/api/routes/models.ts b/src/api/routes/models.ts
new file mode 100644
index 0000000..1178194
--- /dev/null
+++ b/src/api/routes/models.ts
@@ -0,0 +1,41 @@
+import _ from 'lodash';
+
+export default {
+
+ prefix: '/v1',
+
+ get: {
+ '/models': async () => {
+ return {
+ "data": [
+ {
+ "id": "glm-3-turbo",
+ "object": "model",
+ "owned_by": "zhipuai-agent-to-openai"
+ },
+ {
+ "id": "glm-4",
+ "object": "model",
+ "owned_by": "zhipuai-agent-to-openai"
+ },
+ {
+ "id": "glm-4v",
+ "object": "model",
+ "owned_by": "zhipuai-agent-to-openai"
+ },
+ {
+ "id": "glm-v1",
+ "object": "model",
+ "owned_by": "zhipuai-agent-to-openai"
+ },
+ {
+ "id": "glm-v1-vision",
+ "object": "model",
+ "owned_by": "zhipuai-agent-to-openai"
+ }
+ ]
+ };
+ }
+
+ }
+}
\ No newline at end of file
diff --git a/src/api/routes/ping.ts b/src/api/routes/ping.ts
new file mode 100644
index 0000000..dc9af72
--- /dev/null
+++ b/src/api/routes/ping.ts
@@ -0,0 +1,6 @@
+export default {
+ prefix: '/ping',
+ get: {
+ '': async () => "pong"
+ }
+}
\ No newline at end of file
diff --git a/src/index.ts b/src/index.ts
new file mode 100644
index 0000000..60a0e91
--- /dev/null
+++ b/src/index.ts
@@ -0,0 +1,32 @@
+"use strict";
+
+import environment from "@/lib/environment.ts";
+import config from "@/lib/config.ts";
+import "@/lib/initialize.ts";
+import server from "@/lib/server.ts";
+import routes from "@/api/routes/index.ts";
+import logger from "@/lib/logger.ts";
+
+const startupTime = performance.now();
+
+(async () => {
+ logger.header();
+
+ logger.info("<<<< glm free server >>>>");
+ logger.info("Version:", environment.package.version);
+ logger.info("Process id:", process.pid);
+ logger.info("Environment:", environment.env);
+ logger.info("Service name:", config.service.name);
+
+ server.attachRoutes(routes);
+ await server.listen();
+
+ config.service.bindAddress &&
+ logger.success("Service bind address:", config.service.bindAddress);
+})()
+ .then(() =>
+ logger.success(
+ `Service startup completed (${Math.floor(performance.now() - startupTime)}ms)`
+ )
+ )
+ .catch((err) => console.error(err));
diff --git a/src/lib/config.ts b/src/lib/config.ts
new file mode 100644
index 0000000..b6072d2
--- /dev/null
+++ b/src/lib/config.ts
@@ -0,0 +1,14 @@
+import serviceConfig from "./configs/service-config.ts";
+import systemConfig from "./configs/system-config.ts";
+
+class Config {
+
+ /** 服务配置 */
+ service = serviceConfig;
+
+ /** 系统配置 */
+ system = systemConfig;
+
+}
+
+export default new Config();
\ No newline at end of file
diff --git a/src/lib/configs/service-config.ts b/src/lib/configs/service-config.ts
new file mode 100644
index 0000000..8a15391
--- /dev/null
+++ b/src/lib/configs/service-config.ts
@@ -0,0 +1,68 @@
+import path from 'path';
+
+import fs from 'fs-extra';
+import yaml from 'yaml';
+import _ from 'lodash';
+
+import environment from '../environment.ts';
+import util from '../util.ts';
+
+const CONFIG_PATH = path.join(path.resolve(), 'configs/', environment.env, "/service.yml");
+
+/**
+ * 服务配置
+ */
+export class ServiceConfig {
+
+ /** 服务名称 */
+ name: string;
+ /** @type {string} 服务绑定主机地址 */
+ host;
+ /** @type {number} 服务绑定端口 */
+ port;
+ /** @type {string} 服务路由前缀 */
+ urlPrefix;
+ /** @type {string} 服务绑定地址(外部访问地址) */
+ bindAddress;
+
+ constructor(options?: any) {
+ const { name, host, port, urlPrefix, bindAddress } = options || {};
+ this.name = _.defaultTo(name, 'zhipuai-agent-to-openai');
+ this.host = _.defaultTo(host, '0.0.0.0');
+ this.port = _.defaultTo(port, 5566);
+ this.urlPrefix = _.defaultTo(urlPrefix, '');
+ this.bindAddress = bindAddress;
+ }
+
+ get addressHost() {
+ if(this.bindAddress) return this.bindAddress;
+ const ipAddresses = util.getIPAddressesByIPv4();
+ for(let ipAddress of ipAddresses) {
+ if(ipAddress === this.host)
+ return ipAddress;
+ }
+ return ipAddresses[0] || "127.0.0.1";
+ }
+
+ get address() {
+ return `${this.addressHost}:${this.port}`;
+ }
+
+ get pageDirUrl() {
+ return `http://127.0.0.1:${this.port}/page`;
+ }
+
+ get publicDirUrl() {
+ return `http://127.0.0.1:${this.port}/public`;
+ }
+
+ static load() {
+ const external = _.pickBy(environment, (v, k) => ["name", "host", "port"].includes(k) && !_.isUndefined(v));
+ if(!fs.pathExistsSync(CONFIG_PATH)) return new ServiceConfig(external);
+ const data = yaml.parse(fs.readFileSync(CONFIG_PATH).toString());
+ return new ServiceConfig({ ...data, ...external });
+ }
+
+}
+
+export default ServiceConfig.load();
\ No newline at end of file
diff --git a/src/lib/configs/system-config.ts b/src/lib/configs/system-config.ts
new file mode 100644
index 0000000..7c589a6
--- /dev/null
+++ b/src/lib/configs/system-config.ts
@@ -0,0 +1,84 @@
+import path from 'path';
+
+import fs from 'fs-extra';
+import yaml from 'yaml';
+import _ from 'lodash';
+
+import environment from '../environment.ts';
+
+const CONFIG_PATH = path.join(path.resolve(), 'configs/', environment.env, "/system.yml");
+
+/**
+ * 系统配置
+ */
+export class SystemConfig {
+
+ /** 是否开启请求日志 */
+ requestLog: boolean;
+ /** 临时目录路径 */
+ tmpDir: string;
+ /** 日志目录路径 */
+ logDir: string;
+ /** 日志写入间隔(毫秒) */
+ logWriteInterval: number;
+ /** 日志文件有效期(毫秒) */
+ logFileExpires: number;
+ /** 公共目录路径 */
+ publicDir: string;
+ /** 临时文件有效期(毫秒) */
+ tmpFileExpires: number;
+ /** 请求体配置 */
+ requestBody: any;
+ /** 是否调试模式 */
+ debug: boolean;
+
+ constructor(options?: any) {
+ const { requestLog, tmpDir, logDir, logWriteInterval, logFileExpires, publicDir, tmpFileExpires, requestBody, debug } = options || {};
+ this.requestLog = _.defaultTo(requestLog, false);
+ this.tmpDir = _.defaultTo(tmpDir, './tmp');
+ this.logDir = _.defaultTo(logDir, './logs');
+ this.logWriteInterval = _.defaultTo(logWriteInterval, 200);
+ this.logFileExpires = _.defaultTo(logFileExpires, 2626560000);
+ this.publicDir = _.defaultTo(publicDir, './public');
+ this.tmpFileExpires = _.defaultTo(tmpFileExpires, 86400000);
+ this.requestBody = Object.assign(requestBody || {}, {
+ enableTypes: ['json', 'form', 'text', 'xml'],
+ encoding: 'utf-8',
+ formLimit: '100mb',
+ jsonLimit: '100mb',
+ textLimit: '100mb',
+ xmlLimit: '100mb',
+ formidable: {
+ maxFileSize: '100mb'
+ },
+ multipart: true,
+ parsedMethods: ['POST', 'PUT', 'PATCH']
+ });
+ this.debug = _.defaultTo(debug, true);
+ }
+
+ get rootDirPath() {
+ return path.resolve();
+ }
+
+ get tmpDirPath() {
+ return path.resolve(this.tmpDir);
+ }
+
+ get logDirPath() {
+ return path.resolve(this.logDir);
+ }
+
+ get publicDirPath() {
+ return path.resolve(this.publicDir);
+ }
+
+ static load() {
+ if (!fs.pathExistsSync(CONFIG_PATH)) return new SystemConfig();
+ const data = yaml.parse(fs.readFileSync(CONFIG_PATH).toString());
+ return new SystemConfig(data);
+ }
+
+}
+
+export default SystemConfig.load();
\ No newline at end of file
diff --git a/src/lib/consts/exceptions.ts b/src/lib/consts/exceptions.ts
new file mode 100644
index 0000000..7a9b788
--- /dev/null
+++ b/src/lib/consts/exceptions.ts
@@ -0,0 +1,5 @@
+export default {
+ SYSTEM_ERROR: [-1000, '系统异常'],
+ SYSTEM_REQUEST_VALIDATION_ERROR: [-1001, '请求参数校验错误'],
+ SYSTEM_NOT_ROUTE_MATCHING: [-1002, '无匹配的路由']
+} as Record
\ No newline at end of file
diff --git a/src/lib/environment.ts b/src/lib/environment.ts
new file mode 100644
index 0000000..6e52a84
--- /dev/null
+++ b/src/lib/environment.ts
@@ -0,0 +1,44 @@
+import path from 'path';
+
+import fs from 'fs-extra';
+import minimist from 'minimist';
+import _ from 'lodash';
+
+const cmdArgs = minimist(process.argv.slice(2)); //获取命令行参数
+const envVars = process.env; //获取环境变量
+
+class Environment {
+
+ /** 命令行参数 */
+ cmdArgs: any;
+ /** 环境变量 */
+ envVars: any;
+ /** 环境名称 */
+ env?: string;
+ /** 服务名称 */
+ name?: string;
+ /** 服务地址 */
+ host?: string;
+ /** 服务端口 */
+ port?: number;
+ /** 包参数 */
+ package: any;
+
+ constructor(options: any = {}) {
+ const { cmdArgs, envVars, package: _package } = options;
+ this.cmdArgs = cmdArgs;
+ this.envVars = envVars;
+ this.env = _.defaultTo(cmdArgs.env || envVars.SERVER_ENV, 'dev');
+ this.name = cmdArgs.name || envVars.SERVER_NAME || undefined;
+ this.host = cmdArgs.host || envVars.SERVER_HOST || undefined;
+ this.port = Number(cmdArgs.port || envVars.SERVER_PORT) ? Number(cmdArgs.port || envVars.SERVER_PORT) : undefined;
+ this.package = _package;
+ }
+
+}
+
+export default new Environment({
+ cmdArgs,
+ envVars,
+ package: JSON.parse(fs.readFileSync(path.join(path.resolve(), "package.json")).toString())
+});
\ No newline at end of file
diff --git a/src/lib/exceptions/APIException.ts b/src/lib/exceptions/APIException.ts
new file mode 100644
index 0000000..515c806
--- /dev/null
+++ b/src/lib/exceptions/APIException.ts
@@ -0,0 +1,14 @@
+import Exception from './Exception.js';
+
+export default class APIException extends Exception {
+
+ /**
+ * 构造异常
+ *
+ * @param {[number, string]} exception 异常
+ */
+ constructor(exception: (string | number)[], errmsg?: string) {
+ super(exception, errmsg);
+ }
+
+}
\ No newline at end of file
diff --git a/src/lib/exceptions/Exception.ts b/src/lib/exceptions/Exception.ts
new file mode 100644
index 0000000..ef0372f
--- /dev/null
+++ b/src/lib/exceptions/Exception.ts
@@ -0,0 +1,47 @@
+import assert from 'assert';
+
+import _ from 'lodash';
+
+export default class Exception extends Error {
+
+ /** 错误码 */
+ errcode: number;
+ /** 错误消息 */
+ errmsg: string;
+ /** 数据 */
+ data: any;
+ /** HTTP状态码 */
+ httpStatusCode: number;
+
+ /**
+ * 构造异常
+ *
+ * @param exception 异常
+ * @param _errmsg 异常消息
+ */
+ constructor(exception: (string | number)[], _errmsg?: string) {
+ assert(_.isArray(exception), 'Exception must be Array');
+ const [errcode, errmsg] = exception as [number, string];
+ assert(_.isFinite(errcode), 'Exception errcode invalid');
+ assert(_.isString(errmsg), 'Exception errmsg invalid');
+ super(_errmsg || errmsg);
+ this.errcode = errcode;
+ this.errmsg = _errmsg || errmsg;
+ }
+
+ compare(exception: (string | number)[]) {
+ const [errcode] = exception as [number, string];
+ return this.errcode == errcode;
+ }
+
+ setHTTPStatusCode(value: number) {
+ this.httpStatusCode = value;
+ return this;
+ }
+
+ setData(value: any) {
+ this.data = _.defaultTo(value, null);
+ return this;
+ }
+
+}
\ No newline at end of file
diff --git a/src/lib/http-status-codes.ts b/src/lib/http-status-codes.ts
new file mode 100644
index 0000000..cc0c571
--- /dev/null
+++ b/src/lib/http-status-codes.ts
@@ -0,0 +1,61 @@
+export default {
+
+ CONTINUE: 100, //客户端应当继续发送请求。这个临时响应是用来通知客户端它的部分请求已经被服务器接收,且仍未被拒绝。客户端应当继续发送请求的剩余部分,或者如果请求已经完成,忽略这个响应。服务器必须在请求完成后向客户端发送一个最终响应
+ SWITCHING_PROTOCOLS: 101, //服务器已经理解了客户端的请求,并将通过Upgrade 消息头通知客户端采用不同的协议来完成这个请求。在发送完这个响应最后的空行后,服务器将会切换到在Upgrade 消息头中定义的那些协议。只有在切换新的协议更有好处的时候才应该采取类似措施。例如,切换到新的HTTP 版本比旧版本更有优势,或者切换到一个实时且同步的协议以传送利用此类特性的资源
+ PROCESSING: 102, //处理将被继续执行
+
+ OK: 200, //请求已成功,请求所希望的响应头或数据体将随此响应返回
+ CREATED: 201, //请求已经被实现,而且有一个新的资源已经依据请求的需要而建立,且其 URI 已经随Location 头信息返回。假如需要的资源无法及时建立的话,应当返回 '202 Accepted'
+ ACCEPTED: 202, //服务器已接受请求,但尚未处理。正如它可能被拒绝一样,最终该请求可能会也可能不会被执行。在异步操作的场合下,没有比发送这个状态码更方便的做法了。返回202状态码的响应的目的是允许服务器接受其他过程的请求(例如某个每天只执行一次的基于批处理的操作),而不必让客户端一直保持与服务器的连接直到批处理操作全部完成。在接受请求处理并返回202状态码的响应应当在返回的实体中包含一些指示处理当前状态的信息,以及指向处理状态监视器或状态预测的指针,以便用户能够估计操作是否已经完成
+ NON_AUTHORITATIVE_INFO: 203, //服务器已成功处理了请求,但返回的实体头部元信息不是在原始服务器上有效的确定集合,而是来自本地或者第三方的拷贝。当前的信息可能是原始版本的子集或者超集。例如,包含资源的元数据可能导致原始服务器知道元信息的超级。使用此状态码不是必须的,而且只有在响应不使用此状态码便会返回200 OK的情况下才是合适的
+ NO_CONTENT: 204, //服务器成功处理了请求,但不需要返回任何实体内容,并且希望返回更新了的元信息。响应可能通过实体头部的形式,返回新的或更新后的元信息。如果存在这些头部信息,则应当与所请求的变量相呼应。如果客户端是浏览器的话,那么用户浏览器应保留发送了该请求的页面,而不产生任何文档视图上的变化,即使按照规范新的或更新后的元信息应当被应用到用户浏览器活动视图中的文档。由于204响应被禁止包含任何消息体,因此它始终以消息头后的第一个空行结尾
+ RESET_CONTENT: 205, //服务器成功处理了请求,且没有返回任何内容。但是与204响应不同,返回此状态码的响应要求请求者重置文档视图。该响应主要是被用于接受用户输入后,立即重置表单,以便用户能够轻松地开始另一次输入。与204响应一样,该响应也被禁止包含任何消息体,且以消息头后的第一个空行结束
+ PARTIAL_CONTENT: 206, //服务器已经成功处理了部分 GET 请求。类似于FlashGet或者迅雷这类的HTTP下载工具都是使用此类响应实现断点续传或者将一个大文档分解为多个下载段同时下载。该请求必须包含 Range 头信息来指示客户端希望得到的内容范围,并且可能包含 If-Range 来作为请求条件。响应必须包含如下的头部域:Content-Range 用以指示本次响应中返回的内容的范围;如果是Content-Type为multipart/byteranges的多段下载,则每一段multipart中都应包含Content-Range域用以指示本段的内容范围。假如响应中包含Content-Length,那么它的数值必须匹配它返回的内容范围的真实字节数。Date和ETag或Content-Location,假如同样的请求本应该返回200响应。Expires, Cache-Control,和/或 Vary,假如其值可能与之前相同变量的其他响应对应的值不同的话。假如本响应请求使用了 If-Range 强缓存验证,那么本次响应不应该包含其他实体头;假如本响应的请求使用了 If-Range 弱缓存验证,那么本次响应禁止包含其他实体头;这避免了缓存的实体内容和更新了的实体头信息之间的不一致。否则,本响应就应当包含所有本应该返回200响应中应当返回的所有实体头部域。假如 ETag 或 Latest-Modified 头部不能精确匹配的话,则客户端缓存应禁止将206响应返回的内容与之前任何缓存过的内容组合在一起。任何不支持 Range 以及 Content-Range 头的缓存都禁止缓存206响应返回的内容
+ MULTIPLE_STATUS: 207, //代表之后的消息体将是一个XML消息,并且可能依照之前子请求数量的不同,包含一系列独立的响应代码
+
+ MULTIPLE_CHOICES: 300, //被请求的资源有一系列可供选择的回馈信息,每个都有自己特定的地址和浏览器驱动的商议信息。用户或浏览器能够自行选择一个首选的地址进行重定向。除非这是一个HEAD请求,否则该响应应当包括一个资源特性及地址的列表的实体,以便用户或浏览器从中选择最合适的重定向地址。这个实体的格式由Content-Type定义的格式所决定。浏览器可能根据响应的格式以及浏览器自身能力,自动作出最合适的选择。当然,RFC 2616规范并没有规定这样的自动选择该如何进行。如果服务器本身已经有了首选的回馈选择,那么在Location中应当指明这个回馈的 URI;浏览器可能会将这个 Location 值作为自动重定向的地址。此外,除非额外指定,否则这个响应也是可缓存的
+ MOVED_PERMANENTLY: 301, //被请求的资源已永久移动到新位置,并且将来任何对此资源的引用都应该使用本响应返回的若干个URI之一。如果可能,拥有链接编辑功能的客户端应当自动把请求的地址修改为从服务器反馈回来的地址。除非额外指定,否则这个响应也是可缓存的。新的永久性的URI应当在响应的Location域中返回。除非这是一个HEAD请求,否则响应的实体中应当包含指向新的URI的超链接及简短说明。如果这不是一个GET或者HEAD请求,因此浏览器禁止自动进行重定向,除非得到用户的确认,因为请求的条件可能因此发生变化。注意:对于某些使用 HTTP/1.0 协议的浏览器,当它们发送的POST请求得到了一个301响应的话,接下来的重定向请求将会变成GET方式
+ FOUND: 302, //请求的资源现在临时从不同的URI响应请求。由于这样的重定向是临时的,客户端应当继续向原有地址发送以后的请求。只有在Cache-Control或Expires中进行了指定的情况下,这个响应才是可缓存的。新的临时性的URI应当在响应的 Location 域中返回。除非这是一个HEAD请求,否则响应的实体中应当包含指向新的URI的超链接及简短说明。如果这不是一个GET或者HEAD请求,那么浏览器禁止自动进行重定向,除非得到用户的确认,因为请求的条件可能因此发生变化。注意:虽然RFC 1945和RFC 2068规范不允许客户端在重定向时改变请求的方法,但是很多现存的浏览器将302响应视作为303响应,并且使用GET方式访问在Location中规定的URI,而无视原先请求的方法。状态码303和307被添加了进来,用以明确服务器期待客户端进行何种反应
+ SEE_OTHER: 303, //对应当前请求的响应可以在另一个URI上被找到,而且客户端应当采用 GET 的方式访问那个资源。这个方法的存在主要是为了允许由脚本激活的POST请求输出重定向到一个新的资源。这个新的 URI 不是原始资源的替代引用。同时,303响应禁止被缓存。当然,第二个请求(重定向)可能被缓存。新的 URI 应当在响应的Location域中返回。除非这是一个HEAD请求,否则响应的实体中应当包含指向新的URI的超链接及简短说明。注意:许多 HTTP/1.1 版以前的浏览器不能正确理解303状态。如果需要考虑与这些浏览器之间的互动,302状态码应该可以胜任,因为大多数的浏览器处理302响应时的方式恰恰就是上述规范要求客户端处理303响应时应当做的
+ NOT_MODIFIED: 304, //如果客户端发送了一个带条件的GET请求且该请求已被允许,而文档的内容(自上次访问以来或者根据请求的条件)并没有改变,则服务器应当返回这个状态码。304响应禁止包含消息体,因此始终以消息头后的第一个空行结尾。该响应必须包含以下的头信息:Date,除非这个服务器没有时钟。假如没有时钟的服务器也遵守这些规则,那么代理服务器以及客户端可以自行将Date字段添加到接收到的响应头中去(正如RFC 2068中规定的一样),缓存机制将会正常工作。ETag或 Content-Location,假如同样的请求本应返回200响应。Expires, Cache-Control,和/或Vary,假如其值可能与之前相同变量的其他响应对应的值不同的话。假如本响应请求使用了强缓存验证,那么本次响应不应该包含其他实体头;否则(例如,某个带条件的 GET 请求使用了弱缓存验证),本次响应禁止包含其他实体头;这避免了缓存了的实体内容和更新了的实体头信息之间的不一致。假如某个304响应指明了当前某个实体没有缓存,那么缓存系统必须忽视这个响应,并且重复发送不包含限制条件的请求。假如接收到一个要求更新某个缓存条目的304响应,那么缓存系统必须更新整个条目以反映所有在响应中被更新的字段的值
+ USE_PROXY: 305, //被请求的资源必须通过指定的代理才能被访问。Location域中将给出指定的代理所在的URI信息,接收者需要重复发送一个单独的请求,通过这个代理才能访问相应资源。只有原始服务器才能建立305响应。注意:RFC 2068中没有明确305响应是为了重定向一个单独的请求,而且只能被原始服务器建立。忽视这些限制可能导致严重的安全后果
+ UNUSED: 306, //在最新版的规范中,306状态码已经不再被使用
+ TEMPORARY_REDIRECT: 307, //请求的资源现在临时从不同的URI 响应请求。由于这样的重定向是临时的,客户端应当继续向原有地址发送以后的请求。只有在Cache-Control或Expires中进行了指定的情况下,这个响应才是可缓存的。新的临时性的URI 应当在响应的Location域中返回。除非这是一个HEAD请求,否则响应的实体中应当包含指向新的URI 的超链接及简短说明。因为部分浏览器不能识别307响应,因此需要添加上述必要信息以便用户能够理解并向新的 URI 发出访问请求。如果这不是一个GET或者HEAD请求,那么浏览器禁止自动进行重定向,除非得到用户的确认,因为请求的条件可能因此发生变化
+
+ BAD_REQUEST: 400, //1.语义有误,当前请求无法被服务器理解。除非进行修改,否则客户端不应该重复提交这个请求 2.请求参数有误
+ UNAUTHORIZED: 401, //当前请求需要用户验证。该响应必须包含一个适用于被请求资源的 WWW-Authenticate 信息头用以询问用户信息。客户端可以重复提交一个包含恰当的 Authorization 头信息的请求。如果当前请求已经包含了 Authorization 证书,那么401响应代表着服务器验证已经拒绝了那些证书。如果401响应包含了与前一个响应相同的身份验证询问,且浏览器已经至少尝试了一次验证,那么浏览器应当向用户展示响应中包含的实体信息,因为这个实体信息中可能包含了相关诊断信息。参见RFC 2617
+ PAYMENT_REQUIRED: 402, //该状态码是为了将来可能的需求而预留的
+ FORBIDDEN: 403, //服务器已经理解请求,但是拒绝执行它。与401响应不同的是,身份验证并不能提供任何帮助,而且这个请求也不应该被重复提交。如果这不是一个HEAD请求,而且服务器希望能够讲清楚为何请求不能被执行,那么就应该在实体内描述拒绝的原因。当然服务器也可以返回一个404响应,假如它不希望让客户端获得任何信息
+ NOT_FOUND: 404, //请求失败,请求所希望得到的资源未被在服务器上发现。没有信息能够告诉用户这个状况到底是暂时的还是永久的。假如服务器知道情况的话,应当使用410状态码来告知旧资源因为某些内部的配置机制问题,已经永久的不可用,而且没有任何可以跳转的地址。404这个状态码被广泛应用于当服务器不想揭示到底为何请求被拒绝或者没有其他适合的响应可用的情况下
+ METHOD_NOT_ALLOWED: 405, //请求行中指定的请求方法不能被用于请求相应的资源。该响应必须返回一个Allow 头信息用以表示出当前资源能够接受的请求方法的列表。鉴于PUT,DELETE方法会对服务器上的资源进行写操作,因而绝大部分的网页服务器都不支持或者在默认配置下不允许上述请求方法,对于此类请求均会返回405错误
+ NO_ACCEPTABLE: 406, //请求的资源的内容特性无法满足请求头中的条件,因而无法生成响应实体。除非这是一个 HEAD 请求,否则该响应就应当返回一个包含可以让用户或者浏览器从中选择最合适的实体特性以及地址列表的实体。实体的格式由Content-Type头中定义的媒体类型决定。浏览器可以根据格式及自身能力自行作出最佳选择。但是,规范中并没有定义任何作出此类自动选择的标准
+ PROXY_AUTHENTICATION_REQUIRED: 407, //与401响应类似,只不过客户端必须在代理服务器上进行身份验证。代理服务器必须返回一个Proxy-Authenticate用以进行身份询问。客户端可以返回一个Proxy-Authorization信息头用以验证。参见RFC 2617
+ REQUEST_TIMEOUT: 408, //请求超时。客户端没有在服务器预备等待的时间内完成一个请求的发送。客户端可以随时再次提交这一请求而无需进行任何更改
+ CONFLICT: 409, //由于和被请求的资源的当前状态之间存在冲突,请求无法完成。这个代码只允许用在这样的情况下才能被使用:用户被认为能够解决冲突,并且会重新提交新的请求。该响应应当包含足够的信息以便用户发现冲突的源头。冲突通常发生于对PUT请求的处理中。例如,在采用版本检查的环境下,某次PUT提交的对特定资源的修改请求所附带的版本信息与之前的某个(第三方)请求向冲突,那么此时服务器就应该返回一个409错误,告知用户请求无法完成。此时,响应实体中很可能会包含两个冲突版本之间的差异比较,以便用户重新提交归并以后的新版本
+ GONE: 410, //被请求的资源在服务器上已经不再可用,而且没有任何已知的转发地址。这样的状况应当被认为是永久性的。如果可能,拥有链接编辑功能的客户端应当在获得用户许可后删除所有指向这个地址的引用。如果服务器不知道或者无法确定这个状况是否是永久的,那么就应该使用404状态码。除非额外说明,否则这个响应是可缓存的。410响应的目的主要是帮助网站管理员维护网站,通知用户该资源已经不再可用,并且服务器拥有者希望所有指向这个资源的远端连接也被删除。这类事件在限时、增值服务中很普遍。同样,410响应也被用于通知客户端在当前服务器站点上,原本属于某个个人的资源已经不再可用。当然,是否需要把所有永久不可用的资源标记为'410 Gone',以及是否需要保持此标记多长时间,完全取决于服务器拥有者
+ LENGTH_REQUIRED: 411, //服务器拒绝在没有定义Content-Length头的情况下接受请求。在添加了表明请求消息体长度的有效Content-Length头之后,客户端可以再次提交该请求
+ PRECONDITION_FAILED: 412, //服务器在验证在请求的头字段中给出先决条件时,没能满足其中的一个或多个。这个状态码允许客户端在获取资源时在请求的元信息(请求头字段数据)中设置先决条件,以此避免该请求方法被应用到其希望的内容以外的资源上
+ REQUEST_ENTITY_TOO_LARGE: 413, //服务器拒绝处理当前请求,因为该请求提交的实体数据大小超过了服务器愿意或者能够处理的范围。此种情况下,服务器可以关闭连接以免客户端继续发送此请求。如果这个状况是临时的,服务器应当返回一个 Retry-After 的响应头,以告知客户端可以在多少时间以后重新尝试
+ REQUEST_URI_TOO_LONG: 414, //请求的URI长度超过了服务器能够解释的长度,因此服务器拒绝对该请求提供服务。这比较少见,通常的情况包括:本应使用POST方法的表单提交变成了GET方法,导致查询字符串(Query String)过长。重定向URI “黑洞”,例如每次重定向把旧的URI作为新的URI的一部分,导致在若干次重定向后URI超长。客户端正在尝试利用某些服务器中存在的安全漏洞攻击服务器。这类服务器使用固定长度的缓冲读取或操作请求的URI,当GET后的参数超过某个数值后,可能会产生缓冲区溢出,导致任意代码被执行[1]。没有此类漏洞的服务器,应当返回414状态码
+ UNSUPPORTED_MEDIA_TYPE: 415, //对于当前请求的方法和所请求的资源,请求中提交的实体并不是服务器中所支持的格式,因此请求被拒绝
+ REQUESTED_RANGE_NOT_SATISFIABLE: 416, //如果请求中包含了Range请求头,并且Range中指定的任何数据范围都与当前资源的可用范围不重合,同时请求中又没有定义If-Range请求头,那么服务器就应当返回416状态码。假如Range使用的是字节范围,那么这种情况就是指请求指定的所有数据范围的首字节位置都超过了当前资源的长度。服务器也应当在返回416状态码的同时,包含一个Content-Range实体头,用以指明当前资源的长度。这个响应也被禁止使用multipart/byteranges作为其 Content-Type
+ EXPECTION_FAILED: 417, //在请求头Expect中指定的预期内容无法被服务器满足,或者这个服务器是一个代理服务器,它有明显的证据证明在当前路由的下一个节点上,Expect的内容无法被满足
+ TOO_MANY_CONNECTIONS: 421, //从当前客户端所在的IP地址到服务器的连接数超过了服务器许可的最大范围。通常,这里的IP地址指的是从服务器上看到的客户端地址(比如用户的网关或者代理服务器地址)。在这种情况下,连接数的计算可能涉及到不止一个终端用户
+ UNPROCESSABLE_ENTITY: 422, //请求格式正确,但是由于含有语义错误,无法响应
+ FAILED_DEPENDENCY: 424, //由于之前的某个请求发生的错误,导致当前请求失败,例如PROPPATCH
+ UNORDERED_COLLECTION: 425, //在WebDav Advanced Collections 草案中定义,但是未出现在《WebDAV 顺序集协议》(RFC 3658)中
+ UPGRADE_REQUIRED: 426, //客户端应当切换到TLS/1.0
+ RETRY_WITH: 449, //由微软扩展,代表请求应当在执行完适当的操作后进行重试
+
+ INTERNAL_SERVER_ERROR: 500, //服务器遇到了一个未曾预料的状况,导致了它无法完成对请求的处理。一般来说,这个问题都会在服务器的程序码出错时出现
+ NOT_IMPLEMENTED: 501, //服务器不支持当前请求所需要的某个功能。当服务器无法识别请求的方法,并且无法支持其对任何资源的请求
+ BAD_GATEWAY: 502, //作为网关或者代理工作的服务器尝试执行请求时,从上游服务器接收到无效的响应
+ SERVICE_UNAVAILABLE: 503, //由于临时的服务器维护或者过载,服务器当前无法处理请求。这个状况是临时的,并且将在一段时间以后恢复。如果能够预计延迟时间,那么响应中可以包含一个 Retry-After 头用以标明这个延迟时间。如果没有给出这个 Retry-After 信息,那么客户端应当以处理500响应的方式处理它。注意:503状态码的存在并不意味着服务器在过载的时候必须使用它。某些服务器只不过是希望拒绝客户端的连接
+ GATEWAY_TIMEOUT: 504, //作为网关或者代理工作的服务器尝试执行请求时,未能及时从上游服务器(URI标识出的服务器,例如HTTP、FTP、LDAP)或者辅助服务器(例如DNS)收到响应。注意:某些代理服务器在DNS查询超时时会返回400或者500错误
+ HTTP_VERSION_NOT_SUPPORTED: 505, //服务器不支持,或者拒绝支持在请求中使用的HTTP版本。这暗示着服务器不能或不愿使用与客户端相同的版本。响应中应当包含一个描述了为何版本不被支持以及服务器支持哪些协议的实体
+ VARIANT_ALSO_NEGOTIATES: 506, //服务器存在内部配置错误:被请求的协商变元资源被配置为在透明内容协商中使用自己,因此在一个协商处理中不是一个合适的重点
+ INSUFFICIENT_STORAGE: 507, //服务器无法存储完成请求所必须的内容。这个状况被认为是临时的
+ BANDWIDTH_LIMIT_EXCEEDED: 509, //服务器达到带宽限制。这不是一个官方的状态码,但是仍被广泛使用
+ NOT_EXTENDED: 510 //获取资源所需要的策略并没有没满足
+
+};
\ No newline at end of file
diff --git a/src/lib/initialize.ts b/src/lib/initialize.ts
new file mode 100644
index 0000000..953d224
--- /dev/null
+++ b/src/lib/initialize.ts
@@ -0,0 +1,28 @@
+import logger from './logger.js';
+
+// 允许无限量的监听器
+process.setMaxListeners(Infinity);
+// 输出未捕获异常
+process.on("uncaughtException", (err, origin) => {
+ logger.error(`An unhandled error occurred: ${origin}`, err);
+});
+// 输出未处理的Promise.reject
+process.on("unhandledRejection", (_, promise) => {
+ promise.catch(err => logger.error("An unhandled rejection occurred:", err));
+});
+// 输出系统警告信息
+process.on("warning", warning => logger.warn("System warning: ", warning));
+// 进程退出监听
+process.on("exit", () => {
+ logger.info("Service exit");
+ logger.footer();
+});
+// 进程被kill
+process.on("SIGTERM", () => {
+ logger.warn("received kill signal");
+ process.exit(2);
+});
+// Ctrl-C进程退出
+process.on("SIGINT", () => {
+ process.exit(0);
+});
\ No newline at end of file
diff --git a/src/lib/interfaces/ICompletionMessage.ts b/src/lib/interfaces/ICompletionMessage.ts
new file mode 100644
index 0000000..5aad345
--- /dev/null
+++ b/src/lib/interfaces/ICompletionMessage.ts
@@ -0,0 +1,4 @@
+export default interface ICompletionMessage {
+ role: 'system' | 'assistant' | 'user' | 'function';
+ content: string;
+}
\ No newline at end of file
diff --git a/src/lib/logger.ts b/src/lib/logger.ts
new file mode 100644
index 0000000..32cb3a6
--- /dev/null
+++ b/src/lib/logger.ts
@@ -0,0 +1,184 @@
+import path from 'path';
+import _util from 'util';
+
+import 'colors';
+import _ from 'lodash';
+import fs from 'fs-extra';
+import { format as dateFormat } from 'date-fns';
+
+import config from './config.ts';
+import util from './util.ts';
+
+const isVercelEnv = process.env.VERCEL;
+
+class LogWriter {
+
+ #buffers = [];
+
+ constructor() {
+ !isVercelEnv && fs.ensureDirSync(config.system.logDirPath);
+ !isVercelEnv && this.work();
+ }
+
+ push(content) {
+ const buffer = Buffer.from(content);
+ this.#buffers.push(buffer);
+ }
+
+ writeSync(buffer) {
+ !isVercelEnv && fs.appendFileSync(path.join(config.system.logDirPath, `/${util.getDateString()}.log`), buffer);
+ }
+
+ async write(buffer) {
+ !isVercelEnv && await fs.appendFile(path.join(config.system.logDirPath, `/${util.getDateString()}.log`), buffer);
+ }
+
+ flush() {
+ if(!this.#buffers.length) return;
+ !isVercelEnv && fs.appendFileSync(path.join(config.system.logDirPath, `/${util.getDateString()}.log`), Buffer.concat(this.#buffers));
+ }
+
+ work() {
+ if (!this.#buffers.length) return setTimeout(this.work.bind(this), config.system.logWriteInterval);
+ const buffer = Buffer.concat(this.#buffers);
+ this.#buffers = [];
+ this.write(buffer)
+ .finally(() => setTimeout(this.work.bind(this), config.system.logWriteInterval))
+ .catch(err => console.error("Log write error:", err));
+ }
+
+}
+
+class LogText {
+
+ /** @type {string} 日志级别 */
+ level;
+ /** @type {string} 日志文本 */
+ text;
+ /** @type {string} 日志来源 */
+ source;
+ /** @type {Date} 日志发生时间 */
+ time = new Date();
+
+ constructor(level, ...params) {
+ this.level = level;
+ this.text = _util.format.apply(null, params);
+ this.source = this.#getStackTopCodeInfo();
+ }
+
+ #getStackTopCodeInfo() {
+ const unknownInfo = { name: "unknown", codeLine: 0, codeColumn: 0 };
+ const stackArray = new Error().stack.split("\n");
+ const text = stackArray[4];
+ if (!text)
+ return unknownInfo;
+ const match = text.match(/at (.+) \((.+)\)/) || text.match(/at (.+)/);
+ if (!match || !_.isString(match[2] || match[1]))
+ return unknownInfo;
+ const temp = match[2] || match[1];
+ const _match = temp.match(/([a-zA-Z0-9_\-\.]+)\:(\d+)\:(\d+)$/);
+ if (!_match)
+ return unknownInfo;
+ const [, scriptPath, codeLine, codeColumn] = _match as any;
+ return {
+ name: scriptPath ? scriptPath.replace(/.js$/, "") : "unknown",
+ path: scriptPath || null,
+ codeLine: parseInt(codeLine || 0),
+ codeColumn: parseInt(codeColumn || 0)
+ };
+ }
+
+ toString() {
+ return `[${dateFormat(this.time, "yyyy-MM-dd HH:mm:ss.SSS")}][${this.level}][${this.source.name}<${this.source.codeLine},${this.source.codeColumn}>] ${this.text}`;
+ }
+
+}
+
+class Logger {
+
+ /** @type {Object} 系统配置 */
+ config = {};
+ /** @type {Object} 日志级别映射 */
+ static Level = {
+ Success: "success",
+ Info: "info",
+ Log: "log",
+ Debug: "debug",
+ Warning: "warning",
+ Error: "error",
+ Fatal: "fatal"
+ };
+ /** @type {Object} 日志级别文本颜色樱色 */
+ static LevelColor = {
+ [Logger.Level.Success]: "green",
+ [Logger.Level.Info]: "brightCyan",
+ [Logger.Level.Debug]: "white",
+ [Logger.Level.Warning]: "brightYellow",
+ [Logger.Level.Error]: "brightRed",
+ [Logger.Level.Fatal]: "red"
+ };
+ #writer;
+
+ constructor() {
+ this.#writer = new LogWriter();
+ }
+
+ header() {
+ this.#writer.writeSync(Buffer.from(`\n\n===================== LOG START ${dateFormat(new Date(), "yyyy-MM-dd HH:mm:ss.SSS")} =====================\n\n`));
+ }
+
+ footer() {
+ this.#writer.flush(); //将未写入文件的日志缓存写入
+ this.#writer.writeSync(Buffer.from(`\n\n===================== LOG END ${dateFormat(new Date(), "yyyy-MM-dd HH:mm:ss.SSS")} =====================\n\n`));
+ }
+
+ success(...params) {
+ const content = new LogText(Logger.Level.Success, ...params).toString();
+ console.info(content[Logger.LevelColor[Logger.Level.Success]]);
+ this.#writer.push(content + "\n");
+ }
+
+ info(...params) {
+ const content = new LogText(Logger.Level.Info, ...params).toString();
+ console.info(content[Logger.LevelColor[Logger.Level.Info]]);
+ this.#writer.push(content + "\n");
+ }
+
+ log(...params) {
+ const content = new LogText(Logger.Level.Log, ...params).toString();
+ console.log(content[Logger.LevelColor[Logger.Level.Log]]);
+ this.#writer.push(content + "\n");
+ }
+
+ debug(...params) {
+ if(!config.system.debug) return; //非调试模式忽略debug
+ const content = new LogText(Logger.Level.Debug, ...params).toString();
+ console.debug(content[Logger.LevelColor[Logger.Level.Debug]]);
+ this.#writer.push(content + "\n");
+ }
+
+ warn(...params) {
+ const content = new LogText(Logger.Level.Warning, ...params).toString();
+ console.warn(content[Logger.LevelColor[Logger.Level.Warning]]);
+ this.#writer.push(content + "\n");
+ }
+
+ error(...params) {
+ const content = new LogText(Logger.Level.Error, ...params).toString();
+ console.error(content[Logger.LevelColor[Logger.Level.Error]]);
+ this.#writer.push(content);
+ }
+
+ fatal(...params) {
+ const content = new LogText(Logger.Level.Fatal, ...params).toString();
+ console.error(content[Logger.LevelColor[Logger.Level.Fatal]]);
+ this.#writer.push(content);
+ }
+
+ destory() {
+ this.#writer.destory();
+ }
+
+}
+
+export default new Logger();
\ No newline at end of file
diff --git a/src/lib/request/Request.ts b/src/lib/request/Request.ts
new file mode 100644
index 0000000..fce6045
--- /dev/null
+++ b/src/lib/request/Request.ts
@@ -0,0 +1,72 @@
+import _ from 'lodash';
+
+import APIException from '@/lib/exceptions/APIException.ts';
+import EX from '@/api/consts/exceptions.ts';
+import logger from '@/lib/logger.ts';
+import util from '@/lib/util.ts';
+
+export interface RequestOptions {
+ time?: number;
+}
+
+export default class Request {
+
+ /** 请求方法 */
+ method: string;
+ /** 请求URL */
+ url: string;
+ /** 请求路径 */
+ path: string;
+ /** 请求载荷类型 */
+ type: string;
+ /** 请求headers */
+ headers: any;
+ /** 请求原始查询字符串 */
+ search: string;
+ /** 请求查询参数 */
+ query: any;
+ /** 请求URL参数 */
+ params: any;
+ /** 请求载荷 */
+ body: any;
+ /** 上传的文件 */
+ files: any[];
+ /** 客户端IP地址 */
+ remoteIP: string | null;
+ /** 请求接受时间戳(毫秒) */
+ time: number;
+
+ constructor(ctx, options: RequestOptions = {}) {
+ const { time } = options;
+ this.method = ctx.request.method;
+ this.url = ctx.request.url;
+ this.path = ctx.request.path;
+ this.type = ctx.request.type;
+ this.headers = ctx.request.headers || {};
+ this.search = ctx.request.search;
+ this.query = ctx.query || {};
+ this.params = ctx.params || {};
+ this.body = ctx.request.body || {};
+ this.files = ctx.request.files || {};
+ this.remoteIP = this.headers["X-Real-IP"] || this.headers["x-real-ip"] || this.headers["X-Forwarded-For"] || this.headers["x-forwarded-for"] || ctx.ip || null;
+ this.time = Number(_.defaultTo(time, util.timestamp()));
+ }
+
+ validate(key: string, fn?: Function) {
+ try {
+ const value = _.get(this, key);
+ if (fn) {
+ if (fn(value) === false)
+ throw `[Mismatch] -> ${fn}`;
+ }
+ else if (_.isUndefined(value))
+ throw '[Undefined]';
+ }
+ catch (err) {
+ logger.warn(`Params ${key} invalid:`, err);
+ throw new APIException(EX.API_REQUEST_PARAMS_INVALID, `Params ${key} invalid`);
+ }
+ return this;
+ }
+
+}
\ No newline at end of file
diff --git a/src/lib/response/Body.ts b/src/lib/response/Body.ts
new file mode 100644
index 0000000..9cf8574
--- /dev/null
+++ b/src/lib/response/Body.ts
@@ -0,0 +1,41 @@
+import _ from 'lodash';
+
+export interface BodyOptions {
+ code?: number;
+ message?: string;
+ data?: any;
+ statusCode?: number;
+}
+
+export default class Body {
+
+ /** 状态码 */
+ code: number;
+ /** 状态消息 */
+ message: string;
+ /** 载荷 */
+ data: any;
+ /** HTTP状态码 */
+ statusCode: number;
+
+ constructor(options: BodyOptions = {}) {
+ const { code, message, data, statusCode } = options;
+ this.code = Number(_.defaultTo(code, 0));
+ this.message = _.defaultTo(message, 'OK');
+ this.data = _.defaultTo(data, null);
+ this.statusCode = Number(_.defaultTo(statusCode, 200));
+ }
+
+ toObject() {
+ return {
+ code: this.code,
+ message: this.message,
+ data: this.data
+ };
+ }
+
+ static isInstance(value) {
+ return value instanceof Body;
+ }
+
+}
\ No newline at end of file
diff --git a/src/lib/response/FailureBody.ts b/src/lib/response/FailureBody.ts
new file mode 100644
index 0000000..33d7fb9
--- /dev/null
+++ b/src/lib/response/FailureBody.ts
@@ -0,0 +1,31 @@
+import _ from 'lodash';
+
+import Body from './Body.ts';
+import Exception from '../exceptions/Exception.ts';
+import APIException from '../exceptions/APIException.ts';
+import EX from '../consts/exceptions.ts';
+import HTTP_STATUS_CODES from '../http-status-codes.ts';
+
+export default class FailureBody extends Body {
+
+ constructor(error: APIException | Exception | Error, _data?: any) {
+ let errcode, errmsg, data = _data, httpStatusCode = HTTP_STATUS_CODES.OK;;
+ if(_.isString(error))
+ error = new Exception(EX.SYSTEM_ERROR, error);
+ else if(error instanceof APIException || error instanceof Exception)
+ ({ errcode, errmsg, data, httpStatusCode } = error);
+ else if(_.isError(error))
+ ({ errcode, errmsg, data, httpStatusCode } = new Exception(EX.SYSTEM_ERROR, error.message));
+ super({
+ code: errcode || -1,
+ message: errmsg || 'Internal error',
+ data,
+ statusCode: httpStatusCode
+ });
+ }
+
+ static isInstance(value) {
+ return value instanceof FailureBody;
+ }
+
+}
\ No newline at end of file
diff --git a/src/lib/response/Response.ts b/src/lib/response/Response.ts
new file mode 100644
index 0000000..816397d
--- /dev/null
+++ b/src/lib/response/Response.ts
@@ -0,0 +1,63 @@
+import mime from 'mime';
+import _ from 'lodash';
+
+import Body from './Body.ts';
+import util from '../util.ts';
+
+export interface ResponseOptions {
+ statusCode?: number;
+ type?: string;
+ headers?: Record;
+ redirect?: string;
+ body?: any;
+ size?: number;
+ time?: number;
+}
+
+export default class Response {
+
+ /** 响应HTTP状态码 */
+ statusCode: number;
+ /** 响应内容类型 */
+ type: string;
+ /** 响应headers */
+ headers: Record;
+ /** 重定向目标 */
+ redirect: string;
+ /** 响应载荷 */
+ body: any;
+ /** 响应载荷大小 */
+ size: number;
+ /** 响应时间戳 */
+ time: number;
+
+ constructor(body: any, options: ResponseOptions = {}) {
+ const { statusCode, type, headers, redirect, size, time } = options;
+ this.statusCode = Number(_.defaultTo(statusCode, Body.isInstance(body) ? body.statusCode : undefined))
+ this.type = type;
+ this.headers = headers;
+ this.redirect = redirect;
+ this.size = size;
+ this.time = Number(_.defaultTo(time, util.timestamp()));
+ this.body = body;
+ }
+
+ injectTo(ctx) {
+ this.redirect && ctx.redirect(this.redirect);
+ this.statusCode && (ctx.status = this.statusCode);
+ this.type && (ctx.type = mime.getType(this.type) || this.type);
+ const headers = this.headers || {};
+ if(this.size && !headers["Content-Length"] && !headers["content-length"])
+ headers["Content-Length"] = this.size;
+ ctx.set(headers);
+ if(Body.isInstance(this.body))
+ ctx.body = this.body.toObject();
+ else
+ ctx.body = this.body;
+ }
+
+ static isInstance(value) {
+ return value instanceof Response;
+ }
+
+}
\ No newline at end of file
diff --git a/src/lib/response/SuccessfulBody.ts b/src/lib/response/SuccessfulBody.ts
new file mode 100644
index 0000000..639d0d8
--- /dev/null
+++ b/src/lib/response/SuccessfulBody.ts
@@ -0,0 +1,19 @@
+import _ from 'lodash';
+
+import Body from './Body.ts';
+
+export default class SuccessfulBody extends Body {
+
+ constructor(data: any, message?: string) {
+ super({
+ code: 0,
+ message: _.defaultTo(message, "OK"),
+ data
+ });
+ }
+
+ static isInstance(value) {
+ return value instanceof SuccessfulBody;
+ }
+
+}
\ No newline at end of file
diff --git a/src/lib/server.ts b/src/lib/server.ts
new file mode 100644
index 0000000..8c0e46a
--- /dev/null
+++ b/src/lib/server.ts
@@ -0,0 +1,173 @@
+import Koa from 'koa';
+import KoaRouter from 'koa-router';
+import koaRange from 'koa-range';
+import koaCors from "koa2-cors";
+import koaBody from 'koa-body';
+import _ from 'lodash';
+
+import Exception from './exceptions/Exception.ts';
+import Request from './request/Request.ts';
+import Response from './response/Response.js';
+import FailureBody from './response/FailureBody.ts';
+import EX from './consts/exceptions.ts';
+import logger from './logger.ts';
+import config from './config.ts';
+
+class Server {
+
+ app;
+ router;
+
+ constructor() {
+ this.app = new Koa();
+ this.app.use(koaCors());
+ // 范围请求支持
+ this.app.use(koaRange);
+ this.router = new KoaRouter({ prefix: config.service.urlPrefix });
+ // 前置处理异常拦截
+ this.app.use(async (ctx: any, next: Function) => {
+ if(ctx.request.type === "application/xml" || ctx.request.type === "application/ssml+xml")
+ ctx.req.headers["content-type"] = "text/xml";
+ try { await next() }
+ catch (err) {
+ logger.error(err);
+ const failureBody = new FailureBody(err);
+ new Response(failureBody).injectTo(ctx);
+ }
+ });
+ // 载荷解析器支持
+ this.app.use(koaBody(_.clone(config.system.requestBody)));
+ this.app.on("error", (err: any) => {
+ // 忽略连接重试、中断、管道、取消错误
+ if (["ECONNRESET", "ECONNABORTED", "EPIPE", "ECANCELED"].includes(err.code)) return;
+ logger.error(err);
+ });
+ logger.success("Server initialized");
+ }
+
+ /**
+ * 附加路由
+ *
+ * @param routes 路由列表
+ */
+ attachRoutes(routes: any[]) {
+ routes.forEach((route: any) => {
+ const prefix = route.prefix || "";
+ for (let method in route) {
+ if(method === "prefix") continue;
+ if (!_.isObject(route[method])) {
+ logger.warn(`Router ${prefix} ${method} invalid`);
+ continue;
+ }
+ for (let uri in route[method]) {
+ this.router[method](`${prefix}${uri}`, async ctx => {
+ const { request, response } = await this.#requestProcessing(ctx, route[method][uri]);
+ if(response != null && config.system.requestLog)
+ logger.info(`<- ${request.method} ${request.url} ${response.time - request.time}ms`);
+ });
+ }
+ }
+ logger.info(`Route ${config.service.urlPrefix || ""}${prefix} attached`);
+ });
+ this.app.use(this.router.routes());
+ this.app.use((ctx: any) => {
+ const request = new Request(ctx);
+ logger.debug(`-> ${ctx.request.method} ${ctx.request.url} request is not supported - ${request.remoteIP || "unknown"}`);
+ // const failureBody = new FailureBody(new Exception(EX.SYSTEM_NOT_ROUTE_MATCHING, "Request is not supported"));
+ // const response = new Response(failureBody);
+ const message = `[请求有误]: 正确请求为 POST -> /v1/chat/completions,当前请求为 ${ctx.request.method} -> ${ctx.request.url} 请纠正`;
+ logger.warn(message);
+ const failureBody = new FailureBody(new Error(message));
+ const response = new Response(failureBody);
+ response.injectTo(ctx);
+ if(config.system.requestLog)
+ logger.info(`<- ${request.method} ${request.url} ${response.time - request.time}ms`);
+ });
+ }
+
+ /**
+ * 请求处理
+ *
+ * @param ctx 上下文
+ * @param routeFn 路由方法
+ */
+ #requestProcessing(ctx: any, routeFn: Function): Promise {
+ return new Promise(resolve => {
+ const request = new Request(ctx);
+ try {
+ if(config.system.requestLog)
+ logger.info(`-> ${request.method} ${request.url}`);
+ routeFn(request)
+ .then(response => {
+ try {
+ if(!Response.isInstance(response)) {
+ const _response = new Response(response);
+ _response.injectTo(ctx);
+ return resolve({ request, response: _response });
+ }
+ response.injectTo(ctx);
+ resolve({ request, response });
+ }
+ catch(err) {
+ logger.error(err);
+ const failureBody = new FailureBody(err);
+ const response = new Response(failureBody);
+ response.injectTo(ctx);
+ resolve({ request, response });
+ }
+ })
+ .catch(err => {
+ try {
+ logger.error(err);
+ const failureBody = new FailureBody(err);
+ const response = new Response(failureBody);
+ response.injectTo(ctx);
+ resolve({ request, response });
+ }
+ catch(err) {
+ logger.error(err);
+ const failureBody = new FailureBody(err);
+ const response = new Response(failureBody);
+ response.injectTo(ctx);
+ resolve({ request, response });
+ }
+ });
+ }
+ catch(err) {
+ logger.error(err);
+ const failureBody = new FailureBody(err);
+ const response = new Response(failureBody);
+ response.injectTo(ctx);
+ resolve({ request, response });
+ }
+ });
+ }
+
+ /**
+ * 监听端口
+ */
+ async listen() {
+ const host = config.service.host;
+ const port = config.service.port;
+ await Promise.all([
+ new Promise((resolve, reject) => {
+ if(host === "0.0.0.0" || host === "localhost" || host === "127.0.0.1")
+ return resolve(null);
+ this.app.listen(port, "localhost", err => {
+ if(err) return reject(err);
+ resolve(null);
+ });
+ }),
+ new Promise((resolve, reject) => {
+ this.app.listen(port, host, err => {
+ if(err) return reject(err);
+ resolve(null);
+ });
+ })
+ ]);
+ logger.success(`Server listening on port ${port} (${host})`);
+ }
+
+}
+
+export default new Server();
\ No newline at end of file
diff --git a/src/lib/util.ts b/src/lib/util.ts
new file mode 100644
index 0000000..0f3fd16
--- /dev/null
+++ b/src/lib/util.ts
@@ -0,0 +1,307 @@
+import os from "os";
+import path from "path";
+import crypto from "crypto";
+import { Readable, Writable } from "stream";
+
+import "colors";
+import mime from "mime";
+import axios from "axios";
+import fs from "fs-extra";
+import { v1 as uuid } from "uuid";
+import { format as dateFormat } from "date-fns";
+import CRC32 from "crc-32";
+import randomstring from "randomstring";
+import _ from "lodash";
+import { CronJob } from "cron";
+
+import HTTP_STATUS_CODE from "./http-status-codes.ts";
+
+const autoIdMap = new Map();
+
+const util = {
+ is2DArrays(value: any) {
+ return (
+ _.isArray(value) &&
+ (!value[0] || (_.isArray(value[0]) && _.isArray(value[value.length - 1])))
+ );
+ },
+
+ uuid: (separator = true) => (separator ? uuid() : uuid().replace(/\-/g, "")),
+
+ autoId: (prefix = "") => {
+ let index = autoIdMap.get(prefix);
+ if (index > 999999) index = 0; //超过最大数字则重置为0
+ autoIdMap.set(prefix, (index || 0) + 1);
+ return `${prefix}${index || 1}`;
+ },
+
+ ignoreJSONParse(value: string) {
+ const result = _.attempt(() => JSON.parse(value));
+ if (_.isError(result)) return null;
+ return result;
+ },
+
+ generateRandomString(options: any): string {
+ return randomstring.generate(options);
+ },
+
+ getResponseContentType(value: any): string | null {
+ return value.headers
+ ? value.headers["content-type"] || value.headers["Content-Type"]
+ : null;
+ },
+
+ mimeToExtension(value: string) {
+ let extension = mime.getExtension(value);
+ if (extension == "mpga") return "mp3";
+ return extension;
+ },
+
+ extractURLExtension(value: string) {
+ const extname = path.extname(new URL(value).pathname);
+ return extname.substring(1).toLowerCase();
+ },
+
+ createCronJob(cronPatterns: any, callback?: Function) {
+ if (!_.isFunction(callback))
+ throw new Error("callback must be an Function");
+ return new CronJob(
+ cronPatterns,
+ () => callback(),
+ null,
+ false,
+ "Asia/Shanghai"
+ );
+ },
+
+ getDateString(format = "yyyy-MM-dd", date = new Date()) {
+ return dateFormat(date, format);
+ },
+
+ getIPAddressesByIPv4(): string[] {
+ const interfaces = os.networkInterfaces();
+ const addresses = [];
+ for (let name in interfaces) {
+ const networks = interfaces[name];
+ const results = networks.filter(
+ (network) =>
+ network.family === "IPv4" &&
+ network.address !== "127.0.0.1" &&
+ !network.internal
+ );
+ if (results[0] && results[0].address) addresses.push(results[0].address);
+ }
+ return addresses;
+ },
+
+ getMACAddressesByIPv4(): string[] {
+ const interfaces = os.networkInterfaces();
+ const addresses = [];
+ for (let name in interfaces) {
+ const networks = interfaces[name];
+ const results = networks.filter(
+ (network) =>
+ network.family === "IPv4" &&
+ network.address !== "127.0.0.1" &&
+ !network.internal
+ );
+ if (results[0] && results[0].mac) addresses.push(results[0].mac);
+ }
+ return addresses;
+ },
+
+ generateSSEData(event?: string, data?: string, retry?: number) {
+ return `event: ${event || "message"}\ndata: ${(data || "")
+ .replace(/\n/g, "\\n")
+ .replace(/\s/g, "\\s")}\nretry: ${retry || 3000}\n\n`;
+ },
+
+ buildDataBASE64(type, ext, buffer) {
+ return `data:${type}/${ext.replace("jpg", "jpeg")};base64,${buffer.toString(
+ "base64"
+ )}`;
+ },
+
+ isLinux() {
+ return os.platform() !== "win32";
+ },
+
+ isIPAddress(value) {
+ return (
+ _.isString(value) &&
+ (/^((2[0-4]\d|25[0-5]|[01]?\d\d?)\.){3}(2[0-4]\d|25[0-5]|[01]?\d\d?)$/.test(
+ value
+ ) ||
+ /\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?\s*/.test(
+ value
+ ))
+ );
+ },
+
+ isPort(value) {
+ return _.isNumber(value) && value > 0 && value < 65536;
+ },
+
+ isReadStream(value): boolean {
+ return (
+ value &&
+ (value instanceof Readable || "readable" in value || value.readable)
+ );
+ },
+
+ isWriteStream(value): boolean {
+ return (
+ value &&
+ (value instanceof Writable || "writable" in value || value.writable)
+ );
+ },
+
+ isHttpStatusCode(value) {
+ return _.isNumber(value) && Object.values(HTTP_STATUS_CODE).includes(value);
+ },
+
+ isURL(value) {
+ return !_.isUndefined(value) && /^(http|https)/.test(value);
+ },
+
+ isSrc(value) {
+ return !_.isUndefined(value) && /^\/.+\.[0-9a-zA-Z]+(\?.+)?$/.test(value);
+ },
+
+ isBASE64(value) {
+ return !_.isUndefined(value) && /^[a-zA-Z0-9\/\+]+(=?)+$/.test(value);
+ },
+
+ isBASE64Data(value) {
+ return /^data:/.test(value);
+ },
+
+ extractBASE64DataFormat(value): string | null {
+ const match = value.trim().match(/^data:(.+);base64,/);
+ if (!match) return null;
+ return match[1];
+ },
+
+ removeBASE64DataHeader(value): string {
+ return value.replace(/^data:(.+);base64,/, "");
+ },
+
+ isDataString(value): boolean {
+ return /^(base64|json):/.test(value);
+ },
+
+ isStringNumber(value) {
+ return _.isFinite(Number(value));
+ },
+
+ isUnixTimestamp(value) {
+ return /^[0-9]{10}$/.test(`${value}`);
+ },
+
+ isTimestamp(value) {
+ return /^[0-9]{13}$/.test(`${value}`);
+ },
+
+ isEmail(value) {
+ return /^([a-zA-Z0-9]+[_|\_|\.]?)*[a-zA-Z0-9]+@([a-zA-Z0-9]+[_|\_|\.]?)*[a-zA-Z0-9]+\.[a-zA-Z]{2,3}$/.test(
+ value
+ );
+ },
+
+ isAsyncFunction(value) {
+ return Object.prototype.toString.call(value) === "[object AsyncFunction]";
+ },
+
+ async isAPNG(filePath) {
+ let head;
+ const readStream = fs.createReadStream(filePath, { start: 37, end: 40 });
+ const readPromise = new Promise((resolve, reject) => {
+ readStream.once("end", resolve);
+ readStream.once("error", reject);
+ });
+ readStream.once("data", (data) => (head = data));
+ await readPromise;
+ return head.compare(Buffer.from([0x61, 0x63, 0x54, 0x4c])) === 0;
+ },
+
+ unixTimestamp() {
+ return parseInt(`${Date.now() / 1000}`);
+ },
+
+ timestamp() {
+ return Date.now();
+ },
+
+ urlJoin(...values) {
+ let url = "";
+ for (let i = 0; i < values.length; i++)
+ url += `${i > 0 ? "/" : ""}${values[i]
+ .replace(/^\/*/, "")
+ .replace(/\/*$/, "")}`;
+ return url;
+ },
+
+ millisecondsToHmss(milliseconds) {
+ if (_.isString(milliseconds)) return milliseconds;
+ milliseconds = parseInt(milliseconds);
+ const sec = Math.floor(milliseconds / 1000);
+ const hours = Math.floor(sec / 3600);
+ const minutes = Math.floor((sec - hours * 3600) / 60);
+ const seconds = sec - hours * 3600 - minutes * 60;
+ const ms = (milliseconds % 60000) - seconds * 1000;
+ return `${hours > 9 ? hours : "0" + hours}:${
+ minutes > 9 ? minutes : "0" + minutes
+ }:${seconds > 9 ? seconds : "0" + seconds}.${ms}`;
+ },
+
+ millisecondsToTimeString(milliseconds) {
+ if (milliseconds < 1000) return `${milliseconds}ms`;
+ if (milliseconds < 60000)
+ return `${parseFloat((milliseconds / 1000).toFixed(2))}s`;
+ return `${Math.floor(milliseconds / 1000 / 60)}m${Math.floor(
+ (milliseconds / 1000) % 60
+ )}s`;
+ },
+
+ rgbToHex(r, g, b): string {
+ return ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
+ },
+
+ hexToRgb(hex) {
+ const value = parseInt(hex.replace(/^#/, ""), 16);
+ return [(value >> 16) & 255, (value >> 8) & 255, value & 255];
+ },
+
+ md5(value) {
+ return crypto.createHash("md5").update(value).digest("hex");
+ },
+
+ crc32(value) {
+ return _.isBuffer(value) ? CRC32.buf(value) : CRC32.str(value);
+ },
+
+ arrayParse(value): any[] {
+ return _.isArray(value) ? value : [value];
+ },
+
+ booleanParse(value) {
+ return value === "true" || value === true ? true : false;
+ },
+
+ encodeBASE64(value) {
+ return Buffer.from(value).toString("base64");
+ },
+
+ decodeBASE64(value) {
+ return Buffer.from(value, "base64").toString();
+ },
+
+ async fetchFileBASE64(url: string) {
+ const result = await axios.get(url, {
+ responseType: "arraybuffer",
+ });
+ return result.data.toString("base64");
+ },
+};
+
+export default util;
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..b6477c3
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "baseUrl": ".",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "allowImportingTsExtensions": true,
+ "allowSyntheticDefaultImports": true,
+ "noEmit": true,
+ "paths": {
+ "@/*": ["src/*"]
+ },
+ "outDir": "./dist"
+ },
+ "include": ["src/**/*", "libs.d.ts"],
+ "exclude": ["node_modules", "dist"]
+}
\ No newline at end of file
diff --git a/vercel.json b/vercel.json
new file mode 100644
index 0000000..74f98bc
--- /dev/null
+++ b/vercel.json
@@ -0,0 +1,27 @@
+{
+ "builds": [
+ {
+ "src": "./dist/*.html",
+ "use": "@vercel/static"
+ },
+ {
+ "src": "./dist/index.js",
+ "use": "@vercel/node"
+ }
+ ],
+ "routes": [
+ {
+ "src": "/",
+ "dest": "/dist/welcome.html"
+ },
+ {
+ "src": "/(.*)",
+ "dest": "/dist",
+ "headers": {
+ "Access-Control-Allow-Credentials": "true",
+ "Access-Control-Allow-Methods": "GET,OPTIONS,PATCH,DELETE,POST,PUT",
+ "Access-Control-Allow-Headers": "X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, Content-Type, Authorization"
+ }
+ }
+ ]
+}
\ No newline at end of file