@baishuyun/chat-backend 0.0.18 → 0.0.20
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 +16 -0
- package/config/default.ts +13 -5
- package/dist/config/default.js +12 -5
- package/dist/src/app/main.js +9 -0
- package/dist/src/config/hono.config.js +2 -1
- package/dist/src/const/error_code.js +3 -0
- package/dist/src/controllers/agent/bots.controller.js +1 -1
- package/dist/src/controllers/common/connect.controll.js +4 -2
- package/dist/src/controllers/common/model.js +2 -0
- package/dist/src/controllers/common/transformer-factory/agent-calling-listener.js +44 -0
- package/dist/src/controllers/form/attachment-upload.controller.js +15 -15
- package/dist/src/controllers/form/build/build.controller.js +3 -2
- package/dist/src/controllers/form/conversation/clear.controller.js +6 -6
- package/dist/src/controllers/form/fill/batch-fill.controller.js +3 -4
- package/dist/src/controllers/form/fill/createBatchFillingTransformStream.js +1 -17
- package/dist/src/controllers/form/fill/fill.controller.js +2 -1
- package/dist/src/controllers/form/fill/utils.js +16 -0
- package/dist/src/controllers/report/query/query.controller.js +1 -1
- package/dist/src/middleware/botRouter.js +34 -0
- package/dist/src/routes/common/common.route.js +2 -1
- package/dist/src/services/asr/asr-websocket.js +183 -0
- package/dist/src/services/asr/volc-protocol.js +143 -0
- package/dist/src/services/fetchCozeInfo.js +1 -1
- package/dist/src/utils/createFakeUIMessageStreamResponse.js +25 -0
- package/dist/src/utils/createJsonStreamTransformer.js +17 -2
- package/dist/src/utils/createSpecialPartMeta.js +5 -0
- package/package.json +7 -5
- package/src/app/main.ts +12 -0
- package/src/config/hono.config.ts +2 -1
- package/src/const/error_code.ts +3 -0
- package/src/controllers/agent/bots.controller.ts +1 -1
- package/src/controllers/common/connect.controll.ts +5 -2
- package/src/controllers/common/model.ts +2 -0
- package/src/controllers/common/transformer-factory/agent-calling-listener.ts +69 -0
- package/src/controllers/form/attachment-upload.controller.ts +16 -15
- package/src/controllers/form/build/build.controller.ts +5 -2
- package/src/controllers/form/conversation/clear.controller.ts +12 -15
- package/src/controllers/form/fill/batch-fill.controller.ts +3 -5
- package/src/controllers/form/fill/createBatchFillingTransformStream.ts +1 -20
- package/src/controllers/form/fill/fill.controller.ts +3 -1
- package/src/controllers/form/fill/utils.ts +18 -0
- package/src/controllers/report/query/query.controller.ts +1 -1
- package/src/middleware/botRouter.ts +41 -0
- package/src/routes/common/common.route.ts +2 -1
- package/src/services/asr/asr-websocket.ts +231 -0
- package/src/services/asr/volc-protocol.ts +220 -0
- package/src/services/fetchCozeInfo.ts +1 -1
- package/src/utils/createFakeUIMessageStreamResponse.ts +27 -0
- package/src/utils/createJsonStreamTransformer.ts +21 -2
- package/src/utils/createSpecialPartMeta.ts +7 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
import config from 'config';
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
import { createFullClientRequest, createAudioOnlyRequest, parseHeader, readPayloadSize, parseJsonPayload, parseErrorPayload, MSG_TYPE_FULL_SERVER_RESPONSE, MSG_TYPE_ERROR_RESPONSE, } from './volc-protocol.js';
|
|
5
|
+
import { logger } from '../../logger/index.js';
|
|
6
|
+
/**
|
|
7
|
+
* 处理浏览器 ASR WebSocket 连接
|
|
8
|
+
* 作为浏览器与火山引擎 ASR 之间的双向代理
|
|
9
|
+
*/
|
|
10
|
+
export function handleASRConnection(clientWs, req) {
|
|
11
|
+
const asrConfig = config.get('agent.asr');
|
|
12
|
+
if (!asrConfig?.appid || !asrConfig?.token || !asrConfig?.cluster) {
|
|
13
|
+
logger.error('ASR configuration missing: appid, token, cluster are required');
|
|
14
|
+
clientWs.close(1011, 'ASR config missing');
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
logger.info('ASR client connected');
|
|
18
|
+
// 音频数据缓冲区(在火山连接建立前暂存)
|
|
19
|
+
const audioBuffer = [];
|
|
20
|
+
let isVolcReady = false;
|
|
21
|
+
let isClosing = false;
|
|
22
|
+
// v3 接口地址:双向流式优化版
|
|
23
|
+
const volcUrl = `wss://${asrConfig.host}/api/v3/sauc/bigmodel_async`;
|
|
24
|
+
// 鉴权 Header(v3 大模型语音识别通过 HTTP Header 鉴权)
|
|
25
|
+
const connectId = crypto.randomUUID();
|
|
26
|
+
const requestHeaders = {
|
|
27
|
+
'X-Api-App-Key': asrConfig.appid,
|
|
28
|
+
'X-Api-Access-Key': asrConfig.token,
|
|
29
|
+
'X-Api-Resource-Id': asrConfig.cluster,
|
|
30
|
+
'X-Api-Connect-Id': connectId,
|
|
31
|
+
};
|
|
32
|
+
logger.info({ url: volcUrl, headers: { ...requestHeaders, 'X-Api-Access-Key': '***' } }, 'Connecting to Volc ASR v3');
|
|
33
|
+
const volcWs = new WebSocket(volcUrl, {
|
|
34
|
+
headers: requestHeaders,
|
|
35
|
+
perMessageDeflate: false,
|
|
36
|
+
});
|
|
37
|
+
volcWs.on('unexpected-response', (req, res) => {
|
|
38
|
+
let body = '';
|
|
39
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
40
|
+
res.on('end', () => {
|
|
41
|
+
logger.error({ statusCode: res.statusCode, statusMessage: res.statusMessage, body }, 'Volc ASR unexpected response');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
// 火山引擎连接建立后立即发送初始化请求并开始转发音频
|
|
45
|
+
volcWs.on('open', () => {
|
|
46
|
+
logger.info('Connected to Volc ASR v3, sending init request');
|
|
47
|
+
volcWs.send(createFullClientRequest());
|
|
48
|
+
// 火山引擎不返回初始化确认,直接开始等待音频
|
|
49
|
+
// 立即开始转发缓冲的音频数据
|
|
50
|
+
isVolcReady = true;
|
|
51
|
+
while (audioBuffer.length > 0) {
|
|
52
|
+
const buffered = audioBuffer.shift();
|
|
53
|
+
if (volcWs.readyState === WebSocket.OPEN) {
|
|
54
|
+
volcWs.send(createAudioOnlyRequest(buffered));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
// 处理火山引擎返回的消息(二进制帧)
|
|
59
|
+
volcWs.on('message', (data) => {
|
|
60
|
+
if (isClosing)
|
|
61
|
+
return;
|
|
62
|
+
if (!Buffer.isBuffer(data) || data.length < 4) {
|
|
63
|
+
logger.warn({ len: data.length, type: typeof data }, 'Invalid ASR message');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const { msgType, flags, bodyOffset } = parseHeader(data);
|
|
67
|
+
if (msgType === MSG_TYPE_FULL_SERVER_RESPONSE) {
|
|
68
|
+
// 根据 flags 判断是否有 sequence(flags bit0=1 表示有正 sequence)
|
|
69
|
+
const hasSequence = (flags & 0b0001) !== 0;
|
|
70
|
+
const payloadSizeOffset = hasSequence ? bodyOffset + 4 : bodyOffset;
|
|
71
|
+
const payloadOffset = payloadSizeOffset + 4;
|
|
72
|
+
if (data.length < payloadOffset) {
|
|
73
|
+
logger.warn({ len: data.length, payloadOffset, hex: data.toString('hex') }, 'ASR response too short');
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const payloadSize = readPayloadSize(data, payloadSizeOffset);
|
|
77
|
+
if (data.length < payloadOffset + payloadSize) {
|
|
78
|
+
logger.warn({ len: data.length, payloadSize, payloadOffset }, 'ASR response incomplete');
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const payload = data.slice(payloadOffset, payloadOffset + payloadSize);
|
|
82
|
+
const response = parseJsonPayload(payload);
|
|
83
|
+
if (!response) {
|
|
84
|
+
logger.warn({ payloadStr: payload.toString('utf-8').slice(0, 200) }, 'Failed to parse ASR JSON');
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const text = response.result?.text;
|
|
88
|
+
const utterances = response.result?.utterances;
|
|
89
|
+
const isDefinite = utterances?.some((u) => u.definite);
|
|
90
|
+
logger.info({ text, definite: isDefinite }, 'ASR result');
|
|
91
|
+
// 转发识别文本给浏览器
|
|
92
|
+
if (text !== undefined && clientWs.readyState === WebSocket.OPEN) {
|
|
93
|
+
clientWs.send(JSON.stringify({
|
|
94
|
+
type: isDefinite ? 'final' : 'result',
|
|
95
|
+
text,
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
if (isDefinite && clientWs.readyState === WebSocket.OPEN) {
|
|
99
|
+
clientWs.send(JSON.stringify({ type: 'done' }));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
else if (msgType === MSG_TYPE_ERROR_RESPONSE) {
|
|
103
|
+
const error = parseErrorPayload(data, bodyOffset);
|
|
104
|
+
logger.error({ error }, 'ASR error response');
|
|
105
|
+
if (clientWs.readyState === WebSocket.OPEN) {
|
|
106
|
+
clientWs.send(JSON.stringify({
|
|
107
|
+
type: 'error',
|
|
108
|
+
message: error?.message || `ASR error code: ${error?.code}`,
|
|
109
|
+
}));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
// 处理浏览器发来的消息
|
|
114
|
+
clientWs.on('message', (data) => {
|
|
115
|
+
if (isClosing)
|
|
116
|
+
return;
|
|
117
|
+
// 结束信号
|
|
118
|
+
if (typeof data === 'string' && data === 'end') {
|
|
119
|
+
if (volcWs.readyState === WebSocket.OPEN) {
|
|
120
|
+
volcWs.send(createAudioOnlyRequest(Buffer.alloc(0), true));
|
|
121
|
+
}
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
// 将数据转为 Buffer
|
|
125
|
+
let audioData;
|
|
126
|
+
if (Buffer.isBuffer(data)) {
|
|
127
|
+
audioData = data;
|
|
128
|
+
}
|
|
129
|
+
else if (data instanceof ArrayBuffer) {
|
|
130
|
+
audioData = Buffer.from(data);
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (audioData.length === 0)
|
|
136
|
+
return;
|
|
137
|
+
// 火山连接就绪则直接转发,否则缓冲
|
|
138
|
+
if (isVolcReady && volcWs.readyState === WebSocket.OPEN) {
|
|
139
|
+
volcWs.send(createAudioOnlyRequest(audioData));
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
audioBuffer.push(audioData);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
// 浏览器关闭
|
|
146
|
+
clientWs.on('close', () => {
|
|
147
|
+
logger.info('ASR client disconnected');
|
|
148
|
+
isClosing = true;
|
|
149
|
+
if (volcWs.readyState === WebSocket.OPEN) {
|
|
150
|
+
volcWs.send(createAudioOnlyRequest(Buffer.alloc(0), true));
|
|
151
|
+
setTimeout(() => volcWs.close(), 500);
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
volcWs.close();
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
// 火山引擎关闭
|
|
158
|
+
volcWs.on('close', () => {
|
|
159
|
+
logger.info('Volc ASR connection closed');
|
|
160
|
+
isClosing = true;
|
|
161
|
+
if (clientWs.readyState === WebSocket.OPEN) {
|
|
162
|
+
clientWs.close();
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
// 浏览器端错误
|
|
166
|
+
clientWs.on('error', (err) => {
|
|
167
|
+
logger.error({ err }, 'Client WebSocket error');
|
|
168
|
+
isClosing = true;
|
|
169
|
+
volcWs.close();
|
|
170
|
+
});
|
|
171
|
+
// 火山引擎错误
|
|
172
|
+
volcWs.on('error', (err) => {
|
|
173
|
+
logger.error({ err }, 'Volc ASR WebSocket error');
|
|
174
|
+
isClosing = true;
|
|
175
|
+
if (clientWs.readyState === WebSocket.OPEN) {
|
|
176
|
+
clientWs.send(JSON.stringify({
|
|
177
|
+
type: 'error',
|
|
178
|
+
message: 'ASR service connection error',
|
|
179
|
+
}));
|
|
180
|
+
clientWs.close(1011, 'ASR error');
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 火山引擎 ASR v3 大模型语音识别 WebSocket 二进制协议
|
|
3
|
+
* 协议文档: https://www.volcengine.com/docs/6561/1354869
|
|
4
|
+
*
|
|
5
|
+
* v3 协议要点:
|
|
6
|
+
* - 4 字节 header
|
|
7
|
+
* - payloadSize 为 4 字节 uint32 大端
|
|
8
|
+
* - FullServerResponse 包含 4 字节 sequence 字段
|
|
9
|
+
* - ErrorResponse 包含 errorCode(4B) + errorMsgSize(4B) + errorMsg
|
|
10
|
+
* - 鉴权在 HTTP Header 中,不在 payload
|
|
11
|
+
*/
|
|
12
|
+
const PROTOCOL_VERSION = 0x01;
|
|
13
|
+
const HEADER_SIZE_VALUE = 0x01; // actual header size = 1 * 4 = 4 bytes
|
|
14
|
+
/** 消息类型 */
|
|
15
|
+
export const MSG_TYPE_FULL_CLIENT_REQUEST = 0x01;
|
|
16
|
+
export const MSG_TYPE_AUDIO_ONLY_REQUEST = 0x02;
|
|
17
|
+
export const MSG_TYPE_FULL_SERVER_RESPONSE = 0x09;
|
|
18
|
+
export const MSG_TYPE_ERROR_RESPONSE = 0x0f;
|
|
19
|
+
/** 序列化方式 */
|
|
20
|
+
const SERIALIZATION_JSON = 0x01;
|
|
21
|
+
const SERIALIZATION_NONE = 0x00;
|
|
22
|
+
/** 压缩方式 */
|
|
23
|
+
const COMPRESSION_NONE = 0x00;
|
|
24
|
+
/**
|
|
25
|
+
* 构建 4 字节协议头
|
|
26
|
+
*
|
|
27
|
+
* byte0: version(4bit) | header_size(4bit)
|
|
28
|
+
* byte1: msg_type(4bit) | flags(4bit)
|
|
29
|
+
* byte2: serialization(4bit) | compression(4bit)
|
|
30
|
+
* byte3: reserved
|
|
31
|
+
*/
|
|
32
|
+
function build4ByteHeader(msgType, flags, serialization) {
|
|
33
|
+
const header = Buffer.alloc(4);
|
|
34
|
+
header[0] = (PROTOCOL_VERSION << 4) | HEADER_SIZE_VALUE;
|
|
35
|
+
header[1] = (msgType << 4) | (flags & 0x0f);
|
|
36
|
+
header[2] = (serialization << 4) | COMPRESSION_NONE;
|
|
37
|
+
header[3] = 0x00;
|
|
38
|
+
return header;
|
|
39
|
+
}
|
|
40
|
+
/** 构建完整消息:header + payloadSize(4B) + payload */
|
|
41
|
+
function buildMessage(msgType, flags, serialization, payload) {
|
|
42
|
+
const header = build4ByteHeader(msgType, flags, serialization);
|
|
43
|
+
const payloadSize = Buffer.alloc(4);
|
|
44
|
+
payloadSize.writeUInt32BE(payload.length, 0);
|
|
45
|
+
return Buffer.concat([header, payloadSize, payload]);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* 解析 4 字节协议头
|
|
49
|
+
*/
|
|
50
|
+
export function parseHeader(buffer) {
|
|
51
|
+
const version = buffer[0] >> 4;
|
|
52
|
+
const headerSizeValue = buffer[0] & 0x0f;
|
|
53
|
+
const headerSize = headerSizeValue * 4;
|
|
54
|
+
const msgType = buffer[1] >> 4;
|
|
55
|
+
const flags = buffer[1] & 0x0f;
|
|
56
|
+
const serialization = buffer[2] >> 4;
|
|
57
|
+
const compression = buffer[2] & 0x0f;
|
|
58
|
+
return {
|
|
59
|
+
msgType,
|
|
60
|
+
flags,
|
|
61
|
+
bodyOffset: headerSize,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* 读取 payload size(4 字节 uint32 大端)
|
|
66
|
+
*/
|
|
67
|
+
export function readPayloadSize(buffer, offset) {
|
|
68
|
+
return buffer.readUInt32BE(offset);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* 创建 FullClientRequest(初始化请求)
|
|
72
|
+
*
|
|
73
|
+
* Payload JSON 格式:
|
|
74
|
+
* {
|
|
75
|
+
* user: { uid: string },
|
|
76
|
+
* audio: { format, rate, bits, channel, codec, language },
|
|
77
|
+
* request: { model_name, enable_itn, enable_punc, ... }
|
|
78
|
+
* }
|
|
79
|
+
*/
|
|
80
|
+
export function createFullClientRequest() {
|
|
81
|
+
const payload = Buffer.from(JSON.stringify({
|
|
82
|
+
user: {
|
|
83
|
+
uid: '0',
|
|
84
|
+
},
|
|
85
|
+
audio: {
|
|
86
|
+
format: 'pcm',
|
|
87
|
+
rate: 16000,
|
|
88
|
+
bits: 16,
|
|
89
|
+
channel: 1,
|
|
90
|
+
codec: 'raw',
|
|
91
|
+
language: 'zh-CN',
|
|
92
|
+
},
|
|
93
|
+
request: {
|
|
94
|
+
model_name: 'bigmodel',
|
|
95
|
+
enable_itn: true,
|
|
96
|
+
enable_punc: true,
|
|
97
|
+
enable_ddc: false,
|
|
98
|
+
},
|
|
99
|
+
}), 'utf-8');
|
|
100
|
+
return buildMessage(MSG_TYPE_FULL_CLIENT_REQUEST, 0b0000, SERIALIZATION_JSON, payload);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* 创建 AudioOnlyRequest(音频数据请求)
|
|
104
|
+
*
|
|
105
|
+
* flags:
|
|
106
|
+
* - 0b0000: 普通音频包
|
|
107
|
+
* - 0b0010: 最后一包音频(负包)
|
|
108
|
+
*/
|
|
109
|
+
export function createAudioOnlyRequest(audioData, isLast = false) {
|
|
110
|
+
const flags = isLast ? 0b0010 : 0b0000;
|
|
111
|
+
return buildMessage(MSG_TYPE_AUDIO_ONLY_REQUEST, flags, SERIALIZATION_NONE, audioData);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* 解析 JSON payload
|
|
115
|
+
*/
|
|
116
|
+
export function parseJsonPayload(payloadBuffer) {
|
|
117
|
+
try {
|
|
118
|
+
return JSON.parse(payloadBuffer.toString('utf-8'));
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* 解析 ErrorResponse
|
|
126
|
+
*
|
|
127
|
+
* 格式:header(4B) + errorCode(4B) + errorMsgSize(4B) + errorMsg
|
|
128
|
+
*/
|
|
129
|
+
export function parseErrorPayload(buffer, bodyOffset) {
|
|
130
|
+
const errorCodeOffset = bodyOffset;
|
|
131
|
+
const errorMsgSizeOffset = errorCodeOffset + 4;
|
|
132
|
+
const errorMsgOffset = errorMsgSizeOffset + 4;
|
|
133
|
+
if (buffer.length < errorMsgOffset) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
const code = buffer.readUInt32BE(errorCodeOffset);
|
|
137
|
+
const msgSize = buffer.readUInt32BE(errorMsgSizeOffset);
|
|
138
|
+
if (buffer.length < errorMsgOffset + msgSize) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
const message = buffer.slice(errorMsgOffset, errorMsgOffset + msgSize).toString('utf-8');
|
|
142
|
+
return { code, message };
|
|
143
|
+
}
|
|
@@ -2,7 +2,7 @@ import config from 'config';
|
|
|
2
2
|
import { logger } from '../logger/index.js';
|
|
3
3
|
export const fetchCozeInfo = async (bsTeamId) => {
|
|
4
4
|
const agentHost = config.get('agent.host');
|
|
5
|
-
const apiUrl = `
|
|
5
|
+
const apiUrl = `https://${agentHost}/api/by_teamid/tokens`;
|
|
6
6
|
const res = await fetch(apiUrl, {
|
|
7
7
|
method: 'POST',
|
|
8
8
|
headers: {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createUIMessageStream, createUIMessageStreamResponse, generateId } from 'ai';
|
|
2
|
+
import { createSpecialPartMeta } from './createSpecialPartMeta.js';
|
|
3
|
+
/**
|
|
4
|
+
* 创建一个与 `streamText().toUIMessageStreamResponse()` 格式一致的假流式响应,
|
|
5
|
+
* 用于在无法调用真实模型时向客户端推送一条指定的文本消息。
|
|
6
|
+
*/
|
|
7
|
+
export const createFakeUIMessageStreamResponse = (text) => {
|
|
8
|
+
const stream = createUIMessageStream({
|
|
9
|
+
execute: ({ writer }) => {
|
|
10
|
+
const messageId = generateId();
|
|
11
|
+
const textId = generateId();
|
|
12
|
+
writer.write({ type: 'start', messageId });
|
|
13
|
+
writer.write({ type: 'text-start', id: textId });
|
|
14
|
+
writer.write({
|
|
15
|
+
type: 'text-delta',
|
|
16
|
+
id: textId,
|
|
17
|
+
delta: text,
|
|
18
|
+
providerMetadata: createSpecialPartMeta('error-bot-id-required'),
|
|
19
|
+
});
|
|
20
|
+
writer.write({ type: 'text-end', id: textId });
|
|
21
|
+
writer.write({ type: 'finish', finishReason: 'stop' });
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
return createUIMessageStreamResponse({ stream });
|
|
25
|
+
};
|
|
@@ -35,6 +35,7 @@ class JsonStreamProcessor {
|
|
|
35
35
|
parseCompleted = null;
|
|
36
36
|
resolveParseCompleted = null;
|
|
37
37
|
terminated = false;
|
|
38
|
+
passthroughMode = false; // 当启用且遇到非 JSON chunk 时,切换到透传模式
|
|
38
39
|
constructor(options) {
|
|
39
40
|
this.options = options;
|
|
40
41
|
}
|
|
@@ -51,6 +52,10 @@ class JsonStreamProcessor {
|
|
|
51
52
|
logger.warn('JSON parser not initialized or stream already terminated');
|
|
52
53
|
return;
|
|
53
54
|
}
|
|
55
|
+
if (this.passthroughMode) {
|
|
56
|
+
controller.enqueue(chunk);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
54
59
|
try {
|
|
55
60
|
// 1) error chunk → 通知下游后终止
|
|
56
61
|
if (chunk.type === 'error') {
|
|
@@ -71,6 +76,11 @@ class JsonStreamProcessor {
|
|
|
71
76
|
}
|
|
72
77
|
}
|
|
73
78
|
catch (error) {
|
|
79
|
+
if (this.options.bypassParseError) {
|
|
80
|
+
this.passthroughMode = true;
|
|
81
|
+
controller.enqueue(chunk);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
74
84
|
this.enqueueError(controller, error);
|
|
75
85
|
}
|
|
76
86
|
}
|
|
@@ -120,6 +130,10 @@ class JsonStreamProcessor {
|
|
|
120
130
|
});
|
|
121
131
|
};
|
|
122
132
|
this.parser.onError = (err) => {
|
|
133
|
+
if (this.options.bypassParseError) {
|
|
134
|
+
this.passthroughMode = true;
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
123
137
|
if (!this.terminated) {
|
|
124
138
|
const msg = this.options.onParseError
|
|
125
139
|
? this.options.onParseError(err)
|
|
@@ -130,8 +144,9 @@ class JsonStreamProcessor {
|
|
|
130
144
|
}
|
|
131
145
|
};
|
|
132
146
|
this.parser.onEnd = () => {
|
|
133
|
-
|
|
134
|
-
|
|
147
|
+
if (!this.options.bypassParseError) {
|
|
148
|
+
enqueue(' ', JSON.stringify({ appendConfirm: 'save-fields' }));
|
|
149
|
+
}
|
|
135
150
|
this.completeParsing();
|
|
136
151
|
};
|
|
137
152
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@baishuyun/chat-backend",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.20",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -22,21 +22,23 @@
|
|
|
22
22
|
"lru-cache": "^11.2.7",
|
|
23
23
|
"parse-sse": "^0.1.0",
|
|
24
24
|
"pino": "^10.1.0",
|
|
25
|
+
"ws": "^8.18.0",
|
|
25
26
|
"zod": "^4.1.13",
|
|
26
|
-
"@baishuyun/
|
|
27
|
-
"@baishuyun/types": "
|
|
28
|
-
"@baishuyun/
|
|
27
|
+
"@baishuyun/agents": "1.0.0",
|
|
28
|
+
"@baishuyun/types": "2.0.0",
|
|
29
|
+
"@baishuyun/coze-provider": "1.0.0"
|
|
29
30
|
},
|
|
30
31
|
"devDependencies": {
|
|
31
32
|
"@types/config": "^3.3.5",
|
|
32
33
|
"@types/dotenv": "^8.2.3",
|
|
33
34
|
"@types/node": "^20.11.17",
|
|
35
|
+
"@types/ws": "^8.5.13",
|
|
34
36
|
"cross-env": "^10.1.0",
|
|
35
37
|
"pino-pretty": "^13.1.3",
|
|
36
38
|
"pm2": "^6.0.14",
|
|
37
39
|
"tsx": "^4.7.1",
|
|
38
40
|
"typescript": "^5.8.3",
|
|
39
|
-
"@baishuyun/typescript-config": "
|
|
41
|
+
"@baishuyun/typescript-config": "1.0.0"
|
|
40
42
|
},
|
|
41
43
|
"scripts": {
|
|
42
44
|
"dev": "cross-env NODE_ENV=development tsx watch src/index.ts",
|
package/src/app/main.ts
CHANGED
|
@@ -26,3 +26,15 @@ app.get('/web/api/health', (c) => {
|
|
|
26
26
|
service: 'hono-app',
|
|
27
27
|
});
|
|
28
28
|
});
|
|
29
|
+
|
|
30
|
+
// 挂载 ASR WebSocket Server
|
|
31
|
+
import { WebSocketServer } from 'ws';
|
|
32
|
+
import { server } from '../config/hono.config.js';
|
|
33
|
+
import { handleASRConnection } from '../services/asr/asr-websocket.js';
|
|
34
|
+
|
|
35
|
+
const wss = new WebSocketServer({
|
|
36
|
+
server: server as any,
|
|
37
|
+
path: '/web/api/asr',
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
wss.on('connection', handleASRConnection);
|
|
@@ -47,7 +47,7 @@ app.onError((err, c) => {
|
|
|
47
47
|
return c.json({ message: 'Internal Server Error' }, 500);
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
-
serve(
|
|
50
|
+
const server = serve(
|
|
51
51
|
{
|
|
52
52
|
fetch: app.fetch,
|
|
53
53
|
hostname: config.get<string>('app.host') || '',
|
|
@@ -58,4 +58,5 @@ serve(
|
|
|
58
58
|
}
|
|
59
59
|
);
|
|
60
60
|
|
|
61
|
+
export { server };
|
|
61
62
|
export default app;
|
|
@@ -6,7 +6,7 @@ import type { ICozeInfoOfTokenExchange } from '../../types/coze.js';
|
|
|
6
6
|
|
|
7
7
|
export const listBots = async (c: Context) => {
|
|
8
8
|
const agentHost = config.get<string>('agent.host');
|
|
9
|
-
const apiBots = `
|
|
9
|
+
const apiBots = `https://${agentHost}/v1/bots`;
|
|
10
10
|
const cozeInfo = c.get('X-Coze-Info') as ICozeInfoOfTokenExchange;
|
|
11
11
|
|
|
12
12
|
logger.debug(`Fetching bots from ${apiBots} with cozeToken: ${cozeInfo.cozeToken}`);
|
|
@@ -2,6 +2,7 @@ import { convertToModelMessages, streamText } from 'ai';
|
|
|
2
2
|
import type { Context } from 'hono';
|
|
3
3
|
import { createBaseModel } from './model.js';
|
|
4
4
|
import type { ICozeInfoOfTokenExchange } from '../../types/coze.js';
|
|
5
|
+
import { logger } from '../../logger/index.js';
|
|
5
6
|
|
|
6
7
|
export const connectToAgent = async (c: Context) => {
|
|
7
8
|
let requestBody;
|
|
@@ -18,13 +19,15 @@ export const connectToAgent = async (c: Context) => {
|
|
|
18
19
|
|
|
19
20
|
const botId = c.req.header('X-Bot-Id') || '';
|
|
20
21
|
|
|
22
|
+
logger.debug('enter common controller');
|
|
23
|
+
|
|
21
24
|
const stream = streamText({
|
|
22
|
-
model: createBaseModel(botId, cozeInfo
|
|
25
|
+
model: createBaseModel(botId, cozeInfo?.cozeToken),
|
|
23
26
|
messages: convertToModelMessages(messages),
|
|
24
27
|
includeRawChunks: true,
|
|
25
28
|
headers: {
|
|
26
29
|
'x-user-var': requestBody.userVar,
|
|
27
|
-
'x-user-id': cozeInfo
|
|
30
|
+
'x-user-id': cozeInfo?.userId,
|
|
28
31
|
},
|
|
29
32
|
});
|
|
30
33
|
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { createCoze } from '@baishuyun/coze-provider';
|
|
2
2
|
import config from 'config';
|
|
3
|
+
import { createAgentCallingListener } from './transformer-factory/agent-calling-listener.js';
|
|
3
4
|
|
|
4
5
|
export const createBaseModel = (botId: string, token?: string) => {
|
|
5
6
|
const coze = createCoze({
|
|
6
7
|
apiKey: token || config.get<string>('agent.common.apiKey'),
|
|
7
8
|
baseURL: config.get<string>('agent.common.baseUrl'),
|
|
8
9
|
botId: botId,
|
|
10
|
+
extraStreamTransformers: [createAgentCallingListener],
|
|
9
11
|
});
|
|
10
12
|
|
|
11
13
|
return coze.chat('chat');
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createJsonStreamTransformer,
|
|
3
|
+
type IParserCtx,
|
|
4
|
+
} from '../../../utils/createJsonStreamTransformer.js';
|
|
5
|
+
import { JSONParser } from '@streamparser/json';
|
|
6
|
+
import { isTargetElement } from '../../report/query/utils.js';
|
|
7
|
+
|
|
8
|
+
function createJSONParser(): JSONParser {
|
|
9
|
+
return new JSONParser({
|
|
10
|
+
stringBufferSize: undefined,
|
|
11
|
+
numberBufferSize: undefined,
|
|
12
|
+
separator: '',
|
|
13
|
+
paths: ['$.type', '$.prompt', '$.agent'],
|
|
14
|
+
keepStack: true,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface IAgentCallingCtx {
|
|
19
|
+
type: string;
|
|
20
|
+
prompt: string;
|
|
21
|
+
agent: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type PathHandler = {
|
|
25
|
+
path: string;
|
|
26
|
+
handler: (ctx: IParserCtx<IAgentCallingCtx>, value: unknown) => void;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const fieldHandlers: PathHandler[] = [
|
|
30
|
+
{
|
|
31
|
+
path: '$.type',
|
|
32
|
+
handler: (ctx, value) => ctx.setPartialResult({ type: value as string }),
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
path: '$.prompt',
|
|
36
|
+
handler: (ctx, value) => ctx.setPartialResult({ prompt: value as string }),
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
path: '$.agent',
|
|
40
|
+
handler: (ctx, value) => ctx.setPartialResult({ agent: value as string }),
|
|
41
|
+
},
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
function handleParsedValue(ctx: IParserCtx<IAgentCallingCtx>): void {
|
|
45
|
+
const { parsedInfo, getResult, currentChunkId, deltaChunkEnqueuer: enqueueTextDelta, ctrl } = ctx;
|
|
46
|
+
const { value } = parsedInfo;
|
|
47
|
+
|
|
48
|
+
for (const { path, handler } of fieldHandlers) {
|
|
49
|
+
if (isTargetElement(path, parsedInfo)) {
|
|
50
|
+
handler(ctx, value);
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
enqueueTextDelta(
|
|
56
|
+
`${JSON.stringify(value)},`,
|
|
57
|
+
{ type: 'agent-calling', result: JSON.stringify(getResult()) },
|
|
58
|
+
currentChunkId,
|
|
59
|
+
true
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const createAgentCallingListener = () => {
|
|
64
|
+
return createJsonStreamTransformer({
|
|
65
|
+
bypassParseError: true,
|
|
66
|
+
createJSONParser,
|
|
67
|
+
onParseValue: handleParsedValue,
|
|
68
|
+
});
|
|
69
|
+
};
|
|
@@ -1,27 +1,27 @@
|
|
|
1
|
-
import { type Context } from
|
|
2
|
-
import config from
|
|
3
|
-
import { logger } from
|
|
4
|
-
import { parseImg } from
|
|
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
5
|
|
|
6
6
|
export const uploadAttachment = async (c: Context) => {
|
|
7
7
|
const formData = await c.req.formData();
|
|
8
|
-
const file = formData.get(
|
|
8
|
+
const file = formData.get('file'); // as FormData | null;
|
|
9
9
|
// 校验文件是否存在
|
|
10
10
|
if (!file || !(file instanceof Blob)) {
|
|
11
|
-
return c.json({ error:
|
|
11
|
+
return c.json({ error: '请上传有效的文件' }, 400);
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
const forwardFormData = new FormData();
|
|
15
|
-
forwardFormData.append(
|
|
15
|
+
forwardFormData.append('file', file);
|
|
16
16
|
|
|
17
|
-
const apiKey = config.get<string>(
|
|
18
|
-
const host = config.get<string>(
|
|
19
|
-
const api = `
|
|
17
|
+
const apiKey = config.get<string>('agent.apiAuthKey');
|
|
18
|
+
const host = config.get<string>('agent.host');
|
|
19
|
+
const api = `https://${host}/v1/files/upload`;
|
|
20
20
|
|
|
21
21
|
logger.debug(c.body);
|
|
22
22
|
|
|
23
23
|
const response = await fetch(api, {
|
|
24
|
-
method:
|
|
24
|
+
method: 'POST',
|
|
25
25
|
body: formData,
|
|
26
26
|
headers: {
|
|
27
27
|
// Add any auth headers
|
|
@@ -30,9 +30,7 @@ export const uploadAttachment = async (c: Context) => {
|
|
|
30
30
|
});
|
|
31
31
|
|
|
32
32
|
if (!response.ok) {
|
|
33
|
-
const errorData = await response
|
|
34
|
-
.json()
|
|
35
|
-
.catch(() => ({ message: "上传失败" }));
|
|
33
|
+
const errorData = await response.json().catch(() => ({ message: '上传失败' }));
|
|
36
34
|
return c.json({
|
|
37
35
|
error: `目标接口返回错误: ${errorData.message}`,
|
|
38
36
|
status: response.status,
|
|
@@ -44,8 +42,11 @@ export const uploadAttachment = async (c: Context) => {
|
|
|
44
42
|
|
|
45
43
|
const orcResult = await parseImg(file);
|
|
46
44
|
|
|
45
|
+
// replace file url protol to https
|
|
46
|
+
const secureUrl = url.replace('http://', 'https://');
|
|
47
|
+
|
|
47
48
|
return c.json({
|
|
48
|
-
url,
|
|
49
|
+
url: secureUrl,
|
|
49
50
|
name: uri,
|
|
50
51
|
contentType: file.type,
|
|
51
52
|
parsedData: orcResult,
|