@amaster.ai/components-templates 1.3.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.
- package/README.md +193 -0
- package/bin/amaster.js +2 -0
- package/components/ai-assistant/example.md +34 -0
- package/components/ai-assistant/package.json +34 -0
- package/components/ai-assistant/template/ai-assistant.tsx +88 -0
- package/components/ai-assistant/template/components/Markdown.tsx +70 -0
- package/components/ai-assistant/template/components/chat-assistant-message.tsx +190 -0
- package/components/ai-assistant/template/components/chat-banner.tsx +17 -0
- package/components/ai-assistant/template/components/chat-display-mode-switcher.tsx +70 -0
- package/components/ai-assistant/template/components/chat-floating-button.tsx +56 -0
- package/components/ai-assistant/template/components/chat-floating-card.tsx +43 -0
- package/components/ai-assistant/template/components/chat-header.tsx +66 -0
- package/components/ai-assistant/template/components/chat-input.tsx +143 -0
- package/components/ai-assistant/template/components/chat-messages.tsx +81 -0
- package/components/ai-assistant/template/components/chat-recommends.tsx +36 -0
- package/components/ai-assistant/template/components/chat-speech-button.tsx +43 -0
- package/components/ai-assistant/template/components/chat-user-message.tsx +26 -0
- package/components/ai-assistant/template/components/ui-renderer-lazy.tsx +307 -0
- package/components/ai-assistant/template/components/ui-renderer.tsx +34 -0
- package/components/ai-assistant/template/components/voice-input.tsx +43 -0
- package/components/ai-assistant/template/hooks/useAssistantStore.tsx +36 -0
- package/components/ai-assistant/template/hooks/useAutoScroll.ts +90 -0
- package/components/ai-assistant/template/hooks/useConversationProcessor.ts +649 -0
- package/components/ai-assistant/template/hooks/useDisplayMode.tsx +74 -0
- package/components/ai-assistant/template/hooks/useDraggable.ts +125 -0
- package/components/ai-assistant/template/hooks/usePosition.ts +206 -0
- package/components/ai-assistant/template/hooks/useSpeak.ts +50 -0
- package/components/ai-assistant/template/hooks/useVoiceInput.ts +172 -0
- package/components/ai-assistant/template/i18n.ts +114 -0
- package/components/ai-assistant/template/index.ts +6 -0
- package/components/ai-assistant/template/inline-ai-assistant.tsx +78 -0
- package/components/ai-assistant/template/mock/mock-data.ts +643 -0
- package/components/ai-assistant/template/types.ts +72 -0
- package/index.js +13 -0
- package/package.json +67 -0
- package/packages/cli/dist/index.d.ts +3 -0
- package/packages/cli/dist/index.d.ts.map +1 -0
- package/packages/cli/dist/index.js +335 -0
- package/packages/cli/dist/index.js.map +1 -0
- package/packages/cli/package.json +35 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { IDisplayMode } from "../components/chat-display-mode-switcher";
|
|
3
|
+
|
|
4
|
+
const getStorageKey = () => `${location.hostname}-ai-assistant-display-mode`;
|
|
5
|
+
|
|
6
|
+
export const useDisplayMode = (enabled: boolean) => {
|
|
7
|
+
const STORAGE_KEY = getStorageKey();
|
|
8
|
+
const [displayMode, setDisplayMode] = useState<IDisplayMode>(
|
|
9
|
+
(localStorage.getItem(STORAGE_KEY) as IDisplayMode) || "floating",
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
localStorage.setItem(STORAGE_KEY, displayMode);
|
|
14
|
+
|
|
15
|
+
const layoutRoot =
|
|
16
|
+
document.querySelector<HTMLElement>("[data-role='main-layout']") ||
|
|
17
|
+
document.querySelector<HTMLElement>("#root") ||
|
|
18
|
+
document.body;
|
|
19
|
+
|
|
20
|
+
if (!layoutRoot) return;
|
|
21
|
+
|
|
22
|
+
const removeAttr = () => layoutRoot.removeAttribute("data-display-mode");
|
|
23
|
+
|
|
24
|
+
removeAttr();
|
|
25
|
+
|
|
26
|
+
if (enabled) {
|
|
27
|
+
if (displayMode === "side-left") {
|
|
28
|
+
layoutRoot.setAttribute("data-display-mode", "side-left");
|
|
29
|
+
} else if (displayMode === "side-right") {
|
|
30
|
+
layoutRoot.setAttribute("data-display-mode", "side-right");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return () => {
|
|
35
|
+
removeAttr();
|
|
36
|
+
};
|
|
37
|
+
}, [displayMode, enabled]);
|
|
38
|
+
|
|
39
|
+
const removeStyle = () => {
|
|
40
|
+
const style = document.getElementById("ai-assistant-display-mode-style");
|
|
41
|
+
if (style) {
|
|
42
|
+
document.head.removeChild(style);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (!document.getElementById("ai-assistant-display-mode-style")) {
|
|
48
|
+
const style = document.createElement("style");
|
|
49
|
+
style.id = "ai-assistant-display-mode-style";
|
|
50
|
+
style.innerHTML = `
|
|
51
|
+
[data-display-mode="side-left"] {
|
|
52
|
+
padding-left: 420px !important;
|
|
53
|
+
padding-right: 0 !important;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
[data-display-mode="side-right"] {
|
|
57
|
+
padding-left: 0 !important;
|
|
58
|
+
padding-right: 420px !important;
|
|
59
|
+
}
|
|
60
|
+
`;
|
|
61
|
+
document.head.appendChild(style);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!enabled) {
|
|
65
|
+
removeStyle();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return () => {
|
|
69
|
+
removeStyle();
|
|
70
|
+
};
|
|
71
|
+
}, [enabled]);
|
|
72
|
+
|
|
73
|
+
return [displayMode, setDisplayMode] as const;
|
|
74
|
+
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import type { Position } from "../types";
|
|
3
|
+
|
|
4
|
+
interface UseDraggableProps {
|
|
5
|
+
isFullscreen: boolean;
|
|
6
|
+
getActualPosition: () => Position;
|
|
7
|
+
clampToBounds: (pos: Position) => Position;
|
|
8
|
+
snapToEdge: (pos: Position) => Position;
|
|
9
|
+
setPosition: React.Dispatch<React.SetStateAction<Position>>;
|
|
10
|
+
savePosition: (pos: Position) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const useDraggable = ({
|
|
14
|
+
isFullscreen,
|
|
15
|
+
getActualPosition,
|
|
16
|
+
clampToBounds,
|
|
17
|
+
snapToEdge,
|
|
18
|
+
setPosition,
|
|
19
|
+
savePosition,
|
|
20
|
+
}: UseDraggableProps) => {
|
|
21
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
22
|
+
const dragRef = useRef<{
|
|
23
|
+
startX: number;
|
|
24
|
+
startY: number;
|
|
25
|
+
startPosX: number;
|
|
26
|
+
startPosY: number;
|
|
27
|
+
} | null>(null);
|
|
28
|
+
|
|
29
|
+
const handleDragStart = useCallback(
|
|
30
|
+
(e: React.MouseEvent) => {
|
|
31
|
+
if (isFullscreen) return;
|
|
32
|
+
|
|
33
|
+
e.preventDefault();
|
|
34
|
+
e.stopPropagation();
|
|
35
|
+
|
|
36
|
+
const actualPos = getActualPosition();
|
|
37
|
+
dragRef.current = {
|
|
38
|
+
startX: e.clientX,
|
|
39
|
+
startY: e.clientY,
|
|
40
|
+
startPosX: actualPos.x,
|
|
41
|
+
startPosY: actualPos.y,
|
|
42
|
+
};
|
|
43
|
+
setIsDragging(true);
|
|
44
|
+
},
|
|
45
|
+
[isFullscreen, getActualPosition],
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const handleTouchStart = useCallback(
|
|
49
|
+
(e: React.TouchEvent) => {
|
|
50
|
+
if (isFullscreen) return;
|
|
51
|
+
|
|
52
|
+
const touch = e.touches[0];
|
|
53
|
+
if (!touch) return;
|
|
54
|
+
|
|
55
|
+
const actualPos = getActualPosition();
|
|
56
|
+
dragRef.current = {
|
|
57
|
+
startX: touch.clientX,
|
|
58
|
+
startY: touch.clientY,
|
|
59
|
+
startPosX: actualPos.x,
|
|
60
|
+
startPosY: actualPos.y,
|
|
61
|
+
};
|
|
62
|
+
setIsDragging(true);
|
|
63
|
+
},
|
|
64
|
+
[isFullscreen, getActualPosition],
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (!isDragging) return;
|
|
69
|
+
|
|
70
|
+
const handleMove = (clientX: number, clientY: number) => {
|
|
71
|
+
if (!dragRef.current) return;
|
|
72
|
+
|
|
73
|
+
const deltaX = clientX - dragRef.current.startX;
|
|
74
|
+
const deltaY = clientY - dragRef.current.startY;
|
|
75
|
+
|
|
76
|
+
const newX = dragRef.current.startPosX + deltaX;
|
|
77
|
+
const newY = dragRef.current.startPosY + deltaY;
|
|
78
|
+
|
|
79
|
+
setPosition(clampToBounds({ x: newX, y: newY }));
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
83
|
+
handleMove(e.clientX, e.clientY);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const handleTouchMove = (e: TouchEvent) => {
|
|
87
|
+
const touch = e.touches[0];
|
|
88
|
+
if (touch) {
|
|
89
|
+
handleMove(touch.clientX, touch.clientY);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const handleEnd = (event) => {
|
|
94
|
+
if (dragRef.current) {
|
|
95
|
+
setPosition((prev) => {
|
|
96
|
+
const snapped = snapToEdge(
|
|
97
|
+
prev.x === -1 && prev.y === -1 ? { x: event.x, y: event.y } : prev,
|
|
98
|
+
);
|
|
99
|
+
savePosition(snapped);
|
|
100
|
+
return snapped;
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
dragRef.current = null;
|
|
104
|
+
setIsDragging(false);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
document.addEventListener("mousemove", handleMouseMove);
|
|
108
|
+
document.addEventListener("mouseup", handleEnd);
|
|
109
|
+
document.addEventListener("touchmove", handleTouchMove, { passive: true });
|
|
110
|
+
document.addEventListener("touchend", handleEnd);
|
|
111
|
+
|
|
112
|
+
return () => {
|
|
113
|
+
document.removeEventListener("mousemove", handleMouseMove);
|
|
114
|
+
document.removeEventListener("mouseup", handleEnd);
|
|
115
|
+
document.removeEventListener("touchmove", handleTouchMove);
|
|
116
|
+
document.removeEventListener("touchend", handleEnd);
|
|
117
|
+
};
|
|
118
|
+
}, [isDragging, snapToEdge, clampToBounds, setPosition, savePosition]);
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
isDragging,
|
|
122
|
+
handleDragStart,
|
|
123
|
+
handleTouchStart,
|
|
124
|
+
};
|
|
125
|
+
};
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import type { Position } from "../types";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_POSITION: Position = { x: -1, y: -1 };
|
|
5
|
+
const POSITION_STORAGE_KEY = "ai-assistant-position";
|
|
6
|
+
|
|
7
|
+
const getSavedPosition = (): Position => {
|
|
8
|
+
try {
|
|
9
|
+
const saved = localStorage.getItem(POSITION_STORAGE_KEY);
|
|
10
|
+
if (saved) {
|
|
11
|
+
const parsed = JSON.parse(saved);
|
|
12
|
+
if (
|
|
13
|
+
typeof parsed.x === "number" &&
|
|
14
|
+
typeof parsed.y === "number" &&
|
|
15
|
+
parsed.x >= 0 &&
|
|
16
|
+
parsed.y >= 0 &&
|
|
17
|
+
parsed.x < window.innerWidth &&
|
|
18
|
+
parsed.y < window.innerHeight
|
|
19
|
+
) {
|
|
20
|
+
return parsed;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
} catch {
|
|
24
|
+
// Ignore parsing errors
|
|
25
|
+
}
|
|
26
|
+
return DEFAULT_POSITION;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const savePosition = (pos: Position) => {
|
|
30
|
+
try {
|
|
31
|
+
localStorage.setItem(POSITION_STORAGE_KEY, JSON.stringify(pos));
|
|
32
|
+
} catch {
|
|
33
|
+
// Ignore storage errors
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const usePosition = (isOpen: boolean, isFullscreen: boolean) => {
|
|
38
|
+
const [position, setPosition] = useState<Position>(getSavedPosition);
|
|
39
|
+
// 使用 ref 来跟踪是否已经初始化,避免重复执行
|
|
40
|
+
const initializedRef = useRef(false);
|
|
41
|
+
const edgeDistance = 16; // 距离边缘的最小距离
|
|
42
|
+
|
|
43
|
+
const getElementDimensions = useCallback(() => {
|
|
44
|
+
const buttonSize = 56;
|
|
45
|
+
const dialogWidth = 420;
|
|
46
|
+
const dialogHeight =
|
|
47
|
+
window.innerHeight < 600 ? window.innerHeight : window.innerHeight - 200;
|
|
48
|
+
return {
|
|
49
|
+
width: isOpen ? dialogWidth : buttonSize,
|
|
50
|
+
height: isOpen ? dialogHeight : buttonSize,
|
|
51
|
+
};
|
|
52
|
+
}, [isOpen]);
|
|
53
|
+
|
|
54
|
+
const getActualPosition = useCallback((): Position => {
|
|
55
|
+
if (position.x < 0 || position.y < 0) {
|
|
56
|
+
return {
|
|
57
|
+
x: window.innerWidth - edgeDistance,
|
|
58
|
+
y: window.innerHeight - edgeDistance,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return position;
|
|
62
|
+
}, [position]);
|
|
63
|
+
|
|
64
|
+
const clampToBounds = useCallback(
|
|
65
|
+
(pos: Position): Position => {
|
|
66
|
+
const margin = edgeDistance;
|
|
67
|
+
const { width, height } = getElementDimensions();
|
|
68
|
+
|
|
69
|
+
const minX = width + margin;
|
|
70
|
+
const minY = height + margin;
|
|
71
|
+
const maxX = window.innerWidth - margin;
|
|
72
|
+
const maxY = window.innerHeight - margin;
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
x: Math.max(minX, Math.min(pos.x, maxX)),
|
|
76
|
+
y: Math.max(minY, Math.min(pos.y, maxY)),
|
|
77
|
+
};
|
|
78
|
+
},
|
|
79
|
+
[getElementDimensions],
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const snapToEdge = useCallback(
|
|
83
|
+
(pos: Position): Position => {
|
|
84
|
+
const { width, height } = getElementDimensions();
|
|
85
|
+
|
|
86
|
+
const minX = width + edgeDistance;
|
|
87
|
+
const minY = height + edgeDistance;
|
|
88
|
+
const maxX = window.innerWidth - edgeDistance;
|
|
89
|
+
const maxY = window.innerHeight - edgeDistance;
|
|
90
|
+
const snapDistance = edgeDistance;
|
|
91
|
+
|
|
92
|
+
let newX = Math.max(minX, Math.min(pos.x, maxX));
|
|
93
|
+
let newY = Math.max(minY, Math.min(pos.y, maxY));
|
|
94
|
+
|
|
95
|
+
const leftEdge = newX - width;
|
|
96
|
+
const distToLeft = leftEdge - edgeDistance;
|
|
97
|
+
const distToRight = maxX - newX;
|
|
98
|
+
|
|
99
|
+
if (distToLeft < distToRight && distToLeft < snapDistance) {
|
|
100
|
+
newX = width + edgeDistance;
|
|
101
|
+
} else if (distToRight < snapDistance) {
|
|
102
|
+
newX = maxX;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const topEdge = newY - height;
|
|
106
|
+
const distToTop = topEdge - edgeDistance;
|
|
107
|
+
const distToBottom = maxY - newY;
|
|
108
|
+
|
|
109
|
+
if (distToTop < distToBottom && distToTop < snapDistance) {
|
|
110
|
+
newY = height + edgeDistance;
|
|
111
|
+
} else if (distToBottom < snapDistance) {
|
|
112
|
+
newY = maxY;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { x: newX, y: newY };
|
|
116
|
+
},
|
|
117
|
+
[getElementDimensions],
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const getPositionStyles = useCallback((): React.CSSProperties => {
|
|
121
|
+
if (isFullscreen) {
|
|
122
|
+
return {};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const actualPos = getActualPosition();
|
|
126
|
+
const { width, height } = getElementDimensions();
|
|
127
|
+
|
|
128
|
+
const rightPx = window.innerWidth - actualPos.x;
|
|
129
|
+
const bottomPx = window.innerHeight - actualPos.y;
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
position: "fixed",
|
|
133
|
+
right: Math.max(
|
|
134
|
+
0,
|
|
135
|
+
Math.min(rightPx, window.innerWidth - width - edgeDistance),
|
|
136
|
+
),
|
|
137
|
+
bottom: Math.max(
|
|
138
|
+
0,
|
|
139
|
+
Math.min(bottomPx, window.innerHeight - height - edgeDistance),
|
|
140
|
+
),
|
|
141
|
+
width,
|
|
142
|
+
height,
|
|
143
|
+
left: "auto",
|
|
144
|
+
top: "auto",
|
|
145
|
+
};
|
|
146
|
+
}, [isFullscreen, getActualPosition, getElementDimensions]);
|
|
147
|
+
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
const handleResize = () => {
|
|
150
|
+
setPosition((prev) => {
|
|
151
|
+
if (prev.x < 0 || prev.y < 0) return prev;
|
|
152
|
+
return clampToBounds(prev);
|
|
153
|
+
});
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
window.addEventListener("resize", handleResize);
|
|
157
|
+
return () => window.removeEventListener("resize", handleResize);
|
|
158
|
+
}, [clampToBounds]);
|
|
159
|
+
|
|
160
|
+
// 修复:只在 isOpen 状态改变时检查边界,避免无限循环
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
if (position.x < 0 || position.y < 0) {
|
|
163
|
+
initializedRef.current = true;
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 只在状态变化时执行一次
|
|
168
|
+
if (!initializedRef.current) {
|
|
169
|
+
const clamped = clampToBounds(position);
|
|
170
|
+
if (clamped.x !== position.x || clamped.y !== position.y) {
|
|
171
|
+
setPosition(clamped);
|
|
172
|
+
savePosition(clamped);
|
|
173
|
+
}
|
|
174
|
+
initializedRef.current = true;
|
|
175
|
+
}
|
|
176
|
+
}, [isOpen, clampToBounds]); // 移除 position 依赖,避免循环
|
|
177
|
+
|
|
178
|
+
const setPositionTo = useCallback(
|
|
179
|
+
(x: "left" | "right", y: "top" | "bottom") => {
|
|
180
|
+
const { width, height } = getElementDimensions();
|
|
181
|
+
const newPos: Position = {
|
|
182
|
+
x:
|
|
183
|
+
x === "left"
|
|
184
|
+
? width + edgeDistance
|
|
185
|
+
: window.innerWidth - edgeDistance,
|
|
186
|
+
y:
|
|
187
|
+
y === "top"
|
|
188
|
+
? height + edgeDistance
|
|
189
|
+
: window.innerHeight - 200,
|
|
190
|
+
};
|
|
191
|
+
setPosition(newPos);
|
|
192
|
+
savePosition(newPos);
|
|
193
|
+
},
|
|
194
|
+
[],
|
|
195
|
+
);
|
|
196
|
+
return {
|
|
197
|
+
position,
|
|
198
|
+
setPosition,
|
|
199
|
+
getActualPosition,
|
|
200
|
+
setPositionTo,
|
|
201
|
+
clampToBounds,
|
|
202
|
+
snapToEdge,
|
|
203
|
+
getPositionStyles,
|
|
204
|
+
savePosition,
|
|
205
|
+
};
|
|
206
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { client } from "@/lib/client";
|
|
2
|
+
import type { TTSClient } from "@amaster.ai/client";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
|
|
5
|
+
let globalTTSClient: TTSClient | null = null;
|
|
6
|
+
let globalVoice: string = "Cherry";
|
|
7
|
+
|
|
8
|
+
const createTTSClient = async (): Promise<TTSClient> => {
|
|
9
|
+
const ttsClient = client.tts({
|
|
10
|
+
voice: globalVoice,
|
|
11
|
+
autoPlay: true,
|
|
12
|
+
audioFormat: "pcm",
|
|
13
|
+
sampleRate: 24000
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
await ttsClient.connect();
|
|
17
|
+
globalTTSClient = ttsClient;
|
|
18
|
+
return ttsClient;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const stopTTS = () => {
|
|
22
|
+
if (globalTTSClient) {
|
|
23
|
+
globalTTSClient.stop();
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const useSpeak = () => {
|
|
28
|
+
const [speaking, setSpeaking] = useState(false);
|
|
29
|
+
|
|
30
|
+
const speak = async (text: string) => {
|
|
31
|
+
if (!text) return;
|
|
32
|
+
stopTTS();
|
|
33
|
+
setSpeaking(true);
|
|
34
|
+
if (!globalTTSClient) {
|
|
35
|
+
globalTTSClient = await createTTSClient();
|
|
36
|
+
}
|
|
37
|
+
globalTTSClient.speak(text);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const stop = () => {
|
|
41
|
+
stopTTS();
|
|
42
|
+
setSpeaking(false);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
speak,
|
|
47
|
+
stop,
|
|
48
|
+
speaking,
|
|
49
|
+
};
|
|
50
|
+
};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { getText } from "../i18n";
|
|
3
|
+
import { client } from "@/lib/client";
|
|
4
|
+
import { ASRClient } from "@amaster.ai/client";
|
|
5
|
+
import { toast } from "@/hooks/use-toast";
|
|
6
|
+
|
|
7
|
+
type Status =
|
|
8
|
+
| "idle"
|
|
9
|
+
| "starting"
|
|
10
|
+
| "ready"
|
|
11
|
+
| "speaking"
|
|
12
|
+
| "recording"
|
|
13
|
+
| "stopping"
|
|
14
|
+
| "ended"
|
|
15
|
+
| "error"
|
|
16
|
+
| "closed";
|
|
17
|
+
|
|
18
|
+
export const useVoiceInput = ({
|
|
19
|
+
onChange,
|
|
20
|
+
value = "",
|
|
21
|
+
disabled,
|
|
22
|
+
}: {
|
|
23
|
+
onChange: (value: string) => void;
|
|
24
|
+
value?: string;
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
}) => {
|
|
27
|
+
const [status, setStatus] = useState<Status>("idle");
|
|
28
|
+
const [waitingEnd, setWaitingEnd] = useState(false);
|
|
29
|
+
const waitingEndTimer = useRef<NodeJS.Timeout | null>(null);
|
|
30
|
+
const AsrClientRef = useRef<ASRClient | null>(null);
|
|
31
|
+
const runningRef = useRef(false);
|
|
32
|
+
const stoppable = useMemo(() => {
|
|
33
|
+
return ["ready", "speaking", "recording"].includes(status);
|
|
34
|
+
}, [status]);
|
|
35
|
+
const disabledClick = useMemo(() => {
|
|
36
|
+
return ["starting", "ended", "closed", "error", "stopping"].includes(
|
|
37
|
+
status,
|
|
38
|
+
);
|
|
39
|
+
}, [status]);
|
|
40
|
+
const running = useMemo(() => {
|
|
41
|
+
return status !== "idle";
|
|
42
|
+
}, [status]);
|
|
43
|
+
|
|
44
|
+
const start = async () => {
|
|
45
|
+
if (runningRef.current) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let result = value || "";
|
|
50
|
+
let temp = "";
|
|
51
|
+
|
|
52
|
+
const asrClient = client.asr({
|
|
53
|
+
onReady() {
|
|
54
|
+
setStatus("ready");
|
|
55
|
+
},
|
|
56
|
+
onSpeechStart() {
|
|
57
|
+
setStatus((status) => (status !== "stopping" ? "recording" : status));
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
onSpeechEnd() {
|
|
61
|
+
setStatus((status) => (status !== "stopping" ? "recording" : status));
|
|
62
|
+
},
|
|
63
|
+
onTranscript(text, isFinal) {
|
|
64
|
+
if (!isFinal) {
|
|
65
|
+
temp = text;
|
|
66
|
+
onChange(result + temp);
|
|
67
|
+
} else {
|
|
68
|
+
temp = "";
|
|
69
|
+
result += text;
|
|
70
|
+
onChange(result);
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
onSessionFinished() {
|
|
74
|
+
setStatus("ended");
|
|
75
|
+
},
|
|
76
|
+
onError() {
|
|
77
|
+
reset();
|
|
78
|
+
},
|
|
79
|
+
onClose() {
|
|
80
|
+
setStatus((status) => (status !== "closed" ? "closed" : status));
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
setStatus("starting");
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
await asrClient.connect();
|
|
88
|
+
await asrClient.startRecording();
|
|
89
|
+
AsrClientRef.current = asrClient;
|
|
90
|
+
} catch (error) {
|
|
91
|
+
const message = error.message;
|
|
92
|
+
const showError = (title: string) => toast({title})
|
|
93
|
+
if (message.includes("Microphone access denied")) {
|
|
94
|
+
showError(getText().voiceInputError.microphoneAccessDenied);
|
|
95
|
+
} else if (message.includes("No speech detected")) {
|
|
96
|
+
showError(getText().voiceInputError.noSpeechDetected);
|
|
97
|
+
} else if (message.includes("Network error")) {
|
|
98
|
+
showError(getText().voiceInputError.networkError);
|
|
99
|
+
} else if (message.includes("Unsupported browser")) {
|
|
100
|
+
showError(getText().voiceInputError.unknownError);
|
|
101
|
+
} else if (message.includes("Permission denied")) {
|
|
102
|
+
showError(getText().voiceInputError.permissionDenied);
|
|
103
|
+
} else {
|
|
104
|
+
console.error(error);
|
|
105
|
+
showError(getText().voiceInputError.unknownError);
|
|
106
|
+
}
|
|
107
|
+
reset();
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const stop = () => {
|
|
112
|
+
setStatus("stopping");
|
|
113
|
+
AsrClientRef.current?.stopRecording();
|
|
114
|
+
AsrClientRef.current?.close();
|
|
115
|
+
waitClose();
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const reset = () => {
|
|
119
|
+
AsrClientRef.current = null;
|
|
120
|
+
waitingEndTimer.current && clearTimeout(waitingEndTimer.current);
|
|
121
|
+
setWaitingEnd(false);
|
|
122
|
+
setStatus("idle");
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const waitClose = () => {
|
|
126
|
+
setWaitingEnd(true);
|
|
127
|
+
waitingEndTimer.current && clearTimeout(waitingEndTimer.current);
|
|
128
|
+
waitingEndTimer.current = setTimeout(() => {
|
|
129
|
+
setStatus("closed");
|
|
130
|
+
}, 3000);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
return () => {
|
|
135
|
+
stop();
|
|
136
|
+
reset();
|
|
137
|
+
};
|
|
138
|
+
}, []);
|
|
139
|
+
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
runningRef.current = running;
|
|
142
|
+
}, [running]);
|
|
143
|
+
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
if (status === "closed") {
|
|
146
|
+
reset();
|
|
147
|
+
}
|
|
148
|
+
}, [status]);
|
|
149
|
+
|
|
150
|
+
const statusTextMap = {
|
|
151
|
+
// idle: t('translation:voiceInputStatus.idle'),
|
|
152
|
+
idle: "",
|
|
153
|
+
starting: getText().voiceInputStatus.starting,
|
|
154
|
+
ready: getText().voiceInputStatus.ready,
|
|
155
|
+
speaking: getText().voiceInputStatus.speaking,
|
|
156
|
+
recording: getText().voiceInputStatus.recording,
|
|
157
|
+
stopping: getText().voiceInputStatus.stopping,
|
|
158
|
+
ended: getText().voiceInputStatus.ended,
|
|
159
|
+
error: getText().voiceInputStatus.error,
|
|
160
|
+
closed: getText().voiceInputStatus.closed,
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
running,
|
|
165
|
+
stoppable,
|
|
166
|
+
disabled: disabled || waitingEnd || disabledClick,
|
|
167
|
+
start,
|
|
168
|
+
stop,
|
|
169
|
+
status,
|
|
170
|
+
statusText: statusTextMap[status],
|
|
171
|
+
};
|
|
172
|
+
};
|