@talknote/create-talknote-h5 0.1.3 → 0.1.5
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/bin/create-talknote-h5.js +14 -0
- package/package.json +1 -1
- package/template/.agents/skills/add-h5-page/SKILL.md +116 -0
- package/template/.eslintrc.js +2 -0
- package/template/README.md +143 -117
- package/template/_gitignore +0 -3
- package/template/config/config.ts +49 -4
- package/template/config/proxy.ts +28 -31
- package/template/docker/nginx/error_page.conf +51 -0
- package/template/docker/nginx/nginx.conf +54 -0
- package/template/docker/nginx/webApp.conf +13 -0
- package/template/docker/webApp.Dockerfile +12 -0
- package/template/package.json +6 -2
- package/template/packages/xiaozhi-chat/README.md +53 -0
- package/template/packages/xiaozhi-chat/XiaozhiChatPage.less +50 -0
- package/template/packages/xiaozhi-chat/XiaozhiChatPage.tsx +172 -0
- package/template/packages/xiaozhi-chat/assets/images/bg.jpg +0 -0
- package/template/packages/xiaozhi-chat/assets/images/icon-arrow-back.png +0 -0
- package/template/packages/xiaozhi-chat/assets/images/icon-close.png +0 -0
- package/template/packages/xiaozhi-chat/assets/images/icon-voice.png +0 -0
- package/template/packages/xiaozhi-chat/assets/images/keyboard.png +0 -0
- package/template/packages/xiaozhi-chat/assets/images/send-icon.png +0 -0
- package/template/packages/xiaozhi-chat/assets/images/stop-icon.png +0 -0
- package/template/packages/xiaozhi-chat/components/AssistantRichContent/index.less +3 -0
- package/template/packages/xiaozhi-chat/components/AssistantRichContent/index.tsx +44 -0
- package/template/packages/xiaozhi-chat/components/ChatHeader/index.less +84 -0
- package/template/packages/xiaozhi-chat/components/ChatHeader/index.tsx +44 -0
- package/template/packages/xiaozhi-chat/components/ChatInput/HoldToTalk.less +59 -0
- package/template/packages/xiaozhi-chat/components/ChatInput/HoldToTalk.tsx +167 -0
- package/template/packages/xiaozhi-chat/components/ChatInput/index.less +189 -0
- package/template/packages/xiaozhi-chat/components/ChatInput/index.tsx +203 -0
- package/template/packages/xiaozhi-chat/components/DeepThinkingWrapper/index.less +80 -0
- package/template/packages/xiaozhi-chat/components/DeepThinkingWrapper/index.tsx +54 -0
- package/template/packages/xiaozhi-chat/components/MarkdownContent/index.less +209 -0
- package/template/packages/xiaozhi-chat/components/MarkdownContent/index.tsx +41 -0
- package/template/packages/xiaozhi-chat/components/MessageList/index.less +74 -0
- package/template/packages/xiaozhi-chat/components/MessageList/index.tsx +195 -0
- package/template/packages/xiaozhi-chat/components/ThinkingCompleteEntry/index.less +56 -0
- package/template/packages/xiaozhi-chat/components/ThinkingCompleteEntry/index.tsx +65 -0
- package/template/packages/xiaozhi-chat/components/ToolCallList/index.less +41 -0
- package/template/packages/xiaozhi-chat/components/ToolCallList/index.tsx +51 -0
- package/template/packages/xiaozhi-chat/components/TypingIndicator/index.less +35 -0
- package/template/packages/xiaozhi-chat/components/TypingIndicator/index.tsx +12 -0
- package/template/packages/xiaozhi-chat/components/Welcome/index.less +105 -0
- package/template/packages/xiaozhi-chat/components/Welcome/index.tsx +84 -0
- package/template/packages/xiaozhi-chat/hooks/useChatSession.ts +235 -0
- package/template/packages/xiaozhi-chat/hooks/useRealtimeTranscription.ts +226 -0
- package/template/packages/xiaozhi-chat/hooks/useTypewriter.ts +215 -0
- package/template/packages/xiaozhi-chat/hooks/useViewportHeight.ts +43 -0
- package/template/packages/xiaozhi-chat/hooks/useVoiceRecording.ts +128 -0
- package/template/packages/xiaozhi-chat/index.ts +11 -0
- package/template/packages/xiaozhi-chat/package.json +34 -0
- package/template/packages/xiaozhi-chat/services/chatApi.ts +585 -0
- package/template/packages/xiaozhi-chat/types.ts +80 -0
- package/template/packages/xiaozhi-chat/utils/bridge.ts +131 -0
- package/template/packages/xiaozhi-chat/utils/deepThinking.ts +200 -0
- package/template/packages/xiaozhi-chat/utils/parseAssistantContent.ts +233 -0
- package/template/packages/xiaozhi-chat/utils/typewriter.ts +265 -0
- package/template/scripts/dev.js +6 -7
- package/template/src/app.tsx +1 -73
- package/template/src/assets/error-network.png +0 -0
- package/template/src/assets/error.png +0 -0
- package/template/src/assets/success.png +0 -0
- package/template/src/assets/welcome.png +0 -0
- package/template/src/components/Header/index.less +70 -0
- package/template/src/components/Header/index.tsx +46 -0
- package/template/src/components/TabBar/index.less +13 -0
- package/template/src/components/TabBar/index.tsx +58 -0
- package/template/src/global.css +27 -0
- package/template/src/layouts/BlankLayout/index.less +5 -0
- package/template/src/layouts/BlankLayout/index.tsx +14 -0
- package/template/src/layouts/HeaderLayout/index.less +25 -0
- package/template/src/layouts/HeaderLayout/index.tsx +42 -0
- package/template/src/layouts/LayoutOutlet.tsx +7 -0
- package/template/src/layouts/SSOLoginLayout/index.less +28 -0
- package/template/src/layouts/SSOLoginLayout/index.tsx +92 -0
- package/template/src/layouts/TabBarLayout/index.less +21 -0
- package/template/src/layouts/TabBarLayout/index.tsx +29 -0
- package/template/src/layouts/index.tsx +30 -0
- package/template/src/layouts/usePageHeaderProps.ts +9 -0
- package/template/src/pages/index/index.css +5 -5
- package/template/src/pages/index/index.tsx +15 -2
- package/template/src/pages/list/index.tsx +5 -5
- package/template/src/pages/test/index.tsx +12 -0
- package/template/src/pages/xiaozhi-chat/index.less +4 -0
- package/template/src/pages/xiaozhi-chat/index.tsx +26 -0
- package/template/src/routes.ts +49 -0
- package/template/src/services/api.ts +7 -2
- package/template/src/theme/variables.css +5 -0
- package/template/src/utils/ensureSsoLogin.ts +28 -0
- package/template/src/utils/goBack.ts +10 -0
- package/template/src/utils/request.ts +106 -0
- package/template/tsconfig.json +4 -1
- package/template/typings.d.ts +15 -0
- package/template/yarn.lock +2168 -61
- package/template/src/assets/demoIcon/home.png +0 -0
- package/template/src/assets/demoIcon/home1.png +0 -0
- package/template/src/assets/demoIcon/list.png +0 -0
- package/template/src/assets/demoIcon/list1.png +0 -0
- package/template/src/assets/demoIcon/setting.png +0 -0
- package/template/src/assets/demoIcon/setting1.png +0 -0
- package/template/src/assets/yay.jpg +0 -0
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ChatSession,
|
|
3
|
+
CreateSessionParams,
|
|
4
|
+
StreamChatCallbacks,
|
|
5
|
+
StreamChatParams,
|
|
6
|
+
} from '../types';
|
|
7
|
+
import { DEFAULT_CHAT_VERSION } from '../types';
|
|
8
|
+
|
|
9
|
+
const API_PREFIX = process.env.API_PREFIX || './server/nes';
|
|
10
|
+
const BOTE_CHAT_CREATE_SESSION_PATH = '/bote/chat/createBoteSession';
|
|
11
|
+
const BOTE_CHAT_EXECUTE_PATH = '/bote/chat/execute';
|
|
12
|
+
const DEFAULT_BOT_ID = '1404742587840745472';
|
|
13
|
+
const DEFAULT_SCENE_ID = '1404655744776425472';
|
|
14
|
+
|
|
15
|
+
const getBoteChatUrl = (path: string) => `${API_PREFIX}${path}`;
|
|
16
|
+
|
|
17
|
+
const getSpaceIdFromLocation = (): string | null => {
|
|
18
|
+
if (typeof window === 'undefined') return null;
|
|
19
|
+
const fromSearch = new URLSearchParams(window.location.search).get('spaceId');
|
|
20
|
+
if (fromSearch?.trim()) return fromSearch.trim();
|
|
21
|
+
const hash = window.location.hash || '';
|
|
22
|
+
const queryIndex = hash.indexOf('?');
|
|
23
|
+
if (queryIndex < 0) return null;
|
|
24
|
+
const fromHash = new URLSearchParams(hash.slice(queryIndex + 1)).get('spaceId');
|
|
25
|
+
return fromHash?.trim() || null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// const getDevToken = (): string | null => {
|
|
29
|
+
// if (process.env.NODE_ENV !== 'development') return null;
|
|
30
|
+
// try {
|
|
31
|
+
// const fromStorage =
|
|
32
|
+
// typeof localStorage !== 'undefined' ? localStorage.getItem('DEV_TOKEN') : null;
|
|
33
|
+
// return fromStorage || process.env.DEV_TOKEN || null;
|
|
34
|
+
// } catch {
|
|
35
|
+
// return process.env.DEV_TOKEN || null;
|
|
36
|
+
// }
|
|
37
|
+
// };
|
|
38
|
+
|
|
39
|
+
const buildRequestHeaders = async (): Promise<Record<string, string>> => {
|
|
40
|
+
const headers: Record<string, string> = {
|
|
41
|
+
Accept: 'text/event-stream, application/json',
|
|
42
|
+
'Content-Type': 'application/json',
|
|
43
|
+
'Cache-Control': 'no-cache',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// 使用session: getAuthTicket -> SsoLogin登录 -> set-cookie
|
|
47
|
+
// try {
|
|
48
|
+
// const info = await TalkNoteH5Bridge.getRequestBaseInfo();
|
|
49
|
+
// if (info) {
|
|
50
|
+
// Object.assign(headers, info.headers || {});
|
|
51
|
+
// if (!headers.token && !headers.Authorization && info.token) {
|
|
52
|
+
// headers.Authorization = info.token;
|
|
53
|
+
// }
|
|
54
|
+
// }
|
|
55
|
+
// } catch {
|
|
56
|
+
// const devToken = getDevToken();
|
|
57
|
+
// if (devToken) headers.Authorization = devToken;
|
|
58
|
+
// }
|
|
59
|
+
|
|
60
|
+
// if (!headers.Authorization && !headers.token) {
|
|
61
|
+
// const devToken = getDevToken();
|
|
62
|
+
// if (devToken) headers.Authorization = devToken;
|
|
63
|
+
// }
|
|
64
|
+
|
|
65
|
+
const spaceId = getSpaceIdFromLocation();
|
|
66
|
+
if (spaceId) headers['X-LX-Space'] = spaceId;
|
|
67
|
+
|
|
68
|
+
return headers;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const isAbortError = (error: unknown) =>
|
|
72
|
+
(error instanceof DOMException && error.name === 'AbortError') ||
|
|
73
|
+
(error instanceof Error && error.name === 'AbortError');
|
|
74
|
+
|
|
75
|
+
const unwrapRecord = (value: unknown): Record<string, unknown> | null => {
|
|
76
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
77
|
+
return value as Record<string, unknown>;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const unwrapPayload = (value: unknown): unknown => {
|
|
81
|
+
const record = unwrapRecord(value);
|
|
82
|
+
if (!record) return value;
|
|
83
|
+
const inner = record.resultObject;
|
|
84
|
+
const nested = unwrapRecord(inner);
|
|
85
|
+
if (nested && 'resultObject' in nested) return nested.resultObject ?? nested;
|
|
86
|
+
if (inner != null && inner !== record) return inner;
|
|
87
|
+
if (record.data != null && record.data !== record) return record.data;
|
|
88
|
+
return record;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const pickString = (value: unknown): string => (typeof value === 'string' ? value : '');
|
|
92
|
+
|
|
93
|
+
const pickText = (value: unknown, depth = 0): string => {
|
|
94
|
+
if (depth > 6 || value == null) return '';
|
|
95
|
+
if (typeof value === 'string') return value;
|
|
96
|
+
if (Array.isArray(value)) {
|
|
97
|
+
return value.map((item) => pickText(item, depth + 1)).join('');
|
|
98
|
+
}
|
|
99
|
+
const record = unwrapRecord(value);
|
|
100
|
+
if (!record) return '';
|
|
101
|
+
|
|
102
|
+
const directKeys = ['delta', 'content', 'text', 'answer', 'output', 'chunk', 'result'];
|
|
103
|
+
for (const key of directKeys) {
|
|
104
|
+
const current = record[key];
|
|
105
|
+
if (typeof current === 'string' && current) return current;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (Array.isArray(record.choices) && record.choices[0]) {
|
|
109
|
+
const choice = unwrapRecord(record.choices[0]);
|
|
110
|
+
const fromChoice =
|
|
111
|
+
pickText(choice?.delta, depth + 1) ||
|
|
112
|
+
pickText(choice?.message, depth + 1) ||
|
|
113
|
+
pickText(choice, depth + 1);
|
|
114
|
+
if (fromChoice) return fromChoice;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const nestedKeys = ['resultObject', 'data', 'message', 'payload'];
|
|
118
|
+
for (const key of nestedKeys) {
|
|
119
|
+
const nested = pickText(record[key], depth + 1);
|
|
120
|
+
if (nested) return nested;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return '';
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const pickSession = (value: unknown): ChatSession | null => {
|
|
127
|
+
const record = unwrapRecord(unwrapPayload(value)) || unwrapRecord(value);
|
|
128
|
+
if (!record) return null;
|
|
129
|
+
const chatId = pickString(record.chatId || record.sessionId || record.conversationId);
|
|
130
|
+
const contextId = pickString(record.contextId || record.ctxId);
|
|
131
|
+
if (!chatId && !contextId) return null;
|
|
132
|
+
return { chatId, contextId };
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const isSuccessCode = (code: unknown) => {
|
|
136
|
+
if (code == null || code === '') return true;
|
|
137
|
+
const normalized = String(code);
|
|
138
|
+
return normalized === '0' || normalized === '0000' || normalized === '200';
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const pickErrorMessage = (value: unknown): string => {
|
|
142
|
+
const record = unwrapRecord(value);
|
|
143
|
+
if (!record) return '';
|
|
144
|
+
return (
|
|
145
|
+
pickString(record.resultMsg) ||
|
|
146
|
+
pickString(record.message) ||
|
|
147
|
+
pickString(record.error) ||
|
|
148
|
+
pickString(record.msg) ||
|
|
149
|
+
pickText(record.resultObject) ||
|
|
150
|
+
''
|
|
151
|
+
);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const isDonePayload = (eventName: string, payload: unknown, raw: string) => {
|
|
155
|
+
const normalizedEvent = eventName.toLowerCase();
|
|
156
|
+
if (['done', 'end', 'finish', 'complete', 'message_end', 'stop'].includes(normalizedEvent)) {
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
const trimmed = raw.trim();
|
|
160
|
+
if (trimmed === '[DONE]' || trimmed === 'DONE' || trimmed === '[done]') return true;
|
|
161
|
+
const record = unwrapRecord(payload);
|
|
162
|
+
if (!record) return false;
|
|
163
|
+
if (record.done === true || record.finished === true || record.isEnd === true) return true;
|
|
164
|
+
const marker = String(record.event || record.type || record.status || record.finish_reason || '')
|
|
165
|
+
.toLowerCase();
|
|
166
|
+
return ['done', 'end', 'finish', 'complete', 'message_end', 'stop'].includes(marker);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const isErrorPayload = (eventName: string, payload: unknown) => {
|
|
170
|
+
if (eventName.toLowerCase() === 'error') return true;
|
|
171
|
+
const record = unwrapRecord(payload);
|
|
172
|
+
if (!record) return false;
|
|
173
|
+
const marker = String(record.event || record.type || record.status || '').toLowerCase();
|
|
174
|
+
if (marker === 'error' || record.error === true) return true;
|
|
175
|
+
if ('resultCode' in record && !isSuccessCode(record.resultCode)) return true;
|
|
176
|
+
return false;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const META_EVENT_NAMES = new Set([
|
|
180
|
+
'contextid',
|
|
181
|
+
'ctxid',
|
|
182
|
+
'chatid',
|
|
183
|
+
'sessionid',
|
|
184
|
+
'conversationid',
|
|
185
|
+
'questions',
|
|
186
|
+
'references',
|
|
187
|
+
'ping',
|
|
188
|
+
'heartbeat',
|
|
189
|
+
]);
|
|
190
|
+
|
|
191
|
+
const TEXT_EVENT_NAMES = new Set([
|
|
192
|
+
'text',
|
|
193
|
+
'message',
|
|
194
|
+
'delta',
|
|
195
|
+
'answer',
|
|
196
|
+
'content',
|
|
197
|
+
'data',
|
|
198
|
+
]);
|
|
199
|
+
|
|
200
|
+
const isMetaEvent = (eventName: string) => META_EVENT_NAMES.has(eventName.toLowerCase());
|
|
201
|
+
|
|
202
|
+
const normalizeSSEText = (message: string) => {
|
|
203
|
+
const raw = message.startsWith('"') && message.endsWith('"')
|
|
204
|
+
? message.slice(1, -1)
|
|
205
|
+
: message;
|
|
206
|
+
return raw.replace(/\\n/g, '\n');
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const parseSessionFromMeta = (eventName: string, raw: string, payload: unknown): ChatSession | null => {
|
|
210
|
+
const fromPayload = pickSession(payload);
|
|
211
|
+
if (fromPayload) return fromPayload;
|
|
212
|
+
|
|
213
|
+
const fromString = typeof payload === 'string' ? payload : raw;
|
|
214
|
+
const id = fromString.replace(/^["']|["']$/g, '').trim();
|
|
215
|
+
if (!id || id.startsWith('{') || id.startsWith('[')) return null;
|
|
216
|
+
|
|
217
|
+
const eventKey = eventName.toLowerCase();
|
|
218
|
+
if (eventKey === 'contextid' || eventKey === 'ctxid') {
|
|
219
|
+
return { chatId: '', contextId: id };
|
|
220
|
+
}
|
|
221
|
+
if (eventKey === 'chatid' || eventKey === 'sessionid' || eventKey === 'conversationid') {
|
|
222
|
+
return { chatId: id, contextId: '' };
|
|
223
|
+
}
|
|
224
|
+
return null;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const sanitizeVisibleText = (text: string, session: ChatSession | null) => {
|
|
228
|
+
if (!text) return '';
|
|
229
|
+
const trimmed = text.trim();
|
|
230
|
+
if (session?.contextId && trimmed === session.contextId) return '';
|
|
231
|
+
if (session?.chatId && trimmed === session.chatId) return '';
|
|
232
|
+
return text;
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const toDelta = (incoming: string, emitted: string) => {
|
|
236
|
+
if (!incoming) return '';
|
|
237
|
+
if (emitted && incoming.startsWith(emitted)) return incoming.slice(emitted.length);
|
|
238
|
+
return incoming;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const consumePayload = (
|
|
242
|
+
eventName: string,
|
|
243
|
+
rawData: string,
|
|
244
|
+
emitted: string,
|
|
245
|
+
): {
|
|
246
|
+
delta: string;
|
|
247
|
+
done: boolean;
|
|
248
|
+
error?: string;
|
|
249
|
+
session?: ChatSession | null;
|
|
250
|
+
} => {
|
|
251
|
+
const trimmed = rawData.trim();
|
|
252
|
+
if (!trimmed) return { delta: '', done: false };
|
|
253
|
+
|
|
254
|
+
let payload: unknown = trimmed;
|
|
255
|
+
let isJson = false;
|
|
256
|
+
try {
|
|
257
|
+
payload = JSON.parse(trimmed);
|
|
258
|
+
isJson = true;
|
|
259
|
+
} catch {
|
|
260
|
+
payload = trimmed;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (isMetaEvent(eventName)) {
|
|
264
|
+
return {
|
|
265
|
+
delta: '',
|
|
266
|
+
done: false,
|
|
267
|
+
session: parseSessionFromMeta(eventName, trimmed, isJson ? payload : trimmed),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (!isJson) {
|
|
272
|
+
if (isDonePayload(eventName, trimmed, trimmed)) {
|
|
273
|
+
return { delta: '', done: true };
|
|
274
|
+
}
|
|
275
|
+
if (!TEXT_EVENT_NAMES.has(eventName.toLowerCase())) {
|
|
276
|
+
return { delta: '', done: false };
|
|
277
|
+
}
|
|
278
|
+
return { delta: toDelta(normalizeSSEText(trimmed), emitted), done: false };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (isErrorPayload(eventName, payload)) {
|
|
282
|
+
return {
|
|
283
|
+
delta: '',
|
|
284
|
+
done: true,
|
|
285
|
+
error: pickErrorMessage(payload) || '问答服务返回异常',
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const record = unwrapRecord(payload) || unwrapRecord(unwrapPayload(payload));
|
|
290
|
+
const marker = String(record?.event || record?.type || '').toLowerCase();
|
|
291
|
+
if (marker && isMetaEvent(marker)) {
|
|
292
|
+
return {
|
|
293
|
+
delta: '',
|
|
294
|
+
done: false,
|
|
295
|
+
session: parseSessionFromMeta(marker, trimmed, payload),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const session = pickSession(payload);
|
|
300
|
+
|
|
301
|
+
if (isDonePayload(eventName, payload, trimmed)) {
|
|
302
|
+
const text = sanitizeVisibleText(pickText(unwrapPayload(payload)), session);
|
|
303
|
+
return {
|
|
304
|
+
delta: toDelta(text, emitted),
|
|
305
|
+
done: true,
|
|
306
|
+
session,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const text = sanitizeVisibleText(pickText(unwrapPayload(payload)), session);
|
|
311
|
+
return {
|
|
312
|
+
delta: toDelta(text, emitted),
|
|
313
|
+
done: false,
|
|
314
|
+
session,
|
|
315
|
+
};
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const parseJsonResponse = async (response: Response): Promise<unknown> => {
|
|
319
|
+
const text = await response.text();
|
|
320
|
+
if (!text.trim()) return null;
|
|
321
|
+
try {
|
|
322
|
+
return JSON.parse(text) as unknown;
|
|
323
|
+
} catch {
|
|
324
|
+
throw new Error(text.slice(0, 200) || '接口返回格式异常');
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const assertHttpOk = async (response: Response, fallback: string) => {
|
|
329
|
+
if (response.ok) return;
|
|
330
|
+
let message = `${fallback}(${response.status})`;
|
|
331
|
+
try {
|
|
332
|
+
const body = await parseJsonResponse(response);
|
|
333
|
+
message = pickErrorMessage(body) || message;
|
|
334
|
+
} catch (error) {
|
|
335
|
+
if (error instanceof Error) {
|
|
336
|
+
const { message: errorMessage } = error;
|
|
337
|
+
if (errorMessage.trim()) message = errorMessage;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
throw new Error(message);
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const assertBusinessOk = (value: unknown, fallback: string) => {
|
|
344
|
+
const record = unwrapRecord(value);
|
|
345
|
+
if (record && 'resultCode' in record && !isSuccessCode(record.resultCode)) {
|
|
346
|
+
throw new Error(pickErrorMessage(record) || fallback);
|
|
347
|
+
}
|
|
348
|
+
const inner = unwrapRecord(record?.resultObject);
|
|
349
|
+
if (inner && 'resultCode' in inner && !isSuccessCode(inner.resultCode)) {
|
|
350
|
+
throw new Error(pickErrorMessage(inner) || fallback);
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* 欢迎页首次提问:POST /nes/bote/chat/createBoteSession,拿到 chatId / contextId。
|
|
356
|
+
*/
|
|
357
|
+
export const createSession = async (params: CreateSessionParams): Promise<ChatSession> => {
|
|
358
|
+
const {
|
|
359
|
+
query,
|
|
360
|
+
knowledgeIdList = [],
|
|
361
|
+
noteIdList = [],
|
|
362
|
+
chatType = 'knowledge',
|
|
363
|
+
chatVersion = DEFAULT_CHAT_VERSION,
|
|
364
|
+
signal,
|
|
365
|
+
} = params;
|
|
366
|
+
|
|
367
|
+
if (signal?.aborted) {
|
|
368
|
+
throw new DOMException('Aborted', 'AbortError');
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const headers = await buildRequestHeaders();
|
|
372
|
+
headers.Accept = 'application/json';
|
|
373
|
+
|
|
374
|
+
const response = await fetch(getBoteChatUrl(BOTE_CHAT_CREATE_SESSION_PATH), {
|
|
375
|
+
method: 'POST',
|
|
376
|
+
credentials: 'include',
|
|
377
|
+
headers,
|
|
378
|
+
body: JSON.stringify({
|
|
379
|
+
query,
|
|
380
|
+
stream: false,
|
|
381
|
+
knowledgeIdList,
|
|
382
|
+
noteIdList,
|
|
383
|
+
chatType,
|
|
384
|
+
chatVersion,
|
|
385
|
+
botId: DEFAULT_BOT_ID,
|
|
386
|
+
sceneId: DEFAULT_SCENE_ID,
|
|
387
|
+
}),
|
|
388
|
+
signal,
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
if (signal?.aborted) {
|
|
392
|
+
throw new DOMException('Aborted', 'AbortError');
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
await assertHttpOk(response, '创建会话失败');
|
|
396
|
+
const payload = await parseJsonResponse(response);
|
|
397
|
+
assertBusinessOk(payload, '创建会话失败');
|
|
398
|
+
|
|
399
|
+
const session = pickSession(payload);
|
|
400
|
+
if (!session?.chatId) {
|
|
401
|
+
throw new Error(pickErrorMessage(payload) || '创建会话失败:未返回会话标识');
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return session;
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
const readJsonFallback = async (response: Response): Promise<string> => {
|
|
408
|
+
const text = await response.text();
|
|
409
|
+
if (!text.trim()) return '';
|
|
410
|
+
try {
|
|
411
|
+
const json = JSON.parse(text) as unknown;
|
|
412
|
+
if (isErrorPayload('message', json)) {
|
|
413
|
+
throw new Error(pickErrorMessage(json) || '问答服务返回异常');
|
|
414
|
+
}
|
|
415
|
+
return pickText(unwrapPayload(json)) || text;
|
|
416
|
+
} catch (error) {
|
|
417
|
+
if (error instanceof SyntaxError) return text;
|
|
418
|
+
throw error;
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* 流式问答:POST /nes/bote/chat/execute,按 SSE 逐段回调。
|
|
424
|
+
*/
|
|
425
|
+
export const streamChat = async (
|
|
426
|
+
params: StreamChatParams,
|
|
427
|
+
callbacks: StreamChatCallbacks,
|
|
428
|
+
): Promise<void> => {
|
|
429
|
+
const {
|
|
430
|
+
query,
|
|
431
|
+
chatId,
|
|
432
|
+
contextId,
|
|
433
|
+
knowledgeIdList = [],
|
|
434
|
+
noteIdList = [],
|
|
435
|
+
chatType = 'knowledge',
|
|
436
|
+
chatVersion = DEFAULT_CHAT_VERSION,
|
|
437
|
+
signal,
|
|
438
|
+
} = params;
|
|
439
|
+
|
|
440
|
+
if (signal?.aborted) return;
|
|
441
|
+
|
|
442
|
+
try {
|
|
443
|
+
const headers = await buildRequestHeaders();
|
|
444
|
+
const body: Record<string, unknown> = {
|
|
445
|
+
query,
|
|
446
|
+
stream: true,
|
|
447
|
+
knowledgeIdList,
|
|
448
|
+
noteIdList,
|
|
449
|
+
chatType,
|
|
450
|
+
chatVersion,
|
|
451
|
+
botId: DEFAULT_BOT_ID,
|
|
452
|
+
sceneId: DEFAULT_SCENE_ID,
|
|
453
|
+
chatId: chatId || '',
|
|
454
|
+
contextId: contextId || '',
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
const response = await fetch(getBoteChatUrl(BOTE_CHAT_EXECUTE_PATH), {
|
|
458
|
+
method: 'POST',
|
|
459
|
+
credentials: 'include',
|
|
460
|
+
headers,
|
|
461
|
+
body: JSON.stringify(body),
|
|
462
|
+
signal,
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
if (signal?.aborted) return;
|
|
466
|
+
|
|
467
|
+
if (!response.ok) {
|
|
468
|
+
let message = `问答请求失败(${response.status})`;
|
|
469
|
+
try {
|
|
470
|
+
const errBody = await response.text();
|
|
471
|
+
if (errBody.trim()) {
|
|
472
|
+
try {
|
|
473
|
+
message = pickErrorMessage(JSON.parse(errBody)) || errBody.slice(0, 200) || message;
|
|
474
|
+
} catch {
|
|
475
|
+
message = errBody.slice(0, 200);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
} catch {
|
|
479
|
+
// ignore
|
|
480
|
+
}
|
|
481
|
+
callbacks.onError(message);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const contentType = response.headers.get('content-type') || '';
|
|
486
|
+
const isJsonBody = contentType.includes('application/json');
|
|
487
|
+
|
|
488
|
+
if (!response.body || isJsonBody) {
|
|
489
|
+
const text = await readJsonFallback(response);
|
|
490
|
+
if (signal?.aborted) return;
|
|
491
|
+
if (text) callbacks.onChunk(text);
|
|
492
|
+
callbacks.onDone();
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const reader = response.body.getReader();
|
|
497
|
+
const decoder = new TextDecoder('utf-8');
|
|
498
|
+
let buffer = '';
|
|
499
|
+
let emitted = '';
|
|
500
|
+
let settled = false;
|
|
501
|
+
let currentEvent = 'message';
|
|
502
|
+
|
|
503
|
+
const emitSession = (session: ChatSession | null | undefined) => {
|
|
504
|
+
if (!session || (!session.chatId && !session.contextId)) return;
|
|
505
|
+
callbacks.onSession?.({
|
|
506
|
+
chatId: session.chatId || chatId,
|
|
507
|
+
contextId: session.contextId || contextId,
|
|
508
|
+
});
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
const finish = (error?: string) => {
|
|
512
|
+
if (settled) return;
|
|
513
|
+
settled = true;
|
|
514
|
+
if (error) callbacks.onError(error);
|
|
515
|
+
else callbacks.onDone();
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
const handleData = (eventName: string, rawData: string) => {
|
|
519
|
+
const result = consumePayload(eventName, rawData, emitted);
|
|
520
|
+
emitSession(result.session);
|
|
521
|
+
if (result.delta) {
|
|
522
|
+
emitted += result.delta;
|
|
523
|
+
callbacks.onChunk(result.delta);
|
|
524
|
+
}
|
|
525
|
+
if (result.error) {
|
|
526
|
+
finish(result.error);
|
|
527
|
+
return true;
|
|
528
|
+
}
|
|
529
|
+
if (result.done) {
|
|
530
|
+
finish();
|
|
531
|
+
return true;
|
|
532
|
+
}
|
|
533
|
+
return false;
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
const consumeLine = (rawLine: string) => {
|
|
537
|
+
const line = rawLine.replace(/^\uFEFF/, '');
|
|
538
|
+
if (!line) {
|
|
539
|
+
currentEvent = 'message';
|
|
540
|
+
return false;
|
|
541
|
+
}
|
|
542
|
+
if (line.startsWith(':')) return false;
|
|
543
|
+
if (line.startsWith('event:')) {
|
|
544
|
+
currentEvent = line.slice(6).trim() || 'message';
|
|
545
|
+
return false;
|
|
546
|
+
}
|
|
547
|
+
if (line.startsWith('data:')) {
|
|
548
|
+
return handleData(currentEvent, line.slice(5).trimStart());
|
|
549
|
+
}
|
|
550
|
+
if (line.startsWith('{') || line.startsWith('[')) {
|
|
551
|
+
return handleData(currentEvent, line);
|
|
552
|
+
}
|
|
553
|
+
return false;
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
while (!signal?.aborted && !settled) {
|
|
557
|
+
const { done, value } = await reader.read();
|
|
558
|
+
if (done) {
|
|
559
|
+
buffer += decoder.decode();
|
|
560
|
+
break;
|
|
561
|
+
}
|
|
562
|
+
buffer += decoder.decode(value, { stream: true });
|
|
563
|
+
const lines = buffer.split(/\r?\n/);
|
|
564
|
+
buffer = lines.pop() || '';
|
|
565
|
+
for (const line of lines) {
|
|
566
|
+
if (consumeLine(line)) {
|
|
567
|
+
await reader.cancel().catch(() => undefined);
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
if (signal?.aborted) {
|
|
574
|
+
await reader.cancel().catch(() => undefined);
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (buffer.trim()) consumeLine(buffer.trim());
|
|
579
|
+
if (!settled) finish();
|
|
580
|
+
} catch (error) {
|
|
581
|
+
if (signal?.aborted || isAbortError(error)) return;
|
|
582
|
+
const message = error instanceof Error && error.message.trim() ? error.message : '流式输出异常';
|
|
583
|
+
callbacks.onError(message);
|
|
584
|
+
}
|
|
585
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
export type ChatRole = 'user' | 'assistant';
|
|
4
|
+
|
|
5
|
+
export type ChatMessage = {
|
|
6
|
+
id: string;
|
|
7
|
+
role: ChatRole;
|
|
8
|
+
content: string;
|
|
9
|
+
timestamp: number;
|
|
10
|
+
|
|
11
|
+
/** 用户手动停止流式输出 */
|
|
12
|
+
isStopped?: boolean;
|
|
13
|
+
|
|
14
|
+
/** 占位/真实流式异常 */
|
|
15
|
+
isReplyError?: boolean;
|
|
16
|
+
|
|
17
|
+
/** 回复版本:1 快速 / 2 专家 */
|
|
18
|
+
chatVersion?: '1' | '2';
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type ChatVersion = '1' | '2';
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_CHAT_VERSION: ChatVersion = '2';
|
|
24
|
+
|
|
25
|
+
export type ChatSession = {
|
|
26
|
+
chatId: string;
|
|
27
|
+
contextId: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type ChatType = 'knowledge' | 'note';
|
|
31
|
+
|
|
32
|
+
export type CreateSessionParams = {
|
|
33
|
+
query: string;
|
|
34
|
+
knowledgeIdList?: string[];
|
|
35
|
+
noteIdList?: string[];
|
|
36
|
+
chatType?: ChatType;
|
|
37
|
+
chatVersion?: ChatVersion;
|
|
38
|
+
signal?: AbortSignal;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type StreamChatCallbacks = {
|
|
42
|
+
onChunk: (text: string) => void;
|
|
43
|
+
onDone: () => void;
|
|
44
|
+
onError: (message: string) => void;
|
|
45
|
+
|
|
46
|
+
/** execute 流中回写的会话标识 */
|
|
47
|
+
onSession?: (session: ChatSession) => void;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type StreamChatParams = {
|
|
51
|
+
query: string;
|
|
52
|
+
chatId: string;
|
|
53
|
+
contextId: string;
|
|
54
|
+
knowledgeIdList?: string[];
|
|
55
|
+
noteIdList?: string[];
|
|
56
|
+
chatType?: ChatType;
|
|
57
|
+
chatVersion?: ChatVersion;
|
|
58
|
+
signal?: AbortSignal;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export type XiaozhiChatPageProps = {
|
|
62
|
+
|
|
63
|
+
/** 输入框上方快捷菜单插槽 */
|
|
64
|
+
shortcutSlot?: ReactNode;
|
|
65
|
+
|
|
66
|
+
/** 欢迎页主标题 */
|
|
67
|
+
welcomeTitle?: string;
|
|
68
|
+
|
|
69
|
+
/** 欢迎页副文案 */
|
|
70
|
+
welcomeSubtitle?: string;
|
|
71
|
+
|
|
72
|
+
/** 输入框占位符 */
|
|
73
|
+
placeholder?: string;
|
|
74
|
+
|
|
75
|
+
/** 顶部标题 */
|
|
76
|
+
title?: string;
|
|
77
|
+
|
|
78
|
+
/** 自定义返回 */
|
|
79
|
+
onBack?: () => void;
|
|
80
|
+
};
|