@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.
Files changed (40) hide show
  1. package/README.md +193 -0
  2. package/bin/amaster.js +2 -0
  3. package/components/ai-assistant/example.md +34 -0
  4. package/components/ai-assistant/package.json +34 -0
  5. package/components/ai-assistant/template/ai-assistant.tsx +88 -0
  6. package/components/ai-assistant/template/components/Markdown.tsx +70 -0
  7. package/components/ai-assistant/template/components/chat-assistant-message.tsx +190 -0
  8. package/components/ai-assistant/template/components/chat-banner.tsx +17 -0
  9. package/components/ai-assistant/template/components/chat-display-mode-switcher.tsx +70 -0
  10. package/components/ai-assistant/template/components/chat-floating-button.tsx +56 -0
  11. package/components/ai-assistant/template/components/chat-floating-card.tsx +43 -0
  12. package/components/ai-assistant/template/components/chat-header.tsx +66 -0
  13. package/components/ai-assistant/template/components/chat-input.tsx +143 -0
  14. package/components/ai-assistant/template/components/chat-messages.tsx +81 -0
  15. package/components/ai-assistant/template/components/chat-recommends.tsx +36 -0
  16. package/components/ai-assistant/template/components/chat-speech-button.tsx +43 -0
  17. package/components/ai-assistant/template/components/chat-user-message.tsx +26 -0
  18. package/components/ai-assistant/template/components/ui-renderer-lazy.tsx +307 -0
  19. package/components/ai-assistant/template/components/ui-renderer.tsx +34 -0
  20. package/components/ai-assistant/template/components/voice-input.tsx +43 -0
  21. package/components/ai-assistant/template/hooks/useAssistantStore.tsx +36 -0
  22. package/components/ai-assistant/template/hooks/useAutoScroll.ts +90 -0
  23. package/components/ai-assistant/template/hooks/useConversationProcessor.ts +649 -0
  24. package/components/ai-assistant/template/hooks/useDisplayMode.tsx +74 -0
  25. package/components/ai-assistant/template/hooks/useDraggable.ts +125 -0
  26. package/components/ai-assistant/template/hooks/usePosition.ts +206 -0
  27. package/components/ai-assistant/template/hooks/useSpeak.ts +50 -0
  28. package/components/ai-assistant/template/hooks/useVoiceInput.ts +172 -0
  29. package/components/ai-assistant/template/i18n.ts +114 -0
  30. package/components/ai-assistant/template/index.ts +6 -0
  31. package/components/ai-assistant/template/inline-ai-assistant.tsx +78 -0
  32. package/components/ai-assistant/template/mock/mock-data.ts +643 -0
  33. package/components/ai-assistant/template/types.ts +72 -0
  34. package/index.js +13 -0
  35. package/package.json +67 -0
  36. package/packages/cli/dist/index.d.ts +3 -0
  37. package/packages/cli/dist/index.d.ts.map +1 -0
  38. package/packages/cli/dist/index.js +335 -0
  39. package/packages/cli/dist/index.js.map +1 -0
  40. 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
+ };