@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,226 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { Toast } from 'antd-mobile';
3
+ import TalkNoteH5Bridge from '@talknote/h5-bridge';
4
+ import {
5
+ getVibeNoteBridge,
6
+ isVibeNoteBridgeAvailable,
7
+ resolveAsrWsUrl,
8
+ vibeNoteCanIUse,
9
+ type TranscriptionErrorData,
10
+ type TranscriptionResultData,
11
+ } from '../utils/bridge';
12
+
13
+ const MAX_INPUT_LENGTH = 500;
14
+
15
+ const getErrorHint = (error: unknown): string => {
16
+ const code =
17
+ error && typeof error === 'object' && 'code' in error
18
+ ? String((error as { code?: string }).code || '')
19
+ : '';
20
+
21
+ if (code === 'permission_denied') return '需要麦克风权限才能使用语音输入';
22
+ if (code === 'cannot_voice_input') return '当前不可录音,请稍后再试';
23
+ if (code === 'start_failed' || code === 'invalid_url') return '启动转写失败,请稍后再试';
24
+ return '语音输入失败,请改用键盘输入';
25
+ };
26
+
27
+ const joinTranscript = (base: string, extra: string) => {
28
+ const next = extra.trim();
29
+ if (!next) return base;
30
+ if (!base) return next;
31
+ const needSpace = /[A-Za-z0-9]$/.test(base) && /^[A-Za-z0-9]/.test(next);
32
+ return needSpace ? `${base} ${next}` : `${base}${next}`;
33
+ };
34
+
35
+ const clipText = (text: string) =>
36
+ text.length > MAX_INPUT_LENGTH ? text.slice(0, MAX_INPUT_LENGTH) : text;
37
+
38
+ type UseRealtimeTranscriptionOptions = {
39
+ value: string;
40
+ onChange: (value: string) => void;
41
+ };
42
+
43
+ type UseRealtimeTranscriptionResult = {
44
+ transcribing: boolean;
45
+ start: () => Promise<boolean>;
46
+ stop: (options?: { silent?: boolean }) => Promise<string>;
47
+ toggle: () => Promise<void>;
48
+ };
49
+
50
+ export const useRealtimeTranscription = ({
51
+ value,
52
+ onChange,
53
+ }: UseRealtimeTranscriptionOptions): UseRealtimeTranscriptionResult => {
54
+ const [transcribing, setTranscribing] = useState(false);
55
+ const valueRef = useRef(value);
56
+ const committedRef = useRef('');
57
+ const transcribingRef = useRef(false);
58
+ const recordingRef = useRef(false);
59
+ const channelRef = useRef(false);
60
+ const busyRef = useRef(false);
61
+ const unsubscribersRef = useRef<Array<() => void>>([]);
62
+ const onChangeRef = useRef(onChange);
63
+
64
+ valueRef.current = value;
65
+ onChangeRef.current = onChange;
66
+
67
+ const teardownListeners = useCallback(() => {
68
+ unsubscribersRef.current.forEach((off) => off());
69
+ unsubscribersRef.current = [];
70
+ }, []);
71
+
72
+ const applyTranscript = useCallback((text: string, isFinal: boolean) => {
73
+ const merged = clipText(joinTranscript(committedRef.current, text));
74
+ if (isFinal) {
75
+ committedRef.current = merged;
76
+ }
77
+ onChangeRef.current(merged);
78
+ return merged;
79
+ }, []);
80
+
81
+ const stop = useCallback(async (options?: { silent?: boolean }) => {
82
+ if (!transcribingRef.current && !recordingRef.current && !channelRef.current) {
83
+ setTranscribing(false);
84
+ return valueRef.current;
85
+ }
86
+
87
+ const bridge = getVibeNoteBridge();
88
+ const hadChannel = channelRef.current;
89
+ const hadRecording = recordingRef.current;
90
+ transcribingRef.current = false;
91
+ setTranscribing(false);
92
+
93
+ try {
94
+ if (hadChannel) {
95
+ await bridge?.stopRealtimeTranscription?.();
96
+ }
97
+ } catch (error) {
98
+ if (!options?.silent) {
99
+ console.warn('stopRealtimeTranscription failed:', error);
100
+ }
101
+ } finally {
102
+ channelRef.current = false;
103
+ }
104
+
105
+ try {
106
+ if (hadRecording) {
107
+ if (hadChannel) {
108
+ await bridge?.cancelVoiceRecording?.();
109
+ } else {
110
+ const result = await bridge?.stopVoiceRecording?.();
111
+ const text = String(result?.text || '').trim();
112
+ if (text) {
113
+ applyTranscript(text, true);
114
+ } else if (!options?.silent) {
115
+ Toast.show({ content: '未识别到内容,请重试' });
116
+ }
117
+ }
118
+ }
119
+ } catch (error) {
120
+ if (!options?.silent) {
121
+ Toast.show({ content: getErrorHint(error) });
122
+ }
123
+ } finally {
124
+ recordingRef.current = false;
125
+ }
126
+
127
+ teardownListeners();
128
+ return valueRef.current;
129
+ }, [applyTranscript, teardownListeners]);
130
+
131
+ const stopRef = useRef(stop);
132
+ stopRef.current = stop;
133
+
134
+ const start = useCallback(async () => {
135
+ if (busyRef.current || transcribingRef.current) return false;
136
+ if (!isVibeNoteBridgeAvailable()) {
137
+ Toast.show({ content: '请在 TalkNote App 内使用语音输入' });
138
+ return false;
139
+ }
140
+ if (!vibeNoteCanIUse('startVoiceRecording')) {
141
+ Toast.show({ content: '当前 App 版本不支持语音输入,请升级后重试' });
142
+ return false;
143
+ }
144
+
145
+ const bridge = getVibeNoteBridge();
146
+ if (!bridge?.canVoiceInput || !bridge.startVoiceRecording) {
147
+ Toast.show({ content: '请在 TalkNote App 内使用语音输入' });
148
+ return false;
149
+ }
150
+
151
+ const realtimeSupported = vibeNoteCanIUse('startRealtimeTranscription');
152
+ busyRef.current = true;
153
+ committedRef.current = valueRef.current.trimEnd();
154
+
155
+ try {
156
+ const check = await bridge.canVoiceInput();
157
+ if (!check?.canVoiceInput) {
158
+ Toast.show({ content: '当前不可录音,请稍后再试' });
159
+ return false;
160
+ }
161
+
162
+ if (realtimeSupported) {
163
+ teardownListeners();
164
+ const offResult = bridge.addListener?.('onTranscriptionResult', (event) => {
165
+ const data = (event?.data || {}) as TranscriptionResultData;
166
+ applyTranscript(String(data.text || ''), Boolean(data.isFinal));
167
+ });
168
+ const offError = bridge.addListener?.('onTranscriptionError', (event) => {
169
+ const data = (event?.data || {}) as TranscriptionErrorData;
170
+ Toast.show({ content: data.message || '转写失败,请重试' });
171
+ void stopRef.current({ silent: true });
172
+ });
173
+ if (offResult) unsubscribersRef.current.push(offResult);
174
+ if (offError) unsubscribersRef.current.push(offError);
175
+ }
176
+
177
+ await bridge.startVoiceRecording();
178
+ recordingRef.current = true;
179
+ transcribingRef.current = true;
180
+ setTranscribing(true);
181
+
182
+ if (realtimeSupported && bridge.startRealtimeTranscription) {
183
+ let info: { baseUrl?: string; env?: string } | null = null;
184
+ try {
185
+ info =
186
+ (await bridge.getRequestBaseInfo?.()) || (await TalkNoteH5Bridge.getRequestBaseInfo());
187
+ } catch {
188
+ info = null;
189
+ }
190
+ const wsUrl = resolveAsrWsUrl(info?.baseUrl, info?.env);
191
+ await bridge.startRealtimeTranscription({ wsUrl });
192
+ channelRef.current = true;
193
+ }
194
+
195
+ return true;
196
+ } catch (error) {
197
+ Toast.show({ content: getErrorHint(error) });
198
+ await stopRef.current({ silent: true });
199
+ return false;
200
+ } finally {
201
+ busyRef.current = false;
202
+ }
203
+ }, [applyTranscript, teardownListeners]);
204
+
205
+ const toggle = useCallback(async () => {
206
+ if (transcribingRef.current) {
207
+ await stop();
208
+ return;
209
+ }
210
+ await start();
211
+ }, [start, stop]);
212
+
213
+ useEffect(
214
+ () => () => {
215
+ void stopRef.current({ silent: true });
216
+ },
217
+ [],
218
+ );
219
+
220
+ return {
221
+ transcribing,
222
+ start,
223
+ stop,
224
+ toggle,
225
+ };
226
+ };
@@ -0,0 +1,215 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import {
3
+ computeTypewriterPacing,
4
+ resolveTypewriterDisplayLength,
5
+ TYPEWRITER_DEFAULTS,
6
+ } from '../utils/typewriter';
7
+
8
+ type Timer = ReturnType<typeof setTimeout>;
9
+
10
+ export type UseTypewriterOptions = {
11
+ atomicTokens?: string[];
12
+ atomicClosedTags?: string[];
13
+ };
14
+
15
+ export const useTypewriter = (options: UseTypewriterOptions = {}) => {
16
+ const {
17
+ atomicTokens = [],
18
+ atomicClosedTags = [],
19
+ } = options;
20
+
21
+ const atomicTokensRef = useRef(atomicTokens);
22
+ const atomicClosedTagsRef = useRef(atomicClosedTags);
23
+ atomicTokensRef.current = atomicTokens;
24
+ atomicClosedTagsRef.current = atomicClosedTags;
25
+
26
+ const [displayedContent, setDisplayedContent] = useState('');
27
+ const [isTyping, setIsTyping] = useState(false);
28
+ const [streamComplete, setStreamComplete] = useState(false);
29
+
30
+ const receivedContentRef = useRef('');
31
+ const displayedContentRef = useRef('');
32
+ const streamCompleteRef = useRef(false);
33
+ const finishPhaseDeadlineRef = useRef<number | null>(null);
34
+ const pumpTimerRef = useRef<Timer | null>(null);
35
+ const pumpRunningRef = useRef(false);
36
+ const pumpTickRef = useRef<() => void>(() => {});
37
+
38
+ const isOutputComplete =
39
+ streamComplete && !isTyping && displayedContent === receivedContentRef.current;
40
+
41
+ const clearPumpTimer = useCallback(() => {
42
+ if (pumpTimerRef.current) {
43
+ clearTimeout(pumpTimerRef.current);
44
+ pumpTimerRef.current = null;
45
+ }
46
+ }, []);
47
+
48
+ const stopPump = useCallback(() => {
49
+ pumpRunningRef.current = false;
50
+ clearPumpTimer();
51
+ setIsTyping(false);
52
+ }, [clearPumpTimer]);
53
+
54
+ const getPacing = useCallback((lag: number) => {
55
+ const finishRemainingMs = finishPhaseDeadlineRef.current !== null
56
+ ? finishPhaseDeadlineRef.current - Date.now()
57
+ : TYPEWRITER_DEFAULTS.finishTargetMs;
58
+
59
+ return computeTypewriterPacing({
60
+ lag,
61
+ ...TYPEWRITER_DEFAULTS,
62
+ isStreamFinished: streamCompleteRef.current,
63
+ finishRemainingMs,
64
+ });
65
+ }, []);
66
+
67
+ const advanceDisplayedBy = useCallback((advanceSize: number): boolean => {
68
+ const currentReceived = receivedContentRef.current;
69
+ const currentDisplayed = displayedContentRef.current;
70
+ if (!currentReceived || currentDisplayed.length >= currentReceived.length) {
71
+ return false;
72
+ }
73
+
74
+ const lag = currentReceived.length - currentDisplayed.length;
75
+ const nextLength = resolveTypewriterDisplayLength(
76
+ currentDisplayed.length,
77
+ currentReceived,
78
+ Math.min(lag, Math.max(1, advanceSize)),
79
+ atomicTokensRef.current,
80
+ atomicClosedTagsRef.current,
81
+ );
82
+
83
+ if (nextLength <= currentDisplayed.length) {
84
+ return false;
85
+ }
86
+
87
+ const nextContent = currentReceived.substring(0, nextLength);
88
+ displayedContentRef.current = nextContent;
89
+ setDisplayedContent((prev) => (prev.length >= nextLength ? prev : nextContent));
90
+ return true;
91
+ }, []);
92
+
93
+ const scheduleNextPumpTick = useCallback((delayMs: number) => {
94
+ clearPumpTimer();
95
+ pumpTimerRef.current = setTimeout(() => {
96
+ pumpTimerRef.current = null;
97
+ if (pumpRunningRef.current) {
98
+ pumpTickRef.current();
99
+ }
100
+ }, delayMs);
101
+ }, [clearPumpTimer]);
102
+
103
+ const pumpTick = useCallback(() => {
104
+ if (!pumpRunningRef.current) {
105
+ return;
106
+ }
107
+
108
+ const lag = receivedContentRef.current.length - displayedContentRef.current.length;
109
+ if (lag <= 0) {
110
+ if (streamCompleteRef.current) {
111
+ stopPump();
112
+ return;
113
+ }
114
+ scheduleNextPumpTick(TYPEWRITER_DEFAULTS.typingSpeed);
115
+ return;
116
+ }
117
+
118
+ setIsTyping(true);
119
+ const { advanceSize, delayMs } = getPacing(lag);
120
+ if (advanceSize > 0) {
121
+ const moved = advanceDisplayedBy(advanceSize);
122
+ if (!moved && streamCompleteRef.current) {
123
+ displayedContentRef.current = receivedContentRef.current;
124
+ setDisplayedContent(receivedContentRef.current);
125
+ stopPump();
126
+ return;
127
+ }
128
+ }
129
+
130
+ const nextLag = receivedContentRef.current.length - displayedContentRef.current.length;
131
+ if (nextLag <= 0 && streamCompleteRef.current) {
132
+ stopPump();
133
+ return;
134
+ }
135
+
136
+ const nextDelay = nextLag > 0 ? getPacing(nextLag).delayMs : TYPEWRITER_DEFAULTS.typingSpeed;
137
+ scheduleNextPumpTick(nextDelay || delayMs);
138
+ }, [advanceDisplayedBy, getPacing, scheduleNextPumpTick, stopPump]);
139
+
140
+ pumpTickRef.current = pumpTick;
141
+
142
+ const ensurePumpRunning = useCallback(() => {
143
+ if (pumpRunningRef.current) {
144
+ return;
145
+ }
146
+ if (
147
+ streamCompleteRef.current
148
+ && displayedContentRef.current.length >= receivedContentRef.current.length
149
+ ) {
150
+ return;
151
+ }
152
+ if (!receivedContentRef.current) {
153
+ return;
154
+ }
155
+ pumpRunningRef.current = true;
156
+ pumpTick();
157
+ }, [pumpTick]);
158
+
159
+ const append = useCallback((text: string) => {
160
+ if (!text) return;
161
+ const next = receivedContentRef.current + text;
162
+ receivedContentRef.current = next;
163
+ ensurePumpRunning();
164
+ }, [ensurePumpRunning]);
165
+
166
+ const markStreamComplete = useCallback(() => {
167
+ if (streamCompleteRef.current) return;
168
+ streamCompleteRef.current = true;
169
+ setStreamComplete(true);
170
+
171
+ const lag = receivedContentRef.current.length - displayedContentRef.current.length;
172
+ if (lag <= 0) {
173
+ stopPump();
174
+ return;
175
+ }
176
+
177
+ const targetMs = Math.min(
178
+ TYPEWRITER_DEFAULTS.finishMaxMs,
179
+ Math.max(TYPEWRITER_DEFAULTS.finishMinMs, TYPEWRITER_DEFAULTS.finishTargetMs),
180
+ );
181
+ finishPhaseDeadlineRef.current = Date.now() + targetMs;
182
+ ensurePumpRunning();
183
+ }, [ensurePumpRunning, stopPump]);
184
+
185
+ const reset = useCallback(() => {
186
+ stopPump();
187
+ receivedContentRef.current = '';
188
+ displayedContentRef.current = '';
189
+ streamCompleteRef.current = false;
190
+ finishPhaseDeadlineRef.current = null;
191
+ setDisplayedContent('');
192
+ setStreamComplete(false);
193
+ }, [stopPump]);
194
+
195
+ const skip = useCallback(() => {
196
+ stopPump();
197
+ displayedContentRef.current = receivedContentRef.current;
198
+ setDisplayedContent(receivedContentRef.current);
199
+ }, [stopPump]);
200
+
201
+ useEffect(() => () => {
202
+ stopPump();
203
+ }, [stopPump]);
204
+
205
+ return {
206
+ displayedContent,
207
+ isTyping,
208
+ isOutputComplete,
209
+ append,
210
+ markStreamComplete,
211
+ reset,
212
+ skip,
213
+ stop: stopPump,
214
+ };
215
+ };
@@ -0,0 +1,43 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+
3
+ type UseViewportHeightOptions = {
4
+ enabled?: boolean;
5
+ };
6
+
7
+ /**
8
+ * 跟随 visualViewport 高度,键盘弹出时只压缩中间可滚动区域,头部/底部保持在视口内。
9
+ * 客户端已用 setKeyboardAvoidMode('resize') 压缩 WebView 时请关闭,避免与原生高度叠加。
10
+ */
11
+ export const useViewportHeight = (cssVarName = '--xiaozhi-vh', options?: UseViewportHeightOptions) => {
12
+ const enabled = options?.enabled !== false;
13
+ const [height, setHeight] = useState<number | null>(null);
14
+
15
+ const sync = useCallback(() => {
16
+ const vv = window.visualViewport;
17
+ const next = Math.round(vv?.height ?? window.innerHeight);
18
+ setHeight(next);
19
+ document.documentElement.style.setProperty(cssVarName, `${next}px`);
20
+ }, [cssVarName]);
21
+
22
+ useEffect(() => {
23
+ if (!enabled) {
24
+ document.documentElement.style.removeProperty(cssVarName);
25
+ setHeight(null);
26
+ return;
27
+ }
28
+ sync();
29
+ const vv = window.visualViewport;
30
+ vv?.addEventListener('resize', sync);
31
+ vv?.addEventListener('scroll', sync);
32
+ window.addEventListener('resize', sync);
33
+ // eslint-disable-next-line consistent-return
34
+ return () => {
35
+ vv?.removeEventListener('resize', sync);
36
+ vv?.removeEventListener('scroll', sync);
37
+ window.removeEventListener('resize', sync);
38
+ document.documentElement.style.removeProperty(cssVarName);
39
+ };
40
+ }, [cssVarName, enabled, sync]);
41
+
42
+ return height;
43
+ };
@@ -0,0 +1,128 @@
1
+ import { useCallback, useRef, useState } from 'react';
2
+ import { Toast } from 'antd-mobile';
3
+ import {
4
+ getVibeNoteBridge,
5
+ isVibeNoteBridgeAvailable,
6
+ vibeNoteCanIUse,
7
+ } from '../utils/bridge';
8
+
9
+ export type VoiceRecordingPhase = 'idle' | 'recording' | 'canceling';
10
+
11
+ const getErrorHint = (error: unknown): string => {
12
+ const code =
13
+ error && typeof error === 'object' && 'code' in error
14
+ ? String((error as { code?: string }).code || '')
15
+ : '';
16
+
17
+ if (code === 'permission_denied') {
18
+ return '需要麦克风权限才能使用语音输入';
19
+ }
20
+ if (code === 'cannot_voice_input') {
21
+ return '当前不可录音,请稍后再试';
22
+ }
23
+ if (code === 'start_failed') {
24
+ return '启动录音失败,请稍后再试';
25
+ }
26
+
27
+ return '语音输入失败,请改用键盘输入';
28
+ };
29
+
30
+ export const useVoiceRecording = () => {
31
+ const [phase, setPhase] = useState<VoiceRecordingPhase>('idle');
32
+ const startedRef = useRef(false);
33
+ const busyRef = useRef(false);
34
+
35
+ const ensureSupported = useCallback(() => {
36
+ if (!isVibeNoteBridgeAvailable()) {
37
+ Toast.show({ content: '请在 TalkNote App 内使用语音输入' });
38
+ return false;
39
+ }
40
+ if (!vibeNoteCanIUse('startVoiceRecording')) {
41
+ Toast.show({ content: '当前 App 版本不支持语音输入,请升级后重试' });
42
+ return false;
43
+ }
44
+ return true;
45
+ }, []);
46
+
47
+ const start = useCallback(async () => {
48
+ if (busyRef.current || startedRef.current) return false;
49
+ if (!ensureSupported()) return false;
50
+
51
+ const bridge = getVibeNoteBridge();
52
+ if (!bridge?.canVoiceInput || !bridge.startVoiceRecording) {
53
+ Toast.show({ content: '请在 TalkNote App 内使用语音输入' });
54
+ return false;
55
+ }
56
+
57
+ busyRef.current = true;
58
+ try {
59
+ const check = await bridge.canVoiceInput();
60
+ if (!check?.canVoiceInput) {
61
+ Toast.show({ content: '当前不可录音,请稍后再试' });
62
+ return false;
63
+ }
64
+
65
+ await bridge.startVoiceRecording();
66
+ startedRef.current = true;
67
+ setPhase('recording');
68
+ return true;
69
+ } catch (error) {
70
+ Toast.show({ content: getErrorHint(error) });
71
+ startedRef.current = false;
72
+ setPhase('idle');
73
+ return false;
74
+ } finally {
75
+ busyRef.current = false;
76
+ }
77
+ }, [ensureSupported]);
78
+
79
+ const cancel = useCallback(async () => {
80
+ if (!startedRef.current) {
81
+ setPhase('idle');
82
+ return;
83
+ }
84
+
85
+ const bridge = getVibeNoteBridge();
86
+ busyRef.current = true;
87
+ setPhase('canceling');
88
+ try {
89
+ await bridge?.cancelVoiceRecording?.();
90
+ } catch (error) {
91
+ console.warn('cancelVoiceRecording failed:', error);
92
+ } finally {
93
+ startedRef.current = false;
94
+ busyRef.current = false;
95
+ setPhase('idle');
96
+ }
97
+ }, []);
98
+
99
+ const stop = useCallback(async (): Promise<string> => {
100
+ if (!startedRef.current) {
101
+ setPhase('idle');
102
+ return '';
103
+ }
104
+
105
+ const bridge = getVibeNoteBridge();
106
+ busyRef.current = true;
107
+ try {
108
+ const result = await bridge?.stopVoiceRecording?.();
109
+ return (result?.text || '').trim();
110
+ } catch (error) {
111
+ Toast.show({ content: getErrorHint(error) });
112
+ return '';
113
+ } finally {
114
+ startedRef.current = false;
115
+ busyRef.current = false;
116
+ setPhase('idle');
117
+ }
118
+ }, []);
119
+
120
+ return {
121
+ phase,
122
+ isRecording: phase === 'recording',
123
+ start,
124
+ cancel,
125
+ stop,
126
+ ensureSupported,
127
+ };
128
+ };
@@ -0,0 +1,11 @@
1
+ export { default as XiaozhiChatPage } from './XiaozhiChatPage';
2
+ export { default } from './XiaozhiChatPage';
3
+ export type {
4
+ XiaozhiChatPageProps,
5
+ ChatMessage,
6
+ ChatSession,
7
+ ChatVersion,
8
+ CreateSessionParams,
9
+ StreamChatParams,
10
+ } from './types';
11
+ export { DEFAULT_CHAT_VERSION } from './types';
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@talknote/xiaozhi-chat",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "description": "Ask AI H5 问答模块,供主工程引入",
6
+ "main": "index.ts",
7
+ "module": "index.ts",
8
+ "types": "index.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./index.ts",
12
+ "import": "./index.ts",
13
+ "require": "./index.ts",
14
+ "default": "./index.ts"
15
+ }
16
+ },
17
+ "files": [
18
+ "**/*"
19
+ ],
20
+ "sideEffects": [
21
+ "**/*.less",
22
+ "**/*.css"
23
+ ],
24
+ "dependencies": {
25
+ "react-markdown": "^8.0.7",
26
+ "remark-gfm": "^3.0.1"
27
+ },
28
+ "peerDependencies": {
29
+ "@talknote/h5-bridge": "^0.0.5",
30
+ "antd-mobile": "^5.15.1",
31
+ "react": "^18.2.0",
32
+ "react-dom": "^18.2.0"
33
+ }
34
+ }