@baishuyun/chat-backend 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +17 -0
- package/CHANGELOG.md +9 -0
- package/README.md +8 -0
- package/config/default.ts +35 -0
- package/config/development.ts +0 -0
- package/config/index.ts +14 -0
- package/config/production.ts +0 -0
- package/dist/config/default.js +31 -0
- package/dist/config/development.js +1 -0
- package/dist/config/index.js +12 -0
- package/dist/config/production.js +1 -0
- package/dist/src/app/main.js +13 -0
- package/dist/src/config/hono.config.js +34 -0
- package/dist/src/controllers/form/attachment-upload.controller.js +48 -0
- package/dist/src/controllers/form/build/build.controller.js +33 -0
- package/dist/src/controllers/form/build/model.js +11 -0
- package/dist/src/controllers/form/conversation/clear.controller.js +22 -0
- package/dist/src/controllers/form/fill/createFieldsFillingResultTransformStream.js +110 -0
- package/dist/src/controllers/form/fill/fill.controller.js +41 -0
- package/dist/src/controllers/form/fill/model.js +13 -0
- package/dist/src/controllers/form/fill/utils.js +56 -0
- package/dist/src/index.js +2 -0
- package/dist/src/logger/index.js +46 -0
- package/dist/src/logger/log-middleware.js +20 -0
- package/dist/src/routes/form/form.route.js +17 -0
- package/package.json +43 -0
- package/src/app/main.ts +17 -0
- package/src/config/hono.config.ts +47 -0
- package/src/controllers/form/attachment-upload.controller.ts +57 -0
- package/src/controllers/form/build/build.controller.ts +40 -0
- package/src/controllers/form/build/model.ts +16 -0
- package/src/controllers/form/conversation/clear.controller.ts +31 -0
- package/src/controllers/form/fill/createFieldsFillingResultTransformStream.ts +138 -0
- package/src/controllers/form/fill/fill.controller.ts +45 -0
- package/src/controllers/form/fill/model.ts +17 -0
- package/src/controllers/form/fill/utils.ts +75 -0
- package/src/index.ts +2 -0
- package/src/logger/index.ts +54 -0
- package/src/logger/log-middleware.ts +25 -0
- package/src/routes/form/form.route.ts +23 -0
- package/tsconfig.json +10 -0
package/.env.example
ADDED
package/CHANGELOG.md
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { asyncConfig } from "config/async.js";
|
|
2
|
+
// load async configurations
|
|
3
|
+
const fetchRemoteConfig = async () => {
|
|
4
|
+
return {
|
|
5
|
+
// mock remote config
|
|
6
|
+
foo: "bar",
|
|
7
|
+
};
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export default {
|
|
11
|
+
app: {
|
|
12
|
+
port: process.env.PORT || 3001,
|
|
13
|
+
host: process.env.HOST || "localhost",
|
|
14
|
+
},
|
|
15
|
+
agent: {
|
|
16
|
+
host: process.env.AGENT_HOST || "47.99.202.157",
|
|
17
|
+
|
|
18
|
+
apiAuthKey: process.env.COZE_API_KEY,
|
|
19
|
+
|
|
20
|
+
form: {
|
|
21
|
+
build: {
|
|
22
|
+
botId: process.env.BUILD_BOT_ID || "7579927677256073216",
|
|
23
|
+
baseUrl: `http://${process.env.AGENT_HOST}/v3/`,
|
|
24
|
+
apiKey: process.env.BOT_API_KEY, // load from env
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
fill: {
|
|
28
|
+
botId: process.env.FILL_BOT_ID || "7586483957357608960",
|
|
29
|
+
baseUrl: `http://${process.env.AGENT_HOST}/v3/`,
|
|
30
|
+
apiKey: process.env.BOT_API_KEY, // load from env
|
|
31
|
+
ocrApiKey: process.env.OCR_API_KEY || "", // load from env
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
};
|
|
File without changes
|
package/config/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import dotenv from "dotenv";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
|
|
5
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
6
|
+
const __dirname = path.dirname(__filename);
|
|
7
|
+
|
|
8
|
+
// export const parseConfig = () => {
|
|
9
|
+
if (process.env.NODE_ENV !== "production") {
|
|
10
|
+
// 加载本地.env文件到process.env
|
|
11
|
+
dotenv.config({
|
|
12
|
+
path: path.resolve(__dirname, "../.env"),
|
|
13
|
+
});
|
|
14
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { asyncConfig } from "config/async.js";
|
|
2
|
+
// load async configurations
|
|
3
|
+
const fetchRemoteConfig = async () => {
|
|
4
|
+
return {
|
|
5
|
+
// mock remote config
|
|
6
|
+
foo: "bar",
|
|
7
|
+
};
|
|
8
|
+
};
|
|
9
|
+
export default {
|
|
10
|
+
app: {
|
|
11
|
+
port: process.env.PORT || 3001,
|
|
12
|
+
host: process.env.HOST || "localhost",
|
|
13
|
+
},
|
|
14
|
+
agent: {
|
|
15
|
+
host: process.env.AGENT_HOST || "47.99.202.157",
|
|
16
|
+
apiAuthKey: process.env.COZE_API_KEY,
|
|
17
|
+
form: {
|
|
18
|
+
build: {
|
|
19
|
+
botId: process.env.BUILD_BOT_ID || "7579927677256073216",
|
|
20
|
+
baseUrl: `http://${process.env.AGENT_HOST}/v3/`,
|
|
21
|
+
apiKey: process.env.BOT_API_KEY, // load from env
|
|
22
|
+
},
|
|
23
|
+
fill: {
|
|
24
|
+
botId: process.env.FILL_BOT_ID || "7586483957357608960",
|
|
25
|
+
baseUrl: `http://${process.env.AGENT_HOST}/v3/`,
|
|
26
|
+
apiKey: process.env.BOT_API_KEY, // load from env
|
|
27
|
+
ocrApiKey: process.env.OCR_API_KEY || "", // load from env
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import dotenv from "dotenv";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
5
|
+
const __dirname = path.dirname(__filename);
|
|
6
|
+
// export const parseConfig = () => {
|
|
7
|
+
if (process.env.NODE_ENV !== "production") {
|
|
8
|
+
// 加载本地.env文件到process.env
|
|
9
|
+
dotenv.config({
|
|
10
|
+
path: path.resolve(__dirname, "../.env"),
|
|
11
|
+
});
|
|
12
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 后端应用入口
|
|
3
|
+
*/
|
|
4
|
+
// 应用实例
|
|
5
|
+
import app from '../config/hono.config.js';
|
|
6
|
+
// 子路由
|
|
7
|
+
import { createFormRouter } from '../routes/form/form.route.js';
|
|
8
|
+
// 挂载子路由
|
|
9
|
+
app.route('api/form', createFormRouter());
|
|
10
|
+
// add test route
|
|
11
|
+
app.get('/api/test', (c) => {
|
|
12
|
+
return c.json({ message: 'Test route is working!' });
|
|
13
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { serve } from "@hono/node-server";
|
|
3
|
+
import config from "config";
|
|
4
|
+
import { cors } from "hono/cors";
|
|
5
|
+
import { logMiddleware } from "../logger/log-middleware.js";
|
|
6
|
+
import { logger } from "../logger/index.js";
|
|
7
|
+
/**
|
|
8
|
+
* 公共配置层,跨域、日志等中间件配置
|
|
9
|
+
*/
|
|
10
|
+
const app = new Hono();
|
|
11
|
+
// Enable CORS for all routes starting with /api/
|
|
12
|
+
app.use("/api/*", cors());
|
|
13
|
+
// Logging middleware
|
|
14
|
+
app.use(logMiddleware);
|
|
15
|
+
// 全局错误处理中间件(捕获所有路由/中间件异常)
|
|
16
|
+
app.onError((err, c) => {
|
|
17
|
+
// 记录错误日志(error 级别,包含异常堆栈)
|
|
18
|
+
logger.error({
|
|
19
|
+
err: err, // 异常对象(自动包含 stack 堆栈信息)
|
|
20
|
+
method: c.req.method,
|
|
21
|
+
path: c.req.path,
|
|
22
|
+
status: 500, // 默认 500 错误
|
|
23
|
+
}, "Request failed with unhandled error"); // 错误描述
|
|
24
|
+
// 返回统一的 JSON 错误响应
|
|
25
|
+
return c.json({ message: "Internal Server Error" }, 500);
|
|
26
|
+
});
|
|
27
|
+
serve({
|
|
28
|
+
fetch: app.fetch,
|
|
29
|
+
hostname: config.get("app.host") || "",
|
|
30
|
+
port: config.get("app.port") || 3001,
|
|
31
|
+
}, (info) => {
|
|
32
|
+
logger.info(`Server is running on http://localhost:${info.port}`);
|
|
33
|
+
});
|
|
34
|
+
export default app;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import {} from "hono";
|
|
2
|
+
import config from "config";
|
|
3
|
+
import { logger } from "../../logger/index.js";
|
|
4
|
+
import { parseImg } from "./fill/utils.js";
|
|
5
|
+
export const uploadAttachment = async (c) => {
|
|
6
|
+
const formData = await c.req.formData();
|
|
7
|
+
const file = formData.get("file"); // as FormData | null;
|
|
8
|
+
// 校验文件是否存在
|
|
9
|
+
if (!file || !(file instanceof Blob)) {
|
|
10
|
+
return c.json({ error: "请上传有效的文件" }, 400);
|
|
11
|
+
}
|
|
12
|
+
const forwardFormData = new FormData();
|
|
13
|
+
forwardFormData.append("file", file);
|
|
14
|
+
const apiKey = config.get("agent.apiAuthKey");
|
|
15
|
+
const host = config.get("agent.host");
|
|
16
|
+
const api = `http://${host}/v1/files/upload`;
|
|
17
|
+
logger.debug(c.body);
|
|
18
|
+
const response = await fetch(api, {
|
|
19
|
+
method: "POST",
|
|
20
|
+
body: formData,
|
|
21
|
+
headers: {
|
|
22
|
+
// Add any auth headers
|
|
23
|
+
Authorization: `Bearer ${apiKey}`,
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
if (!response.ok) {
|
|
27
|
+
const errorData = await response
|
|
28
|
+
.json()
|
|
29
|
+
.catch(() => ({ message: "上传失败" }));
|
|
30
|
+
return c.json({
|
|
31
|
+
error: `目标接口返回错误: ${errorData.message}`,
|
|
32
|
+
status: response.status,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
const data = await response.json();
|
|
36
|
+
const { url, uri, ...rest } = data?.data || {};
|
|
37
|
+
const orcResult = await parseImg(file);
|
|
38
|
+
return c.json({
|
|
39
|
+
url,
|
|
40
|
+
name: uri,
|
|
41
|
+
contentType: file.type,
|
|
42
|
+
parsedData: orcResult,
|
|
43
|
+
uploadMetadata: {
|
|
44
|
+
...rest,
|
|
45
|
+
orcResult,
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { convertToModelMessages, streamText } from "ai";
|
|
2
|
+
import {} from "hono";
|
|
3
|
+
import { createModel } from "./model.js";
|
|
4
|
+
import { createFieldsJsonTransformStream, SuggestionTransformStream, } from "@baishuyun/coze-provider";
|
|
5
|
+
/**
|
|
6
|
+
* 搭建表单
|
|
7
|
+
* @param c
|
|
8
|
+
* @returns
|
|
9
|
+
*/
|
|
10
|
+
export const buildForm = async (c) => {
|
|
11
|
+
let requestBody;
|
|
12
|
+
try {
|
|
13
|
+
const json = await c.req.json();
|
|
14
|
+
requestBody = json;
|
|
15
|
+
}
|
|
16
|
+
catch (_) {
|
|
17
|
+
return c.json({ error: "Invalid JSON" }, 400);
|
|
18
|
+
}
|
|
19
|
+
const isBuildStage = requestBody.stage === "build";
|
|
20
|
+
const stream = streamText({
|
|
21
|
+
model: createModel([
|
|
22
|
+
createFieldsJsonTransformStream(isBuildStage),
|
|
23
|
+
new SuggestionTransformStream(isBuildStage),
|
|
24
|
+
]),
|
|
25
|
+
messages: convertToModelMessages(requestBody.messages),
|
|
26
|
+
includeRawChunks: true,
|
|
27
|
+
headers: {
|
|
28
|
+
"x-user-stage": requestBody.stage,
|
|
29
|
+
"x-user-id": Date.now().toString(),
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
return stream.toUIMessageStreamResponse();
|
|
33
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createCoze } from "@baishuyun/coze-provider";
|
|
2
|
+
import config from "config";
|
|
3
|
+
export const createModel = (extraStreamTransformers) => {
|
|
4
|
+
const coze = createCoze({
|
|
5
|
+
apiKey: config.get("agent.form.build.apiKey"),
|
|
6
|
+
baseURL: config.get("agent.form.build.baseUrl"),
|
|
7
|
+
botId: config.get("agent.form.build.botId"),
|
|
8
|
+
extraStreamTransformers,
|
|
9
|
+
});
|
|
10
|
+
return coze.chat("chat");
|
|
11
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import config from "config";
|
|
2
|
+
export const clearConversation = async (c) => {
|
|
3
|
+
let requestBody;
|
|
4
|
+
const agentHost = config.get("agent.host");
|
|
5
|
+
const baseUrl = `http://${agentHost}/v1`;
|
|
6
|
+
const apiKey = config.get("agent.apiAuthKey");
|
|
7
|
+
try {
|
|
8
|
+
const json = await c.req.json();
|
|
9
|
+
requestBody = json;
|
|
10
|
+
}
|
|
11
|
+
catch (_) {
|
|
12
|
+
return c.json({ error: "Invalid JSON" }, 400);
|
|
13
|
+
}
|
|
14
|
+
const result = await fetch(`${baseUrl}/conversations/${requestBody.conversationId}/clear`, {
|
|
15
|
+
method: "POST",
|
|
16
|
+
headers: {
|
|
17
|
+
// Add any auth headers
|
|
18
|
+
Authorization: `Bearer ${apiKey}`,
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
return c.json(result);
|
|
22
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import {} from '@ai-sdk/provider';
|
|
2
|
+
import { JSONParser } from '@streamparser/json';
|
|
3
|
+
import { createTextInfoEnqueuer } from '@baishuyun/coze-provider';
|
|
4
|
+
import { getLastSubFormField } from './utils.js';
|
|
5
|
+
import { logger } from '../../../logger/index.js';
|
|
6
|
+
export const createFieldsFillingResultTransformer = (enableJsonParser) => {
|
|
7
|
+
let parser;
|
|
8
|
+
let id;
|
|
9
|
+
// 解析完成标记:用于等待解析器内部队列处理完毕
|
|
10
|
+
let parseCompleted;
|
|
11
|
+
let resolveParseCompleted;
|
|
12
|
+
const chunkGuard = (chunk) => {
|
|
13
|
+
if (!enableJsonParser) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
if (chunk.type !== 'text-delta' || !('delta' in chunk)) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
return true;
|
|
20
|
+
};
|
|
21
|
+
const transformer = {
|
|
22
|
+
flush: async (controller) => {
|
|
23
|
+
try {
|
|
24
|
+
if (parser) {
|
|
25
|
+
await parseCompleted; // 等待闭包中的完成标记
|
|
26
|
+
logger.debug('stop parser');
|
|
27
|
+
// parser.end(); // 此时调用 end() 不会出现未完成 Token 异常
|
|
28
|
+
}
|
|
29
|
+
controller.terminate(); // 信号通知下游可读流已关闭
|
|
30
|
+
}
|
|
31
|
+
catch (e) {
|
|
32
|
+
controller.error('stop error' + e);
|
|
33
|
+
// controller.terminate(); // 信号通知下游可读流已关闭
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
transform: (chunk, controller) => {
|
|
37
|
+
if (!parser) {
|
|
38
|
+
logger.warn('json parser not init');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
if (chunkGuard(chunk)) {
|
|
43
|
+
if ('id' in chunk && 'delta' in chunk) {
|
|
44
|
+
id = chunk.id;
|
|
45
|
+
const regExp = /`?`?`?/;
|
|
46
|
+
let delta = chunk.delta.replace(regExp, '');
|
|
47
|
+
parser.write(delta.replace('json', ''));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
controller.enqueue(chunk);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
controller.error(e);
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
start: (controller) => {
|
|
59
|
+
parser = new JSONParser({
|
|
60
|
+
paths: ['$.*', '$.*.value.*.*'],
|
|
61
|
+
});
|
|
62
|
+
parseCompleted = new Promise((resolve) => {
|
|
63
|
+
resolveParseCompleted = resolve;
|
|
64
|
+
});
|
|
65
|
+
const enqueueTextDelta = enableJsonParser
|
|
66
|
+
? createTextInfoEnqueuer(controller)
|
|
67
|
+
: (content) => { };
|
|
68
|
+
parser.onValue = (parsedInfo) => {
|
|
69
|
+
const value = parsedInfo.value;
|
|
70
|
+
const subFormField = getLastSubFormField(parsedInfo);
|
|
71
|
+
if (subFormField) {
|
|
72
|
+
enqueueTextDelta(JSON.stringify(value), {
|
|
73
|
+
type: 'mcp-fields-json',
|
|
74
|
+
field: subFormField.value,
|
|
75
|
+
});
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (value.fieldType === 'subform') {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
enqueueTextDelta(JSON.stringify(value), {
|
|
82
|
+
type: 'mcp-fields-json',
|
|
83
|
+
field: value,
|
|
84
|
+
}, id);
|
|
85
|
+
};
|
|
86
|
+
let errorLogged = false;
|
|
87
|
+
parser.onError = (err) => {
|
|
88
|
+
// controller.enqueue({
|
|
89
|
+
// type: "error",
|
|
90
|
+
// error: "JsonWidgetStream: JSON Parsing Error:" + err.message,
|
|
91
|
+
// });
|
|
92
|
+
if (!errorLogged) {
|
|
93
|
+
console.error('JsonWidgetStream: JSON Parsing Error:', err);
|
|
94
|
+
errorLogged = true;
|
|
95
|
+
}
|
|
96
|
+
// close stream
|
|
97
|
+
logger.error('JsonStream: closing stream due to parsing error');
|
|
98
|
+
controller.terminate(); // 信号通知下游可读流已关闭
|
|
99
|
+
};
|
|
100
|
+
parser.onEnd = () => {
|
|
101
|
+
enqueueTextDelta(' ', JSON.stringify({
|
|
102
|
+
appendConfirm: 'save-fields',
|
|
103
|
+
}));
|
|
104
|
+
logger.debug('mark as end');
|
|
105
|
+
resolveParseCompleted?.();
|
|
106
|
+
};
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
return new TransformStream(transformer);
|
|
110
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import {} from 'hono';
|
|
2
|
+
import { convertToModelMessages, streamText } from 'ai';
|
|
3
|
+
import { logger } from '../../../logger/index.js';
|
|
4
|
+
import { createFillingModel } from './model.js';
|
|
5
|
+
/**
|
|
6
|
+
* 表单填写
|
|
7
|
+
* @param c
|
|
8
|
+
* @returns
|
|
9
|
+
*/
|
|
10
|
+
export const fillForm = async (c) => {
|
|
11
|
+
let requestBody;
|
|
12
|
+
try {
|
|
13
|
+
const json = await c.req.json();
|
|
14
|
+
requestBody = json;
|
|
15
|
+
}
|
|
16
|
+
catch (_) {
|
|
17
|
+
return c.json({ error: 'Invalid JSON' }, 400);
|
|
18
|
+
}
|
|
19
|
+
const formStructure = requestBody.formStructure;
|
|
20
|
+
if (formStructure) {
|
|
21
|
+
try {
|
|
22
|
+
let lastMsg = requestBody.messages[requestBody.messages.length - 1];
|
|
23
|
+
lastMsg.parts.push({
|
|
24
|
+
type: 'text',
|
|
25
|
+
text: JSON.stringify(formStructure),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
catch (e) {
|
|
29
|
+
logger.error('Failed to append form structure');
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const stream = streamText({
|
|
33
|
+
model: createFillingModel(),
|
|
34
|
+
messages: convertToModelMessages(requestBody.messages),
|
|
35
|
+
includeRawChunks: true,
|
|
36
|
+
headers: {
|
|
37
|
+
'x-user-stage': requestBody.stage,
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
return stream.toUIMessageStreamResponse();
|
|
41
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createCoze } from '@baishuyun/coze-provider';
|
|
2
|
+
import { createFieldsFillingResultTransformer } from './createFieldsFillingResultTransformStream.js';
|
|
3
|
+
import config from 'config';
|
|
4
|
+
export const createFillingModel = (enableParser = true) => {
|
|
5
|
+
const aiFormFilling = createCoze({
|
|
6
|
+
apiKey: config.get('agent.form.fill.apiKey'),
|
|
7
|
+
baseURL: config.get('agent.form.fill.baseUrl'),
|
|
8
|
+
botId: config.get('agent.form.fill.botId'),
|
|
9
|
+
extraStreamTransformers: [createFieldsFillingResultTransformer(enableParser)],
|
|
10
|
+
});
|
|
11
|
+
const formFillingModel = aiFormFilling.chat('chat');
|
|
12
|
+
return formFillingModel;
|
|
13
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import config from "config";
|
|
2
|
+
import { logger } from "../../../logger/index.js";
|
|
3
|
+
export const isSubFormField = (parsedInfo) => {
|
|
4
|
+
if (!parsedInfo || !parsedInfo.stack?.length) {
|
|
5
|
+
return false;
|
|
6
|
+
}
|
|
7
|
+
const lastStackItem = parsedInfo.stack[parsedInfo.stack.length - 1];
|
|
8
|
+
return lastStackItem?.key === "value";
|
|
9
|
+
};
|
|
10
|
+
export const getLastStackItem = (parsedInfo) => {
|
|
11
|
+
if (!parsedInfo || !parsedInfo.stack?.length) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
return parsedInfo.stack[parsedInfo.stack.length - 1];
|
|
15
|
+
};
|
|
16
|
+
export const getLastSubFormField = (parsedInfo) => {
|
|
17
|
+
if (!parsedInfo || !parsedInfo.stack?.length) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const targetStackItem = parsedInfo.stack.findLast((s) => {
|
|
21
|
+
return s.value?.fieldType === "subform";
|
|
22
|
+
});
|
|
23
|
+
return targetStackItem;
|
|
24
|
+
};
|
|
25
|
+
export const parseImg = async (file) => {
|
|
26
|
+
const isImg = file.type.startsWith("image/");
|
|
27
|
+
if (!isImg) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const ocrApiKey = config.get("agent.form.fill.ocrApiKey");
|
|
31
|
+
const ocrEndpoint = "https://api.ocr.space/parse/image";
|
|
32
|
+
const formData = new FormData();
|
|
33
|
+
formData.append("language", "chs");
|
|
34
|
+
formData.append("file", file);
|
|
35
|
+
const response = await fetch(ocrEndpoint, {
|
|
36
|
+
method: "POST",
|
|
37
|
+
body: formData,
|
|
38
|
+
headers: {
|
|
39
|
+
// Add any auth headers
|
|
40
|
+
apiKey: ocrApiKey,
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
const result = await response.json();
|
|
44
|
+
console.log(result);
|
|
45
|
+
if (result.IsErroredOnProcessing) {
|
|
46
|
+
logger.error("image ocr parse error");
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
if (!result.ParsedResults || !result.ParsedResults.length) {
|
|
50
|
+
logger.debug("image ocr parse no result");
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
// join all parsed text
|
|
54
|
+
const parsedTexts = result.ParsedResults.map((pr) => pr.ParsedText);
|
|
55
|
+
return parsedTexts.join("\n");
|
|
56
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import pino from "pino";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import os from "os";
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
6
|
+
const isDev = process.env.NODE_ENV === "development";
|
|
7
|
+
const isProd = process.env.NODE_ENV === "production";
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
+
const __dirname = path.dirname(__filename);
|
|
10
|
+
const logDir = path.join(__dirname, "logs");
|
|
11
|
+
if (isProd && !fs.existsSync(logDir)) {
|
|
12
|
+
fs.mkdirSync(logDir, { recursive: true }); // 递归创建目录
|
|
13
|
+
}
|
|
14
|
+
// 3. 初始化 Pino 日志实例
|
|
15
|
+
const logger = pino({
|
|
16
|
+
level: isDev ? "debug" : "info",
|
|
17
|
+
base: isDev
|
|
18
|
+
? { pid: false, hostname: false }
|
|
19
|
+
: { pid: process.pid, hostname: os.hostname() },
|
|
20
|
+
timestamp: isProd,
|
|
21
|
+
transport: isDev
|
|
22
|
+
? {
|
|
23
|
+
target: "pino-pretty", // 依赖 pino-pretty
|
|
24
|
+
options: {
|
|
25
|
+
colorize: true, // 彩色输出(更易阅读)
|
|
26
|
+
translateTime: "SYS:yyyy-mm-dd HH:MM:ss", // 时间格式转换
|
|
27
|
+
ignore: "pid,hostname", // 忽略无用字段
|
|
28
|
+
singleLine: true, // 单行会输出(简化日志体积)
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
: {
|
|
32
|
+
// 生产环境使用 pino/file 实现日志轮转(官方推荐)
|
|
33
|
+
target: "pino/file",
|
|
34
|
+
options: {
|
|
35
|
+
destination: path.join(logDir, "app.log"), // 基础日志文件路径
|
|
36
|
+
mkdir: true, // 自动创建日志目录(无需手动判断,可选增强)
|
|
37
|
+
// 轮转配置
|
|
38
|
+
rotate: {
|
|
39
|
+
frequency: "daily", // 轮转频率:每日轮转(支持 hourly/weekly/monthly 等)
|
|
40
|
+
maxSize: "10m", // 单个日志文件最大体积(支持 k/m/g 单位)
|
|
41
|
+
maxFiles: 7, // 保留最近 7 个日志文件(自动删除更早的文件)
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
}, isProd ? pino.destination(path.join(logDir, "app.log")) : undefined);
|
|
46
|
+
export { logger };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import {} from "hono";
|
|
2
|
+
import { logger } from "./index.js";
|
|
3
|
+
export const logMiddleware = async (c, next) => {
|
|
4
|
+
// 记录请求开始时间
|
|
5
|
+
const startTime = Date.now();
|
|
6
|
+
// 执行后续中间件/路由处理逻辑
|
|
7
|
+
await next();
|
|
8
|
+
// 计算响应时间
|
|
9
|
+
const responseTime = Date.now() - startTime;
|
|
10
|
+
// 记录访问日志(info 级别,核心请求信息)
|
|
11
|
+
logger.info({
|
|
12
|
+
method: c.req.method, // 请求方法 GET/POST 等
|
|
13
|
+
path: c.req.path, // 请求路径
|
|
14
|
+
status: c.res.status, // 响应状态码
|
|
15
|
+
responseTime: `${responseTime}ms`, // 响应时间
|
|
16
|
+
clientIp: c.req.header("x-forwarded-for") ||
|
|
17
|
+
c.req.header("remote-addr") ||
|
|
18
|
+
"unknown", // 客户端 IP
|
|
19
|
+
}, "Request processed"); // 日志描述信息
|
|
20
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { fillForm } from "../../controllers/form/fill/fill.controller.js";
|
|
3
|
+
import { clearConversation } from "../../controllers/form/conversation/clear.controller.js";
|
|
4
|
+
import { buildForm } from "../../controllers/form/build/build.controller.js";
|
|
5
|
+
import { uploadAttachment } from "../../controllers/form/attachment-upload.controller.js";
|
|
6
|
+
export const createFormRouter = () => {
|
|
7
|
+
const formRouter = new Hono();
|
|
8
|
+
// 智能填数
|
|
9
|
+
formRouter.post("/fill", fillForm);
|
|
10
|
+
// 智能搭件
|
|
11
|
+
formRouter.post("/build", buildForm);
|
|
12
|
+
// 会话清除
|
|
13
|
+
formRouter.post("/conversation/clear", clearConversation);
|
|
14
|
+
// 附件上传
|
|
15
|
+
formRouter.post("/attachment/upload", uploadAttachment);
|
|
16
|
+
return formRouter;
|
|
17
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@baishuyun/chat-backend",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"dependencies": {
|
|
9
|
+
"@ai-sdk/deepseek": "^1.0.29",
|
|
10
|
+
"@ai-sdk/mcp": "^0.0.11",
|
|
11
|
+
"@ai-sdk/provider": "^2.0.0",
|
|
12
|
+
"@ai-sdk/provider-utils": "^3.0.18",
|
|
13
|
+
"@coze/api": "^1.3.9",
|
|
14
|
+
"@hono/node-server": "^1.19.6",
|
|
15
|
+
"@hono/zod-validator": "^0.7.5",
|
|
16
|
+
"@streamparser/json": "^0.0.22",
|
|
17
|
+
"ai": "^5.0.101",
|
|
18
|
+
"axios": "^1.13.2",
|
|
19
|
+
"config": "^4.1.1",
|
|
20
|
+
"dotenv": "^17.2.3",
|
|
21
|
+
"hono": "^4.10.6",
|
|
22
|
+
"parse-sse": "^0.1.0",
|
|
23
|
+
"pino": "^10.1.0",
|
|
24
|
+
"zod": "^4.1.13",
|
|
25
|
+
"@baishuyun/coze-provider": "0.0.3",
|
|
26
|
+
"@baishuyun/types": "1.0.3"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/config": "^3.3.5",
|
|
30
|
+
"@types/dotenv": "^8.2.3",
|
|
31
|
+
"@types/node": "^20.11.17",
|
|
32
|
+
"cross-env": "^10.1.0",
|
|
33
|
+
"pino-pretty": "^13.1.3",
|
|
34
|
+
"tsx": "^4.7.1",
|
|
35
|
+
"typescript": "^5.8.3",
|
|
36
|
+
"@baishuyun/typescript-config": "0.0.3"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"dev": "cross-env NODE_ENV=development tsx watch src/index.ts",
|
|
40
|
+
"build": "tsc",
|
|
41
|
+
"start": "cross-env NODE_ENV=production NODE_CONFIG_DIR=./dist/config node dist/src/index.js"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/app/main.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 后端应用入口
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// 应用实例
|
|
6
|
+
import app from '../config/hono.config.js';
|
|
7
|
+
|
|
8
|
+
// 子路由
|
|
9
|
+
import { createFormRouter } from '../routes/form/form.route.js';
|
|
10
|
+
|
|
11
|
+
// 挂载子路由
|
|
12
|
+
app.route('api/form', createFormRouter());
|
|
13
|
+
|
|
14
|
+
// add test route
|
|
15
|
+
app.get('/api/test', (c) => {
|
|
16
|
+
return c.json({ message: 'Test route is working!' });
|
|
17
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { serve } from "@hono/node-server";
|
|
3
|
+
import config from "config";
|
|
4
|
+
import { cors } from "hono/cors";
|
|
5
|
+
import { logMiddleware } from "../logger/log-middleware.js";
|
|
6
|
+
import { logger } from "../logger/index.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 公共配置层,跨域、日志等中间件配置
|
|
10
|
+
*/
|
|
11
|
+
const app = new Hono();
|
|
12
|
+
|
|
13
|
+
// Enable CORS for all routes starting with /api/
|
|
14
|
+
app.use("/api/*", cors());
|
|
15
|
+
|
|
16
|
+
// Logging middleware
|
|
17
|
+
app.use(logMiddleware);
|
|
18
|
+
|
|
19
|
+
// 全局错误处理中间件(捕获所有路由/中间件异常)
|
|
20
|
+
app.onError((err, c) => {
|
|
21
|
+
// 记录错误日志(error 级别,包含异常堆栈)
|
|
22
|
+
logger.error(
|
|
23
|
+
{
|
|
24
|
+
err: err, // 异常对象(自动包含 stack 堆栈信息)
|
|
25
|
+
method: c.req.method,
|
|
26
|
+
path: c.req.path,
|
|
27
|
+
status: 500, // 默认 500 错误
|
|
28
|
+
},
|
|
29
|
+
"Request failed with unhandled error",
|
|
30
|
+
); // 错误描述
|
|
31
|
+
|
|
32
|
+
// 返回统一的 JSON 错误响应
|
|
33
|
+
return c.json({ message: "Internal Server Error" }, 500);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
serve(
|
|
37
|
+
{
|
|
38
|
+
fetch: app.fetch,
|
|
39
|
+
hostname: config.get<string>("app.host") || "",
|
|
40
|
+
port: config.get<number>("app.port") || 3001,
|
|
41
|
+
},
|
|
42
|
+
(info) => {
|
|
43
|
+
logger.info(`Server is running on http://localhost:${info.port}`);
|
|
44
|
+
},
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
export default app;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { type Context } from "hono";
|
|
2
|
+
import config from "config";
|
|
3
|
+
import { logger } from "../../logger/index.js";
|
|
4
|
+
import { parseImg } from "./fill/utils.js";
|
|
5
|
+
|
|
6
|
+
export const uploadAttachment = async (c: Context) => {
|
|
7
|
+
const formData = await c.req.formData();
|
|
8
|
+
const file = formData.get("file"); // as FormData | null;
|
|
9
|
+
// 校验文件是否存在
|
|
10
|
+
if (!file || !(file instanceof Blob)) {
|
|
11
|
+
return c.json({ error: "请上传有效的文件" }, 400);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const forwardFormData = new FormData();
|
|
15
|
+
forwardFormData.append("file", file);
|
|
16
|
+
|
|
17
|
+
const apiKey = config.get<string>("agent.apiAuthKey");
|
|
18
|
+
const host = config.get<string>("agent.host");
|
|
19
|
+
const api = `http://${host}/v1/files/upload`;
|
|
20
|
+
|
|
21
|
+
logger.debug(c.body);
|
|
22
|
+
|
|
23
|
+
const response = await fetch(api, {
|
|
24
|
+
method: "POST",
|
|
25
|
+
body: formData,
|
|
26
|
+
headers: {
|
|
27
|
+
// Add any auth headers
|
|
28
|
+
Authorization: `Bearer ${apiKey}`,
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (!response.ok) {
|
|
33
|
+
const errorData = await response
|
|
34
|
+
.json()
|
|
35
|
+
.catch(() => ({ message: "上传失败" }));
|
|
36
|
+
return c.json({
|
|
37
|
+
error: `目标接口返回错误: ${errorData.message}`,
|
|
38
|
+
status: response.status,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const data = await response.json();
|
|
43
|
+
const { url, uri, ...rest } = data?.data || {};
|
|
44
|
+
|
|
45
|
+
const orcResult = await parseImg(file);
|
|
46
|
+
|
|
47
|
+
return c.json({
|
|
48
|
+
url,
|
|
49
|
+
name: uri,
|
|
50
|
+
contentType: file.type,
|
|
51
|
+
parsedData: orcResult,
|
|
52
|
+
uploadMetadata: {
|
|
53
|
+
...rest,
|
|
54
|
+
orcResult,
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { convertToModelMessages, streamText } from "ai";
|
|
2
|
+
import { type Context } from "hono";
|
|
3
|
+
import { createModel } from "./model.js";
|
|
4
|
+
import {
|
|
5
|
+
createFieldsJsonTransformStream,
|
|
6
|
+
SuggestionTransformStream,
|
|
7
|
+
} from "@baishuyun/coze-provider";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 搭建表单
|
|
11
|
+
* @param c
|
|
12
|
+
* @returns
|
|
13
|
+
*/
|
|
14
|
+
export const buildForm = async (c: Context) => {
|
|
15
|
+
let requestBody;
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const json = await c.req.json();
|
|
19
|
+
requestBody = json;
|
|
20
|
+
} catch (_) {
|
|
21
|
+
return c.json({ error: "Invalid JSON" }, 400);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const isBuildStage = requestBody.stage === "build";
|
|
25
|
+
|
|
26
|
+
const stream = streamText({
|
|
27
|
+
model: createModel([
|
|
28
|
+
createFieldsJsonTransformStream(isBuildStage),
|
|
29
|
+
new SuggestionTransformStream(isBuildStage),
|
|
30
|
+
]),
|
|
31
|
+
messages: convertToModelMessages(requestBody.messages),
|
|
32
|
+
includeRawChunks: true,
|
|
33
|
+
headers: {
|
|
34
|
+
"x-user-stage": requestBody.stage,
|
|
35
|
+
"x-user-id": Date.now().toString(),
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
return stream.toUIMessageStreamResponse();
|
|
40
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { LanguageModelV2StreamPart } from "@ai-sdk/provider";
|
|
2
|
+
import { createCoze } from "@baishuyun/coze-provider";
|
|
3
|
+
import config from "config";
|
|
4
|
+
|
|
5
|
+
export const createModel = (
|
|
6
|
+
extraStreamTransformers: TransformStream<LanguageModelV2StreamPart, any>[],
|
|
7
|
+
) => {
|
|
8
|
+
const coze = createCoze({
|
|
9
|
+
apiKey: config.get<string>("agent.form.build.apiKey"),
|
|
10
|
+
baseURL: config.get<string>("agent.form.build.baseUrl"),
|
|
11
|
+
botId: config.get<string>("agent.form.build.botId"),
|
|
12
|
+
extraStreamTransformers,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
return coze.chat("chat");
|
|
16
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Context } from "hono";
|
|
2
|
+
import config from "config";
|
|
3
|
+
|
|
4
|
+
export const clearConversation = async (c: Context) => {
|
|
5
|
+
let requestBody;
|
|
6
|
+
|
|
7
|
+
const agentHost = config.get<string>("agent.host");
|
|
8
|
+
|
|
9
|
+
const baseUrl = `http://${agentHost}/v1`;
|
|
10
|
+
const apiKey = config.get<string>("agent.apiAuthKey");
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const json = await c.req.json();
|
|
14
|
+
requestBody = json;
|
|
15
|
+
} catch (_) {
|
|
16
|
+
return c.json({ error: "Invalid JSON" }, 400);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const result = await fetch(
|
|
20
|
+
`${baseUrl}/conversations/${requestBody.conversationId}/clear`,
|
|
21
|
+
{
|
|
22
|
+
method: "POST",
|
|
23
|
+
headers: {
|
|
24
|
+
// Add any auth headers
|
|
25
|
+
Authorization: `Bearer ${apiKey}`,
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
return c.json(result);
|
|
31
|
+
};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { type LanguageModelV2StreamPart } from '@ai-sdk/provider';
|
|
2
|
+
import { JSONParser } from '@streamparser/json';
|
|
3
|
+
import { createTextInfoEnqueuer } from '@baishuyun/coze-provider';
|
|
4
|
+
import { getLastSubFormField } from './utils.js';
|
|
5
|
+
import { logger } from '../../../logger/index.js';
|
|
6
|
+
|
|
7
|
+
export const createFieldsFillingResultTransformer = (enableJsonParser: boolean) => {
|
|
8
|
+
let parser: JSONParser;
|
|
9
|
+
let id: string;
|
|
10
|
+
// 解析完成标记:用于等待解析器内部队列处理完毕
|
|
11
|
+
let parseCompleted: Promise<void>;
|
|
12
|
+
let resolveParseCompleted: () => void;
|
|
13
|
+
|
|
14
|
+
const chunkGuard = (chunk: LanguageModelV2StreamPart): boolean => {
|
|
15
|
+
if (!enableJsonParser) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (chunk.type !== 'text-delta' || !('delta' in chunk)) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return true;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const transformer = {
|
|
27
|
+
flush: async (controller: TransformStreamDefaultController<any>) => {
|
|
28
|
+
try {
|
|
29
|
+
if (parser) {
|
|
30
|
+
await parseCompleted; // 等待闭包中的完成标记
|
|
31
|
+
logger.debug('stop parser');
|
|
32
|
+
// parser.end(); // 此时调用 end() 不会出现未完成 Token 异常
|
|
33
|
+
}
|
|
34
|
+
controller.terminate(); // 信号通知下游可读流已关闭
|
|
35
|
+
} catch (e) {
|
|
36
|
+
controller.error('stop error' + e);
|
|
37
|
+
// controller.terminate(); // 信号通知下游可读流已关闭
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
transform: (
|
|
42
|
+
chunk: LanguageModelV2StreamPart,
|
|
43
|
+
controller: TransformStreamDefaultController<LanguageModelV2StreamPart>
|
|
44
|
+
) => {
|
|
45
|
+
if (!parser) {
|
|
46
|
+
logger.warn('json parser not init');
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
if (chunkGuard(chunk)) {
|
|
52
|
+
if ('id' in chunk && 'delta' in chunk) {
|
|
53
|
+
id = chunk.id;
|
|
54
|
+
|
|
55
|
+
const regExp = /`?`?`?/;
|
|
56
|
+
|
|
57
|
+
let delta = chunk.delta.replace(regExp, '');
|
|
58
|
+
parser.write(delta.replace('json', ''));
|
|
59
|
+
}
|
|
60
|
+
} else {
|
|
61
|
+
controller.enqueue(chunk);
|
|
62
|
+
}
|
|
63
|
+
} catch (e) {
|
|
64
|
+
controller.error(e);
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
start: (controller: TransformStreamDefaultController<any>) => {
|
|
68
|
+
parser = new JSONParser({
|
|
69
|
+
paths: ['$.*', '$.*.value.*.*'],
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
parseCompleted = new Promise((resolve) => {
|
|
73
|
+
resolveParseCompleted = resolve;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const enqueueTextDelta = enableJsonParser
|
|
77
|
+
? createTextInfoEnqueuer(controller)
|
|
78
|
+
: (content: string) => {};
|
|
79
|
+
|
|
80
|
+
parser.onValue = (parsedInfo: any) => {
|
|
81
|
+
const value = parsedInfo.value;
|
|
82
|
+
|
|
83
|
+
const subFormField = getLastSubFormField(parsedInfo);
|
|
84
|
+
if (subFormField) {
|
|
85
|
+
enqueueTextDelta(JSON.stringify(value), {
|
|
86
|
+
type: 'mcp-fields-json',
|
|
87
|
+
field: subFormField.value,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (value.fieldType === 'subform') {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
enqueueTextDelta(
|
|
98
|
+
JSON.stringify(value),
|
|
99
|
+
{
|
|
100
|
+
type: 'mcp-fields-json',
|
|
101
|
+
field: value,
|
|
102
|
+
},
|
|
103
|
+
id
|
|
104
|
+
);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
let errorLogged = false;
|
|
108
|
+
parser.onError = (err: any) => {
|
|
109
|
+
// controller.enqueue({
|
|
110
|
+
// type: "error",
|
|
111
|
+
// error: "JsonWidgetStream: JSON Parsing Error:" + err.message,
|
|
112
|
+
// });
|
|
113
|
+
if (!errorLogged) {
|
|
114
|
+
console.error('JsonWidgetStream: JSON Parsing Error:', err);
|
|
115
|
+
errorLogged = true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// close stream
|
|
119
|
+
logger.error('JsonStream: closing stream due to parsing error');
|
|
120
|
+
controller.terminate(); // 信号通知下游可读流已关闭
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
parser.onEnd = () => {
|
|
124
|
+
enqueueTextDelta(
|
|
125
|
+
' ',
|
|
126
|
+
JSON.stringify({
|
|
127
|
+
appendConfirm: 'save-fields',
|
|
128
|
+
})
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
logger.debug('mark as end');
|
|
132
|
+
resolveParseCompleted?.();
|
|
133
|
+
};
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
return new TransformStream<LanguageModelV2StreamPart, any>(transformer);
|
|
138
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type Context } from 'hono';
|
|
2
|
+
import { convertToModelMessages, streamText } from 'ai';
|
|
3
|
+
import { logger } from '../../../logger/index.js';
|
|
4
|
+
import { createFillingModel } from './model.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 表单填写
|
|
8
|
+
* @param c
|
|
9
|
+
* @returns
|
|
10
|
+
*/
|
|
11
|
+
export const fillForm = async (c: Context) => {
|
|
12
|
+
let requestBody;
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
const json = await c.req.json();
|
|
16
|
+
requestBody = json;
|
|
17
|
+
} catch (_) {
|
|
18
|
+
return c.json({ error: 'Invalid JSON' }, 400);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const formStructure = requestBody.formStructure;
|
|
22
|
+
if (formStructure) {
|
|
23
|
+
try {
|
|
24
|
+
let lastMsg = requestBody.messages[requestBody.messages.length - 1];
|
|
25
|
+
|
|
26
|
+
lastMsg.parts.push({
|
|
27
|
+
type: 'text',
|
|
28
|
+
text: JSON.stringify(formStructure),
|
|
29
|
+
});
|
|
30
|
+
} catch (e) {
|
|
31
|
+
logger.error('Failed to append form structure');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const stream = streamText({
|
|
36
|
+
model: createFillingModel(),
|
|
37
|
+
messages: convertToModelMessages(requestBody.messages),
|
|
38
|
+
includeRawChunks: true,
|
|
39
|
+
headers: {
|
|
40
|
+
'x-user-stage': requestBody.stage,
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return stream.toUIMessageStreamResponse();
|
|
45
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createCoze } from '@baishuyun/coze-provider';
|
|
2
|
+
import { createFieldsFillingResultTransformer } from './createFieldsFillingResultTransformStream.js';
|
|
3
|
+
|
|
4
|
+
import config from 'config';
|
|
5
|
+
|
|
6
|
+
export const createFillingModel = (enableParser: boolean = true) => {
|
|
7
|
+
const aiFormFilling = createCoze({
|
|
8
|
+
apiKey: config.get<string>('agent.form.fill.apiKey'),
|
|
9
|
+
baseURL: config.get<string>('agent.form.fill.baseUrl'),
|
|
10
|
+
botId: config.get<string>('agent.form.fill.botId'),
|
|
11
|
+
extraStreamTransformers: [createFieldsFillingResultTransformer(enableParser)],
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const formFillingModel = aiFormFilling.chat('chat');
|
|
15
|
+
|
|
16
|
+
return formFillingModel;
|
|
17
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { JSONObject } from "@ai-sdk/provider";
|
|
2
|
+
import config from "config";
|
|
3
|
+
import type { ParsedElementInfo } from "@streamparser/json/utils/types/parsedElementInfo.js";
|
|
4
|
+
import { logger } from "../../../logger/index.js";
|
|
5
|
+
|
|
6
|
+
export const isSubFormField = (parsedInfo: ParsedElementInfo) => {
|
|
7
|
+
if (!parsedInfo || !parsedInfo.stack?.length) {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const lastStackItem = parsedInfo.stack[parsedInfo.stack.length - 1];
|
|
12
|
+
return lastStackItem?.key === "value";
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const getLastStackItem = (parsedInfo: ParsedElementInfo) => {
|
|
16
|
+
if (!parsedInfo || !parsedInfo.stack?.length) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return parsedInfo.stack[parsedInfo.stack.length - 1];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const getLastSubFormField = (parsedInfo: ParsedElementInfo) => {
|
|
24
|
+
if (!parsedInfo || !parsedInfo.stack?.length) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const targetStackItem = parsedInfo.stack.findLast((s) => {
|
|
29
|
+
return (s.value as JSONObject)?.fieldType === "subform";
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
return targetStackItem;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const parseImg = async (file: File) => {
|
|
36
|
+
const isImg = file.type.startsWith("image/");
|
|
37
|
+
if (!isImg) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const ocrApiKey = config.get<string>("agent.form.fill.ocrApiKey");
|
|
42
|
+
const ocrEndpoint = "https://api.ocr.space/parse/image";
|
|
43
|
+
|
|
44
|
+
const formData = new FormData();
|
|
45
|
+
|
|
46
|
+
formData.append("language", "chs");
|
|
47
|
+
formData.append("file", file);
|
|
48
|
+
|
|
49
|
+
const response = await fetch(ocrEndpoint, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
body: formData,
|
|
52
|
+
headers: {
|
|
53
|
+
// Add any auth headers
|
|
54
|
+
apiKey: ocrApiKey,
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const result = await response.json();
|
|
59
|
+
console.log(result);
|
|
60
|
+
|
|
61
|
+
if (result.IsErroredOnProcessing) {
|
|
62
|
+
logger.error("image ocr parse error");
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!result.ParsedResults || !result.ParsedResults.length) {
|
|
67
|
+
logger.debug("image ocr parse no result");
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// join all parsed text
|
|
72
|
+
const parsedTexts = result.ParsedResults.map((pr: any) => pr.ParsedText);
|
|
73
|
+
|
|
74
|
+
return parsedTexts.join("\n");
|
|
75
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import pino from "pino";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import os from "os";
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
6
|
+
|
|
7
|
+
const isDev = process.env.NODE_ENV === "development";
|
|
8
|
+
const isProd = process.env.NODE_ENV === "production";
|
|
9
|
+
|
|
10
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
11
|
+
const __dirname = path.dirname(__filename);
|
|
12
|
+
|
|
13
|
+
const logDir = path.join(__dirname, "logs");
|
|
14
|
+
if (isProd && !fs.existsSync(logDir)) {
|
|
15
|
+
fs.mkdirSync(logDir, { recursive: true }); // 递归创建目录
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// 3. 初始化 Pino 日志实例
|
|
19
|
+
const logger = pino(
|
|
20
|
+
{
|
|
21
|
+
level: isDev ? "debug" : "info",
|
|
22
|
+
base: isDev
|
|
23
|
+
? { pid: false, hostname: false }
|
|
24
|
+
: { pid: process.pid, hostname: os.hostname() },
|
|
25
|
+
timestamp: isProd,
|
|
26
|
+
transport: isDev
|
|
27
|
+
? {
|
|
28
|
+
target: "pino-pretty", // 依赖 pino-pretty
|
|
29
|
+
options: {
|
|
30
|
+
colorize: true, // 彩色输出(更易阅读)
|
|
31
|
+
translateTime: "SYS:yyyy-mm-dd HH:MM:ss", // 时间格式转换
|
|
32
|
+
ignore: "pid,hostname", // 忽略无用字段
|
|
33
|
+
singleLine: true, // 单行会输出(简化日志体积)
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
: {
|
|
37
|
+
// 生产环境使用 pino/file 实现日志轮转(官方推荐)
|
|
38
|
+
target: "pino/file",
|
|
39
|
+
options: {
|
|
40
|
+
destination: path.join(logDir, "app.log"), // 基础日志文件路径
|
|
41
|
+
mkdir: true, // 自动创建日志目录(无需手动判断,可选增强)
|
|
42
|
+
// 轮转配置
|
|
43
|
+
rotate: {
|
|
44
|
+
frequency: "daily", // 轮转频率:每日轮转(支持 hourly/weekly/monthly 等)
|
|
45
|
+
maxSize: "10m", // 单个日志文件最大体积(支持 k/m/g 单位)
|
|
46
|
+
maxFiles: 7, // 保留最近 7 个日志文件(自动删除更早的文件)
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
isProd ? pino.destination(path.join(logDir, "app.log")) : undefined,
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
export { logger };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type Context, type Next } from "hono";
|
|
2
|
+
import { logger } from "./index.js";
|
|
3
|
+
|
|
4
|
+
export const logMiddleware = async (c: Context, next: Next) => {
|
|
5
|
+
// 记录请求开始时间
|
|
6
|
+
const startTime = Date.now();
|
|
7
|
+
// 执行后续中间件/路由处理逻辑
|
|
8
|
+
await next();
|
|
9
|
+
// 计算响应时间
|
|
10
|
+
const responseTime = Date.now() - startTime;
|
|
11
|
+
// 记录访问日志(info 级别,核心请求信息)
|
|
12
|
+
logger.info(
|
|
13
|
+
{
|
|
14
|
+
method: c.req.method, // 请求方法 GET/POST 等
|
|
15
|
+
path: c.req.path, // 请求路径
|
|
16
|
+
status: c.res.status, // 响应状态码
|
|
17
|
+
responseTime: `${responseTime}ms`, // 响应时间
|
|
18
|
+
clientIp:
|
|
19
|
+
c.req.header("x-forwarded-for") ||
|
|
20
|
+
c.req.header("remote-addr") ||
|
|
21
|
+
"unknown", // 客户端 IP
|
|
22
|
+
},
|
|
23
|
+
"Request processed",
|
|
24
|
+
); // 日志描述信息
|
|
25
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { fillForm } from "../../controllers/form/fill/fill.controller.js";
|
|
3
|
+
import { clearConversation } from "../../controllers/form/conversation/clear.controller.js";
|
|
4
|
+
import { buildForm } from "../../controllers/form/build/build.controller.js";
|
|
5
|
+
import { uploadAttachment } from "../../controllers/form/attachment-upload.controller.js";
|
|
6
|
+
|
|
7
|
+
export const createFormRouter = () => {
|
|
8
|
+
const formRouter = new Hono();
|
|
9
|
+
|
|
10
|
+
// 智能填数
|
|
11
|
+
formRouter.post("/fill", fillForm);
|
|
12
|
+
|
|
13
|
+
// 智能搭件
|
|
14
|
+
formRouter.post("/build", buildForm);
|
|
15
|
+
|
|
16
|
+
// 会话清除
|
|
17
|
+
formRouter.post("/conversation/clear", clearConversation);
|
|
18
|
+
|
|
19
|
+
// 附件上传
|
|
20
|
+
formRouter.post("/attachment/upload", uploadAttachment);
|
|
21
|
+
|
|
22
|
+
return formRouter;
|
|
23
|
+
};
|
package/tsconfig.json
ADDED