@tbox.cn/app-template-agent 0.1.0

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 (119) hide show
  1. package/.gitignore +13 -0
  2. package/.nvmrc +1 -0
  3. package/.tbox/app.json +4 -0
  4. package/AGENTS.md +101 -0
  5. package/ENV.md +77 -0
  6. package/LEGAL.md +7 -0
  7. package/LICENSE +21 -0
  8. package/README.md +62 -0
  9. package/apps/client/STYLE_GUIDE.md +81 -0
  10. package/apps/client/VISUAL_RESTORE.md +90 -0
  11. package/apps/client/assets/copy@2x.png +0 -0
  12. package/apps/client/assets/debug.png +0 -0
  13. package/apps/client/assets/dislike@2x.png +0 -0
  14. package/apps/client/assets/keyboard-circle.svg +12 -0
  15. package/apps/client/assets/like@2x.png +0 -0
  16. package/apps/client/assets/more-circle.svg +3 -0
  17. package/apps/client/assets/new-chat.svg +3 -0
  18. package/apps/client/assets/quick-command-star.svg +3 -0
  19. package/apps/client/assets/send-gradient.svg +14 -0
  20. package/apps/client/assets/speaker@2x.png +0 -0
  21. package/apps/client/assets/stop@2x.png +0 -0
  22. package/apps/client/assets/voice-circle.svg +3 -0
  23. package/apps/client/babel-plugins/code-inspect.cjs +110 -0
  24. package/apps/client/index.html +36 -0
  25. package/apps/client/package.json +34 -0
  26. package/apps/client/src/app.tsx +5 -0
  27. package/apps/client/src/business/App.tsx +11 -0
  28. package/apps/client/src/business/README.md +82 -0
  29. package/apps/client/src/cards/CardRenderer.tsx +10 -0
  30. package/apps/client/src/cards/registry.ts +22 -0
  31. package/apps/client/src/components/ActionBar/DebugButton.tsx +49 -0
  32. package/apps/client/src/components/ActionBar/index.less +120 -0
  33. package/apps/client/src/components/ActionBar/index.tsx +157 -0
  34. package/apps/client/src/components/ChatHeader/index.less +104 -0
  35. package/apps/client/src/components/ChatHeader/index.tsx +63 -0
  36. package/apps/client/src/components/ChatInput/index.less +332 -0
  37. package/apps/client/src/components/ChatInput/index.tsx +251 -0
  38. package/apps/client/src/components/ChatInput/voice/VoicePressField.tsx +34 -0
  39. package/apps/client/src/components/ChatInput/voice/VoiceRecordingOverlay.tsx +51 -0
  40. package/apps/client/src/components/ChatInput/voice/machine.ts +27 -0
  41. package/apps/client/src/components/ChatInput/voice/useVoicePress.ts +188 -0
  42. package/apps/client/src/components/ErrorBoundary.tsx +2 -0
  43. package/apps/client/src/components/MediaList/index.less +241 -0
  44. package/apps/client/src/components/MediaList/index.tsx +256 -0
  45. package/apps/client/src/components/MessageBubble/index.less +309 -0
  46. package/apps/client/src/components/MessageBubble/index.tsx +163 -0
  47. package/apps/client/src/components/PullDownIndicator/index.less +32 -0
  48. package/apps/client/src/components/PullDownIndicator/index.tsx +33 -0
  49. package/apps/client/src/components/QuickCommands/index.less +48 -0
  50. package/apps/client/src/components/QuickCommands/index.tsx +22 -0
  51. package/apps/client/src/components/ReasoningBlock/index.less +90 -0
  52. package/apps/client/src/components/ReasoningBlock/index.tsx +56 -0
  53. package/apps/client/src/components/ScrollToBottomButton/index.less +32 -0
  54. package/apps/client/src/components/ScrollToBottomButton/index.tsx +10 -0
  55. package/apps/client/src/components/ToolCallBlock/index.less +91 -0
  56. package/apps/client/src/components/ToolCallBlock/index.tsx +63 -0
  57. package/apps/client/src/examples/tourism/TourismLanding.tsx +96 -0
  58. package/apps/client/src/examples/tourism/index.less +192 -0
  59. package/apps/client/src/hooks/useMediaUpload.ts +237 -0
  60. package/apps/client/src/hooks/usePullDownHistory.ts +97 -0
  61. package/apps/client/src/hooks/useTTSAutoPlay.ts +80 -0
  62. package/apps/client/src/main.tsx +17 -0
  63. package/apps/client/src/modules.ts +30 -0
  64. package/apps/client/src/pages/index/index.less +43 -0
  65. package/apps/client/src/pages/index/index.tsx +145 -0
  66. package/apps/client/src/platform/AgentChatApp.tsx +39 -0
  67. package/apps/client/src/platform/agent-runtime.ts +15 -0
  68. package/apps/client/src/platform/state/createPersistedStore.ts +19 -0
  69. package/apps/client/src/shims/node-stub.ts +14 -0
  70. package/apps/client/src/store/chat-config.ts +29 -0
  71. package/apps/client/src/store/chat.ts +201 -0
  72. package/apps/client/src/styles/global.less +60 -0
  73. package/apps/client/src/styles/tokens.less +60 -0
  74. package/apps/client/src/types/chat.ts +29 -0
  75. package/apps/client/src/utils/copyText.ts +11 -0
  76. package/apps/client/src/utils/imagePreview.ts +10 -0
  77. package/apps/client/src/utils/session-error.ts +5 -0
  78. package/apps/client/src/utils/toast.ts +15 -0
  79. package/apps/client/src/vite-env.d.ts +3 -0
  80. package/apps/client/tsconfig.json +11 -0
  81. package/apps/client/vite.config.ts +40 -0
  82. package/apps/server/package.json +40 -0
  83. package/apps/server/src/cli/collect-registry.ts +26 -0
  84. package/apps/server/src/cli/plugin-doctor.ts +89 -0
  85. package/apps/server/src/cli/plugin-inspect.ts +49 -0
  86. package/apps/server/src/index.ts +230 -0
  87. package/apps/server/src/intent-routes.ts +23 -0
  88. package/apps/server/src/modules.ts +31 -0
  89. package/apps/server/src/tools/translate.ts +41 -0
  90. package/apps/server/tests/cards-registry.test.ts +55 -0
  91. package/apps/server/tests/tool-runtime.test.ts +49 -0
  92. package/apps/server/tsconfig.json +5 -0
  93. package/apps/server/tsup.config.ts +25 -0
  94. package/apps/server/vitest.config.ts +8 -0
  95. package/config/chat.json +8 -0
  96. package/config/llm_provider.json +3 -0
  97. package/config/plugins.json +3 -0
  98. package/eslint.config.mjs +41 -0
  99. package/package.json +51 -0
  100. package/packages/module-weather/package.json +35 -0
  101. package/packages/module-weather/src/client/cards/weather/index.less +22 -0
  102. package/packages/module-weather/src/client/cards/weather/index.tsx +14 -0
  103. package/packages/module-weather/src/client/index.ts +19 -0
  104. package/packages/module-weather/src/index.ts +2 -0
  105. package/packages/module-weather/src/server/cards/weather/meta.ts +28 -0
  106. package/packages/module-weather/src/server/index.ts +19 -0
  107. package/packages/module-weather/src/server/tool.ts +29 -0
  108. package/packages/module-weather/tbox.component.json +27 -0
  109. package/packages/module-weather/tsconfig.json +14 -0
  110. package/pnpm-workspace.template.yaml +60 -0
  111. package/scripts/build.sh +39 -0
  112. package/scripts/check-inspector.sh +91 -0
  113. package/scripts/check-workspace.mjs +164 -0
  114. package/scripts/dev.sh +15 -0
  115. package/scripts/prepare.sh +11 -0
  116. package/scripts/start.sh +11 -0
  117. package/scripts/stop.sh +26 -0
  118. package/scripts/verify-generated.mjs +142 -0
  119. package/tsconfig.base.json +20 -0
@@ -0,0 +1,251 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { useMediaUpload } from '@/hooks/useMediaUpload';
3
+ import { useASRAdapter, isInAlipayShell } from '@tbox.cn/app-sdk/client';
4
+ import { useChatConfigStore } from '@/store/chat-config';
5
+
6
+ import type { ChatInputMessage, InputMethod } from '@/types/chat';
7
+ import { MediaList } from '@/components/MediaList';
8
+ import { useVoicePress } from './voice/useVoicePress';
9
+ import { VoicePressField } from './voice/VoicePressField';
10
+ import { VoiceRecordingOverlay } from './voice/VoiceRecordingOverlay';
11
+ import newChatIcon from '../../../assets/new-chat.svg';
12
+ import voiceIcon from '../../../assets/voice-circle.svg';
13
+ import keyboardIcon from '../../../assets/keyboard-circle.svg';
14
+ import sendIcon from '../../../assets/send-gradient.svg';
15
+ import stopIcon from '../../../assets/stop@2x.png';
16
+ import moreIcon from '../../../assets/more-circle.svg';
17
+ import './index.less';
18
+
19
+ type PickKind = 'image' | 'camera' | 'video';
20
+
21
+ /** 媒体选取菜单图标(远程素材,对齐原模板)。 */
22
+ const OPTION_ICONS: Record<PickKind, { icon: string; label: string }> = {
23
+ image: { icon: 'https://mdn.alipayobjects.com/huamei_nzq4hr/afts/img/A*DfDhSYAMzoQAAAAAHiAAAAgAel-hAQ/original', label: '相册' },
24
+ camera: { icon: 'https://mdn.alipayobjects.com/huamei_nzq4hr/afts/img/A*xdKLSabxqmQAAAAAOnAAAAgAel-hAQ/original', label: '拍摄' },
25
+ video: { icon: 'https://mdn.alipayobjects.com/huamei_nzq4hr/afts/img/A*xdKLSabxqmQAAAAAOnAAAAgAel-hAQ/original', label: '视频' },
26
+ };
27
+
28
+ const MENU_ANIMATION_MS = 250;
29
+
30
+ /** 媒体上传默认约束(分类型数量 + 单文件大小)。如需放开/收紧按业务改这里。 */
31
+ const DEFAULT_MEDIA_CONFIG = {
32
+ image: { maxCount: 9, maxSize: 10 * 1024 * 1024 },
33
+ video: { maxCount: 1, maxSize: 50 * 1024 * 1024 },
34
+ file: { maxCount: 5, maxSize: 20 * 1024 * 1024 },
35
+ } as const;
36
+ const MEDIA_MAX_TOTAL = 9;
37
+
38
+ /** 对话输入框(对齐原模板):媒体预览 + 新对话 + 药丸[输入框/按住说话 + 语音切换 + 发送/“+”] + 动画选取菜单 + 免责声明。 */
39
+ export function ChatInput({
40
+ generating,
41
+ onSend,
42
+ onCancel,
43
+ onNewChat,
44
+ disclaimer = '以上内容由AI生成,请谨慎甄别。',
45
+ }: {
46
+ generating?: boolean;
47
+ onSend: (msg: ChatInputMessage) => void;
48
+ onCancel?: () => void;
49
+ onNewChat?: () => void;
50
+ disclaimer?: string;
51
+ }) {
52
+ const [text, setText] = useState('');
53
+ const imageRef = useRef<HTMLInputElement>(null);
54
+ const cameraRef = useRef<HTMLInputElement>(null);
55
+ const videoRef = useRef<HTMLInputElement>(null);
56
+ const closeTimerRef = useRef<ReturnType<typeof setTimeout>>();
57
+ const { items, addFiles, addFromBridge, removeItem, retry, clear, uploading } = useMediaUpload(
58
+ MEDIA_MAX_TOTAL,
59
+ DEFAULT_MEDIA_CONFIG,
60
+ );
61
+ const asr = useASRAdapter();
62
+ const inShell = isInAlipayShell();
63
+ const enableImage = useChatConfigStore((s) => s.enableImageUpload);
64
+ const enableVideo = useChatConfigStore((s) => s.enableVideoUpload);
65
+ const enableVoice = useChatConfigStore((s) => s.enableVoiceInput);
66
+ const defaultInputMode = useChatConfigStore((s) => s.defaultInputMode);
67
+ const [inputMode, setInputMode] = useState<'text' | 'voice'>(
68
+ defaultInputMode === 'VOICE' && enableVoice ? 'voice' : 'text',
69
+ );
70
+ const [menuVisible, setMenuVisible] = useState(false);
71
+ const [menuAnimating, setMenuAnimating] = useState(false);
72
+
73
+ const voice = useVoicePress({
74
+ disabled: generating,
75
+ asr,
76
+ onResult: (t) => onSend({ text: t, mediaItems: [], inputMethod: 'voice' }),
77
+ });
78
+
79
+ const isWebPc = !inShell && typeof window !== 'undefined' && window.innerWidth > 450;
80
+
81
+ const pick = (kind: PickKind) => {
82
+ if (inShell) {
83
+ addFromBridge(kind);
84
+ return;
85
+ }
86
+ (kind === 'image' ? imageRef : kind === 'camera' ? cameraRef : videoRef).current?.click();
87
+ };
88
+
89
+ // 媒体选项:相册 / 拍摄(移动端)/ 视频,按 config 开关
90
+ const menuOptions = [
91
+ enableImage && { type: 'image' as const, ...OPTION_ICONS.image },
92
+ enableImage && !isWebPc && { type: 'camera' as const, ...OPTION_ICONS.camera },
93
+ enableVideo && { type: 'video' as const, ...OPTION_ICONS.video },
94
+ ].filter(Boolean) as { type: PickKind; icon: string; label: string }[];
95
+
96
+ const openMenu = () => {
97
+ setMenuVisible(true);
98
+ requestAnimationFrame(() => requestAnimationFrame(() => setMenuAnimating(true)));
99
+ };
100
+ const closeMenu = () => {
101
+ if (closeTimerRef.current) clearTimeout(closeTimerRef.current);
102
+ setMenuAnimating(false);
103
+ closeTimerRef.current = setTimeout(() => setMenuVisible(false), MENU_ANIMATION_MS);
104
+ };
105
+ const onPlus = () => {
106
+ if (menuVisible) return closeMenu();
107
+ if (menuOptions.length <= 1) return menuOptions[0] && pick(menuOptions[0].type);
108
+ openMenu();
109
+ };
110
+ const onMenuSelect = (kind: PickKind) => {
111
+ closeMenu();
112
+ pick(kind);
113
+ };
114
+
115
+ useEffect(() => {
116
+ if (!menuVisible) return;
117
+ const onDown = (e: MouseEvent | TouchEvent) => {
118
+ const t = e.target as HTMLElement;
119
+ if (t && !t.closest('.tbox-media-picker') && !t.closest('.tbox-media-picker-menu')) closeMenu();
120
+ };
121
+ document.addEventListener('mousedown', onDown);
122
+ document.addEventListener('touchstart', onDown, { passive: true });
123
+ return () => {
124
+ document.removeEventListener('mousedown', onDown);
125
+ document.removeEventListener('touchstart', onDown);
126
+ };
127
+ }, [menuVisible]);
128
+
129
+ useEffect(() => () => closeTimerRef.current && clearTimeout(closeTimerRef.current), []);
130
+
131
+ const canSend = !!(text.trim() || items.length > 0) && !uploading && inputMode === 'text';
132
+
133
+ const submit = () => {
134
+ if (!canSend) return;
135
+ const media = items.filter((it) => it.fileId && !it.illegal);
136
+ const inputMethod: InputMethod = media.length ? media[0].type : 'text';
137
+ onSend({ text: text.trim(), mediaItems: media, inputMethod });
138
+ setText('');
139
+ clear();
140
+ };
141
+
142
+ const onPickFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
143
+ addFiles(e.target.files ? Array.from(e.target.files) : []);
144
+ e.target.value = '';
145
+ };
146
+
147
+ const toggleMode = () => setInputMode((m) => (m === 'text' ? 'voice' : 'text'));
148
+
149
+ return (
150
+ <div className="tbox-chat-input">
151
+ <MediaList
152
+ items={items}
153
+ onRemove={removeItem}
154
+ onRetry={retry}
155
+ onPickImage={() => pick('image')}
156
+ onPickCamera={() => pick('camera')}
157
+ onPickVideo={() => pick('video')}
158
+ enableImage={enableImage}
159
+ enableVideo={enableVideo}
160
+ isWebPc={isWebPc}
161
+ />
162
+
163
+ <div className="tbox-chat-input__row">
164
+ {onNewChat && (
165
+ <div className="tbox-new-chat-btn" onClick={onNewChat}>
166
+ <img className="tbox-new-chat-btn__icon" src={newChatIcon} alt="新对话" />
167
+ </div>
168
+ )}
169
+
170
+ <div className="tbox-chat-input__wrapper">
171
+ <input ref={imageRef} type="file" accept="image/*" multiple hidden onChange={onPickFiles} />
172
+ <input ref={cameraRef} type="file" accept="image/*" capture="environment" hidden onChange={onPickFiles} />
173
+ <input ref={videoRef} type="file" accept="video/*" hidden onChange={onPickFiles} />
174
+
175
+ {inputMode === 'voice' ? (
176
+ <VoicePressField
177
+ disabled={generating}
178
+ interactionState={voice.interactionState}
179
+ handlers={voice.handlers}
180
+ />
181
+ ) : (
182
+ <textarea
183
+ className="tbox-chat-input__field"
184
+ value={text}
185
+ placeholder="有什么需要问我的吗"
186
+ rows={1}
187
+ onChange={(e) => setText(e.target.value)}
188
+ onKeyDown={(e) => {
189
+ if (e.key === 'Enter' && !e.shiftKey) {
190
+ e.preventDefault();
191
+ submit();
192
+ }
193
+ }}
194
+ />
195
+ )}
196
+
197
+ {enableVoice && (
198
+ <div className="tbox-voice-toggle" onClick={toggleMode}>
199
+ <img
200
+ className="tbox-voice-toggle__icon"
201
+ src={inputMode === 'voice' ? keyboardIcon : voiceIcon}
202
+ alt={inputMode === 'voice' ? '键盘' : '语音'}
203
+ />
204
+ </div>
205
+ )}
206
+
207
+ {generating ? (
208
+ <div className="tbox-chat-input__send tbox-chat-input__send--active" onClick={onCancel}>
209
+ <img className="tbox-chat-input__send-img" src={stopIcon} alt="停止" />
210
+ </div>
211
+ ) : canSend ? (
212
+ <div className="tbox-chat-input__send tbox-chat-input__send--active" onClick={submit}>
213
+ <img className="tbox-chat-input__send-img" src={sendIcon} alt="发送" />
214
+ </div>
215
+ ) : menuOptions.length > 0 ? (
216
+ <div className="tbox-media-picker">
217
+ <div
218
+ className={`tbox-media-picker__trigger${menuVisible ? ' tbox-media-picker__trigger--open' : ''}`}
219
+ onClick={onPlus}
220
+ >
221
+ <img className="tbox-media-picker__trigger-icon" src={moreIcon} alt="更多" />
222
+ </div>
223
+ </div>
224
+ ) : null}
225
+ </div>
226
+ </div>
227
+
228
+ {menuVisible && (
229
+ <div className={`tbox-media-picker-menu${menuAnimating ? ' tbox-media-picker-menu--visible' : ''}`}>
230
+ {menuOptions.map((o) => (
231
+ <div key={o.type} className="tbox-media-picker-menu__option" onClick={() => onMenuSelect(o.type)}>
232
+ <div className="tbox-media-picker-menu__option-content">
233
+ <img className="tbox-media-picker-menu__option-icon" src={o.icon} alt="" />
234
+ <span className="tbox-media-picker-menu__option-label">{o.label}</span>
235
+ </div>
236
+ </div>
237
+ ))}
238
+ </div>
239
+ )}
240
+
241
+ {disclaimer && <div className="tbox-chat-input__disclaimer">{disclaimer}</div>}
242
+
243
+ <VoiceRecordingOverlay
244
+ visible={voice.overlayVisible}
245
+ interactionState={voice.interactionState}
246
+ countdown={voice.countdown}
247
+ handlers={voice.handlers}
248
+ />
249
+ </div>
250
+ );
251
+ }
@@ -0,0 +1,34 @@
1
+ import type { VoiceInteractionState } from './machine';
2
+ import type { VoicePressHandlers } from './useVoicePress';
3
+
4
+ function fieldText(s: VoiceInteractionState): string {
5
+ if (s === 'recording-cancel') return '松开取消';
6
+ if (s === 'processing') return '识别中...';
7
+ if (s === 'pressing' || s === 'recording-active') return '松开转文字';
8
+ return '按住说话';
9
+ }
10
+
11
+ /** “按住说话”输入条(替代文本框,语音模式下显示)。 */
12
+ export function VoicePressField({
13
+ disabled,
14
+ interactionState,
15
+ handlers,
16
+ }: {
17
+ disabled?: boolean;
18
+ interactionState: VoiceInteractionState;
19
+ handlers: VoicePressHandlers;
20
+ }) {
21
+ return (
22
+ <div
23
+ className={`tbox-voice-press-field tbox-voice-press-field--${interactionState}${disabled ? ' tbox-voice-press-field--disabled' : ''}`}
24
+ onTouchStart={disabled ? undefined : handlers.onTouchStart}
25
+ onTouchMove={disabled ? undefined : handlers.onTouchMove}
26
+ onTouchEnd={disabled ? undefined : handlers.onTouchEnd}
27
+ onTouchCancel={disabled ? undefined : handlers.onTouchCancel}
28
+ >
29
+ <div className="tbox-voice-press-field__content">
30
+ <span className="tbox-voice-press-field__text">{fieldText(interactionState)}</span>
31
+ </div>
32
+ </div>
33
+ );
34
+ }
@@ -0,0 +1,51 @@
1
+ import type { VoiceInteractionState } from './machine';
2
+ import type { VoicePressHandlers } from './useVoicePress';
3
+
4
+ function overlayTip(s: VoiceInteractionState): string {
5
+ if (s === 'recording-cancel') return '松开取消';
6
+ if (s === 'processing') return '识别中...';
7
+ return '松手发送,上移取消';
8
+ }
9
+
10
+ /** 录音全屏浮层(按压期间显示)。 */
11
+ export function VoiceRecordingOverlay({
12
+ visible,
13
+ interactionState,
14
+ countdown,
15
+ handlers,
16
+ }: {
17
+ visible: boolean;
18
+ interactionState: VoiceInteractionState;
19
+ countdown?: number | null;
20
+ handlers: VoicePressHandlers;
21
+ }) {
22
+ if (!visible) return null;
23
+ const isCancel = interactionState === 'recording-cancel';
24
+
25
+ return (
26
+ <div className="tbox-voice-overlay">
27
+ <div className="tbox-voice-overlay__mask" />
28
+ <div
29
+ className="tbox-voice-overlay__capture"
30
+ onTouchStart={handlers.onTouchStart}
31
+ onTouchMove={handlers.onTouchMove}
32
+ onTouchEnd={handlers.onTouchEnd}
33
+ onTouchCancel={handlers.onTouchCancel}
34
+ >
35
+ <div className="tbox-voice-overlay__panel">
36
+ {countdown != null && interactionState === 'recording-active' && (
37
+ <div className="tbox-voice-overlay__bubble">
38
+ <span className="tbox-voice-overlay__bubble-text">{countdown}秒后自动停止录音</span>
39
+ </div>
40
+ )}
41
+ <span className={`tbox-voice-overlay__tip${isCancel ? ' tbox-voice-overlay__tip--cancel' : ''}`}>
42
+ {overlayTip(interactionState)}
43
+ </span>
44
+ <div className={`tbox-voice-overlay__button${isCancel ? ' tbox-voice-overlay__button--cancel' : ''}`}>
45
+ <div className="tbox-voice-overlay__wave" />
46
+ </div>
47
+ </div>
48
+ </div>
49
+ </div>
50
+ );
51
+ }
@@ -0,0 +1,27 @@
1
+ /** 语音按压交互状态机(纯逻辑,移植自原模板 voice-interaction-machine)。 */
2
+ export type VoiceInteractionState =
3
+ | 'idle'
4
+ | 'pressing'
5
+ | 'recording-active'
6
+ | 'recording-cancel'
7
+ | 'processing';
8
+
9
+ export type VoiceReleaseAction = 'stop' | 'cancel' | 'noop';
10
+
11
+ export const CANCEL_GESTURE_THRESHOLD = 25;
12
+
13
+ /** 按压中上滑超过阈值 → 取消态,否则录音态 */
14
+ export function getMoveInteractionState(
15
+ startClientY: number,
16
+ currentClientY: number,
17
+ threshold = CANCEL_GESTURE_THRESHOLD,
18
+ ): VoiceInteractionState {
19
+ return startClientY - currentClientY > threshold ? 'recording-cancel' : 'recording-active';
20
+ }
21
+
22
+ /** 松手动作:取消 / 停止(转文字) / 无操作 */
23
+ export function getReleaseAction(state: VoiceInteractionState): VoiceReleaseAction {
24
+ if (state === 'recording-cancel') return 'cancel';
25
+ if (state === 'pressing' || state === 'recording-active') return 'stop';
26
+ return 'noop';
27
+ }
@@ -0,0 +1,188 @@
1
+ import { useCallback, useRef, useState } from 'react';
2
+ import type { IASRAdapter } from '@tbox.cn/app-sdk/client';
3
+ import { useRefreshSession } from '@tbox.cn/app-sdk/client';
4
+ import { isSessionExpiredError } from '@/utils/session-error';
5
+ import { toast } from '@/utils/toast';
6
+ import {
7
+ getMoveInteractionState,
8
+ getReleaseAction,
9
+ type VoiceInteractionState,
10
+ } from './machine';
11
+
12
+ export interface VoicePressHandlers {
13
+ onTouchStart: (e: React.TouchEvent) => void;
14
+ onTouchMove: (e: React.TouchEvent) => void;
15
+ onTouchEnd: (e: React.TouchEvent) => void;
16
+ onTouchCancel: (e: React.TouchEvent) => void;
17
+ }
18
+
19
+ const MAX_DURATION = 60; // 录音上限(秒)
20
+ const COUNTDOWN_START = 10; // 剩余 10 秒起显示倒计时
21
+
22
+ // 录音权限被拒/不可用时给用户的提示(补回旧模板丢失的“被拒反馈”——旧版会弹 modal / 触发
23
+ // onPermissionDenied,新版此前是静默回 idle)。两端通用:toast 是 antd-mobile 的纯 H5 实现。
24
+ // 到这里的错误来自实际录音引擎(浏览器;或壳内桥失败降级后的 H5——见 FallbackASRAdapter),
25
+ // 故 NotFoundError 可精确判“无设备”,其余(NotAllowedError/未知)用“检查录音权限 + 重试”兜底。
26
+ const MIC_PERMISSION_MSG = '无法使用麦克风,请检查录音权限后重试';
27
+ function micErrorMessage(err: unknown): string {
28
+ const name = (err as { name?: string } | null)?.name;
29
+ if (name === 'NotFoundError' || name === 'DevicesNotFoundError') return '未检测到麦克风设备';
30
+ return MIC_PERMISSION_MSG;
31
+ }
32
+
33
+ /** 按住说话交互:按下录音、上滑取消、松手转文字、到 60s 自动停。识别结果通过 onResult 回传。 */
34
+ export function useVoicePress({
35
+ disabled,
36
+ asr,
37
+ onResult,
38
+ }: {
39
+ disabled?: boolean;
40
+ asr?: IASRAdapter;
41
+ onResult: (text: string) => void;
42
+ }) {
43
+ const refreshSession = useRefreshSession();
44
+ const [interactionState, setStateRaw] = useState<VoiceInteractionState>('idle');
45
+ const [duration, setDurationRaw] = useState(0);
46
+ const stateRef = useRef<VoiceInteractionState>('idle');
47
+ const durationRef = useRef(0);
48
+ const startYRef = useRef(0);
49
+ const recordingRef = useRef(false);
50
+ // 本次录音实际使用的 adapter:session 过期重试后会切到刷新出来的新 adapter
51
+ const recordingAsrRef = useRef<IASRAdapter | undefined>(asr);
52
+ const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
53
+ const finishRef = useRef<() => void>(() => {});
54
+
55
+ const setState = useCallback((s: VoiceInteractionState) => {
56
+ stateRef.current = s;
57
+ setStateRaw(s);
58
+ }, []);
59
+ const setDur = (n: number) => {
60
+ durationRef.current = n;
61
+ setDurationRaw(n);
62
+ };
63
+ const stopTimer = () => {
64
+ if (timerRef.current) {
65
+ clearInterval(timerRef.current);
66
+ timerRef.current = null;
67
+ }
68
+ };
69
+ const startTimer = () => {
70
+ stopTimer();
71
+ setDur(0);
72
+ timerRef.current = setInterval(() => {
73
+ setDur(durationRef.current + 1);
74
+ if (durationRef.current >= MAX_DURATION) {
75
+ stopTimer();
76
+ finishRef.current(); // 到上限按"松手"处理(转文字 / 若在取消区则取消)
77
+ }
78
+ }, 1000);
79
+ };
80
+
81
+ const onTouchStart = useCallback(
82
+ async (e: React.TouchEvent) => {
83
+ if (disabled || !asr || recordingRef.current) return;
84
+ startYRef.current = e.touches[0]?.clientY ?? 0;
85
+ setState('pressing');
86
+
87
+ // 权限检查(对齐原模板 useVoiceRecorder):未授权/检查异常 → 提示并回 idle
88
+ if (asr.checkPermission) {
89
+ try {
90
+ if (!(await asr.checkPermission())) {
91
+ toast(MIC_PERMISSION_MSG, 'error');
92
+ setState('idle');
93
+ return;
94
+ }
95
+ } catch {
96
+ toast(MIC_PERMISSION_MSG, 'error');
97
+ setState('idle');
98
+ return;
99
+ }
100
+ }
101
+
102
+ let active = asr;
103
+ try {
104
+ try {
105
+ await asr.startRecording();
106
+ } catch (err) {
107
+ // session 过期 → 刷新并用新 adapter 重试一次(对齐原模板)
108
+ if (isSessionExpiredError(err) && refreshSession) {
109
+ const next = await refreshSession();
110
+ if (next.asr) {
111
+ active = next.asr;
112
+ await active.startRecording();
113
+ } else {
114
+ throw err;
115
+ }
116
+ } else {
117
+ throw err;
118
+ }
119
+ }
120
+ recordingAsrRef.current = active;
121
+ recordingRef.current = true;
122
+ startTimer();
123
+ if (stateRef.current === 'pressing') setState('recording-active');
124
+ } catch (err) {
125
+ recordingRef.current = false;
126
+ toast(isSessionExpiredError(err) ? '登录状态已失效,请稍后重试' : micErrorMessage(err), 'error');
127
+ setState('idle');
128
+ }
129
+ },
130
+ [disabled, asr, refreshSession, setState],
131
+ );
132
+
133
+ const onTouchMove = useCallback(
134
+ (e: React.TouchEvent) => {
135
+ if (!recordingRef.current) return;
136
+ const y = e.touches[0]?.clientY ?? 0;
137
+ setState(getMoveInteractionState(startYRef.current, y));
138
+ },
139
+ [setState],
140
+ );
141
+
142
+ const finish = useCallback(async () => {
143
+ stopTimer();
144
+ if (!recordingRef.current) {
145
+ setState('idle');
146
+ return;
147
+ }
148
+ recordingRef.current = false;
149
+ // 用本次录音实际使用的 adapter(可能因 session 重试切换过)
150
+ const active = recordingAsrRef.current ?? asr;
151
+ const action = getReleaseAction(stateRef.current);
152
+ if (action === 'cancel') {
153
+ active?.cancelRecording();
154
+ setState('idle');
155
+ return;
156
+ }
157
+ setState('processing');
158
+ try {
159
+ const text = (await active?.stopRecording()) ?? '';
160
+ if (text) onResult(text);
161
+ } catch {
162
+ /* ignore */
163
+ }
164
+ setState('idle');
165
+ }, [asr, onResult, setState]);
166
+ finishRef.current = finish;
167
+
168
+ const handlers: VoicePressHandlers = {
169
+ onTouchStart,
170
+ onTouchMove,
171
+ onTouchEnd: () => finish(),
172
+ onTouchCancel: () => {
173
+ stopTimer();
174
+ if (recordingRef.current) {
175
+ recordingRef.current = false;
176
+ (recordingAsrRef.current ?? asr)?.cancelRecording();
177
+ }
178
+ setState('idle');
179
+ },
180
+ };
181
+
182
+ const counting =
183
+ recordingRef.current &&
184
+ (interactionState === 'recording-active' || interactionState === 'recording-cancel' || interactionState === 'pressing');
185
+ const countdown = counting && duration >= MAX_DURATION - COUNTDOWN_START ? MAX_DURATION - duration : null;
186
+
187
+ return { interactionState, overlayVisible: interactionState !== 'idle', countdown, handlers };
188
+ }
@@ -0,0 +1,2 @@
1
+ // Re-export from app-sdk
2
+ export { ErrorBoundary } from '@tbox.cn/app-sdk/client';