@talknote/create-talknote-h5 0.1.4 → 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.
Files changed (102) hide show
  1. package/bin/create-talknote-h5.js +14 -0
  2. package/package.json +1 -1
  3. package/template/.agents/skills/add-h5-page/SKILL.md +116 -0
  4. package/template/.eslintrc.js +2 -0
  5. package/template/README.md +143 -117
  6. package/template/_gitignore +0 -3
  7. package/template/config/config.ts +49 -4
  8. package/template/config/proxy.ts +28 -31
  9. package/template/docker/nginx/error_page.conf +51 -0
  10. package/template/docker/nginx/nginx.conf +54 -0
  11. package/template/docker/nginx/webApp.conf +13 -0
  12. package/template/docker/webApp.Dockerfile +12 -0
  13. package/template/package.json +6 -2
  14. package/template/packages/xiaozhi-chat/README.md +53 -0
  15. package/template/packages/xiaozhi-chat/XiaozhiChatPage.less +50 -0
  16. package/template/packages/xiaozhi-chat/XiaozhiChatPage.tsx +172 -0
  17. package/template/packages/xiaozhi-chat/assets/images/bg.jpg +0 -0
  18. package/template/packages/xiaozhi-chat/assets/images/icon-arrow-back.png +0 -0
  19. package/template/packages/xiaozhi-chat/assets/images/icon-close.png +0 -0
  20. package/template/packages/xiaozhi-chat/assets/images/icon-voice.png +0 -0
  21. package/template/packages/xiaozhi-chat/assets/images/keyboard.png +0 -0
  22. package/template/packages/xiaozhi-chat/assets/images/send-icon.png +0 -0
  23. package/template/packages/xiaozhi-chat/assets/images/stop-icon.png +0 -0
  24. package/template/packages/xiaozhi-chat/components/AssistantRichContent/index.less +3 -0
  25. package/template/packages/xiaozhi-chat/components/AssistantRichContent/index.tsx +44 -0
  26. package/template/packages/xiaozhi-chat/components/ChatHeader/index.less +84 -0
  27. package/template/packages/xiaozhi-chat/components/ChatHeader/index.tsx +44 -0
  28. package/template/packages/xiaozhi-chat/components/ChatInput/HoldToTalk.less +59 -0
  29. package/template/packages/xiaozhi-chat/components/ChatInput/HoldToTalk.tsx +167 -0
  30. package/template/packages/xiaozhi-chat/components/ChatInput/index.less +189 -0
  31. package/template/packages/xiaozhi-chat/components/ChatInput/index.tsx +203 -0
  32. package/template/packages/xiaozhi-chat/components/DeepThinkingWrapper/index.less +80 -0
  33. package/template/packages/xiaozhi-chat/components/DeepThinkingWrapper/index.tsx +54 -0
  34. package/template/packages/xiaozhi-chat/components/MarkdownContent/index.less +209 -0
  35. package/template/packages/xiaozhi-chat/components/MarkdownContent/index.tsx +41 -0
  36. package/template/packages/xiaozhi-chat/components/MessageList/index.less +74 -0
  37. package/template/packages/xiaozhi-chat/components/MessageList/index.tsx +195 -0
  38. package/template/packages/xiaozhi-chat/components/ThinkingCompleteEntry/index.less +56 -0
  39. package/template/packages/xiaozhi-chat/components/ThinkingCompleteEntry/index.tsx +65 -0
  40. package/template/packages/xiaozhi-chat/components/ToolCallList/index.less +41 -0
  41. package/template/packages/xiaozhi-chat/components/ToolCallList/index.tsx +51 -0
  42. package/template/packages/xiaozhi-chat/components/TypingIndicator/index.less +35 -0
  43. package/template/packages/xiaozhi-chat/components/TypingIndicator/index.tsx +12 -0
  44. package/template/packages/xiaozhi-chat/components/Welcome/index.less +105 -0
  45. package/template/packages/xiaozhi-chat/components/Welcome/index.tsx +84 -0
  46. package/template/packages/xiaozhi-chat/hooks/useChatSession.ts +235 -0
  47. package/template/packages/xiaozhi-chat/hooks/useRealtimeTranscription.ts +226 -0
  48. package/template/packages/xiaozhi-chat/hooks/useTypewriter.ts +215 -0
  49. package/template/packages/xiaozhi-chat/hooks/useViewportHeight.ts +43 -0
  50. package/template/packages/xiaozhi-chat/hooks/useVoiceRecording.ts +128 -0
  51. package/template/packages/xiaozhi-chat/index.ts +11 -0
  52. package/template/packages/xiaozhi-chat/package.json +34 -0
  53. package/template/packages/xiaozhi-chat/services/chatApi.ts +585 -0
  54. package/template/packages/xiaozhi-chat/types.ts +80 -0
  55. package/template/packages/xiaozhi-chat/utils/bridge.ts +131 -0
  56. package/template/packages/xiaozhi-chat/utils/deepThinking.ts +200 -0
  57. package/template/packages/xiaozhi-chat/utils/parseAssistantContent.ts +233 -0
  58. package/template/packages/xiaozhi-chat/utils/typewriter.ts +265 -0
  59. package/template/scripts/dev.js +6 -7
  60. package/template/src/app.tsx +1 -73
  61. package/template/src/assets/error-network.png +0 -0
  62. package/template/src/assets/error.png +0 -0
  63. package/template/src/assets/success.png +0 -0
  64. package/template/src/assets/welcome.png +0 -0
  65. package/template/src/components/Header/index.less +70 -0
  66. package/template/src/components/Header/index.tsx +46 -0
  67. package/template/src/components/TabBar/index.less +13 -0
  68. package/template/src/components/TabBar/index.tsx +58 -0
  69. package/template/src/global.css +27 -0
  70. package/template/src/layouts/BlankLayout/index.less +5 -0
  71. package/template/src/layouts/BlankLayout/index.tsx +14 -0
  72. package/template/src/layouts/HeaderLayout/index.less +25 -0
  73. package/template/src/layouts/HeaderLayout/index.tsx +42 -0
  74. package/template/src/layouts/LayoutOutlet.tsx +7 -0
  75. package/template/src/layouts/SSOLoginLayout/index.less +28 -0
  76. package/template/src/layouts/SSOLoginLayout/index.tsx +92 -0
  77. package/template/src/layouts/TabBarLayout/index.less +21 -0
  78. package/template/src/layouts/TabBarLayout/index.tsx +29 -0
  79. package/template/src/layouts/index.tsx +30 -0
  80. package/template/src/layouts/usePageHeaderProps.ts +9 -0
  81. package/template/src/pages/index/index.css +5 -5
  82. package/template/src/pages/index/index.tsx +15 -2
  83. package/template/src/pages/list/index.tsx +5 -5
  84. package/template/src/pages/test/index.tsx +12 -0
  85. package/template/src/pages/xiaozhi-chat/index.less +4 -0
  86. package/template/src/pages/xiaozhi-chat/index.tsx +26 -0
  87. package/template/src/routes.ts +49 -0
  88. package/template/src/services/api.ts +7 -2
  89. package/template/src/theme/variables.css +5 -0
  90. package/template/src/utils/ensureSsoLogin.ts +28 -0
  91. package/template/src/utils/goBack.ts +10 -0
  92. package/template/src/utils/request.ts +106 -0
  93. package/template/tsconfig.json +4 -1
  94. package/template/typings.d.ts +15 -0
  95. package/template/yarn.lock +2168 -61
  96. package/template/src/assets/demoIcon/home.png +0 -0
  97. package/template/src/assets/demoIcon/home1.png +0 -0
  98. package/template/src/assets/demoIcon/list.png +0 -0
  99. package/template/src/assets/demoIcon/list1.png +0 -0
  100. package/template/src/assets/demoIcon/setting.png +0 -0
  101. package/template/src/assets/demoIcon/setting1.png +0 -0
  102. package/template/src/assets/yay.jpg +0 -0
@@ -0,0 +1,131 @@
1
+ /** 直接使用 App 注入的 window.vibeNoteBridge */
2
+
3
+ export type KeyboardAvoidMode = 'adjust' | 'resize' | 'none';
4
+
5
+ export type TranscriptionResultData = {
6
+ text?: string;
7
+ isFinal?: boolean;
8
+ translations?: Record<string, string>;
9
+ startMs?: number;
10
+ endMs?: number;
11
+ };
12
+
13
+ export type TranscriptionStatusData = {
14
+ status?: 'idle' | 'connecting' | 'streaming' | 'closed' | 'error';
15
+ };
16
+
17
+ export type TranscriptionErrorData = {
18
+ code?: string;
19
+ message?: string;
20
+ };
21
+
22
+ export type NativeBridgeEvent<T = unknown> = {
23
+ type?: string;
24
+ data?: T;
25
+ };
26
+
27
+ export type StartRealtimeTranscriptionOptions = {
28
+ wsUrl: string;
29
+ targetLanguages?: string[];
30
+ translationMode?: 'fast' | 'normal';
31
+ headers?: Record<string, string>;
32
+ onlineTransBatchId?: string;
33
+ };
34
+
35
+ export type RequestBaseInfo = {
36
+ baseUrl?: string;
37
+ token?: string;
38
+ headers?: Record<string, string>;
39
+ env?: string;
40
+ };
41
+
42
+ export type VibeNoteBridgeLike = {
43
+ canIUse?: (name: string) => boolean;
44
+ goBack?: () => Promise<unknown> | unknown;
45
+ setKeyboardAvoidMode?: (data: {
46
+ mode: KeyboardAvoidMode;
47
+ cursorSpacing?: number;
48
+ }) => Promise<{ mode?: KeyboardAvoidMode; cursorSpacing?: number }>;
49
+ hideKeyboard?: () => Promise<unknown>;
50
+ getRequestBaseInfo?: () => Promise<RequestBaseInfo>;
51
+ canVoiceInput?: () => Promise<{ canVoiceInput?: boolean }>;
52
+ startVoiceRecording?: () => Promise<unknown>;
53
+ cancelVoiceRecording?: () => Promise<unknown>;
54
+ stopVoiceRecording?: () => Promise<{ text?: string; uri?: string }>;
55
+ startRealtimeTranscription?: (
56
+ options: StartRealtimeTranscriptionOptions,
57
+ ) => Promise<{ success?: boolean }>;
58
+ stopRealtimeTranscription?: () => Promise<{ success?: boolean }>;
59
+ addListener?: (
60
+ type: string,
61
+ handler: (event: NativeBridgeEvent) => void,
62
+ ) => () => void;
63
+ getAuthTicket?: () => Promise<{ ticket?: string }>;
64
+ };
65
+
66
+ declare global {
67
+ interface Window {
68
+ vibeNoteBridge?: VibeNoteBridgeLike;
69
+ }
70
+ }
71
+
72
+ export const getVibeNoteBridge = (): VibeNoteBridgeLike | undefined =>
73
+ typeof window !== 'undefined' ? window.vibeNoteBridge : undefined;
74
+
75
+ export const isVibeNoteBridgeAvailable = (): boolean => Boolean(getVibeNoteBridge());
76
+
77
+ export const vibeNoteCanIUse = (name: string): boolean => {
78
+ const bridge = getVibeNoteBridge();
79
+ if (!bridge?.canIUse) return false;
80
+ try {
81
+ return Boolean(bridge.canIUse(name));
82
+ } catch {
83
+ return false;
84
+ }
85
+ };
86
+
87
+ export const setKeyboardAvoidMode = async (mode: KeyboardAvoidMode, cursorSpacing?: number) => {
88
+ if (!vibeNoteCanIUse('setKeyboardAvoidMode')) return false;
89
+ const bridge = getVibeNoteBridge();
90
+ if (!bridge?.setKeyboardAvoidMode) return false;
91
+ const data = cursorSpacing === undefined ? { mode } : { mode, cursorSpacing };
92
+ await bridge.setKeyboardAvoidMode(data);
93
+ return true;
94
+ };
95
+
96
+ export const hideVibeNoteKeyboard = () => {
97
+ const el = typeof document !== 'undefined' ? document.activeElement : null;
98
+ if (el instanceof HTMLElement) {
99
+ el.blur();
100
+ }
101
+ if (!vibeNoteCanIUse('hideKeyboard')) return;
102
+ void getVibeNoteBridge()?.hideKeyboard?.();
103
+ };
104
+
105
+ const ASR_WS_BY_ENV: Record<string, string> = {
106
+ PRO: 'wss://www.zzjilu.com/ws/realtime/transcribe',
107
+ TEST: 'wss://lingxi.iwhalecloud.com/zzjl/ws/realtime/transcribe',
108
+ DEV: 'wss://lingxi.iwhalecloud.com/LCDP-RECORD/ws/realtime/transcribe',
109
+ STAGING: 'wss://www.zzjilu.com:9001/ws/realtime/transcribe',
110
+ };
111
+
112
+ /** 由请求基址或环境推导实时转写 WebSocket 地址 */
113
+ export const resolveAsrWsUrl = (baseUrl?: string, env?: string): string => {
114
+ const envKey = String(env || '')
115
+ .trim()
116
+ .toUpperCase();
117
+ if (ASR_WS_BY_ENV[envKey]) return ASR_WS_BY_ENV[envKey];
118
+
119
+ if (baseUrl) {
120
+ try {
121
+ const url = new URL(baseUrl);
122
+ const protocol = url.protocol === 'http:' ? 'ws:' : 'wss:';
123
+ const prefix = url.pathname.replace(/\/lcdp-app(?:\/.*)?$/i, '').replace(/\/$/, '');
124
+ return `${protocol}//${url.host}${prefix}/ws/realtime/transcribe`;
125
+ } catch {
126
+ /* ignore */
127
+ }
128
+ }
129
+
130
+ return ASR_WS_BY_ENV.PRO;
131
+ };
@@ -0,0 +1,200 @@
1
+ import type { ChatVersion } from '../types';
2
+
3
+ /** Chat V1:打字机输出时保持完整,避免半截标签被当成思考阶段判断 */
4
+ export const KNOWLEDGE_CHAT_TYPEWRITER_ATOMIC_TOKENS = [
5
+ '<|THINKING_START|>',
6
+ '<|THINKING_END|>',
7
+ '<think>',
8
+ '</think>',
9
+ ] as const;
10
+
11
+ export const KNOWLEDGE_CHAT_TYPEWRITER_ATOMIC_CLOSED_TAGS = [
12
+ 'toolCall',
13
+ 'toolCallResult',
14
+ ] as const;
15
+
16
+ const THINKING_BLOCK_REGEX = /<think>[\s\S]*?<\/think>/g;
17
+ const THINKING_BLOCK_CAPTURE_REGEX = /<think>([\s\S]*?)<\/think>/g;
18
+ const THINKING_STAGE_BLOCK_REGEX = /<\|THINKING_START\|>[\s\S]*?<\|THINKING_END\|>/g;
19
+ const THINKING_START = '<|THINKING_START|>';
20
+ const THINKING_END = '<|THINKING_END|>';
21
+
22
+ export type MessageContentView = {
23
+ isDeepThinking: boolean;
24
+ isFinishDeepThink: boolean;
25
+ thinkingContent: string;
26
+ answerContent: string;
27
+ };
28
+
29
+ /** 根据打字机 content 判断是否处于深度思考流式阶段(标记需完整,依赖 atomicTokens) */
30
+ export const isDeepThinkingStreamContent = (content: string): boolean => {
31
+ const thinkingStarted = content.includes('<think>') || content.includes(THINKING_START);
32
+ const thinkingEnded = content.includes(THINKING_END);
33
+ return thinkingStarted && !thinkingEnded;
34
+ };
35
+
36
+ /** 从原始流式文本中去除思考块与阶段标记,得到可展示的回答正文 */
37
+ export const stripAnswerContentFromRaw = (content: string): string =>
38
+ content
39
+ .replace(THINKING_BLOCK_REGEX, '')
40
+ .replace(THINKING_STAGE_BLOCK_REGEX, '')
41
+ .replace(/<\|THINKING_START\|>/g, '')
42
+ .replace(/<\|THINKING_END\|>/g, '')
43
+ .replace(/<think>/g, '')
44
+ .replace(/<\/think>/g, '')
45
+ .trim();
46
+
47
+ /** 流式思考阶段:仅剥除标签,保留思考正文(思考块可能尚未闭合) */
48
+ export const stripThinkingMarkersFromStream = (content: string): string =>
49
+ content
50
+ .replace(/<think>/g, '')
51
+ .replace(/<\/think>/g, '')
52
+ .replace(/<\/redacted_thinking>/g, '')
53
+ .replace(/<\|THINKING_START\|>/g, '')
54
+ .replace(/<\|THINKING_END\|>/g, '');
55
+
56
+ /** 从已闭合的思考块中提取思考正文 */
57
+ export const extractThinkingBlocksFromRaw = (content: string): string => {
58
+ const contents: string[] = [];
59
+ const matches = content.matchAll(THINKING_BLOCK_CAPTURE_REGEX);
60
+ for (const match of matches) {
61
+ if (match[1]?.trim()) {
62
+ contents.push(match[1].trim());
63
+ }
64
+ }
65
+ if (contents.length > 0) {
66
+ return contents.join('\n');
67
+ }
68
+
69
+ const start = content.indexOf(THINKING_START);
70
+ const end = content.indexOf(THINKING_END);
71
+ if (start >= 0 && end > start) {
72
+ return content.slice(start + THINKING_START.length, end).trim();
73
+ }
74
+ return '';
75
+ };
76
+
77
+ /** Chat V1:使用 <|THINKING_*|> / <think> 标记拆分思考过程与回答 */
78
+ export const resolveMessageContentV1 = (
79
+ content: string,
80
+ isStreamingMessage: boolean,
81
+ isReplyError: boolean,
82
+ ): MessageContentView => {
83
+ const streamText = content || '';
84
+ const isDeepThinking = isStreamingMessage && isDeepThinkingStreamContent(streamText);
85
+
86
+ let thinkingContent = '';
87
+ if (streamText) {
88
+ thinkingContent =
89
+ isStreamingMessage && isDeepThinking
90
+ ? stripThinkingMarkersFromStream(streamText).trim()
91
+ : extractThinkingBlocksFromRaw(streamText);
92
+ }
93
+
94
+ const isFinishDeepThink = !isDeepThinking && thinkingContent.length > 0;
95
+
96
+ let answerContent = '';
97
+ if (isReplyError) {
98
+ answerContent = content;
99
+ } else {
100
+ answerContent = streamText && !isDeepThinking ? stripAnswerContentFromRaw(streamText) : '';
101
+ }
102
+
103
+ return {
104
+ isDeepThinking,
105
+ isFinishDeepThink,
106
+ thinkingContent,
107
+ answerContent,
108
+ };
109
+ };
110
+
111
+ /** Markdown 分割线:独立成行的 --- / *** / ___ */
112
+ const MARKDOWN_HR_REGEX = /^ {0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/m;
113
+
114
+ /** Markdown 标题:行首 # ~ ###### */
115
+ const MARKDOWN_HEADING_REGEX = /^#{1,6}\s+.+/m;
116
+
117
+ /**
118
+ * 将紧贴正文的分割线规范为独立成行形式:
119
+ * `---\n\n` / `***\n\n` / `___\n\n` → `\n\n---\n\n`
120
+ */
121
+ const normalizeMarkdownHr = (content: string): string =>
122
+ content.replace(/(?:-{3,}|\*{3,}|_{3,})\r?\n\r?\n/g, '\n\n---\n\n');
123
+
124
+ /**
125
+ * Chat V2:按第一条分割线或第一个标题拆分思考与回答。
126
+ * 尚无分隔标记时,整段视为思考内容。
127
+ */
128
+ export const stripContentFromRawV2 = (content: string): [string, string] => {
129
+ if (!content) {
130
+ return ['', ''];
131
+ }
132
+
133
+ const normalized = normalizeMarkdownHr(content);
134
+
135
+ const hrMatch = normalized.match(MARKDOWN_HR_REGEX);
136
+ if (hrMatch?.index !== undefined) {
137
+ const thinkContent = normalized.slice(0, hrMatch.index).replace(/\r?\n+$/, '');
138
+ const answerContent = normalized.slice(hrMatch.index + hrMatch[0].length).replace(/^\r?\n+/, '');
139
+ return [thinkContent, answerContent];
140
+ }
141
+
142
+ const headingMatch = normalized.match(MARKDOWN_HEADING_REGEX);
143
+ if (headingMatch?.index !== undefined) {
144
+ const thinkContent = normalized.slice(0, headingMatch.index).replace(/\r?\n+$/, '');
145
+ const answerContent = normalized.slice(headingMatch.index);
146
+ return [thinkContent, answerContent];
147
+ }
148
+
149
+ return [content, ''];
150
+ };
151
+
152
+ /** Chat V2:自主规划,按分割线/标题拆分思考过程 */
153
+ export const resolveMessageContentV2 = (
154
+ content: string,
155
+ isStreamingMessage: boolean,
156
+ isReplyError: boolean,
157
+ ): MessageContentView => {
158
+ const streamText = content || '';
159
+
160
+ let thinkingContent = '';
161
+ let answerContent = '';
162
+ if (isReplyError) {
163
+ answerContent = content;
164
+ } else {
165
+ [thinkingContent, answerContent] = stripContentFromRawV2(streamText);
166
+ }
167
+
168
+ if (!isStreamingMessage && !answerContent) {
169
+ answerContent = streamText;
170
+ thinkingContent = '';
171
+ }
172
+
173
+ return {
174
+ isDeepThinking: !!thinkingContent && !answerContent && isStreamingMessage,
175
+ isFinishDeepThink: !!answerContent || !isStreamingMessage,
176
+ thinkingContent,
177
+ answerContent,
178
+ };
179
+ };
180
+
181
+ export const normalizeChatVersion = (chatVersion?: ChatVersion | number): ChatVersion => {
182
+ if (chatVersion === 2 || chatVersion === '2') {
183
+ return '2';
184
+ }
185
+ return '1';
186
+ };
187
+
188
+ /** 按 chatVersion 解析消息 content */
189
+ export const resolveMessageContent = (
190
+ content: string,
191
+ isStreamingMessage: boolean,
192
+ isReplyError: boolean,
193
+ chatVersion?: ChatVersion | number,
194
+ ): MessageContentView => {
195
+ const version = normalizeChatVersion(chatVersion);
196
+ if (version === '2') {
197
+ return resolveMessageContentV2(content, isStreamingMessage, isReplyError);
198
+ }
199
+ return resolveMessageContentV1(content, isStreamingMessage, isReplyError);
200
+ };
@@ -0,0 +1,233 @@
1
+ export type ToolCallGroup = {
2
+ toolNameAlias: string;
3
+ total: number;
4
+ executed: number;
5
+ hasError: boolean;
6
+ };
7
+
8
+ export type AssistantContentSegment =
9
+ | { type: 'markdown'; content: string }
10
+ | { type: 'toolCalls'; groups: ToolCallGroup[] };
11
+
12
+ type ToolCallPayload = {
13
+ toolNameAlias?: string;
14
+ toolCallId?: string;
15
+ toolName?: string;
16
+ };
17
+
18
+ type ToolCallResultPayload = {
19
+ toolNameAlias?: string;
20
+ toolCallId?: string;
21
+ toolName?: string;
22
+ success?: boolean;
23
+ };
24
+
25
+ type ParsedTag =
26
+ | {
27
+ kind: 'toolCall';
28
+ start: number;
29
+ end: number;
30
+ toolNameAlias: string;
31
+ toolCallId: string;
32
+ }
33
+ | {
34
+ kind: 'toolCallResult';
35
+ start: number;
36
+ end: number;
37
+ toolNameAlias: string;
38
+ toolCallId: string;
39
+ success: boolean;
40
+ };
41
+
42
+ type ToolRegion = {
43
+ start: number;
44
+ end: number;
45
+ tags: ParsedTag[];
46
+ };
47
+
48
+ const TOOL_TAG_REGEX = /<(toolCall|toolCallResult)>([\s\S]*?)<\/\1>/g;
49
+
50
+ const isWhitespaceOnly = (value: string): boolean => /^\s*$/.test(value);
51
+
52
+ const safeParseJson = <T>(raw: string): T | null => {
53
+ try {
54
+ return JSON.parse(raw) as T;
55
+ } catch {
56
+ return null;
57
+ }
58
+ };
59
+
60
+ const parseAllTags = (content: string): ParsedTag[] => {
61
+ const tags: ParsedTag[] = [];
62
+ TOOL_TAG_REGEX.lastIndex = 0;
63
+
64
+ let match = TOOL_TAG_REGEX.exec(content);
65
+ while (match) {
66
+ const kind = match[1] as 'toolCall' | 'toolCallResult';
67
+ const rawJson = match[2] || '';
68
+ const start = match.index;
69
+ const end = match.index + match[0].length;
70
+
71
+ if (kind === 'toolCall') {
72
+ const data = safeParseJson<ToolCallPayload>(rawJson);
73
+ const toolNameAlias = data?.toolNameAlias?.trim();
74
+ if (toolNameAlias) {
75
+ tags.push({
76
+ kind: 'toolCall',
77
+ start,
78
+ end,
79
+ toolNameAlias,
80
+ toolCallId: data?.toolCallId || `${toolNameAlias}-${start}`,
81
+ });
82
+ }
83
+ } else {
84
+ const data = safeParseJson<ToolCallResultPayload>(rawJson);
85
+ const toolNameAlias = data?.toolNameAlias?.trim();
86
+ if (toolNameAlias) {
87
+ tags.push({
88
+ kind: 'toolCallResult',
89
+ start,
90
+ end,
91
+ toolNameAlias,
92
+ toolCallId: data?.toolCallId || `${toolNameAlias}-${start}`,
93
+ success: data?.success !== false,
94
+ });
95
+ }
96
+ }
97
+
98
+ match = TOOL_TAG_REGEX.exec(content);
99
+ }
100
+
101
+ return tags;
102
+ };
103
+
104
+ const buildRegions = (content: string, tags: ParsedTag[]): ToolRegion[] => {
105
+ if (!tags.length) {
106
+ return [];
107
+ }
108
+
109
+ const regions: ToolRegion[] = [];
110
+ let current: ToolRegion | null = null;
111
+
112
+ tags.forEach((tag) => {
113
+ if (current && isWhitespaceOnly(content.slice(current.end, tag.start))) {
114
+ current.tags.push(tag);
115
+ current.end = tag.end;
116
+ return;
117
+ }
118
+ current = { start: tag.start, end: tag.end, tags: [tag] };
119
+ regions.push(current);
120
+ });
121
+
122
+ return regions;
123
+ };
124
+
125
+ const buildResultsByCallId = (tags: ParsedTag[]): Map<string, { success: boolean }> => {
126
+ const results = new Map<string, { success: boolean }>();
127
+ tags.forEach((tag) => {
128
+ if (tag.kind !== 'toolCallResult') {
129
+ return;
130
+ }
131
+ const prev = results.get(tag.toolCallId);
132
+ results.set(tag.toolCallId, {
133
+ success: prev ? prev.success && tag.success : tag.success,
134
+ });
135
+ });
136
+ return results;
137
+ };
138
+
139
+ const buildRegionGroups = (
140
+ region: ToolRegion,
141
+ resultsByCallId: Map<string, { success: boolean }>,
142
+ ): ToolCallGroup[] => {
143
+ const aliasOrder: string[] = [];
144
+ const callIdsByAlias = new Map<string, string[]>();
145
+
146
+ region.tags.forEach((tag) => {
147
+ if (tag.kind !== 'toolCall') {
148
+ return;
149
+ }
150
+ let ids = callIdsByAlias.get(tag.toolNameAlias);
151
+ if (!ids) {
152
+ ids = [];
153
+ callIdsByAlias.set(tag.toolNameAlias, ids);
154
+ aliasOrder.push(tag.toolNameAlias);
155
+ }
156
+ if (!ids.includes(tag.toolCallId)) {
157
+ ids.push(tag.toolCallId);
158
+ }
159
+ });
160
+
161
+ return aliasOrder.map((alias) => {
162
+ const callIds = callIdsByAlias.get(alias) || [];
163
+ let executed = 0;
164
+ let hasError = false;
165
+
166
+ callIds.forEach((callId) => {
167
+ const result = resultsByCallId.get(callId);
168
+ if (!result) {
169
+ return;
170
+ }
171
+ executed += 1;
172
+ if (!result.success) {
173
+ hasError = true;
174
+ }
175
+ });
176
+
177
+ return {
178
+ toolNameAlias: alias,
179
+ total: callIds.length,
180
+ executed,
181
+ hasError,
182
+ };
183
+ });
184
+ };
185
+
186
+ const stripIncompleteToolTags = (content: string): string =>
187
+ content.replace(/<(toolCall|toolCallResult)>(?:(?!<\/\1>)[\s\S])*$/, '');
188
+
189
+ /**
190
+ * 将消息内容拆成 markdown 段与工具调用段。
191
+ * 相同 toolNameAlias 仅在相邻(仅空白分隔)时合并。
192
+ */
193
+ export const parseAssistantContentSegments = (content: string): AssistantContentSegment[] => {
194
+ if (!content) {
195
+ return [];
196
+ }
197
+
198
+ const normalized = stripIncompleteToolTags(content);
199
+ const tags = parseAllTags(normalized);
200
+ if (!tags.length) {
201
+ return [{ type: 'markdown', content: normalized }];
202
+ }
203
+
204
+ const resultsByCallId = buildResultsByCallId(tags);
205
+ const regions = buildRegions(normalized, tags);
206
+ const segments: AssistantContentSegment[] = [];
207
+ let cursor = 0;
208
+
209
+ regions.forEach((region) => {
210
+ if (region.start > cursor) {
211
+ const markdown = normalized.slice(cursor, region.start);
212
+ if (markdown) {
213
+ segments.push({ type: 'markdown', content: markdown });
214
+ }
215
+ }
216
+
217
+ const groups = buildRegionGroups(region, resultsByCallId);
218
+ if (groups.length) {
219
+ segments.push({ type: 'toolCalls', groups });
220
+ }
221
+
222
+ cursor = region.end;
223
+ });
224
+
225
+ if (cursor < normalized.length) {
226
+ const markdown = normalized.slice(cursor);
227
+ if (markdown) {
228
+ segments.push({ type: 'markdown', content: markdown });
229
+ }
230
+ }
231
+
232
+ return segments.length ? segments : [{ type: 'markdown', content: normalized }];
233
+ };