@baishuyun/chat-backend 0.0.15 → 0.0.16
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/CHANGELOG.md +8 -0
- package/dist/config/default.js +14 -7
- package/dist/src/app/main.js +10 -3
- package/dist/src/config/hono.config.js +12 -0
- package/dist/src/controllers/form/build/build.controller.js +27 -2
- package/dist/src/controllers/form/fill/createBatchFillingTransformStream.js +9 -0
- package/dist/src/controllers/form/fill/createFieldsFillingResultTransformStream.js +15 -3
- package/dist/src/controllers/form/fill/fill.controller.js +21 -5
- package/dist/src/controllers/form/fill/utils.js +29 -0
- package/dist/src/controllers/report/query/createQuerySuggestionTransStream.js +34 -0
- package/dist/src/controllers/report/query/createQueryTransformStream.js +87 -0
- package/dist/src/controllers/report/query/handler-helpers.js +31 -0
- package/dist/src/controllers/report/query/handler-registry.js +48 -0
- package/dist/src/controllers/report/query/model.js +24 -0
- package/dist/src/controllers/report/query/query.controller.js +54 -0
- package/dist/src/controllers/report/query/suggest.controller.js +39 -0
- package/dist/src/controllers/report/query/types.js +2 -0
- package/dist/src/controllers/report/query/utils.js +63 -0
- package/dist/src/routes/report/report.route.js +10 -0
- package/dist/src/utils/createJsonStreamTransformer.js +198 -0
- package/ecosystem.config.cjs +2 -2
- package/package.json +4 -4
- package/src/config/hono.config.ts +14 -0
- package/src/controllers/form/build/build.controller.ts +8 -1
- package/src/controllers/report/query/createQueryTransformStream.ts +5 -4
- package/src/controllers/report/query/handler-registry.ts +5 -1
- package/src/controllers/report/query/query.controller.ts +1 -1
package/CHANGELOG.md
CHANGED
package/dist/config/default.js
CHANGED
|
@@ -1,30 +1,37 @@
|
|
|
1
|
-
import { asyncConfig } from
|
|
1
|
+
import { asyncConfig } from 'config/async.js';
|
|
2
2
|
// load async configurations
|
|
3
3
|
const fetchRemoteConfig = async () => {
|
|
4
4
|
return {
|
|
5
5
|
// mock remote config
|
|
6
|
-
foo:
|
|
6
|
+
foo: 'bar',
|
|
7
7
|
};
|
|
8
8
|
};
|
|
9
9
|
export default {
|
|
10
10
|
app: {
|
|
11
11
|
port: process.env.PORT || 3001,
|
|
12
|
-
host: process.env.HOST ||
|
|
12
|
+
host: process.env.HOST || 'localhost',
|
|
13
13
|
},
|
|
14
14
|
agent: {
|
|
15
|
-
host: process.env.AGENT_HOST ||
|
|
15
|
+
host: process.env.AGENT_HOST || '47.99.202.157',
|
|
16
16
|
apiAuthKey: process.env.COZE_API_KEY,
|
|
17
17
|
form: {
|
|
18
18
|
build: {
|
|
19
|
-
botId: process.env.BUILD_BOT_ID ||
|
|
19
|
+
botId: process.env.BUILD_BOT_ID || '7579927677256073216',
|
|
20
20
|
baseUrl: `http://${process.env.AGENT_HOST}/v3/`,
|
|
21
21
|
apiKey: process.env.BOT_API_KEY, // load from env
|
|
22
22
|
},
|
|
23
23
|
fill: {
|
|
24
|
-
botId: process.env.FILL_BOT_ID ||
|
|
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
|
+
report: {
|
|
31
|
+
query: {
|
|
32
|
+
botId: process.env.QUERY_BOT_ID || '7595888372090929152',
|
|
25
33
|
baseUrl: `http://${process.env.AGENT_HOST}/v3/`,
|
|
26
34
|
apiKey: process.env.BOT_API_KEY, // load from env
|
|
27
|
-
ocrApiKey: process.env.OCR_API_KEY || "", // load from env
|
|
28
35
|
},
|
|
29
36
|
},
|
|
30
37
|
},
|
package/dist/src/app/main.js
CHANGED
|
@@ -5,9 +5,16 @@
|
|
|
5
5
|
import app from '../config/hono.config.js';
|
|
6
6
|
// 子路由
|
|
7
7
|
import { createFormRouter } from '../routes/form/form.route.js';
|
|
8
|
+
import { createReportRouter } from '../routes/report/report.route.js';
|
|
8
9
|
// 挂载子路由
|
|
9
10
|
app.route('web/api/form', createFormRouter());
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
app.route('web/api/report', createReportRouter());
|
|
12
|
+
// 基础健康检查
|
|
13
|
+
app.get('/web/api/health', (c) => {
|
|
14
|
+
return c.json({
|
|
15
|
+
status: 'ok',
|
|
16
|
+
timestamp: new Date().toISOString(),
|
|
17
|
+
uptime: process.uptime(),
|
|
18
|
+
service: 'hono-app',
|
|
19
|
+
});
|
|
13
20
|
});
|
|
@@ -12,6 +12,18 @@ const app = new Hono();
|
|
|
12
12
|
app.use('web/api/*', cors());
|
|
13
13
|
// Logging middleware
|
|
14
14
|
app.use(logMiddleware);
|
|
15
|
+
// SSE keep-alive middleware - adds headers to prevent proxy timeouts during streaming
|
|
16
|
+
app.use('web/api/*', async (c, next) => {
|
|
17
|
+
await next();
|
|
18
|
+
// Add keep-alive headers to streaming responses (SSE/text-streaming)
|
|
19
|
+
const contentType = c.res.headers.get('content-type');
|
|
20
|
+
if (contentType?.includes('text/plain') || contentType?.includes('text/event-stream')) {
|
|
21
|
+
c.header('Connection', 'keep-alive');
|
|
22
|
+
c.header('Keep-Alive', 'timeout=300'); // 5 minutes
|
|
23
|
+
c.header('X-Accel-Buffering', 'no'); // Disable nginx buffering
|
|
24
|
+
c.header('Cache-Control', 'no-cache, no-transform');
|
|
25
|
+
}
|
|
26
|
+
});
|
|
15
27
|
// 全局错误处理中间件(捕获所有路由/中间件异常)
|
|
16
28
|
app.onError((err, c) => {
|
|
17
29
|
// 记录错误日志(error 级别,包含异常堆栈)
|
|
@@ -17,12 +17,25 @@ export const buildForm = async (c) => {
|
|
|
17
17
|
return c.json({ error: "Invalid JSON" }, 400);
|
|
18
18
|
}
|
|
19
19
|
const isBuildStage = requestBody.stage === "build";
|
|
20
|
+
const formName = requestBody.name || "未命名表单";
|
|
21
|
+
const allMessages = [...requestBody.messages];
|
|
22
|
+
if (isBuildStage) {
|
|
23
|
+
allMessages.push({
|
|
24
|
+
role: "user",
|
|
25
|
+
parts: [
|
|
26
|
+
{
|
|
27
|
+
type: "text",
|
|
28
|
+
text: `确认搭建:${formName}`,
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
});
|
|
32
|
+
}
|
|
20
33
|
const stream = streamText({
|
|
21
34
|
model: createModel([
|
|
22
35
|
() => createFieldsJsonTransformStream(isBuildStage),
|
|
23
36
|
() => new SuggestionTransformStream(isBuildStage),
|
|
24
37
|
]),
|
|
25
|
-
messages: convertToModelMessages(
|
|
38
|
+
messages: convertToModelMessages(allMessages),
|
|
26
39
|
includeRawChunks: true,
|
|
27
40
|
headers: {
|
|
28
41
|
"x-user-stage": requestBody.stage,
|
|
@@ -30,5 +43,17 @@ export const buildForm = async (c) => {
|
|
|
30
43
|
"x-user-token": c.req.header("authorization") || "",
|
|
31
44
|
},
|
|
32
45
|
});
|
|
33
|
-
|
|
46
|
+
const response = stream.toUIMessageStreamResponse();
|
|
47
|
+
// Add SSE keep-alive headers to prevent proxy timeouts during streaming
|
|
48
|
+
return new Response(response.body, {
|
|
49
|
+
status: response.status,
|
|
50
|
+
statusText: response.statusText,
|
|
51
|
+
headers: {
|
|
52
|
+
...Object.fromEntries(response.headers),
|
|
53
|
+
'Connection': 'keep-alive',
|
|
54
|
+
'Keep-Alive': 'timeout=300',
|
|
55
|
+
'X-Accel-Buffering': 'no',
|
|
56
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
57
|
+
},
|
|
58
|
+
});
|
|
34
59
|
};
|
|
@@ -41,6 +41,15 @@ export const createBatchFillingResultTransformer = (enableJsonParser) => {
|
|
|
41
41
|
}
|
|
42
42
|
else {
|
|
43
43
|
// logger.debug(`bypass chunk in batch filling transformer: ${JSON.stringify(chunk)}`);
|
|
44
|
+
if (chunk.type === 'error') {
|
|
45
|
+
enqueueTextDelta('error', {
|
|
46
|
+
type: 'agent-error',
|
|
47
|
+
error: '操作超时,请刷新重试',
|
|
48
|
+
});
|
|
49
|
+
resolveParseCompleted?.();
|
|
50
|
+
controller.terminate(); // 信号通知下游可读流已关闭
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
44
53
|
controller.enqueue(chunk);
|
|
45
54
|
}
|
|
46
55
|
}
|
|
@@ -40,18 +40,28 @@ export const createFieldsFillingResultTransformer = (enableJsonParser) => {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
else {
|
|
43
|
+
if (chunk.type === 'error') {
|
|
44
|
+
enqueueTextDelta('error', {
|
|
45
|
+
type: 'agent-error',
|
|
46
|
+
error: '操作超时,请刷新重试',
|
|
47
|
+
});
|
|
48
|
+
resolveParseCompleted?.();
|
|
49
|
+
controller.terminate(); // 信号通知下游可读流已关闭
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
43
52
|
controller.enqueue(chunk);
|
|
44
53
|
}
|
|
45
54
|
}
|
|
46
55
|
catch (e) {
|
|
47
56
|
logger.debug('exception in transform while processing filling chunk');
|
|
48
|
-
|
|
57
|
+
// Enqueue error message before closing to allow client to receive it
|
|
49
58
|
enqueueTextDelta('error', {
|
|
50
59
|
type: 'agent-error',
|
|
51
60
|
error: '解析异常,请刷新重试',
|
|
52
61
|
});
|
|
53
62
|
resolveParseCompleted?.();
|
|
54
|
-
|
|
63
|
+
// Graceful close after error notification
|
|
64
|
+
controller.terminate();
|
|
55
65
|
}
|
|
56
66
|
},
|
|
57
67
|
start: (controller) => {
|
|
@@ -97,12 +107,14 @@ export const createFieldsFillingResultTransformer = (enableJsonParser) => {
|
|
|
97
107
|
console.error('JsonWidgetStream: JSON Parsing Error:', err);
|
|
98
108
|
errorLogged = true;
|
|
99
109
|
}
|
|
110
|
+
// Enqueue error message before closing to allow client to receive it
|
|
100
111
|
enqueueTextDelta('error', {
|
|
101
112
|
type: 'agent-error',
|
|
102
113
|
error: '操作超时,请刷新重试',
|
|
103
114
|
});
|
|
104
115
|
resolveParseCompleted?.();
|
|
105
|
-
|
|
116
|
+
// Graceful close after error notification
|
|
117
|
+
controller.terminate();
|
|
106
118
|
};
|
|
107
119
|
parser.onEnd = () => {
|
|
108
120
|
enqueueTextDelta(' ', JSON.stringify({
|
|
@@ -2,7 +2,7 @@ import {} from 'hono';
|
|
|
2
2
|
import { convertToModelMessages, streamText, generateId } from 'ai';
|
|
3
3
|
import { logger } from '../../../logger/index.js';
|
|
4
4
|
import { createFillingModel } from './model.js';
|
|
5
|
-
import { mode2part } from './utils.js';
|
|
5
|
+
import { mode2part, trimFormStructure } from './utils.js';
|
|
6
6
|
import { batchFillForm } from './batch-fill.controller.js';
|
|
7
7
|
/**
|
|
8
8
|
* 表单填写
|
|
@@ -18,7 +18,7 @@ export const fillForm = async (c) => {
|
|
|
18
18
|
catch (_) {
|
|
19
19
|
return c.json({ error: 'Invalid JSON' }, 400);
|
|
20
20
|
}
|
|
21
|
-
const formStructure = requestBody.formStructure;
|
|
21
|
+
const formStructure = trimFormStructure(requestBody.formStructure || []);
|
|
22
22
|
const mode = requestBody.mode;
|
|
23
23
|
const num = requestBody.count;
|
|
24
24
|
const messages = requestBody.messages;
|
|
@@ -26,13 +26,16 @@ export const fillForm = async (c) => {
|
|
|
26
26
|
if (mode === 'batch') {
|
|
27
27
|
return batchFillForm(c);
|
|
28
28
|
}
|
|
29
|
+
const flattenParts = messages.flatMap((msg) => msg.parts);
|
|
30
|
+
const msg = [];
|
|
29
31
|
if (formStructure) {
|
|
30
32
|
const extraPart = mode2part(mode, num);
|
|
31
33
|
try {
|
|
32
|
-
|
|
34
|
+
msg.push({
|
|
33
35
|
id: generateId(),
|
|
34
36
|
role: 'user',
|
|
35
37
|
parts: [
|
|
38
|
+
...flattenParts,
|
|
36
39
|
{
|
|
37
40
|
type: 'text',
|
|
38
41
|
text: `formStructure: ${JSON.stringify(formStructure)}`,
|
|
@@ -45,9 +48,10 @@ export const fillForm = async (c) => {
|
|
|
45
48
|
logger.error('Failed to append form structure');
|
|
46
49
|
}
|
|
47
50
|
}
|
|
51
|
+
const modelMessages = convertToModelMessages(msg);
|
|
48
52
|
const stream = streamText({
|
|
49
53
|
model: createFillingModel(),
|
|
50
|
-
messages:
|
|
54
|
+
messages: modelMessages,
|
|
51
55
|
includeRawChunks: true,
|
|
52
56
|
headers: {
|
|
53
57
|
'x-user-stage': requestBody.stage,
|
|
@@ -57,7 +61,19 @@ export const fillForm = async (c) => {
|
|
|
57
61
|
'x-user-token': c.req.header('authorization') || '',
|
|
58
62
|
},
|
|
59
63
|
});
|
|
60
|
-
|
|
64
|
+
const response = stream.toUIMessageStreamResponse({
|
|
61
65
|
originalMessages: messages, // 建议添加,便于消息 ID 管理
|
|
62
66
|
});
|
|
67
|
+
// Add SSE keep-alive headers to prevent proxy timeouts during streaming
|
|
68
|
+
return new Response(response.body, {
|
|
69
|
+
status: response.status,
|
|
70
|
+
statusText: response.statusText,
|
|
71
|
+
headers: {
|
|
72
|
+
...Object.fromEntries(response.headers),
|
|
73
|
+
'Connection': 'keep-alive',
|
|
74
|
+
'Keep-Alive': 'timeout=300',
|
|
75
|
+
'X-Accel-Buffering': 'no',
|
|
76
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
77
|
+
},
|
|
78
|
+
});
|
|
63
79
|
};
|
|
@@ -81,3 +81,32 @@ export const fillingChunkGuard = (chunk, enableGuard = true) => {
|
|
|
81
81
|
}
|
|
82
82
|
return true;
|
|
83
83
|
};
|
|
84
|
+
/**
|
|
85
|
+
* 裁剪表单结构,子表单只展开一层
|
|
86
|
+
*/
|
|
87
|
+
export function trimFormStructure(fields) {
|
|
88
|
+
return fields.map((field) => {
|
|
89
|
+
const trimmed = {
|
|
90
|
+
description: field.description,
|
|
91
|
+
label: field.label,
|
|
92
|
+
widget: {
|
|
93
|
+
type: field.widget.type,
|
|
94
|
+
widgetName: field.widget.widgetName,
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
if (field.widget.items && Array.isArray(field.widget.items)) {
|
|
98
|
+
// 过滤掉无效 item(null/undefined 或缺少 widget)
|
|
99
|
+
trimmed.widget.items = field.widget.items
|
|
100
|
+
.filter((item) => item != null && item.widget != null)
|
|
101
|
+
.map((item) => ({
|
|
102
|
+
description: item.description,
|
|
103
|
+
label: item.label,
|
|
104
|
+
widget: {
|
|
105
|
+
type: item.widget.type,
|
|
106
|
+
widgetName: item.widget.widgetName,
|
|
107
|
+
},
|
|
108
|
+
}));
|
|
109
|
+
}
|
|
110
|
+
return trimmed;
|
|
111
|
+
});
|
|
112
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import {} from '@ai-sdk/provider';
|
|
2
|
+
import { JSONParser } from '@streamparser/json';
|
|
3
|
+
import {} from '@baishuyun/types';
|
|
4
|
+
import { createJsonStreamTransformer, } from '../../../utils/createJsonStreamTransformer.js';
|
|
5
|
+
import { logger } from '../../../logger/index.js';
|
|
6
|
+
export const createQuerySuggestionTransformer = () => {
|
|
7
|
+
return createJsonStreamTransformer({
|
|
8
|
+
createJSONParser: createJSONParser,
|
|
9
|
+
onParseValue: handleParsedValue,
|
|
10
|
+
onParseError(error) {
|
|
11
|
+
logger.error('JSON parsing error in query transformer: ', error);
|
|
12
|
+
return '数据解析异常,请重试';
|
|
13
|
+
},
|
|
14
|
+
onErrorChunk(chunk) {
|
|
15
|
+
logger.error('Received error chunk in query transformer');
|
|
16
|
+
return '';
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
};
|
|
20
|
+
function createJSONParser() {
|
|
21
|
+
return new JSONParser({
|
|
22
|
+
stringBufferSize: undefined,
|
|
23
|
+
numberBufferSize: undefined,
|
|
24
|
+
separator: '',
|
|
25
|
+
paths: ['$.*'],
|
|
26
|
+
keepStack: true,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
function handleParsedValue(ctx) {
|
|
30
|
+
const { parsedInfo, getResult, currentChunkId, deltaChunkEnqueuer: enqueueTextDelta, ctrl } = ctx;
|
|
31
|
+
const { value } = parsedInfo;
|
|
32
|
+
logger.debug('Parsed JSON value: ' + JSON.stringify(value));
|
|
33
|
+
enqueueTextDelta(`${JSON.stringify(value)},`, { type: 'query-suggestion-parsed-info', result: JSON.stringify(getResult()) }, currentChunkId + 2, true);
|
|
34
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import {} from '@ai-sdk/provider';
|
|
2
|
+
import { JSONParser } from '@streamparser/json';
|
|
3
|
+
import {} from '@baishuyun/types';
|
|
4
|
+
import { createJsonStreamTransformer, } from '../../../utils/createJsonStreamTransformer.js';
|
|
5
|
+
import { isTargetElement } from './utils.js';
|
|
6
|
+
import { logger } from '../../../logger/index.js';
|
|
7
|
+
import { fieldHandlers } from './handler-registry.js';
|
|
8
|
+
export const createQueryResultTransformer = () => {
|
|
9
|
+
return createJsonStreamTransformer({
|
|
10
|
+
createJSONParser: createJSONParser,
|
|
11
|
+
onParseValue: handleParsedValue,
|
|
12
|
+
onParseError(error) {
|
|
13
|
+
logger.error('JSON parsing error in query transformer: ', error);
|
|
14
|
+
return '数据解析异常,请重试';
|
|
15
|
+
},
|
|
16
|
+
onErrorChunk(chunk) {
|
|
17
|
+
logger.error('Received error chunk in query transformer');
|
|
18
|
+
return '';
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
};
|
|
22
|
+
function createJSONParser() {
|
|
23
|
+
return new JSONParser({
|
|
24
|
+
stringBufferSize: undefined,
|
|
25
|
+
numberBufferSize: undefined,
|
|
26
|
+
separator: '',
|
|
27
|
+
paths: [
|
|
28
|
+
'$', // 保留完整结果推送
|
|
29
|
+
'$.title',
|
|
30
|
+
'$.source',
|
|
31
|
+
'$.name',
|
|
32
|
+
'$.type',
|
|
33
|
+
'$.userID',
|
|
34
|
+
'$.forms',
|
|
35
|
+
'$.fields.*',
|
|
36
|
+
'$.xFields.*',
|
|
37
|
+
'$.yFields.*',
|
|
38
|
+
'$.metrics.*',
|
|
39
|
+
'$.formulas.*',
|
|
40
|
+
'$.filter.rel',
|
|
41
|
+
'$.filter.cond.*',
|
|
42
|
+
'$.limit',
|
|
43
|
+
],
|
|
44
|
+
keepStack: true,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
function handleParsedValue(ctx) {
|
|
48
|
+
const { parsedInfo, getResult, currentChunkId, deltaChunkEnqueuer: enqueueTextDelta, ctrl } = ctx;
|
|
49
|
+
const { value } = parsedInfo;
|
|
50
|
+
logger.debug('Parsed JSON value: ' + JSON.stringify(value));
|
|
51
|
+
// Phase 0: 报表标题 —— 初始化结果并发送 text-start, 必须为起始字段
|
|
52
|
+
if (isTargetElement('$.title', parsedInfo)) {
|
|
53
|
+
handleTitle(ctx);
|
|
54
|
+
}
|
|
55
|
+
// Phase 4: 按注册的 handler 分发字段处理
|
|
56
|
+
for (const { path, handler } of fieldHandlers) {
|
|
57
|
+
if (isTargetElement(path, parsedInfo)) {
|
|
58
|
+
handler(ctx, value);
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// Phase 5: 向下游推送增量结果
|
|
63
|
+
enqueueTextDelta(`${JSON.stringify(value)},`, { type: 'query-stream-parsed-info', result: JSON.stringify(getResult()) }, currentChunkId + 2, true);
|
|
64
|
+
}
|
|
65
|
+
// ===================== Phase Handlers =====================
|
|
66
|
+
function handleAggregateName(ctx) {
|
|
67
|
+
const { parsedInfo, getResult, currentChunkId, deltaChunkEnqueuer: enqueueTextDelta } = ctx;
|
|
68
|
+
const value = parsedInfo.value;
|
|
69
|
+
ctx.setPartialResult({
|
|
70
|
+
title: value,
|
|
71
|
+
source: 'aggregate',
|
|
72
|
+
type: 'data_table',
|
|
73
|
+
});
|
|
74
|
+
logger.debug('Parsed aggregate table title: ' + value);
|
|
75
|
+
enqueueTextDelta(`${JSON.stringify(value)},`, { type: 'query-stream-parsed-info', result: JSON.stringify(getResult()) }, currentChunkId + 1, false);
|
|
76
|
+
}
|
|
77
|
+
function handleTitle(ctx) {
|
|
78
|
+
const { parsedInfo, currentChunkId, ctrl } = ctx;
|
|
79
|
+
ctx.clearResult();
|
|
80
|
+
ctrl.enqueue({
|
|
81
|
+
type: 'text-start',
|
|
82
|
+
id: currentChunkId + 2,
|
|
83
|
+
});
|
|
84
|
+
ctx.setPartialResult({
|
|
85
|
+
title: parsedInfo.value,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import {} from '@baishuyun/types';
|
|
2
|
+
import {} from '../../../utils/createJsonStreamTransformer.js';
|
|
3
|
+
// ===================== Handler Helpers =====================
|
|
4
|
+
/** 创建一个将值推入指定数组字段的 handler */
|
|
5
|
+
export function pushToArray(field) {
|
|
6
|
+
return (ctx, value) => {
|
|
7
|
+
const result = ctx.getResult();
|
|
8
|
+
if (!result[field]) {
|
|
9
|
+
ctx.setPartialResult({ [field]: [] });
|
|
10
|
+
}
|
|
11
|
+
ctx.getResult()[field].push(value);
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
/** 创建一个将值推入数组字段、并附加 tag/form 元信息的 handler */
|
|
15
|
+
export function pushToArrayWithMeta(field) {
|
|
16
|
+
return (ctx, value) => {
|
|
17
|
+
const result = ctx.getResult();
|
|
18
|
+
if (!result[field]) {
|
|
19
|
+
ctx.setPartialResult({ [field]: [] });
|
|
20
|
+
}
|
|
21
|
+
value.tag = `f_${Date.now()}`;
|
|
22
|
+
value.form = result.formIds ? result.formIds[0] : '';
|
|
23
|
+
ctx.getResult()[field].push(value);
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/** 确保 filter 对象已初始化 */
|
|
27
|
+
export function ensureFilter(ctx) {
|
|
28
|
+
if (!ctx.getResult().filter) {
|
|
29
|
+
ctx.setPartialResult({ filter: { rel: 'and', conds: [] } });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import {} from '@baishuyun/types';
|
|
2
|
+
import { ensureFilter, pushToArray, pushToArrayWithMeta } from './handler-helpers.js';
|
|
3
|
+
export const fieldHandlers = [
|
|
4
|
+
{
|
|
5
|
+
path: '$.source',
|
|
6
|
+
handler: (ctx, value) => ctx.setPartialResult({ source: value }),
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
path: '$.userID',
|
|
10
|
+
handler: (ctx, value) => ctx.setPartialResult({ userID: value }),
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
path: '$.limit',
|
|
14
|
+
handler: (ctx, value) => ctx.setPartialResult({ limit: value }),
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
path: '$.forms',
|
|
18
|
+
handler: (ctx, value) => ctx.setPartialResult({ formIds: [value] }),
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
path: '$.type',
|
|
22
|
+
handler: (ctx, value) => ctx.setPartialResult({ type: value }),
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
path: '$.filter.rel',
|
|
26
|
+
handler: (ctx, value) => {
|
|
27
|
+
ensureFilter(ctx);
|
|
28
|
+
ctx.setPartialResult({
|
|
29
|
+
filter: { ...ctx.getResult().filter, rel: value },
|
|
30
|
+
});
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
path: '$.filter.cond.*',
|
|
35
|
+
handler: (ctx, value) => {
|
|
36
|
+
ensureFilter(ctx);
|
|
37
|
+
const filter = ctx.getResult().filter;
|
|
38
|
+
ctx.setPartialResult({
|
|
39
|
+
filter: { ...filter, conds: [...(filter.conds || []), value] },
|
|
40
|
+
});
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
{ path: '$.fields.*', handler: pushToArray('fields') },
|
|
44
|
+
{ path: '$.formulas.*', handler: pushToArray('formulas') },
|
|
45
|
+
{ path: '$.metrics.*', handler: pushToArrayWithMeta('metrics') },
|
|
46
|
+
{ path: '$.xFields.*', handler: pushToArrayWithMeta('xFields') },
|
|
47
|
+
{ path: '$.yFields.*', handler: pushToArrayWithMeta('yFields') },
|
|
48
|
+
];
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createCoze } from '@baishuyun/coze-provider';
|
|
2
|
+
import config from 'config';
|
|
3
|
+
import { createQueryResultTransformer } from './createQueryTransformStream.js';
|
|
4
|
+
import { createQuerySuggestionTransformer } from './createQuerySuggestionTransStream.js';
|
|
5
|
+
export const createReportQueryModel = () => {
|
|
6
|
+
const reportQuery = createCoze({
|
|
7
|
+
apiKey: config.get('agent.report.query.apiKey'),
|
|
8
|
+
baseURL: config.get('agent.report.query.baseUrl'),
|
|
9
|
+
botId: config.get('agent.report.query.botId'),
|
|
10
|
+
extraStreamTransformers: [createQueryResultTransformer],
|
|
11
|
+
});
|
|
12
|
+
const reportQueryModel = reportQuery.chat('chat');
|
|
13
|
+
return reportQueryModel;
|
|
14
|
+
};
|
|
15
|
+
export const createQuerySuggestionModel = () => {
|
|
16
|
+
const reportQuery = createCoze({
|
|
17
|
+
apiKey: config.get('agent.report.query.apiKey'),
|
|
18
|
+
baseURL: config.get('agent.report.query.baseUrl'),
|
|
19
|
+
botId: config.get('agent.report.query.botId'),
|
|
20
|
+
extraStreamTransformers: [createQuerySuggestionTransformer],
|
|
21
|
+
});
|
|
22
|
+
const reportQueryModel = reportQuery.chat('chat');
|
|
23
|
+
return reportQueryModel;
|
|
24
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import {} from 'hono';
|
|
2
|
+
import { convertToModelMessages, streamText, generateId } from 'ai';
|
|
3
|
+
import { createReportQueryModel } from './model.js';
|
|
4
|
+
import { buildExtraMsgParts } from './utils.js';
|
|
5
|
+
export const queryReport = async (c) => {
|
|
6
|
+
let requestBody;
|
|
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 uid = c.req.header('X-User-Id') || '';
|
|
15
|
+
const messages = requestBody.messages;
|
|
16
|
+
const extraText = buildExtraMsgParts({
|
|
17
|
+
appId: requestBody.appId,
|
|
18
|
+
userId: uid,
|
|
19
|
+
textOnly: true,
|
|
20
|
+
});
|
|
21
|
+
const lastUserTextPartOfMessage = messages
|
|
22
|
+
.slice()
|
|
23
|
+
.reverse()
|
|
24
|
+
.find((msg) => msg.role === 'user')
|
|
25
|
+
?.parts.findLast((part) => part.type === 'text');
|
|
26
|
+
if (lastUserTextPartOfMessage) {
|
|
27
|
+
lastUserTextPartOfMessage.text = `${extraText}${lastUserTextPartOfMessage.text}`;
|
|
28
|
+
}
|
|
29
|
+
const modelMessages = convertToModelMessages(messages);
|
|
30
|
+
console.log('Final messages for report query:', JSON.stringify(modelMessages, null, 2));
|
|
31
|
+
const stream = streamText({
|
|
32
|
+
model: createReportQueryModel(),
|
|
33
|
+
messages: modelMessages,
|
|
34
|
+
includeRawChunks: true,
|
|
35
|
+
headers: {
|
|
36
|
+
'x-user-id': Date.now().toString(), // uid,
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
const response = stream.toUIMessageStreamResponse({
|
|
40
|
+
originalMessages: messages, // 建议添加,便于消息 ID 管理
|
|
41
|
+
});
|
|
42
|
+
// Add SSE keep-alive headers to prevent proxy timeouts during streaming
|
|
43
|
+
return new Response(response.body, {
|
|
44
|
+
status: response.status,
|
|
45
|
+
statusText: response.statusText,
|
|
46
|
+
headers: {
|
|
47
|
+
...Object.fromEntries(response.headers),
|
|
48
|
+
'Connection': 'keep-alive',
|
|
49
|
+
'Keep-Alive': 'timeout=300',
|
|
50
|
+
'X-Accel-Buffering': 'no',
|
|
51
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import {} from 'hono';
|
|
2
|
+
import { convertToModelMessages, streamText, generateId } from 'ai';
|
|
3
|
+
import { createReportQueryModel } from './model.js';
|
|
4
|
+
export const queryReportSuggest = async (c) => {
|
|
5
|
+
let requestBody;
|
|
6
|
+
try {
|
|
7
|
+
const json = await c.req.json();
|
|
8
|
+
requestBody = json;
|
|
9
|
+
}
|
|
10
|
+
catch (_) {
|
|
11
|
+
return c.json({ error: 'Invalid JSON' }, 400);
|
|
12
|
+
}
|
|
13
|
+
const uid = c.req.header('X-User-Id') || '';
|
|
14
|
+
const messages = requestBody.messages;
|
|
15
|
+
const forms = requestBody.forms || [];
|
|
16
|
+
const modelMessages = convertToModelMessages([
|
|
17
|
+
{
|
|
18
|
+
role: 'user',
|
|
19
|
+
parts: [
|
|
20
|
+
{
|
|
21
|
+
type: 'text',
|
|
22
|
+
text: JSON.stringify(forms),
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
]);
|
|
27
|
+
console.log('Final messages for query suggestion: ', JSON.stringify(modelMessages, null, 2));
|
|
28
|
+
const stream = streamText({
|
|
29
|
+
model: createReportQueryModel(),
|
|
30
|
+
messages: modelMessages,
|
|
31
|
+
includeRawChunks: true,
|
|
32
|
+
headers: {
|
|
33
|
+
'x-user-id': uid,
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
return stream.toUIMessageStreamResponse({
|
|
37
|
+
originalMessages: messages, // 建议添加,便于消息 ID 管理
|
|
38
|
+
});
|
|
39
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export const buildExtraMsgParts = ({ appId, userId, textOnly, }) => {
|
|
2
|
+
if (textOnly) {
|
|
3
|
+
return `appID为${appId};用户ID为${userId};`;
|
|
4
|
+
}
|
|
5
|
+
return [
|
|
6
|
+
{
|
|
7
|
+
type: 'text',
|
|
8
|
+
text: `appID为${appId};`,
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
type: 'text',
|
|
12
|
+
text: `用户ID为${userId};`,
|
|
13
|
+
},
|
|
14
|
+
];
|
|
15
|
+
};
|
|
16
|
+
export const queryChunkGuard = (chunk) => {
|
|
17
|
+
if (chunk.type !== 'text-delta' || !('delta' in chunk)) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
return true;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* 通用路径匹配器:判断当前解析到的元素是否命中指定的 JSONParser 路径。
|
|
24
|
+
*
|
|
25
|
+
* 支持两种路径形式:
|
|
26
|
+
* - 精确路径:`$.title`、`$.filter.rel` — 匹配特定 key
|
|
27
|
+
* - 通配路径:`$.fields.*`、`$.filter.cond.*` — 匹配数组元素
|
|
28
|
+
*
|
|
29
|
+
* path 格式与 `JSONParser` 的 `paths` 配置完全一致,无需额外转换。
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* isTargetElement('$.title', parsedInfo)
|
|
33
|
+
* isTargetElement('$.filter.rel', parsedInfo)
|
|
34
|
+
* isTargetElement('$.fields.*', parsedInfo)
|
|
35
|
+
* isTargetElement('$.filter.cond.*', parsedInfo)
|
|
36
|
+
*/
|
|
37
|
+
export const isTargetElement = (path, parsedInfo) => {
|
|
38
|
+
const { key, stack, parent } = parsedInfo;
|
|
39
|
+
// 去掉 '$.' 前缀,按 '.' 分割
|
|
40
|
+
const segments = path.replace(/^\$\./, '').split('.');
|
|
41
|
+
const endsWithWildcard = segments[segments.length - 1] === '*';
|
|
42
|
+
if (endsWithWildcard) {
|
|
43
|
+
// ---- 通配路径:匹配数组中的元素 ----
|
|
44
|
+
const parentKeys = segments.slice(0, -1);
|
|
45
|
+
// 当前 key 必须为数组索引,parent 必须为数组
|
|
46
|
+
if (typeof key !== 'number' || !Array.isArray(parent))
|
|
47
|
+
return false;
|
|
48
|
+
// stack 深度 = root(1) + parentKeys
|
|
49
|
+
if (stack.length !== parentKeys.length + 1)
|
|
50
|
+
return false;
|
|
51
|
+
// 从 stack[1] 开始逐段匹配(跳过 root)
|
|
52
|
+
return parentKeys.every((seg, i) => stack[i + 1]?.key === seg);
|
|
53
|
+
}
|
|
54
|
+
// ---- 精确路径:匹配特定 key ----
|
|
55
|
+
const leafKey = segments[segments.length - 1];
|
|
56
|
+
if (key !== leafKey)
|
|
57
|
+
return false;
|
|
58
|
+
const parentKeys = segments.slice(0, -1);
|
|
59
|
+
// stack 深度 = root(1) + parentKeys(叶子节点自身不在 stack 中)
|
|
60
|
+
if (stack.length !== parentKeys.length + 1)
|
|
61
|
+
return false;
|
|
62
|
+
return parentKeys.every((seg, i) => stack[i + 1]?.key === seg);
|
|
63
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { queryReport } from '../../controllers/report/query/query.controller.js';
|
|
3
|
+
import { queryReportSuggest } from '../../controllers/report/query/suggest.controller.js';
|
|
4
|
+
export const createReportRouter = () => {
|
|
5
|
+
const reportRouter = new Hono();
|
|
6
|
+
// 智能问数
|
|
7
|
+
reportRouter.post('/query', queryReport);
|
|
8
|
+
reportRouter.post('/query/suggest', queryReportSuggest);
|
|
9
|
+
return reportRouter;
|
|
10
|
+
};
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import {} from '@ai-sdk/provider';
|
|
2
|
+
import { createTextInfoEnqueuer } from '@baishuyun/coze-provider';
|
|
3
|
+
import { JSONParser } from '@streamparser/json';
|
|
4
|
+
import { logger } from '../logger/index.js';
|
|
5
|
+
/**
|
|
6
|
+
* 创建一个 TransformStream,将 AI 流式响应中的 JSON 片段解析为结构化数据。
|
|
7
|
+
*
|
|
8
|
+
* 使用者只需关心:
|
|
9
|
+
* 1. `createJSONParser` — 决定 JSON 解析的路径 / 选项
|
|
10
|
+
* 2. `onParseValue` — 处理每个解析出的值
|
|
11
|
+
* 3. 错误处理(均可选)
|
|
12
|
+
*/
|
|
13
|
+
export const createJsonStreamTransformer = (options) => {
|
|
14
|
+
const processor = new JsonStreamProcessor(options);
|
|
15
|
+
return new TransformStream({
|
|
16
|
+
start: (ctrl) => processor.start(ctrl),
|
|
17
|
+
transform: (chunk, ctrl) => processor.transform(chunk, ctrl),
|
|
18
|
+
flush: (ctrl) => processor.flush(ctrl),
|
|
19
|
+
});
|
|
20
|
+
};
|
|
21
|
+
const BACKTICK_RE = /`?`?`?/g;
|
|
22
|
+
const JSON_PREFIX = 'json';
|
|
23
|
+
const ERROR_MESSAGES = {
|
|
24
|
+
PARSE_ERROR: '解析异常,请刷新重试',
|
|
25
|
+
TIMEOUT: '操作超时,请刷新重试',
|
|
26
|
+
GENERAL_ERROR: '发生错误,请刷新重试',
|
|
27
|
+
};
|
|
28
|
+
class JsonStreamProcessor {
|
|
29
|
+
// -- 配置 --
|
|
30
|
+
options;
|
|
31
|
+
// -- 运行时状态 --
|
|
32
|
+
parser = null;
|
|
33
|
+
result = {};
|
|
34
|
+
currentChunkId = '';
|
|
35
|
+
parseCompleted = null;
|
|
36
|
+
resolveParseCompleted = null;
|
|
37
|
+
terminated = false;
|
|
38
|
+
constructor(options) {
|
|
39
|
+
this.options = options;
|
|
40
|
+
}
|
|
41
|
+
// ===================== 生命周期 =====================
|
|
42
|
+
/** TransformStream start:创建 parser、绑定回调 */
|
|
43
|
+
start(controller) {
|
|
44
|
+
this.parser = this.options.createJSONParser();
|
|
45
|
+
this.parseCompleted = this.createCompletionPromise();
|
|
46
|
+
this.bindParserCallbacks(controller);
|
|
47
|
+
}
|
|
48
|
+
/** TransformStream transform:逐 chunk 处理 */
|
|
49
|
+
transform(chunk, controller) {
|
|
50
|
+
if (this.terminated || !this.parser) {
|
|
51
|
+
logger.warn('JSON parser not initialized or stream already terminated');
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
// 1) error chunk → 通知下游后终止
|
|
56
|
+
if (chunk.type === 'error') {
|
|
57
|
+
this.options.onErrorChunk?.(chunk);
|
|
58
|
+
this.enqueueError(controller, ERROR_MESSAGES.GENERAL_ERROR);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
// 2) 不需要解析的 chunk → 透传
|
|
62
|
+
const guard = this.options.chunkGuard ?? defaultChunkGuard;
|
|
63
|
+
if (!guard(chunk)) {
|
|
64
|
+
controller.enqueue(chunk);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
// 3) 提取 delta 写入 parser
|
|
68
|
+
if (isChunkWithDelta(chunk)) {
|
|
69
|
+
this.currentChunkId = chunk.id;
|
|
70
|
+
this.parser.write(stripCodeFence(chunk.delta));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
this.enqueueError(controller, error);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** TransformStream flush:结束 parser 并等待所有回调完成 */
|
|
78
|
+
async flush(controller) {
|
|
79
|
+
try {
|
|
80
|
+
if (this.parser) {
|
|
81
|
+
this.parser.end();
|
|
82
|
+
if (this.parseCompleted)
|
|
83
|
+
await this.parseCompleted;
|
|
84
|
+
logger.debug('Parser stopped gracefully');
|
|
85
|
+
}
|
|
86
|
+
// Graceful close: don't call terminate() which abruptly closes the stream
|
|
87
|
+
// The stream will close naturally when all chunks are processed
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
controller.error(`Cleanup error: ${error}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// ===================== Parser 回调 =====================
|
|
94
|
+
bindParserCallbacks(controller) {
|
|
95
|
+
if (!this.parser)
|
|
96
|
+
return;
|
|
97
|
+
const enqueue = createTextInfoEnqueuer(controller);
|
|
98
|
+
this.parser.onValue = (parsedInfo) => {
|
|
99
|
+
// 超时错误特殊处理
|
|
100
|
+
if (isTimeoutError(parsedInfo.value)) {
|
|
101
|
+
if (!this.terminated) {
|
|
102
|
+
enqueue(JSON.stringify(parsedInfo.value), {
|
|
103
|
+
type: 'agent-error',
|
|
104
|
+
error: ERROR_MESSAGES.TIMEOUT,
|
|
105
|
+
});
|
|
106
|
+
this.terminateStream(controller);
|
|
107
|
+
}
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
this.options.onParseValue({
|
|
111
|
+
parsedInfo,
|
|
112
|
+
deltaChunkEnqueuer: enqueue,
|
|
113
|
+
currentChunkId: this.currentChunkId,
|
|
114
|
+
ctrl: controller,
|
|
115
|
+
getResult: () => this.result,
|
|
116
|
+
clearResult: this.clearResult.bind(this),
|
|
117
|
+
setPartialResult: (partial) => {
|
|
118
|
+
this.result = { ...this.result, ...partial };
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
this.parser.onError = (err) => {
|
|
123
|
+
if (!this.terminated) {
|
|
124
|
+
const msg = this.options.onParseError
|
|
125
|
+
? this.options.onParseError(err)
|
|
126
|
+
: ERROR_MESSAGES.GENERAL_ERROR;
|
|
127
|
+
logger.debug(msg);
|
|
128
|
+
enqueue('error', { type: 'agent-error', error: msg });
|
|
129
|
+
this.terminateStream(controller);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
this.parser.onEnd = () => {
|
|
133
|
+
logger.debug('Parsing completed');
|
|
134
|
+
enqueue(' ', JSON.stringify({ appendConfirm: 'save-fields' }));
|
|
135
|
+
this.completeParsing();
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/** 创建可外部 resolve 的 Promise,用于 flush 等待 parser 回调结束 */
|
|
139
|
+
createCompletionPromise() {
|
|
140
|
+
return new Promise((resolve) => {
|
|
141
|
+
this.resolveParseCompleted = () => {
|
|
142
|
+
resolve();
|
|
143
|
+
logger.debug('Parsing completion promise resolved');
|
|
144
|
+
};
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
clearResult() {
|
|
148
|
+
this.result = {};
|
|
149
|
+
}
|
|
150
|
+
/** 标记解析完成,重置状态 */
|
|
151
|
+
completeParsing() {
|
|
152
|
+
this.resolveParseCompleted?.();
|
|
153
|
+
this.parser = null;
|
|
154
|
+
this.result = {};
|
|
155
|
+
this.currentChunkId = '';
|
|
156
|
+
this.parseCompleted = null;
|
|
157
|
+
this.resolveParseCompleted = null;
|
|
158
|
+
}
|
|
159
|
+
/** 安全地终止流并清理状态 */
|
|
160
|
+
terminateStream(controller) {
|
|
161
|
+
this.completeParsing();
|
|
162
|
+
this.terminated = true;
|
|
163
|
+
// Use error() instead of terminate() to signal an error condition
|
|
164
|
+
// This allows the stream to be properly handled by upstream consumers
|
|
165
|
+
try {
|
|
166
|
+
controller.error(new Error('Stream terminated due to error'));
|
|
167
|
+
}
|
|
168
|
+
catch (e) {
|
|
169
|
+
// Controller may already be closed, ignore
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/** 向下游推送 agent-error 并终止流 */
|
|
173
|
+
enqueueError(controller, error) {
|
|
174
|
+
if (this.terminated)
|
|
175
|
+
return;
|
|
176
|
+
const enqueue = createTextInfoEnqueuer(controller);
|
|
177
|
+
const msg = typeof error === 'string' ? error : ERROR_MESSAGES.PARSE_ERROR;
|
|
178
|
+
logger.error('JsonStreamProcessor error: ' + error);
|
|
179
|
+
// 先 enqueue 错误信息让下游能收到,再 terminate
|
|
180
|
+
// 注意:不能调 controller.error(),否则流进入 errored 状态,enqueue 不再生效
|
|
181
|
+
enqueue('error', { type: 'agent-error', error: msg });
|
|
182
|
+
this.terminateStream(controller);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/** 默认 chunkGuard:只处理带 delta 的 text-delta chunk */
|
|
186
|
+
function defaultChunkGuard(chunk) {
|
|
187
|
+
return chunk.type === 'text-delta' && 'delta' in chunk;
|
|
188
|
+
}
|
|
189
|
+
function isChunkWithDelta(chunk) {
|
|
190
|
+
return 'id' in chunk && 'delta' in chunk;
|
|
191
|
+
}
|
|
192
|
+
/** 去掉 AI 返回的代码围栏标记 (```) 和 json 前缀 */
|
|
193
|
+
function stripCodeFence(delta) {
|
|
194
|
+
return delta.replace(BACKTICK_RE, '').replace(JSON_PREFIX, '');
|
|
195
|
+
}
|
|
196
|
+
function isTimeoutError(value) {
|
|
197
|
+
return value?.error === true && value?.errorMessage === 'timeout';
|
|
198
|
+
}
|
package/ecosystem.config.cjs
CHANGED
|
@@ -8,8 +8,8 @@ module.exports = {
|
|
|
8
8
|
watch: false, // 生产环境关闭文件监听(避免容器内文件变动触发重启)
|
|
9
9
|
max_memory_restart: '2G', // 内存超过 1G 自动重启(防止内存泄漏)
|
|
10
10
|
|
|
11
|
-
listen_timeout:
|
|
12
|
-
kill_timeout:
|
|
11
|
+
listen_timeout: 120000, // 2 minutes for long streaming operations
|
|
12
|
+
kill_timeout: 30000, // 30 seconds for graceful shutdown
|
|
13
13
|
|
|
14
14
|
env: {
|
|
15
15
|
// 开发环境变量
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@baishuyun/chat-backend",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.16",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
"parse-sse": "^0.1.0",
|
|
23
23
|
"pino": "^10.1.0",
|
|
24
24
|
"zod": "^4.1.13",
|
|
25
|
-
"@baishuyun/coze-provider": "0.0.
|
|
26
|
-
"@baishuyun/types": "1.0.
|
|
25
|
+
"@baishuyun/coze-provider": "0.0.16",
|
|
26
|
+
"@baishuyun/types": "1.0.16"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@types/config": "^3.3.5",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"pm2": "^6.0.14",
|
|
35
35
|
"tsx": "^4.7.1",
|
|
36
36
|
"typescript": "^5.8.3",
|
|
37
|
-
"@baishuyun/typescript-config": "0.0.
|
|
37
|
+
"@baishuyun/typescript-config": "0.0.16"
|
|
38
38
|
},
|
|
39
39
|
"scripts": {
|
|
40
40
|
"dev": "cross-env NODE_ENV=development tsx watch src/index.ts",
|
|
@@ -16,6 +16,20 @@ app.use('web/api/*', cors());
|
|
|
16
16
|
// Logging middleware
|
|
17
17
|
app.use(logMiddleware);
|
|
18
18
|
|
|
19
|
+
// SSE keep-alive middleware - adds headers to prevent proxy timeouts during streaming
|
|
20
|
+
app.use('web/api/*', async (c, next) => {
|
|
21
|
+
await next();
|
|
22
|
+
|
|
23
|
+
// Add keep-alive headers to streaming responses (SSE/text-streaming)
|
|
24
|
+
const contentType = c.res.headers.get('content-type');
|
|
25
|
+
if (contentType?.includes('text/plain') || contentType?.includes('text/event-stream')) {
|
|
26
|
+
c.header('Connection', 'keep-alive');
|
|
27
|
+
c.header('Keep-Alive', 'timeout=300'); // 5 minutes
|
|
28
|
+
c.header('X-Accel-Buffering', 'no'); // Disable nginx buffering
|
|
29
|
+
c.header('Cache-Control', 'no-cache, no-transform');
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
19
33
|
// 全局错误处理中间件(捕获所有路由/中间件异常)
|
|
20
34
|
app.onError((err, c) => {
|
|
21
35
|
// 记录错误日志(error 级别,包含异常堆栈)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { convertToModelMessages, streamText } from "ai";
|
|
1
|
+
import { convertToModelMessages, streamText, type TextUIPart } from "ai";
|
|
2
2
|
import { type Context } from "hono";
|
|
3
3
|
import { createModel } from "./model.js";
|
|
4
4
|
import {
|
|
@@ -37,6 +37,13 @@ export const buildForm = async (c: Context) => {
|
|
|
37
37
|
});
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
// clear empty text parts to avoid unnecessary streaming
|
|
41
|
+
allMessages.forEach((message) => {
|
|
42
|
+
message.parts = message.parts.filter(
|
|
43
|
+
(part: TextUIPart) => part.type === "text" && part.text.trim() !== ""
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
40
47
|
const stream = streamText({
|
|
41
48
|
model: createModel([
|
|
42
49
|
() => createFieldsJsonTransformStream(isBuildStage),
|
|
@@ -32,6 +32,7 @@ function createJSONParser(): JSONParser {
|
|
|
32
32
|
paths: [
|
|
33
33
|
'$', // 保留完整结果推送
|
|
34
34
|
'$.title',
|
|
35
|
+
'$.source',
|
|
35
36
|
'$.name',
|
|
36
37
|
'$.type',
|
|
37
38
|
'$.userID',
|
|
@@ -55,14 +56,13 @@ function handleParsedValue(ctx: IParserCtx<IQueryResult>): void {
|
|
|
55
56
|
|
|
56
57
|
logger.debug('Parsed JSON value: ' + JSON.stringify(value));
|
|
57
58
|
|
|
58
|
-
//
|
|
59
|
-
// Phase 1: aggregate 类型 —— name 字段触发,独立处理后直接返回
|
|
59
|
+
// aggregate 类型 —— name 字段触发,独立处理后直接返回
|
|
60
60
|
if (isTargetElement('$.name', parsedInfo)) {
|
|
61
61
|
handleAggregateName(ctx);
|
|
62
62
|
return;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
// Phase
|
|
65
|
+
// Phase 0: 报表标题 —— 初始化结果并发送 text-start, 必须为起始字段
|
|
66
66
|
if (isTargetElement('$.title', parsedInfo)) {
|
|
67
67
|
handleTitle(ctx);
|
|
68
68
|
}
|
|
@@ -95,8 +95,9 @@ function handleAggregateName(ctx: IParserCtx<IQueryResult>): void {
|
|
|
95
95
|
|
|
96
96
|
ctx.setPartialResult({
|
|
97
97
|
title: value as string,
|
|
98
|
-
|
|
98
|
+
source: 'aggregate',
|
|
99
99
|
type: 'data_table',
|
|
100
|
+
isAggregate: true,
|
|
100
101
|
});
|
|
101
102
|
|
|
102
103
|
logger.debug('Parsed aggregate table title: ' + value);
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
import { type IDashWidgetType, type IQueryFilter } from '@baishuyun/types';
|
|
1
|
+
import { type IDashWidgetType, type IQueryFilter, type QuerySourceType } from '@baishuyun/types';
|
|
2
2
|
import type { PathHandler } from './types.js';
|
|
3
3
|
import { ensureFilter, pushToArray, pushToArrayWithMeta } from './handler-helpers.js';
|
|
4
4
|
|
|
5
5
|
export const fieldHandlers: PathHandler[] = [
|
|
6
|
+
{
|
|
7
|
+
path: '$.source',
|
|
8
|
+
handler: (ctx, value) => ctx.setPartialResult({ source: value as QuerySourceType }),
|
|
9
|
+
},
|
|
6
10
|
{
|
|
7
11
|
path: '$.userID',
|
|
8
12
|
handler: (ctx, value) => ctx.setPartialResult({ userID: value as number }),
|